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