Skip to main content

teksilo_widgets/
accordion.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Accordion — a collapsible section with a clickable header that shows or hides
5//! its content when activated.
6//!
7//! In the default vertical mode a horizontally-spanning header row sits above the
8//! content; clicking or pressing Space/Enter toggles visibility with an animated
9//! height disclosure (via [`Collapse`]).
10//! A horizontal mode flips the header into a narrow vertical strip with a rotated
11//! label — used by top/bottom sides of a `DockingLayout`. Fill mode (`.fill(true)`)
12//! is designed for fixed-size slots such as Splitter panes: the content fills all
13//! available space and collapse animation is driven externally by the enclosing
14//! pane rather than by an internal height tween.
15//!
16//! ## Accessibility
17//!
18//! The header is announced as `Role::Button` with `aria-expanded` reflecting the
19//! current state, and `aria-controls` pointing at the content region
20//! (`Role::Region`). Space/Enter toggle the disclosure; AT "click" actions are
21//! also handled. The focus ring appears only on keyboard focus (not on pointer
22//! clicks), matching the IntUI convention.
23//!
24//! ```rust
25//! # use teksilo_widgets::accordion::Accordion;
26//! # use teksilo_core::signal::Signal;
27//! # use teksilo_i18n::lit;
28//! let expanded = Signal::new(false);
29//! let _accordion = Accordion::new(lit!("Advanced settings"), expanded);
30//! ```
31//!
32//! ## Touch and pen
33//!
34//! The header is one target on the density ladder and toggles from its tap, so on
35//! the release. Where a dock panel installs `on_header_drag`, the same header is
36//! also a drag source: `DragActivation::Auto` resolves that to `Immediate` when
37//! nothing competes for the axis and to the hold when a scroller does, so a contact
38//! needs no declaration to move a panel — and a press that lifts without
39//! travelling still toggles.
40//!
41//! The trailing slot is wrapped in a [`DeadZone`](crate::primitives::DeadZone), so
42//! its action buttons and `⋮` menu take a press — jitter and all — without arming
43//! the panel drag behind them.
44
45use teksilo_canvas::{Rect, Size, SizeProposal};
46use teksilo_core::accessibility::AccessNodeBuilder;
47use teksilo_core::binding::BindingLevel;
48use teksilo_core::build_context::BuildContext;
49use teksilo_core::color_prop::{ColorProp, TextStyleProp};
50use teksilo_core::event::{EventResponse, Key, WidgetEvent};
51use teksilo_core::signal::Signal;
52use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
53use teksilo_core::widget_builder::HandlerSet;
54use teksilo_core::widget_id::WidgetId;
55use teksilo_tokens::{BorderRole, InputTokens, TargetRole, TextRole, TextStyleRole};
56
57use crate::animations::collapse::Collapse;
58use crate::primitives::{HStack, IconWidget, MinSize, Spacer, TextWidget, VStack};
59use crate::tool_box::RotatedLabel;
60use teksilo_core::styles::density::{dp, spacing};
61use teksilo_i18n::LocalizedString;
62
63/// Fixed header extent (px) along the main axis in [`Accordion::fill`] mode, so
64/// a collapsed dock pane is exactly the header with no content sliver.
65pub(crate) const ACCORDION_FILL_HEADER_EXTENT: f32 = 30.0;
66
67/// Gap between a fill-mode accordion's header and its body, in dp.
68pub(crate) const ACCORDION_FILL_GAP: f32 = 2.0;
69
70/// [`ACCORDION_FILL_GAP`] scaled by the density's `spacing_factor`
71/// (1.00 / 1.15 / 1.30).
72pub(crate) fn accordion_fill_gap(tokens: &InputTokens) -> f32 {
73    spacing(ACCORDION_FILL_GAP, tokens)
74}
75
76/// [`ACCORDION_FILL_HEADER_EXTENT`] raised to the density's `target_size`
77/// (24 / 32 / 44 dp). The identity at Compact.
78pub(crate) fn accordion_fill_header_extent(tokens: &InputTokens) -> f32 {
79    dp(ACCORDION_FILL_HEADER_EXTENT, TargetRole::Target, tokens)
80}
81/// The size a fill-mode accordion's enclosing Splitter pane collapses to —
82/// the header extent plus the header→body gap. See `place_fill`.
83pub(crate) const ACCORDION_FILL_COLLAPSED_EXTENT: f32 = ACCORDION_FILL_HEADER_EXTENT + 2.0;
84
85// ---------------------------------------------------------------------------
86// AccordionRegion — thin wrapper that exposes Role::Region for aria-controls.
87// ---------------------------------------------------------------------------
88
89#[derive(Debug)]
90struct AccordionRegion {
91    /// Kept as a `LocalizedString` (not eagerly resolved) so the region's
92    /// AT name follows a live locale switch — `accessibility()` re-runs on
93    /// the AT re-walk and re-resolves below.
94    name: LocalizedString,
95    child: Option<WidgetId>,
96}
97
98impl AccordionRegion {
99    fn new(name: LocalizedString, child: WidgetId) -> Self {
100        Self {
101            name,
102            child: Some(child),
103        }
104    }
105}
106
107impl Widget for AccordionRegion {
108    fn layout_response(
109        &self,
110        proposal: SizeProposal,
111        ctx: &LayoutContext,
112    ) -> teksilo_core::widget::LayoutResponse {
113        self.child
114            .and_then(|id| ctx.child_size(id, proposal))
115            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
116            .into()
117    }
118
119    fn place_children(
120        &self,
121        bounds: Rect,
122        _proposal: SizeProposal,
123        children: &mut [WidgetPlacement],
124        _ctx: &LayoutContext,
125    ) {
126        for child in children.iter_mut() {
127            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
128            child.size = bounds.size();
129        }
130    }
131
132    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
133        builder.set_role(teksilo_core::accesskit::Role::Region);
134        builder.set_name(self.name.resolve_now());
135    }
136
137    fn children(&self) -> Vec<WidgetId> {
138        self.child.into_iter().collect()
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Accordion widget
144// ---------------------------------------------------------------------------
145
146/// Height of the accordion header row in pixels (vertical mode).
147pub const ACCORDION_HEADER_HEIGHT: f32 = 28.0;
148
149/// [`ACCORDION_HEADER_HEIGHT`] raised to the density's `target_size`
150/// (24 / 32 / 44 dp). The identity at Compact.
151pub fn accordion_header_height(tokens: &InputTokens) -> f32 {
152    dp(ACCORDION_HEADER_HEIGHT, TargetRole::Target, tokens)
153}
154/// Horizontal padding inside the accordion header on the leading and trailing edges.
155pub const ACCORDION_HEADER_PADDING_HORIZONTAL: f32 = 8.0;
156
157/// [`ACCORDION_HEADER_PADDING_HORIZONTAL`] scaled by the density's `spacing_factor`
158/// (1.00 / 1.15 / 1.30).
159pub fn accordion_header_padding_horizontal(tokens: &InputTokens) -> f32 {
160    spacing(ACCORDION_HEADER_PADDING_HORIZONTAL, tokens)
161}
162/// Size of the chevron disclosure indicator icon in pixels.
163pub const ACCORDION_INDICATOR_SIZE: f32 = 12.0;
164/// Gap between the disclosure indicator and the title label.
165pub const ACCORDION_INDICATOR_GAP: f32 = 6.0;
166
167/// [`ACCORDION_INDICATOR_GAP`] scaled by the density's `spacing_factor`
168/// (1.00 / 1.15 / 1.30).
169pub fn accordion_indicator_gap(tokens: &InputTokens) -> f32 {
170    spacing(ACCORDION_INDICATOR_GAP, tokens)
171}
172/// Corner radius of the keyboard-focus ring painted on the accordion header.
173pub const ACCORDION_CORNER_RADIUS: f32 = 4.0;
174
175/// Orientation of an [`Accordion`]: how its header sits relative to its
176/// content. [`Vertical`](AccordionOrientation::Vertical) (the default) is a
177/// horizontal header row above the content; [`Horizontal`](AccordionOrientation::Horizontal)
178/// is a narrow vertical header **strip** (rotated-90° label, left/right
179/// chevron) beside the content — used by top/bottom dock sides.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub enum AccordionOrientation {
182    /// Header row above the content (default).
183    #[default]
184    Vertical,
185    /// Vertical header strip beside the content.
186    Horizontal,
187}
188
189/// A collapsible section widget whose header button shows or hides attached content.
190///
191/// Supply the title and a `Signal<bool>` for the expanded state, then attach
192/// content via [`.content(w)`](Accordion::content) or
193/// [`.content(id)`](Accordion::content). The signal can be toggled externally
194/// (e.g. from a "collapse all" button) and the disclosure animation will follow.
195pub struct Accordion {
196    /// Header title. Kept as a `LocalizedString` (not eagerly resolved)
197    /// so a `tr!(...)` / `tr_widget!(...)` source re-renders on locale
198    /// change: the title `TextWidget` binds it as a reactive prop.
199    title: LocalizedString,
200    expanded: Signal<bool>,
201    content_id: Option<WidgetId>,
202    pending_content: Option<Box<dyn Widget>>,
203    root_child_id: Option<WidgetId>,
204    /// Region wrapper ID — used for `aria-controls` on the header button.
205    region_id: Option<WidgetId>,
206    /// Optional override for the header foreground color (title text +
207    /// chevron icon). When `None`, the accordion uses [`TextRole::Primary`].
208    /// Set this when the accordion is embedded inside a surface that uses a
209    /// non-standard text color (rich tooltip, dark snackbar, etc.). Accepts
210    /// any `impl Into<ColorProp>` — a literal `Color`, a role, or a
211    /// `Signal<Color>` — so the override stays theme-reactive.
212    title_color: Option<ColorProp>,
213    /// Optional override for the header title's text style. Defaults to
214    /// [`TextStyleRole::Body`] when `None`. Accepts a static
215    /// [`TextStyle`](teksilo_tokens::TextStyle) or a
216    /// [`TextStyleRole`].
217    title_style: Option<TextStyleProp>,
218    /// Header orientation (default [`AccordionOrientation::Vertical`]).
219    orientation: AccordionOrientation,
220    /// When set, the expanded content **fills** the accordion's allotted space
221    /// for a fixed-size slot (e.g. a Splitter pane), with an **animated**
222    /// collapse — rather than the default natural-height disclosure. See
223    /// [`Accordion::fill`].
224    fill: bool,
225    /// Optional drag-source hook: when set, a drag gesture starting on the
226    /// header fires this (it typically calls `ctx.start_drag(...)`). Tap-to-
227    /// toggle still works — the gesture arena disambiguates.
228    on_header_drag: Option<std::rc::Rc<dyn Fn(&mut EventContext)>>,
229    /// Optional trailing header slot — a widget placed at the trailing end of
230    /// the header (before the disclosure chevron), e.g. a "More actions" (`⋮`)
231    /// options button or an inline action toolbar. Its own tap/press recognizer
232    /// captures first (innermost-hit wins), so operating it does **not** toggle
233    /// the disclosure. See [`Accordion::trailing`].
234    trailing: Option<Box<dyn Widget>>,
235    /// A pre-registered trailing slot by id (for callers that must build the
236    /// slot in-context first). Takes precedence over `trailing`. See
237    /// [`Accordion::trailing`].
238    trailing_id: Option<WidgetId>,
239    /// Fill-mode layout state: the header + animated body are direct children
240    /// laid out by the accordion itself (so the body fills the leftover *and*
241    /// animates). `None` in the default (VStack-rooted) mode.
242    fill_header_id: Option<WidgetId>,
243    fill_body_id: Option<WidgetId>,
244}
245
246impl Accordion {
247    /// Create a new accordion with the given `title` and an external `expanded` signal.
248    ///
249    /// The accordion starts collapsed or expanded according to the initial value of
250    /// `expanded`. Toggling the signal later drives the disclosure animation.
251    pub fn new(title: impl Into<LocalizedString>, expanded: Signal<bool>) -> Self {
252        Self {
253            title: title.into(),
254            expanded,
255            content_id: None,
256            pending_content: None,
257            root_child_id: None,
258            region_id: None,
259            title_color: None,
260            title_style: None,
261            orientation: AccordionOrientation::Vertical,
262            fill: false,
263            on_header_drag: None,
264            trailing: None,
265            trailing_id: None,
266            fill_header_id: None,
267            fill_body_id: None,
268        }
269    }
270
271    /// Set the header orientation (default [`AccordionOrientation::Vertical`]).
272    pub fn orientation(mut self, orientation: AccordionOrientation) -> Self {
273        self.orientation = orientation;
274        self
275    }
276
277    /// Shorthand for [`Accordion::orientation`]`(`[`AccordionOrientation::Horizontal`]`)`.
278    pub fn horizontal(mut self) -> Self {
279        self.orientation = AccordionOrientation::Horizontal;
280        self
281    }
282
283    /// Make the expanded content **fill** the accordion's allotted space (the
284    /// leftover after the header) — instead of the default natural-height
285    /// disclosure — while keeping the collapse/expand **animated**. Use when the
286    /// accordion lives in a fixed-size slot such as a Splitter pane (a dock
287    /// panel): the content lays out at exactly the available size (no narrow
288    /// content, no overflow) and the header tween still plays. Default `false`.
289    pub fn fill(mut self, fill: bool) -> Self {
290        self.fill = fill;
291        self
292    }
293
294    /// Make the header a **drag source**: a drag gesture starting on it fires
295    /// `f` (which should begin a drag, e.g. `ctx.start_drag(source, payload)`).
296    /// Tap-to-toggle is unaffected — the gesture arena tells a tap from a drag.
297    pub fn on_header_drag(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
298        self.on_header_drag = Some(std::rc::Rc::new(f));
299        self
300    }
301
302    /// Place a widget at the trailing end of the header, before the disclosure
303    /// chevron — an options (`⋮`) button, an inline action toolbar, etc. The
304    /// slot's own controls capture their gestures (innermost hit wins), so
305    /// clicking them does not toggle the accordion. Mirrors
306    /// [`ToolBoxItem::trailing`](crate::tool_box::ToolBoxItem) /
307    /// [`TabWidget::bar_trailing_slot`](crate::tab_widget::TabWidget::bar_trailing_slot).
308    pub fn trailing(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
309        match teksilo_core::IntoTeksiChild::into_pending(widget) {
310            teksilo_core::PendingChild::Id(id) => {
311                self.trailing_id = Some(id);
312                self
313            }
314            teksilo_core::PendingChild::Deferred(w) => {
315                self.trailing = Some(w);
316                self
317            }
318        }
319    }
320
321    /// Override the header foreground color used for the title text and
322    /// chevron icon. Defaults to [`TextRole::Primary`]. Accepts a literal
323    /// `Color`, a `TextRole`/`SurfaceRole`, or a `Signal<Color>`.
324    pub fn title_color(mut self, color: impl Into<ColorProp>) -> Self {
325        self.title_color = Some(color.into());
326        self
327    }
328
329    /// Override the header title's text style. Use this to make the
330    /// disclosure label smaller (e.g. inside a tooltip) or to match a
331    /// non-body typography role. Accepts a static
332    /// [`TextStyle`](teksilo_tokens::TextStyle) or a
333    /// [`TextStyleRole`].
334    pub fn title_style(mut self, style: impl Into<TextStyleProp>) -> Self {
335        self.title_style = Some(style.into());
336        self
337    }
338
339    /// Set an inline content widget (deferred insertion).
340    pub fn content(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
341        match teksilo_core::IntoTeksiChild::into_pending(widget) {
342            teksilo_core::PendingChild::Id(id) => {
343                self.content_id = Some(id);
344                self
345            }
346            teksilo_core::PendingChild::Deferred(w) => {
347                self.pending_content = Some(w);
348                self
349            }
350        }
351    }
352}
353
354impl std::fmt::Debug for Accordion {
355    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        f.debug_struct("Accordion")
357            .field("title", &self.title)
358            .finish()
359    }
360}
361
362impl Widget for Accordion {
363    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
364        // Resolve deferred content if provided
365        if let Some(pending) = self.pending_content.take() {
366            self.content_id = Some(ctx.add_boxed(pending));
367        }
368
369        let theme = ctx.theme();
370        let accordion_corner_radius = ACCORDION_CORNER_RADIUS;
371        let focus_ring_width = theme.shape.focus_ring_width;
372        let expanded = self.expanded.clone();
373
374        // The header's focus ring (Int UI accent border) shows only on
375        // *keyboard* focus. `header_focused` is the header's own focus; ANDing it
376        // with the tree's focus-visible modality suppresses the ring for any
377        // pointer-origin focus — including focus that falls through to the header
378        // when a trailing-slot control (e.g. a toolbar button) is clicked without
379        // itself taking focus. (This replaces the old pointer-hover heuristic,
380        // which failed once a captured pointer cleared the header's hover.)
381        let header_focused = ctx.signal(false);
382        let kb_focused = header_focused.and(&ctx.focus_visible());
383
384        // Refresh this node's announced `aria-expanded` whenever the state
385        // flips — from a tap, the keyboard, or an external `expanded.set(...)`.
386        // Without binding the signal to the accordion's own node the
387        // `accessibility()` output isn't re-queried, so the announced state
388        // would go stale (same mechanism Button uses for its disclosure
389        // pattern).
390        self.expanded.bind_to(
391            ctx.self_id(),
392            ctx.binding_registry(),
393            BindingLevel::AccessibilityOnly,
394        );
395
396        // Header foreground: caller override wins, otherwise the Primary text
397        // role so the title tracks theme changes. The title style defaults to
398        // the Body role for the same reason.
399        let header_fg: ColorProp = self
400            .title_color
401            .clone()
402            .unwrap_or_else(|| TextRole::Primary.into());
403        let title_style: TextStyleProp = self
404            .title_style
405            .clone()
406            .unwrap_or_else(|| TextStyleRole::Body.into());
407
408        let horizontal = self.orientation == AccordionOrientation::Horizontal;
409
410        // Optional trailing header slot (options `⋮` button / action toolbar),
411        // inserted just before the disclosure chevron in the vertical
412        // orientation, and after the rotated title in the horizontal one (where
413        // the chevron leads the strip).
414        // Wrap it in a [`DeadZone`](crate::primitives::DeadZone) so operating the
415        // controls (even with a few px of click jitter) never starts the header
416        // drag and gap-taps don't toggle the disclosure — while a press anywhere
417        // ELSE on the header still drags. The block is structural (the node-level
418        // `gesture_dead_zone` flag stops drag-arming at the boundary), so it is
419        // robust against the capture-release path a recognizer-shadowing absorber
420        // loses to.
421        let trailing_id = self
422            .trailing_id
423            .or_else(|| self.trailing.take().map(|w| ctx.add_boxed(w)))
424            .map(|tid| ctx.add(crate::primitives::DeadZone::new().child(tid)));
425
426        // Header: a horizontal row (vertical orientation) or a narrow vertical
427        // strip with a rotated label (horizontal orientation). Two chevrons
428        // toggled by `visible_when` so the glyph updates reactively.
429        let header = if horizontal {
430            // Vertical strip: [chevron_left|right] [rotated title] [spacer].
431            // Chevron points right while collapsed (content opens to the
432            // right), left once expanded.
433            let chevron_left_id = ctx.add(IconWidget::chevron_left(16.0).color(header_fg.clone()));
434            let chevron_right_id =
435                ctx.add(IconWidget::chevron_right(16.0).color(header_fg.clone()));
436            ctx.visible_when(chevron_left_id, expanded.clone());
437            ctx.visible_when(chevron_right_id, expanded.map(|v| !*v));
438            let title_id = ctx.add(
439                RotatedLabel::new(self.title.clone(), header_fg.clone()).style(title_style.clone()),
440            );
441            let spacer_id = ctx.add(Spacer::new());
442            let mut col = VStack::new()
443                .spacing(8.0)
444                .child(chevron_left_id)
445                .child(chevron_right_id)
446                .child(title_id);
447            if let Some(t) = trailing_id {
448                col = col.child(t);
449            }
450            ctx.add(col.child(spacer_id))
451        } else {
452            let chevron_down_id = ctx.add(IconWidget::chevron_down(16.0).color(header_fg.clone()));
453            let chevron_right_id =
454                ctx.add(IconWidget::chevron_right(16.0).color(header_fg.clone()));
455            ctx.visible_when(chevron_down_id, expanded.clone());
456            ctx.visible_when(chevron_right_id, expanded.map(|v| !*v));
457
458            // Rigid: the header title never truncates. When the header is tight
459            // the trailing slot (an options `⋮` / a shrinkable toolbar) absorbs
460            // the deficit, so the disclosure's label always stays readable.
461            let title_widget = TextWidget::new(self.title.clone())
462                .color(header_fg)
463                .style(title_style.clone())
464                .single_line()
465                .no_shrink()
466                .a11y_hidden();
467            let title_id = ctx.add(title_widget);
468            let spacer_id = ctx.add(Spacer::new());
469
470            let mut row = HStack::new().spacing(8.0).child(title_id).child(spacer_id);
471            if let Some(t) = trailing_id {
472                row = row.child(t);
473            }
474            ctx.add(row.child(chevron_down_id).child(chevron_right_id))
475        };
476
477        // Int UI focus convention: an accent-colored border
478        // appears on the header row itself on keyboard focus
479        // instead of a separate ring. Header has no visible
480        // rest-state border, so this border is width-zero at
481        // rest and snaps to `focus_ring_width` on focus.
482        let focus_border_role = kb_focused.map(|f| {
483            if *f {
484                BorderRole::Focused
485            } else {
486                BorderRole::Transparent
487            }
488        });
489        let focus_border_width = kb_focused.map(move |f| if *f { focus_ring_width } else { 0.0 });
490        let focus_rect_id = ctx.add(
491            crate::primitives::RectWidget::new()
492                .border_color(focus_border_role)
493                .border_width(focus_border_width)
494                .corner_radius(teksilo_tokens::CornerRadius::uniform(
495                    accordion_corner_radius,
496                )),
497        );
498        let header_with_ring = ctx.add(
499            crate::primitives::ZStack::new()
500                .child(focus_rect_id)
501                .child(header),
502        );
503
504        if self.fill {
505            // Fill mode: the header + a `FillBody` are laid out by the accordion
506            // itself (custom layout below) so the content **fills** the leftover
507            // the enclosing Splitter pane gives it. The collapse *animation* is
508            // the Splitter pane resizing (driven externally by the `expanded`
509            // signal) — not this widget. The header carries a fixed minimum
510            // extent so a fully-collapsed pane is exactly the header.
511            let extent = accordion_fill_header_extent(&ctx.theme().input);
512            let header = if horizontal {
513                ctx.add(MinSize::new(extent, 0.0).child(header_with_ring))
514            } else {
515                ctx.add(MinSize::new(0.0, extent).child(header_with_ring))
516            };
517            self.fill_header_id = Some(header);
518            if let Some(content_id) = self.content_id {
519                let region_id = ctx.add(AccordionRegion::new(self.title.clone(), content_id));
520                self.region_id = Some(region_id);
521                let body = ctx.add(FillBody::new(region_id));
522                self.fill_body_id = Some(body);
523            }
524        } else {
525            // Default: the classic VStack/HStack root.
526            // - horizontal → dormancy (Collapse only animates height).
527            // - vertical → the animated `Collapse` disclosure.
528            let content_wrapper = self.content_id.map(|content_id| {
529                let region_id = ctx.add(AccordionRegion::new(self.title.clone(), content_id));
530                self.region_id = Some(region_id);
531                if horizontal {
532                    ctx.visible_when(region_id, self.expanded.clone());
533                    region_id
534                } else {
535                    ctx.add(Collapse::new(self.expanded.clone()).child(region_id))
536                }
537            });
538            let root = if horizontal {
539                let mut hstack = HStack::new().spacing(2.0).child(header_with_ring);
540                if let Some(w) = content_wrapper {
541                    hstack = hstack.child(w);
542                }
543                ctx.add(hstack)
544            } else {
545                let mut vstack = VStack::new().spacing(2.0).child(header_with_ring);
546                if let Some(w) = content_wrapper {
547                    vstack = vstack.child(w);
548                }
549                ctx.add(vstack)
550            };
551            self.root_child_id = Some(root);
552        }
553
554        // --- V2 attached handlers ---
555        // Handlers just flip `expanded`; the inner `Collapse` widget
556        // observes the signal and drives the height/width tween.
557        let expanded_tap = self.expanded.clone();
558        let expanded_key = self.expanded.clone();
559        let expanded_access = self.expanded.clone();
560        let header_focused_focus = header_focused.clone();
561
562        let mut handler_set = HandlerSet::new()
563            .on_tap({
564                move |_pos, _ctx: &mut EventContext| {
565                    expanded_tap.set(!expanded_tap.get());
566                }
567            })
568            .on_access_action({
569                // An AT "press" / default-action toggles the disclosure, the
570                // same as a pointer tap or Space/Enter. Without this an
571                // assistive technology can navigate to the header (it
572                // advertises `Action::Click`) but cannot operate it.
573                move |action: teksilo_core::accesskit::Action,
574                      _ctx: &mut EventContext|
575                      -> EventResponse {
576                    if action == teksilo_core::accesskit::Action::Click {
577                        expanded_access.set(!expanded_access.get());
578                        EventResponse::Handled
579                    } else {
580                        EventResponse::Ignored
581                    }
582                }
583            })
584            .on_key({
585                move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
586                    match event {
587                        WidgetEvent::KeyDown {
588                            key: Key::Space | Key::Enter,
589                            ..
590                        } => EventResponse::Handled,
591                        WidgetEvent::KeyUp {
592                            key: Key::Space | Key::Enter,
593                            ..
594                        } => {
595                            expanded_key.set(!expanded_key.get());
596                            EventResponse::Handled
597                        }
598                        _ => EventResponse::Ignored,
599                    }
600                }
601            })
602            .on_focus({
603                move |gained: bool, _ctx: &mut EventContext| {
604                    // The `kb_focused` ring signal ANDs this with focus-visible,
605                    // so a pointer-origin focus never lights the ring.
606                    header_focused_focus.set(gained);
607                }
608            })
609            .focusable(true)
610            .cursor(CursorIcon::Pointer);
611
612        // Optional drag source on the header (e.g. a dock panel's drag handle).
613        // The whole header drags — EXCEPT the trailing slot, which is wrapped in
614        // a `DeadZone` so its action buttons / `⋮` menu can be clicked (even with
615        // click jitter) without starting the panel drag.
616        if let Some(drag) = self.on_header_drag.clone() {
617            handler_set = handler_set.on_drag(move |phase, ctx| {
618                if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
619                    (drag)(ctx);
620                }
621            });
622        }
623
624        ctx.apply_self_handlers(handler_set);
625
626        self.child_ids()
627    }
628
629    fn layout_response(
630        &self,
631        proposal: SizeProposal,
632        ctx: &LayoutContext,
633    ) -> teksilo_core::widget::LayoutResponse {
634        if self.fill {
635            // Fill the allotted slot (the dock pane forces our bounds anyway).
636            return proposal
637                .resolve(
638                    proposal.width.unwrap_or(0.0),
639                    proposal.height.unwrap_or(0.0),
640                )
641                .into();
642        }
643        if let Some(root) = self.root_child_id
644            && let Some(size) = ctx.child_size(root, proposal)
645        {
646            return (size).into();
647        }
648        proposal.resolve(0.0, 0.0).into()
649    }
650
651    fn place_children(
652        &self,
653        bounds: Rect,
654        _proposal: SizeProposal,
655        children: &mut [WidgetPlacement],
656        ctx: &LayoutContext,
657    ) {
658        if self.fill {
659            self.place_fill(bounds, children, ctx);
660            return;
661        }
662        for child in children.iter_mut() {
663            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
664            child.size = Size::new(bounds.width, bounds.height);
665        }
666    }
667
668    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
669        builder.set_role(teksilo_core::accesskit::Role::Button);
670        // Resolve at walk time — the AT tree re-walks on locale change.
671        builder.set_name(self.title.resolve_now());
672        builder.set_expanded(self.expanded.get());
673        builder.add_action(teksilo_core::accesskit::Action::Click);
674        builder.add_action(teksilo_core::accesskit::Action::Focus);
675        if let Some(region_id) = self.region_id {
676            builder.push_controlled(teksilo_core::accessibility::widget_id_to_node_id(region_id));
677        }
678    }
679
680    fn children(&self) -> Vec<WidgetId> {
681        self.child_ids()
682    }
683
684    fn clips_children(&self) -> bool {
685        // Fill mode clips so a collapsing body never bleeds past the pane.
686        self.fill
687    }
688}
689
690impl Accordion {
691    /// The accordion's children — `[header, body]` in fill mode (laid out by
692    /// `place_fill`), else the single VStack/HStack root.
693    fn child_ids(&self) -> Vec<WidgetId> {
694        if self.fill {
695            let mut ids = Vec::with_capacity(2);
696            ids.extend(self.fill_header_id);
697            ids.extend(self.fill_body_id);
698            ids
699        } else {
700            self.root_child_id.into_iter().collect()
701        }
702    }
703
704    /// Custom fill-mode layout: the header sits at the top (vertical) or the
705    /// leading edge (horizontal); the `FillBody` takes the leftover the
706    /// enclosing Splitter pane gives this accordion and clips to it. There is no
707    /// internal tween — the Splitter pane folding to the header *is* the
708    /// collapse animation.
709    fn place_fill(&self, bounds: Rect, children: &mut [WidgetPlacement], ctx: &LayoutContext) {
710        let gap = accordion_fill_gap(&ctx.theme.input);
711        let Some(header_id) = self.fill_header_id else {
712            return;
713        };
714        let horizontal = self.orientation == AccordionOrientation::Horizontal;
715
716        // Header extent along the main axis (height for vertical, width for
717        // horizontal); it fills the cross axis.
718        let header_size = ctx
719            .child_size(
720                header_id,
721                if horizontal {
722                    SizeProposal {
723                        width: None,
724                        height: Some(bounds.height),
725                    }
726                } else {
727                    SizeProposal {
728                        width: Some(bounds.width),
729                        height: None,
730                    }
731                },
732            )
733            .unwrap_or(Size::ZERO);
734
735        // children order matches `child_ids()`: [header, body?].
736        let header_rect = if horizontal {
737            Rect::new(bounds.x, bounds.y, header_size.width, bounds.height)
738        } else {
739            Rect::new(bounds.x, bounds.y, bounds.width, header_size.height)
740        };
741        if let Some(c) = children.first_mut() {
742            c.origin = header_rect.origin();
743            c.size = header_rect.size();
744        }
745
746        let Some(body_id) = self.fill_body_id else {
747            return;
748        };
749        // Leftover for the body, and the proposal that makes the `FillBody`
750        // fill (and clip to) that leftover.
751        let (body_origin, body_proposal) = if horizontal {
752            let leftover = (bounds.width - header_size.width - gap).max(0.0);
753            (
754                teksilo_canvas::Point::new(header_rect.right() + gap, bounds.y),
755                SizeProposal {
756                    width: Some(leftover),
757                    height: Some(bounds.height),
758                },
759            )
760        } else {
761            let leftover = (bounds.height - header_size.height - gap).max(0.0);
762            (
763                teksilo_canvas::Point::new(bounds.x, header_rect.bottom() + gap),
764                SizeProposal {
765                    width: Some(bounds.width),
766                    height: Some(leftover),
767                },
768            )
769        };
770        let body_size = ctx.child_size(body_id, body_proposal).unwrap_or(Size::ZERO);
771        if let Some(c) = children.get_mut(1) {
772            c.origin = body_origin;
773            c.size = body_size;
774        }
775    }
776}
777
778// ---------------------------------------------------------------------------
779// FillBody — the fill-mode content body of a dock-panel Accordion: it fills
780// whatever leftover the accordion gives it (which the enclosing Splitter pane
781// animates as the panel collapses / expands) and clips any overflow. Absorbs
782// taps/drags so only the accordion header toggles / drags / moves the panel.
783// The collapse *animation* is the Splitter pane resizing, not this widget.
784// ---------------------------------------------------------------------------
785
786struct FillBody {
787    content_id: WidgetId,
788}
789
790impl FillBody {
791    fn new(content_id: WidgetId) -> Self {
792        Self { content_id }
793    }
794}
795
796impl std::fmt::Debug for FillBody {
797    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
798        f.debug_struct("FillBody").finish()
799    }
800}
801
802impl Widget for FillBody {
803    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
804        // Absorb taps + drags the content's own children didn't handle, so a
805        // tap/drag on empty panel body never reaches the accordion header.
806        ctx.apply_self_handlers(
807            HandlerSet::new()
808                .on_tap(|_e, _ctx| {})
809                .on_drag(|_phase, _ctx| {}),
810        );
811        vec![self.content_id]
812    }
813
814    fn layout_response(
815        &self,
816        proposal: SizeProposal,
817        _ctx: &LayoutContext,
818    ) -> teksilo_core::widget::LayoutResponse {
819        // Fill the leftover the accordion proposes (bounded on both axes).
820        proposal
821            .resolve(
822                proposal.width.unwrap_or(0.0),
823                proposal.height.unwrap_or(0.0),
824            )
825            .into()
826    }
827
828    fn place_children(
829        &self,
830        bounds: Rect,
831        _proposal: SizeProposal,
832        children: &mut [WidgetPlacement],
833        _ctx: &LayoutContext,
834    ) {
835        for child in children.iter_mut() {
836            child.origin = bounds.origin();
837            child.size = bounds.size();
838        }
839    }
840
841    fn clips_children(&self) -> bool {
842        true
843    }
844
845    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {}
846
847    fn children(&self) -> Vec<WidgetId> {
848        vec![self.content_id]
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855    use teksilo_core::WidgetBuilder;
856    use teksilo_core::widget_tree::WidgetTree;
857    use teksilo_i18n::lit;
858
859    #[test]
860    fn rich_tooltip_more_label_is_a_translatable_framework_string() {
861        // Regression: the rich-tooltip "more" disclosure label used to be
862        // a hardcoded `lit!("More")` frozen by `Accordion`'s eager
863        // resolve. It must now resolve through the teksilo-widgets
864        // framework bundle (`tooltip-more`) and follow the active locale.
865        use teksilo_i18n::{I18nConfig, I18nManager};
866        let cfg = I18nConfig::new()
867            .supported_locales(["en-US".parse().unwrap(), "fr-FR".parse().unwrap()])
868            .auto_detect_os_locale(false)
869            .framework_locales(crate::framework_locales());
870        let mgr = I18nManager::from_config(&cfg);
871        assert_eq!(mgr.resolve_widget("tooltip-more", &[]), "More");
872        mgr.set_locale("fr-FR".parse().unwrap());
873        assert_eq!(
874            mgr.resolve_widget("tooltip-more", &[]),
875            "Plus",
876            "tooltip-more must translate to French via the framework bundle"
877        );
878    }
879
880    #[test]
881    fn accordion_builds_collapsed() {
882        let expanded = Signal::new(false);
883        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
884        let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
885        tree.layout(SizeProposal::exact(300.0, 200.0));
886        let b = tree.bounds(acc);
887        assert!(b.width > 0.0);
888    }
889
890    #[test]
891    fn click_toggles_expanded_state() {
892        let expanded = Signal::new(false);
893        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
894        let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
895        tree.layout(SizeProposal::exact(300.0, 200.0));
896
897        tree.click(acc);
898        assert!(expanded.get());
899        tree.click(acc);
900        assert!(!expanded.get());
901    }
902
903    #[test]
904    fn accordion_with_content() {
905        use crate::primitives::TextWidget;
906        let expanded = Signal::new(true);
907        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
908        let content = tree.add(TextWidget::new(lit!("Content text")));
909        let acc = tree.add(Accordion::new(lit!("Details"), expanded.clone()).content(content));
910        tree.layout(SizeProposal::exact(300.0, 200.0));
911        let b = tree.bounds(acc);
912        assert!(b.height > 0.0);
913    }
914
915    #[test]
916    fn accessibility() {
917        let expanded = Signal::new(true);
918        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
919        let acc = tree.add(Accordion::new(lit!("Details"), expanded));
920        tree.layout(SizeProposal::exact(300.0, 200.0));
921        let info = tree.accessibility_node(acc);
922        assert_eq!(info.name(), Some("Details"));
923        assert!(info.is_expanded());
924    }
925
926    #[test]
927    fn access_action_click_toggles_expanded() {
928        // A screen-reader "press" / default action must operate the
929        // disclosure, not just a pointer tap. The accordion advertises
930        // `Action::Click`; dispatching it has to flip `expanded`.
931        let expanded = Signal::new(false);
932        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
933        let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
934        tree.layout(SizeProposal::exact(300.0, 200.0));
935
936        tree.dispatch_event(WidgetEvent::AccessAction {
937            action: teksilo_core::accesskit::Action::Click,
938            target: Some(acc),
939            target_node: teksilo_core::accessibility::root_node_id(),
940            data: None,
941        });
942        assert!(expanded.get(), "AT click expands the accordion");
943
944        tree.dispatch_event(WidgetEvent::AccessAction {
945            action: teksilo_core::accesskit::Action::Click,
946            target: Some(acc),
947            target_node: teksilo_core::accessibility::root_node_id(),
948            data: None,
949        });
950        assert!(!expanded.get(), "a second AT click collapses it");
951    }
952
953    #[test]
954    fn announced_expanded_state_refreshes_on_external_toggle() {
955        // Binding `expanded` to the accordion's own node keeps the announced
956        // `aria-expanded` fresh when the state changes from outside the
957        // widget — without re-querying accessibility() the AT state goes stale.
958        let expanded = Signal::new(false);
959        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
960        let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
961        tree.layout(SizeProposal::exact(300.0, 200.0));
962        // Realize the AT tree once so the binding is in place.
963        let _ = tree.sync_accessibility();
964        assert!(!tree.accessibility_node(acc).is_expanded());
965
966        expanded.set(true);
967        let _ = tree.sync_accessibility();
968        assert!(
969            tree.accessibility_node(acc).is_expanded(),
970            "announced expanded state must follow an external set"
971        );
972    }
973
974    #[test]
975    fn external_signal_set_triggers_animation() {
976        // Simulates an external mutation: app code sets `expanded` to
977        // true without going through the accordion's tap handler. The
978        // `Collapse` observer should still kick off the height tween.
979        use crate::primitives::TextWidget;
980        use std::time::Duration;
981
982        let expanded = Signal::new(false);
983        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
984        let content = tree.add(TextWidget::new(lit!("Some content")));
985        let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()).content(content));
986        tree.layout(SizeProposal {
987            width: Some(300.0),
988            height: None,
989        });
990        let collapsed = tree.bounds(acc).height;
991
992        expanded.set(true);
993        tree.tick_animations(Duration::from_millis(250));
994        tree.layout(SizeProposal {
995            width: Some(300.0),
996            height: None,
997        });
998        let after = tree.bounds(acc).height;
999
1000        assert!(
1001            after > collapsed,
1002            "external set must drive expansion: {} > {}",
1003            after,
1004            collapsed
1005        );
1006    }
1007
1008    #[test]
1009    fn double_toggle_round_trips_height() {
1010        use crate::primitives::TextWidget;
1011        use std::time::Duration;
1012
1013        let expanded = Signal::new(false);
1014        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1015        let content = tree.add(TextWidget::new(lit!("Some content")));
1016        let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()).content(content));
1017        tree.layout(SizeProposal {
1018            width: Some(300.0),
1019            height: None,
1020        });
1021        let collapsed_initial = tree.bounds(acc).height;
1022
1023        // Expand then collapse.
1024        tree.click(acc);
1025        tree.tick_animations(Duration::from_millis(250));
1026        tree.layout(SizeProposal {
1027            width: Some(300.0),
1028            height: None,
1029        });
1030        let expanded_h = tree.bounds(acc).height;
1031
1032        tree.click(acc);
1033        tree.tick_animations(Duration::from_millis(250));
1034        tree.layout(SizeProposal {
1035            width: Some(300.0),
1036            height: None,
1037        });
1038        let collapsed_again = tree.bounds(acc).height;
1039
1040        assert!(expanded_h > collapsed_initial);
1041        assert!(
1042            (collapsed_again - collapsed_initial).abs() < 1.0,
1043            "after collapse round-trip, height should match initial: {} vs {}",
1044            collapsed_again,
1045            collapsed_initial
1046        );
1047    }
1048
1049    #[test]
1050    fn content_dormant_when_collapsed() {
1051        use crate::primitives::TextWidget;
1052        use std::time::Duration;
1053
1054        let expanded = Signal::new(false);
1055        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1056        let content = tree.add(TextWidget::new(lit!("Some content text here")));
1057        let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()).content(content));
1058        tree.layout(SizeProposal {
1059            width: Some(300.0),
1060            height: None,
1061        });
1062        let collapsed_height = tree.bounds(acc).height;
1063
1064        // Click to expand
1065        tree.click(acc);
1066        assert!(expanded.get(), "should be expanded after click");
1067
1068        // Tick animation to completion (accordion uses 200ms animation)
1069        tree.tick_animations(Duration::from_millis(250));
1070        tree.layout(SizeProposal {
1071            width: Some(300.0),
1072            height: None,
1073        });
1074        let expanded_height = tree.bounds(acc).height;
1075
1076        assert!(
1077            expanded_height > collapsed_height,
1078            "expanded height ({}) should be greater than collapsed height ({})",
1079            expanded_height,
1080            collapsed_height
1081        );
1082    }
1083
1084    // ─── fill / drag / orientation (dock panel features) ────────────────
1085
1086    #[test]
1087    fn fill_accordion_header_toggles_but_content_tap_does_not() {
1088        use crate::primitives::TextWidget;
1089        use teksilo_core::event::{Modifiers, PointerButton};
1090
1091        fn tap_at(tree: &mut WidgetTree, p: teksilo_canvas::Point) {
1092            tree.dispatch_event(WidgetEvent::pointer_down(
1093                p,
1094                PointerButton::Primary,
1095                Modifiers::NONE,
1096            ));
1097            tree.dispatch_event(WidgetEvent::pointer_up(
1098                p,
1099                PointerButton::Primary,
1100                Modifiers::NONE,
1101            ));
1102        }
1103
1104        let expanded = Signal::new(true);
1105        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1106        let content = tree.add(TextWidget::new(lit!("dock body")));
1107        let acc = tree.add(
1108            Accordion::new(lit!("Panel"), expanded.clone())
1109                .fill(true)
1110                .content(content),
1111        );
1112        tree.layout(SizeProposal::exact(220.0, 300.0));
1113
1114        // A tap on the header (top of the accordion) toggles.
1115        let b = tree.bounds(acc);
1116        tap_at(&mut tree, teksilo_canvas::Point::new(b.x + 20.0, b.y + 6.0));
1117        assert!(!expanded.get(), "header tap collapses");
1118        tap_at(&mut tree, teksilo_canvas::Point::new(b.x + 20.0, b.y + 6.0));
1119        assert!(expanded.get(), "header tap re-expands");
1120
1121        // A tap deep in the content area is absorbed — it must NOT toggle.
1122        tap_at(
1123            &mut tree,
1124            teksilo_canvas::Point::new(b.x + 110.0, b.y + 200.0),
1125        );
1126        assert!(expanded.get(), "content tap does not collapse the panel");
1127    }
1128
1129    #[test]
1130    fn fill_accordion_header_drag_fires_hook() {
1131        use crate::primitives::TextWidget;
1132        use std::cell::Cell as StdCell;
1133        use std::rc::Rc;
1134        let dragged = Rc::new(StdCell::new(false));
1135        let sink = dragged.clone();
1136        let expanded = Signal::new(true);
1137        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1138        let content = tree.add(TextWidget::new(lit!("dock body")));
1139        let acc = tree.add(
1140            Accordion::new(lit!("Panel"), expanded)
1141                .fill(true)
1142                .on_header_drag(move |_ctx| sink.set(true))
1143                .content(content),
1144        );
1145        tree.layout(SizeProposal::exact(220.0, 300.0));
1146        let b = tree.bounds(acc);
1147        let from = teksilo_canvas::Point::new(b.x + 20.0, b.y + 6.0);
1148        tree.drag(
1149            from,
1150            teksilo_canvas::Point::new(from.x + 130.0, from.y + 30.0),
1151        );
1152        assert!(dragged.get(), "dragging the header fires on_header_drag");
1153    }
1154
1155    #[test]
1156    fn fill_accordion_body_fills_the_leftover() {
1157        use crate::primitives::TextWidget;
1158        let expanded = Signal::new(true);
1159        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1160        let content = tree.add(TextWidget::new(lit!("dock body")));
1161        let acc = tree.add(
1162            Accordion::new(lit!("Panel"), expanded)
1163                .fill(true)
1164                .content(content),
1165        );
1166        tree.layout(SizeProposal::exact(220.0, 300.0));
1167        // children = [header, body]; the body fills the leftover after the
1168        // header, so header + body ≈ the pane. (The collapse animation is the
1169        // enclosing Splitter pane resizing — verified in the splitter tests.)
1170        let header_h = tree.bounds(tree.children(acc)[0]).height;
1171        let body_h = tree.bounds(tree.children(acc)[1]).height;
1172        assert!(
1173            (header_h + body_h - 300.0).abs() < 6.0,
1174            "header({header_h}) + body({body_h}) should fill the 300px pane"
1175        );
1176        assert!(body_h > 200.0, "body fills most of the pane, got {body_h}");
1177    }
1178
1179    #[test]
1180    fn fill_accordion_body_stays_within_the_pane() {
1181        use crate::primitives::{FixedSize, TextWidget};
1182        let expanded = Signal::new(true);
1183        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1184        // Content far taller than the pane.
1185        let content = tree.add(
1186            FixedSize::new()
1187                .width(80.0_f32)
1188                .height(900.0_f32)
1189                .child(TextWidget::new(lit!("x"))),
1190        );
1191        let acc = tree.add(
1192            Accordion::new(lit!("Panel"), expanded)
1193                .fill(true)
1194                .content(content),
1195        );
1196        tree.layout(SizeProposal::exact(220.0, 300.0));
1197        // The collapse body never extends past the pane bottom (the oversized
1198        // content is clipped, not spilled).
1199        let body = tree.children(acc)[1];
1200        assert!(
1201            tree.bounds(body).bottom() <= 300.5,
1202            "body bottom {} must stay within the 300px pane",
1203            tree.bounds(body).bottom()
1204        );
1205    }
1206
1207    #[test]
1208    fn trailing_slot_renders_and_captures_its_own_tap() {
1209        // The header trailing slot (e.g. an options `⋮` button) must be laid out
1210        // in the header AND, when it carries its own tap handler, consume the
1211        // tap so the accordion does not toggle (the gesture arena gives the
1212        // innermost hit precedence — the same property ToolBox slots rely on).
1213        use crate::primitives::{FixedSize, RectWidget};
1214        use std::cell::Cell as StdCell;
1215        use std::rc::Rc;
1216        use teksilo_core::event::{Modifiers, PointerButton};
1217
1218        let expanded = Signal::new(true);
1219        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1220        let tapped = Rc::new(StdCell::new(false));
1221        let sink = tapped.clone();
1222        let trailing = tree.add(
1223            FixedSize::new()
1224                .width(24.0_f32)
1225                .height(24.0_f32)
1226                .child(RectWidget::new())
1227                .on_tap(move |_e, _ctx| sink.set(true)),
1228        );
1229        let content = tree.add(TextWidget::new(lit!("body")));
1230        let acc = tree.add(
1231            Accordion::new(lit!("Panel"), expanded.clone())
1232                .fill(true)
1233                .trailing(trailing)
1234                .content(content),
1235        );
1236        tree.layout(SizeProposal::exact(260.0, 140.0));
1237
1238        // The trailing widget is laid out (non-zero bounds) inside the header.
1239        let tb = tree.bounds(trailing);
1240        assert!(tb.width > 0.0 && tb.height > 0.0, "trailing slot is placed");
1241        let _ = acc;
1242
1243        // Tapping the trailing widget fires its handler and does NOT toggle.
1244        let p = teksilo_canvas::Point::new(tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1245        tree.dispatch_event(WidgetEvent::pointer_down(
1246            p,
1247            PointerButton::Primary,
1248            Modifiers::NONE,
1249        ));
1250        tree.dispatch_event(WidgetEvent::pointer_up(
1251            p,
1252            PointerButton::Primary,
1253            Modifiers::NONE,
1254        ));
1255        assert!(tapped.get(), "trailing widget received the tap");
1256        assert!(
1257            expanded.get(),
1258            "tapping the trailing widget must not toggle the accordion"
1259        );
1260    }
1261
1262    #[test]
1263    fn dragging_the_trailing_slot_does_not_start_the_header_drag() {
1264        // Regression: a draggable header (`on_header_drag`) must NOT be dragged
1265        // by interacting with its trailing controls — clicking/dragging an
1266        // options button there used to arm the header-drag recognizer and a few
1267        // px of jitter started dragging the whole dock. The trailing slot's
1268        // `DeadZone` stops the header-drag arming structurally.
1269        use crate::primitives::{FixedSize, RectWidget};
1270        use std::cell::Cell as StdCell;
1271        use std::rc::Rc;
1272
1273        let expanded = Signal::new(true);
1274        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1275        let header_dragged = Rc::new(StdCell::new(false));
1276        let hd = header_dragged.clone();
1277        let trailing = tree.add(
1278            FixedSize::new()
1279                .width(24.0_f32)
1280                .height(24.0_f32)
1281                .child(RectWidget::new()),
1282        );
1283        let content = tree.add(TextWidget::new(lit!("body")));
1284        let acc = tree.add(
1285            Accordion::new(lit!("Panel"), expanded.clone())
1286                .fill(true)
1287                .trailing(trailing)
1288                .on_header_drag(move |_ctx| hd.set(true))
1289                .content(content),
1290        );
1291        tree.layout(SizeProposal::exact(260.0, 140.0));
1292
1293        // Dragging from the trailing control must NOT start the header drag.
1294        let tb = tree.bounds(trailing);
1295        let from = teksilo_canvas::Point::new(tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1296        tree.drag(
1297            from,
1298            teksilo_canvas::Point::new(from.x + 90.0, from.y + 12.0),
1299        );
1300        assert!(
1301            !header_dragged.get(),
1302            "dragging the trailing control must not start the header drag"
1303        );
1304
1305        // Sanity: dragging the header title area still starts the drag.
1306        let ab = tree.bounds(acc);
1307        tree.drag(
1308            teksilo_canvas::Point::new(ab.x + 10.0, ab.y + 6.0),
1309            teksilo_canvas::Point::new(ab.x + 120.0, ab.y + 30.0),
1310        );
1311        assert!(
1312            header_dragged.get(),
1313            "dragging the header title still starts the drag"
1314        );
1315    }
1316
1317    #[test]
1318    fn incremental_move_on_trailing_button_does_not_drag_header() {
1319        // The real-mouse case the user hit: pressing a header action button and
1320        // moving the pointer a few px (a normal click jitter) must NOT start the
1321        // header drag — even though the button's tap is cancelled by the move
1322        // and its capture is released mid-gesture.
1323        use crate::primitives::{FixedSize, RectWidget};
1324        use std::cell::Cell as StdCell;
1325        use std::rc::Rc;
1326        use teksilo_core::event::PointerButton;
1327
1328        let expanded = Signal::new(true);
1329        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1330        let header_dragged = Rc::new(StdCell::new(false));
1331        let hd = header_dragged.clone();
1332        // A real interactive trailing control (captures the tap, like a button).
1333        let trailing = tree.add(
1334            FixedSize::new()
1335                .width(24.0_f32)
1336                .height(24.0_f32)
1337                .child(RectWidget::new())
1338                .on_tap(|_e, _ctx| {}),
1339        );
1340        let content = tree.add(TextWidget::new(lit!("body")));
1341        let _acc = tree.add(
1342            Accordion::new(lit!("Panel"), expanded.clone())
1343                .fill(true)
1344                .trailing(trailing)
1345                .on_header_drag(move |_ctx| hd.set(true))
1346                .content(content),
1347        );
1348        tree.layout(SizeProposal::exact(260.0, 140.0));
1349
1350        let tb = tree.bounds(trailing);
1351        let (cx, cy) = (tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1352        tree.pointer_down_button(teksilo_canvas::Point::new(cx, cy), PointerButton::Primary);
1353        // Incremental small moves (a real mouse stream), accumulating well past
1354        // the drag threshold.
1355        for i in 1..=10 {
1356            tree.pointer_move(teksilo_canvas::Point::new(cx + (i as f32) * 3.0, cy + 1.0));
1357        }
1358        tree.pointer_up_button(
1359            teksilo_canvas::Point::new(cx + 30.0, cy + 1.0),
1360            PointerButton::Primary,
1361        );
1362        assert!(
1363            !header_dragged.get(),
1364            "a jittery click on the trailing control must not start the header drag"
1365        );
1366    }
1367
1368    #[test]
1369    fn horizontal_fill_accordion_builds() {
1370        use crate::primitives::TextWidget;
1371        let expanded = Signal::new(true);
1372        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1373        let content = tree.add(TextWidget::new(lit!("c")));
1374        let acc = tree.add(
1375            Accordion::new(lit!("Panel"), expanded)
1376                .horizontal()
1377                .fill(true)
1378                .content(content),
1379        );
1380        tree.layout(SizeProposal::exact(320.0, 120.0));
1381        let b = tree.bounds(acc);
1382        assert!(
1383            b.width > 0.0 && b.height > 0.0,
1384            "horizontal accordion builds"
1385        );
1386    }
1387
1388    /// An accordion opened in a window without focus opens.
1389    ///
1390    /// The dialog is the shape that showed it: an in-tree modal holding a
1391    /// scrolling column, with the accordion's body somewhere in the column,
1392    /// driven the way a window drives it (laid out and painted, then a frame
1393    /// at a time). Nothing about that shape matters, though: what kept the
1394    /// body at no height was the window not having focus. The scheduler
1395    /// paused every animation of an inactive window, the body's height tween
1396    /// among them, so a disclosure opened by an assistive technology, an
1397    /// automation client or the app itself in a window behind another
1398    /// announced itself expanded and showed nothing until the window was
1399    /// focused again. A headless tree is active unless told otherwise, which
1400    /// is why every other test here passed.
1401    #[test]
1402    fn an_accordion_opened_in_an_unfocused_window_opens() {
1403        use crate::primitives::{Expand, FixedSize, Padding, TextWidget, VStack};
1404        use crate::scroll_area::ScrollArea;
1405        use std::time::Duration;
1406        use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
1407
1408        let expanded = Signal::new(false);
1409        let mut tree = WidgetTree::new()
1410            .with_theme(teksilo_core::presets::intui::light())
1411            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1412                teksilo_canvas::MockTextBackend::new(),
1413            )));
1414        let window = SizeProposal::exact(800.0, 600.0);
1415        let source = tree.add(TextWidget::new(lit!("Show the report")));
1416        tree.layout(window);
1417        let _ = tree.render();
1418
1419        let items = VStack::new()
1420            .spacing(6.0)
1421            .child(TextWidget::new(lit!("Draft / Arrival")))
1422            .child(TextWidget::new(lit!("Draft / Storm")));
1423        let column = VStack::new()
1424            .spacing(10.0)
1425            .child(TextWidget::new(lit!("What came across")))
1426            .child(Accordion::new(lit!("2 items"), expanded.clone()).content(items))
1427            .child(TextWidget::new(lit!("Next finding")));
1428        // The way `present_modal` mounts in-tree content: built, parked,
1429        // then woken as the overlay that shows it.
1430        let dialog =
1431            tree.add(FixedSize::new().width(400.0).height(300.0).child(
1432                VStack::new().child(Expand::vertical().child(
1433                    ScrollArea::new().child(Padding::new(8.0, 8.0, 8.0, 8.0).child(column)),
1434                )),
1435            ));
1436        tree.set_dormant(dialog);
1437        tree.activate(dialog);
1438        tree.show_overlay(OverlayRequest::new(
1439            dialog,
1440            source,
1441            OverlayPlacement::Centered,
1442            DismissBehavior::Manual,
1443        ));
1444        tree.layout(window);
1445        let _ = tree.render();
1446
1447        let header = tree
1448            .find_by_label("2 items")
1449            .expect("the accordion's header");
1450        let next = tree
1451            .find_by_label("Next finding")
1452            .expect("the line below it");
1453        let before = tree.bounds(next).y;
1454
1455        // Another window takes the focus.
1456        tree.set_window_active(false);
1457        tree.click(header);
1458        assert!(expanded.get(), "the click opens the accordion");
1459        for _ in 0..30 {
1460            tree.advance_time(Duration::from_millis(16));
1461            tree.layout(window);
1462            let _ = tree.render();
1463        }
1464
1465        let after = tree.bounds(next).y;
1466        assert!(
1467            after > before + 20.0,
1468            "the accordion's body must take room in a window without focus: \
1469             the line below it went from {before} to {after}"
1470        );
1471        let item = tree
1472            .find_by_label("Draft / Storm")
1473            .expect("the body's last item");
1474        assert!(
1475            tree.bounds(item).y < after,
1476            "the body's items sit above the line that follows them"
1477        );
1478    }
1479}