Skip to main content

teksilo_widgets/menu_bar/
widget_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The [`Widget`] trait implementations for [`MenuBar`] and the two private
5//! wrappers it builds — `MenuOverlayHost` (the open dropdown's frame, which
6//! resets the bar's open index on dismissal and routes ArrowLeft / ArrowRight
7//! between sibling menus) and `RevealHeightBox` (which matches the floating
8//! collapsed bar's height to its hamburger).
9
10use super::*;
11
12// ---------------------------------------------------------------------------
13// MenuBar Widget impl
14// ---------------------------------------------------------------------------
15
16impl Widget for MenuBar {
17    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
18        // Mirror the model into the native OS menu bar (macOS) when requested.
19        // The bridge is a no-op without a `NativeMenuHandle` in app-state.
20        if self.native_mode.installs_native()
21            && cfg!(target_os = "macos")
22            && let Some(model) = &self.model
23        {
24            *self.native_binding.borrow_mut() = crate::menu::native::install(model, ctx);
25        }
26
27        // A runtime structural change (`MenuModel::push_item`/`remove`/…) bumps
28        // the model version; rebuild so the in-window dropdowns AND the native
29        // menu re-derive from the new structure.
30        if let Some(model) = &self.model {
31            model.version().bind_to(
32                ctx.self_id(),
33                ctx.binding_registry(),
34                teksilo_core::BindingLevel::Rebuild,
35            );
36        }
37
38        // On macOS with `Suppress`, the global menu bar IS the menu — render
39        // only the optional leading/trailing slots in-window (no triggers, no
40        // F10/Alt dispatcher).
41        if self.native_mode.suppresses_in_window() {
42            return self.build_suppressed(ctx);
43        }
44
45        let theme_signal = ctx.theme_signal();
46
47        let open_index: Signal<Option<usize>> = ctx.signal(None);
48        let menu_ctx = MenuContext::new(open_index);
49
50        // Build the full row: [leading_slot | triggers... | Spacer | trailing_slot]
51        let mut row = HStack::new().spacing(2.0);
52
53        // Leading slot (memoized — the same widgets survive each rebuild)
54        row = Self::add_slot(ctx, row, &mut self.leading_slot, &mut self.leading_slot_ids);
55
56        // Menu triggers + content
57        let mut trigger_ids = Vec::new();
58        let mut content_ids = Vec::new();
59        // Mnemonic table built alongside triggers: `lowercase char →
60        // trigger array index`. Drives the window-level dispatcher
61        // for Alt+letter activation.
62        let mut mnemonic_table: HashMap<char, usize> = HashMap::new();
63
64        // Both bar flavours re-derive their entries every build and re-run
65        // the (Fn) factories, so neither consumes the state it needs to
66        // rebuild: model-built bars re-derive from the (possibly mutated)
67        // model, classic `.menu()` bars iterate their retained entries by
68        // reference. Consuming `self.entries` here (the old `mem::take`)
69        // left the bar empty on the next theme / locale rebuild.
70        let model_entries = self.model.as_ref().map(Self::model_entries);
71        let entries: &[MenuBarEntry] = match &model_entries {
72            Some(derived) => derived,
73            None => &self.entries,
74        };
75        for (i, entry) in entries.iter().enumerate() {
76            let parsed: ParsedMnemonic = parse_mnemonic(&entry.label.resolve_now());
77
78            // Wrap factory output in MenuOverlayHost for focus/key handling
79            let host = MenuOverlayHost {
80                inner: Some((entry.factory)()),
81                menu_ctx: menu_ctx.clone(),
82                menu_index: i,
83                inner_id: None,
84            };
85            // Detached: a menu's content is shown through an overlay, never
86            // inline under the bar. Owned all the same, so a rebuilt menubar
87            // reaps the menus it replaced instead of stranding one host — and
88            // its whole `MenuList` — per rebuild.
89            // Built the first time *this* menu is opened. A menu bar used to
90            // build every menu's whole `MenuList` — and every submenu under it —
91            // on each rebuild of the bar, which a locale or shortcut change
92            // triggers. See `teksilo_core::deferred_subtree::DeferredSubtree`.
93            let opened_here = menu_ctx.open_index.map(move |open| *open == Some(i));
94            let content_id = ctx.add_detached_deferred(opened_here, host);
95            ctx.set_dormant(content_id);
96
97            let trigger = MenuBarTrigger {
98                label: entry.label.clone(),
99                stripped_name: parsed.stripped.clone(),
100                mnemonic_key: parsed.key_lower,
101                index: i,
102                menu_ctx: menu_ctx.clone(),
103                root_child_id: None,
104            };
105            let trigger_id = ctx.add(trigger);
106            row = row.child(trigger_id);
107
108            if let Some(k) = parsed.key_lower {
109                if let Some(prev) = mnemonic_table.insert(k, i) {
110                    debug_assert!(
111                        false,
112                        "MenuBar: duplicate mnemonic {:?} (triggers {} and {})",
113                        k, prev, i
114                    );
115                }
116            }
117
118            trigger_ids.push(trigger_id);
119            content_ids.push(content_id);
120        }
121
122        // Register all trigger/content IDs in the context.
123        // focus_id is initially content_id; MenuOverlayHost::build() will
124        // overwrite it with the actual inner MenuList ID.
125        for (i, (&tid, &cid)) in trigger_ids.iter().zip(content_ids.iter()).enumerate() {
126            menu_ctx.register(i, tid, cid, cid);
127        }
128
129        // Spacer pushes triggers left, trailing slot right
130        row = row.child(Spacer::new());
131
132        // Trailing slot (memoized — the same widgets survive each rebuild)
133        row = Self::add_slot(
134            ctx,
135            row,
136            &mut self.trailing_slot,
137            &mut self.trailing_slot_ids,
138        );
139
140        let row_id = ctx.add(row);
141
142        let bg = RectWidget::new()
143            .background(SurfaceRole::Main)
144            .border_color(theme_signal.map(|t| t.colors.border.with_alpha(0.2)))
145            .border_width(0.0_f32);
146        let bg_id = ctx.add(bg);
147
148        let padding = Padding::symmetric(0.0, 2.0).child(row_id);
149        let padding_id = ctx.add(padding);
150
151        let zstack_id = ctx.add(ZStack::new().child(bg_id).child(padding_id));
152        // Shared cell holding the hamburger id once it's built below — the
153        // `RevealHeightBox` measures it to size the floating bar (filled at
154        // `anchor_cell.set(...)`, the same pattern as the overlay anchor).
155        let ham_cell: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
156        // In collapsible mode the `Role::MenuBar` landmark lives on the
157        // bar content node (not the composing widget) so it travels into
158        // the floating overlay AND so `overlay_is_host_surface` treats
159        // the revealed bar as a host (menu-open dismissal spares it). The
160        // content is wrapped in a `RevealHeightBox` so the *floating* bar's
161        // height matches the hamburger button — the triggers center
162        // vertically (the inner `HStack`'s default `VAlignment::Center`);
163        // the inline bar keeps its natural height. That, in turn, is
164        // wrapped in an `Unroll` so the floating bar unrolls out of the
165        // hamburger on open and rolls back into it on close (driven by
166        // `reveal_progress`; the overlay owns the tween + dismissal
167        // deferral — see the reveal closure below). `reveal_progress`
168        // stays at `1.0` for the inline bar, so `Unroll` is a no-op there.
169        let root_id = if self.collapse_policy.is_some() {
170            let height_box = ctx.add(RevealHeightBox {
171                child_id: None,
172                pending_child: Some(PendingChild::Id(zstack_id)),
173                revealed: self.revealed.clone(),
174                hamburger_id: ham_cell.clone(),
175            });
176            // Unrolls trailing-ward from the hamburger's edge (RTL flip is
177            // a follow-up, matching the docking handle-direction caveat).
178            ctx.add(
179                Unroll::from_progress(self.reveal_progress.clone())
180                    .child(height_box)
181                    .access_role(teksilo_core::accesskit::Role::MenuBar),
182            )
183        } else {
184            zstack_id
185        };
186        self.root_child_id = Some(root_id);
187        self.bar_id = Some(root_id);
188
189        // Collapsible (hamburger) mode: build the hamburger button and
190        // the reveal closure that floats the bar as an overlay; gate
191        // inline visibility on `collapsed` / `revealed`.
192        let mut children = vec![root_id];
193        let collapsible_reveal: Option<MenubarReveal> = if self.collapse_policy.is_some() {
194            let bar_id = root_id;
195            let revealed = self.revealed.clone();
196            let collapsed = self.collapsed.clone();
197            // Captured at build (EventContext can't reach motion / pref):
198            // the unroll tween duration and whether to snap. A theme /
199            // reduced-motion change rebuilds the bar, refreshing both.
200            let reveal_progress = self.reveal_progress.clone();
201            let reveal_duration = ctx.theme().motion.duration_collapse;
202            let reduced_motion = ctx.prefers_reduced_motion();
203
204            // The bar overlay trails the hamburger (the developer is
205            // responsible for placing the hamburger). The anchor cell is
206            // filled after the button is added, since the reveal closure
207            // is created before the button id is known.
208            let anchor_cell: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
209            // First trigger, focused on reveal so the bar is immediately
210            // keyboard-navigable (arrows move between menus, Enter opens).
211            let first_trigger = trigger_ids.first().copied();
212
213            let reveal: MenubarReveal = {
214                let revealed = revealed.clone();
215                let anchor_cell = anchor_cell.clone();
216                let reveal_progress = reveal_progress.clone();
217                Rc::new(move |ctx: &mut EventContext| {
218                    if revealed.get() {
219                        return; // idempotent — already revealed
220                    }
221                    revealed.set(true);
222                    ctx.activate(bar_id);
223                    let anchor = anchor_cell.get().unwrap_or(bar_id);
224                    let on_dismiss: teksilo_core::overlay::OverlayDismissCallback = {
225                        let revealed = revealed.clone();
226                        Rc::new(move |_, _| revealed.set(false))
227                    };
228                    let request = OverlayRequest {
229                        content_id: bar_id,
230                        anchor,
231                        placement: OverlayPlacement::TrailingEdge,
232                        dismiss: DismissBehavior::EscapeOrClickOutside,
233                        layer: OverlayLayer::InTree,
234                        parent_overlay: None,
235                        on_dismiss: Some(on_dismiss),
236                        fade_duration: None,
237                    };
238                    if reduced_motion {
239                        // No tween: show fully unrolled; dismissal is immediate.
240                        reveal_progress.set(1.0);
241                        ctx.show_overlay(request);
242                    } else {
243                        // Start rolled up, then the overlay tweens 0 → 1 on
244                        // show and 1 → 0 on close (deferring teardown until
245                        // the roll-back completes).
246                        reveal_progress.set(0.0);
247                        ctx.show_overlay_with_reveal(
248                            request,
249                            reveal_progress.clone(),
250                            reveal_duration,
251                        );
252                    }
253                    if let Some(trigger) = first_trigger {
254                        ctx.request_focus(trigger);
255                    }
256                })
257            };
258
259            // `IconButton::menu()` already advertises `HasPopup::Menu` and
260            // an accessible name ("Menu"). Binding `expanded_when(revealed)`
261            // completes the ARIA disclosure pattern: the button reports
262            // `expanded=true` while the bar is shown, `false` while collapsed.
263            let hamburger = IconButton::menu()
264                .size(self.hamburger_size)
265                .expanded_when(revealed.clone())
266                .on_activate_fn({
267                    let reveal = reveal.clone();
268                    move |ctx| reveal(ctx)
269                });
270            let hamburger_id = ctx.add(hamburger);
271            anchor_cell.set(Some(hamburger_id));
272            // Let the bar's `RevealHeightBox` measure the hamburger so the
273            // floating overlay's height matches the button.
274            ham_cell.set(Some(hamburger_id));
275            self.hamburger_id = Some(hamburger_id);
276
277            // Hamburger visible only while collapsed.
278            ctx.visible_when(hamburger_id, collapsed.clone());
279            // Bar active when shown inline (`!collapsed`) OR as the
280            // floating overlay (`revealed`). Keeping it active while
281            // revealed prevents the visibility binding from fighting the
282            // overlay activation.
283            let bar_active = collapsed.zip(&revealed).map(|(c, r)| !*c || *r);
284            ctx.visible_when(bar_id, bar_active);
285
286            children.push(hamburger_id);
287            Some(reveal)
288        } else {
289            None
290        };
291
292        // Window-level menubar key dispatcher (F10 / Alt+letter /
293        // Alt-tap). Installed on every platform — `MenuBar` is an
294        // in-window widget menu, not the OS system menu, so the
295        // dispatcher's job is to wire framework menus to keyboard
296        // accelerators regardless of host OS.
297        //
298        // **macOS**: the dispatcher's `Alt+letter` branch is compiled
299        // out (see `MenuBarDispatcher::try_handle`) because the OS
300        // rewrites Option+letter for accented character composition
301        // before the app sees the keystroke. F10 and bare-Alt-tap
302        // continue to fire on macOS through this same dispatcher.
303        //
304        // Drop the previous guard BEFORE installing the new one so
305        // the slot is empty when `install_menubar_dispatcher` runs
306        // its `debug_assert!(slot.is_none())`. Otherwise a rebuild
307        // of `MenuBar` (e.g. when a composing ancestor rebuilds)
308        // trips the assert in debug builds and would over-write the
309        // slot under another live guard in release.
310        if self.install_dispatcher
311            && let Some(window) = ctx.window()
312        {
313            *self.menubar_guard.borrow_mut() = None;
314            let inner = MenuBarDispatcher {
315                trigger_ids: trigger_ids.clone(),
316                mnemonic_table,
317            };
318            let dispatcher: Rc<dyn MenubarDispatcher> = match collapsible_reveal {
319                Some(reveal) => Rc::new(CollapsibleMenuBarDispatcher {
320                    inner,
321                    collapsed: self.collapsed.clone(),
322                    reveal,
323                }),
324                None => Rc::new(inner),
325            };
326            let guard = window.install_menubar_dispatcher(dispatcher);
327            *self.menubar_guard.borrow_mut() = Some(guard);
328        }
329
330        children
331    }
332
333    fn layout_response(
334        &self,
335        proposal: SizeProposal,
336        ctx: &LayoutContext,
337    ) -> teksilo_core::widget::LayoutResponse {
338        // Collapsed: size to the hamburger's natural size (a small box),
339        // don't stretch to the full allotted width.
340        if self.collapse_policy.is_some() && self.collapsed.get() {
341            return match self.hamburger_id {
342                Some(id) => ctx
343                    .child_size(id, SizeProposal::unspecified())
344                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
345                None => proposal.resolve(0.0, 0.0),
346            }
347            .into();
348        }
349        match self.root_child_id {
350            Some(id) => {
351                let content_proposal = SizeProposal {
352                    width: proposal.width,
353                    height: None,
354                };
355                let size = ctx
356                    .child_size(id, content_proposal)
357                    .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
358                Size::new(proposal.width.unwrap_or(size.width), size.height)
359            }
360            None => proposal.resolve(0.0, 0.0),
361        }
362        .into()
363    }
364
365    fn place_children(
366        &self,
367        bounds: Rect,
368        proposal: SizeProposal,
369        children: &mut [WidgetPlacement],
370        ctx: &LayoutContext,
371    ) {
372        // Responsive collapse decision (Toolbar pattern): compare the
373        // bar's intrinsic width against the allotted width and toggle
374        // `collapsed`, idempotently (the guard avoids relayout churn).
375        if let Some(policy) = self.collapse_policy {
376            let should_collapse = match policy {
377                CollapsePolicy::Always => true,
378                CollapsePolicy::Responsive => {
379                    if self.revealed.get() {
380                        // Don't un-collapse while the overlay is up — it
381                        // would make the bar both inline and floating.
382                        self.collapsed.get()
383                    } else if let (Some(bar_id), Some(avail)) = (self.bar_id, proposal.width) {
384                        ctx.measure_intrinsic(bar_id, SizeProposal::unspecified())
385                            .map(|s| s.width)
386                            .unwrap_or(0.0)
387                            > avail + 0.5
388                    } else {
389                        // Unbounded width (or no bar) → never collapse.
390                        false
391                    }
392                }
393            };
394            if self.last_collapsed.get() != should_collapse {
395                self.last_collapsed.set(should_collapse);
396                self.collapsed.set(should_collapse);
397            }
398        }
399
400        // The hamburger keeps a constant width: place it at its intrinsic
401        // size, leading-aligned, so a stretching parent can't widen it.
402        // Everything else (the bar, inline or as the re-laid overlay) fills
403        // the bounds; dormant children are skipped by the layout pass.
404        let collapsed = self.collapse_policy.is_some() && self.collapsed.get();
405        for child in children.iter_mut() {
406            if collapsed && Some(child.id) == self.hamburger_id {
407                let size = ctx
408                    .measure_intrinsic(child.id, SizeProposal::unspecified())
409                    .unwrap_or_else(|| bounds.size());
410                let x = if ctx.is_rtl() {
411                    bounds.right() - size.width
412                } else {
413                    bounds.x
414                };
415                child.origin = Point::new(x, bounds.y);
416                child.size = size;
417            } else {
418                child.origin = bounds.origin();
419                child.size = bounds.size();
420            }
421        }
422    }
423
424    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
425        // In collapsible mode the `Role::MenuBar` landmark lives on the
426        // bar content node so it travels into the floating overlay; the
427        // composing widget node stays a generic container.
428        if self.collapse_policy.is_none() {
429            builder.set_role(teksilo_core::accesskit::Role::MenuBar);
430        }
431    }
432
433    fn children(&self) -> Vec<WidgetId> {
434        let mut v: Vec<WidgetId> = self.root_child_id.into_iter().collect();
435        if let Some(h) = self.hamburger_id {
436            v.push(h);
437        }
438        v
439    }
440
441    /// Reconcile on rebuild. The menu triggers are re-derived fresh each build
442    /// (the model may have changed) and the reconcile reaps the superseded
443    /// ones; the memoized leading/trailing slot widgets (see `add_slot`) are
444    /// re-attached by id and kept alive, so a stateful slot control — a search
445    /// field, a focused button, an avatar with hover state — survives a
446    /// model-version / theme / locale rebuild instead of being rebuilt from
447    /// scratch.
448    fn preserves_children_on_rebuild(&self) -> bool {
449        true
450    }
451}
452impl Widget for RevealHeightBox {
453    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
454        if let Some(pending) = self.pending_child.take() {
455            self.child_id = Some(match pending {
456                PendingChild::Id(id) => id,
457                PendingChild::Deferred(w) => ctx.add_boxed(w),
458            });
459        }
460        // Re-layout when the bar reveals / hides so the height switches
461        // between hamburger-matched (floating) and natural (inline).
462        self.revealed.bind_to(
463            ctx.self_id(),
464            ctx.binding_registry(),
465            teksilo_core::binding::BindingLevel::Relayout,
466        );
467        self.child_id.into_iter().collect()
468    }
469
470    fn layout_response(
471        &self,
472        proposal: SizeProposal,
473        ctx: &LayoutContext,
474    ) -> teksilo_core::widget::LayoutResponse {
475        let child = self.child_id;
476        if self.revealed.get() {
477            if let Some(ham) = self.hamburger_id.get() {
478                if let Some(h) = ctx
479                    .measure_intrinsic(ham, SizeProposal::unspecified())
480                    .map(|s| s.height)
481                {
482                    let child_w = child
483                        .and_then(|id| {
484                            ctx.child_size(
485                                id,
486                                SizeProposal {
487                                    width: proposal.width,
488                                    height: Some(h),
489                                },
490                            )
491                        })
492                        .map(|s| s.width)
493                        .unwrap_or(0.0);
494                    let w = proposal.width.unwrap_or(child_w);
495                    return Size::new(w, h).into();
496                }
497            }
498        }
499        child
500            .and_then(|id| ctx.child_size(id, proposal))
501            .unwrap_or(Size::ZERO)
502            .into()
503    }
504
505    fn place_children(
506        &self,
507        bounds: Rect,
508        _proposal: SizeProposal,
509        children: &mut [WidgetPlacement],
510        _ctx: &LayoutContext,
511    ) {
512        for child in children.iter_mut() {
513            child.origin = bounds.origin();
514            child.size = bounds.size();
515        }
516    }
517
518    fn children(&self) -> Vec<WidgetId> {
519        self.child_id.into_iter().collect()
520    }
521}
522impl Widget for MenuOverlayHost {
523    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
524        let inner_widget = self.inner.take().expect("MenuOverlayHost built twice");
525        let id = ctx.add_boxed(inner_widget);
526        self.inner_id = Some(id);
527
528        // Register inner widget as the focus target for this menu index
529        self.menu_ctx.set_focus_id(self.menu_index, id);
530
531        let menu_ctx = self.menu_ctx.clone();
532        let menu_index = self.menu_index;
533        let handler_set = HandlerSet::new()
534            .on_focus({
535                let menu_ctx = menu_ctx.clone();
536                move |gained: bool, _ctx: &mut EventContext| {
537                    // Focus left this menu, so it is on its way out — record
538                    // that, and nothing more. The dismissal itself belongs to
539                    // the framework's focus-out rule
540                    // (`dismiss_overlays_left_by_focus`), and the trigger gets
541                    // its focus back from the overlay's own `focus_restore`.
542                    //
543                    // Doing either of those *here* was a race: this handler
544                    // fires from inside the `FocusLost` dispatch, i.e. before
545                    // `focus_with_origin_ops` has installed the new target, so
546                    // the `request_focus(trigger)` it used to queue resolved
547                    // first and was then silently overwritten by the very
548                    // `set_focused` that was still in flight — a focus flash
549                    // onto the trigger that no `FocusLost` ever accounted for.
550                    // Keeping only the signal write leaves this side idempotent
551                    // and lets every dismissal path (Escape, click-outside,
552                    // Tab) converge on the same a11y state.
553                    if !gained && menu_ctx.open_index.get() == Some(menu_index) {
554                        menu_ctx.open_index.set(None);
555                    }
556                }
557            })
558            .on_key({
559                let menu_ctx = menu_ctx.clone();
560                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
561                    // These keys bubble up from the inner MenuList when it
562                    // returns Ignored. Under RTL the bar is laid out
563                    // right-to-left, so the previous/next arrows swap.
564                    let (left_delta, right_delta) = if ctx.is_rtl() { (1, -1) } else { (-1, 1) };
565                    match event {
566                        WidgetEvent::KeyDown {
567                            key: Key::ArrowLeft,
568                            ..
569                        } => {
570                            menu_ctx.navigate(left_delta, ctx);
571                            EventResponse::Handled
572                        }
573                        WidgetEvent::KeyDown {
574                            key: Key::ArrowRight,
575                            ..
576                        } => {
577                            menu_ctx.navigate(right_delta, ctx);
578                            EventResponse::Handled
579                        }
580                        WidgetEvent::KeyDown {
581                            key: Key::Escape, ..
582                        } => {
583                            menu_ctx.close(ctx);
584                            EventResponse::Handled
585                        }
586                        _ => EventResponse::Ignored,
587                    }
588                }
589            });
590        // NOT focusable — the inner MenuList receives focus directly.
591        // ArrowLeft/Right and FocusLost bubble from MenuList through here.
592        ctx.apply_self_handlers(handler_set);
593
594        vec![id]
595    }
596
597    fn layout_response(
598        &self,
599        proposal: SizeProposal,
600        ctx: &LayoutContext,
601    ) -> teksilo_core::widget::LayoutResponse {
602        self.inner_id
603            .and_then(|id| ctx.child_size(id, proposal))
604            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
605            .into()
606    }
607
608    fn place_children(
609        &self,
610        bounds: Rect,
611        _proposal: SizeProposal,
612        children: &mut [WidgetPlacement],
613        _ctx: &LayoutContext,
614    ) {
615        for child in children.iter_mut() {
616            child.origin = bounds.origin();
617            child.size = bounds.size();
618        }
619    }
620
621    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
622        // The inner widget (typically `MenuList`) owns the `Role::Menu`
623        // semantics. A second Menu role here would nest two Menu nodes
624        // per dropdown, confusing screen readers that look for a single
625        // Menu per popup. `GenericContainer` is the ARIA `none`/`presentation`
626        // equivalent: the host is kept in the tree for focus/key routing
627        // but is ignored by assistive tech.
628        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
629    }
630
631    fn children(&self) -> Vec<WidgetId> {
632        self.inner_id.into_iter().collect()
633    }
634}