Skip to main content

herogpui_components/
dropdown.rs

1//! Dropdown & Menu — port of `@heroui/dropdown`, `@heroui/menu` and
2//! `@heroui/listbox`.
3
4use gpui::{
5    prelude::FluentBuilder as _, px, AnyElement, App, Bounds, ClickEvent, InteractiveElement,
6    IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement,
7    Styled, Window,
8};
9use herogpui_core::{element_id, SelectionMode};
10use herogpui_theme::ActiveTheme;
11
12use crate::a11y::{self, A11y as _};
13use crate::icons;
14
15/// One entry of a dropdown menu.
16pub enum MenuItem {
17    /// Section caption (`<MenuSection>` title).
18    SectionLabel(SharedString),
19    /// A divider row between items.
20    Separator,
21    /// A menu row.
22    ///
23    /// `#[non_exhaustive]`: build one through [`MenuItem::new`] and its
24    /// builders, which cover every field, so a later row capability is an
25    /// additive change rather than a break for literal construction.
26    #[non_exhaustive]
27    Item {
28        /// Stable key identifying the item.
29        key: SharedString,
30        /// Visible text of the item.
31        label: SharedString,
32        /// Optional shortcut hint shown on the row.
33        shortcut: Option<SharedString>,
34        /// Optional icon asset path.
35        icon: Option<&'static str>,
36        /// Whether the item uses danger styling.
37        is_danger: bool,
38        /// `Description` inside a `Dropdown.Item` — v3's "With Descriptions".
39        description: Option<SharedString>,
40        /// `Dropdown.SubmenuTrigger` — the rows this item opens. The row grows a
41        /// trailing indicator and the panel appears beside it.
42        submenu: Vec<MenuItem>,
43        /// Whether the row's own content owns the interaction, in place of the
44        /// row acting as one button. See [`MenuItem::is_interactive`].
45        is_interactive: bool,
46    },
47}
48
49impl MenuItem {
50    /// Creates a menu item from a key and a label.
51    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
52        MenuItem::Item {
53            key: key.into(),
54            label: label.into(),
55            shortcut: None,
56            icon: None,
57            is_danger: false,
58            description: None,
59            submenu: Vec::new(),
60            is_interactive: false,
61        }
62    }
63
64    /// `Description` — the second line v3 composes inside an item.
65    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
66        if let MenuItem::Item { description, .. } = &mut self {
67            *description = Some(text.into());
68        }
69        self
70    }
71
72    /// Hands the row's interaction to the element `Menu::item_content` draws
73    /// for it, in place of the row behaving as one button.
74    ///
75    /// HeroUI composes rows that carry an inline secondary control — a small
76    /// select, a stepper — on the trailing edge, where pressing that control
77    /// must not also pick the row and close the menu. A stock row attaches an
78    /// unconditional click that reports the action and dismisses, plus the
79    /// `[data-pressed]` 98% scale over the whole row, so a hosted control is
80    /// unusable inside one. Setting this drops all three, and Enter and Space
81    /// stop activating the row, leaving the hosted element to answer the
82    /// pointer and the keyboard itself.
83    ///
84    /// What it keeps: the row stays a keyboard stop with its hover fill,
85    /// highlight and focus ring, and it keeps its `menuitem` role and
86    /// accessible name, so arrowing through the menu is unchanged.
87    ///
88    /// Nothing else is needed to host a popup-opening control such as a
89    /// [`crate::select::Select`]. Such a control registers its own panel on the
90    /// shared overlay stack, which makes it topmost, and the menu's
91    /// outside-press and Escape dismissals are already gated on being topmost —
92    /// so they stand down for as long as the inner panel is open.
93    ///
94    /// Ignored together with [`MenuItem::submenu`]: a submenu trigger already
95    /// opts out of the row click, and its flyout is the interaction.
96    pub fn is_interactive(mut self, interactive: bool) -> Self {
97        if let MenuItem::Item { is_interactive, .. } = &mut self {
98            *is_interactive = interactive;
99        }
100        self
101    }
102
103    /// `Dropdown.SubmenuTrigger` — the rows this item opens.
104    pub fn submenu(mut self, items: Vec<MenuItem>) -> Self {
105        if let MenuItem::Item { submenu, .. } = &mut self {
106            *submenu = items;
107        }
108        self
109    }
110
111    /// Sets the shortcut hint shown on the row.
112    pub fn shortcut(mut self, s: impl Into<SharedString>) -> Self {
113        if let MenuItem::Item { shortcut, .. } = &mut self {
114            *shortcut = Some(s.into());
115        }
116        self
117    }
118
119    /// Sets the icon asset path.
120    pub fn icon(mut self, path: &'static str) -> Self {
121        if let MenuItem::Item { icon, .. } = &mut self {
122            *icon = Some(path);
123        }
124        self
125    }
126
127    /// Marks the item as a danger item.
128    pub fn danger(mut self) -> Self {
129        if let MenuItem::Item { is_danger, .. } = &mut self {
130            *is_danger = true;
131        }
132        self
133    }
134}
135
136type OnSelect = std::sync::Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
137
138/// Menu panel (`<Menu>` / `<Listbox>`).
139/// `type` on `Dropdown.ItemIndicator` — how a selected item is marked.
140#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
141pub enum IndicatorKind {
142    /// A checkmark indicator.
143    #[default]
144    Checkmark,
145    /// A dot indicator.
146    Dot,
147}
148
149impl IndicatorKind {
150    /// Every indicator kind, in display order.
151    pub const ALL: [IndicatorKind; 2] = [IndicatorKind::Checkmark, IndicatorKind::Dot];
152
153    /// The display name of this indicator kind.
154    pub fn label(self) -> &'static str {
155        match self {
156            IndicatorKind::Checkmark => "Checkmark",
157            IndicatorKind::Dot => "Dot",
158        }
159    }
160}
161
162/// `onSelectionChange` — the whole selection after an item is activated.
163pub type OnSelectionChange =
164    std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>;
165
166type ItemContent =
167    std::sync::Arc<dyn Fn(&SharedString, crate::util::InteractiveState) -> AnyElement + 'static>;
168type ItemIndicatorContent =
169    std::sync::Arc<dyn Fn(&SharedString, bool, bool) -> AnyElement + 'static>;
170type OnDismiss = std::rc::Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
171type PanelBounds = std::rc::Rc<std::cell::RefCell<Vec<Bounds<Pixels>>>>;
172
173/// A menu panel listing `MenuItem` rows.
174#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
175#[derive(IntoElement)]
176pub struct Menu {
177    /// Set by `Dropdown` while the menu is playing its `[data-exiting]` run.
178    exiting: bool,
179    /// Submenus are already inside their parent's deferred draw and cannot
180    /// defer a second time.
181    deferred: bool,
182    panel_bounds: Option<PanelBounds>,
183    focus_first: Option<gpui::Entity<bool>>,
184    on_back: Option<std::sync::Arc<dyn Fn(&mut Window, &mut App) + 'static>>,
185    /// `children` on `Dropdown.Item` — v3's render prop, handed the item's
186    /// key, selection, focus, disabled and pressed state.
187    item_content: Option<ItemContent>,
188    /// `children` on `Dropdown.ItemIndicator` — handed the item's key,
189    /// `isSelected` and `isIndeterminate`.
190    indicator_content: Option<ItemIndicatorContent>,
191    id: gpui::ElementId,
192    items: Vec<MenuItem>,
193    selected_key: Option<SharedString>,
194    selection_mode: SelectionMode,
195    selected_keys: Vec<SharedString>,
196    default_selected_keys: Vec<SharedString>,
197    selection_is_controlled: bool,
198    disallow_empty_selection: bool,
199    disabled_keys: Vec<SharedString>,
200    indicator: IndicatorKind,
201    on_selection_change: Option<OnSelectionChange>,
202    on_action: Option<OnSelect>,
203    /// The fill a hovered menu row takes, in place of `--default`.
204    row_hover_bg: Option<gpui::Hsla>,
205    row_hover_foreground: Option<gpui::Hsla>,
206    /// The panel's corner radius, in place of the owning `container_radius`
207    /// helper. [`Dropdown`] forwards its own override here.
208    radius: Option<Pixels>,
209    panel_min_width: Option<Pixels>,
210    panel_max_width: Option<Pixels>,
211    panel_max_height: Option<Pixels>,
212    row_height: Option<Pixels>,
213    row_padding_x: Option<Pixels>,
214    row_padding_y: Option<Pixels>,
215    row_text_size: Option<Pixels>,
216    row_gap: Option<Pixels>,
217    panel_padding: Option<Pixels>,
218    panel_gap: Option<Pixels>,
219    separator_inset: Option<Pixels>,
220    separator_thickness: Option<Pixels>,
221    animate_entry: bool,
222    animate_entry_is_set: bool,
223    focus_handle: Option<gpui::FocusHandle>,
224    /// Set by `Dropdown`: the menu panel is where Escape and an outside press
225    /// land, and the open state belongs to the wrapper. The `bool` says
226    /// whether the trigger should take the focus back: Escape, an outside
227    /// press and a mouse pick can, because no key-up follows them; an Enter
228    /// pick cannot, because gpui activates a focused element on key up and the
229    /// trigger's click listener would reopen the menu it just closed.
230    on_dismiss: Option<OnDismiss>,
231    overlay_token: Option<crate::util::OverlayToken>,
232    dropdown_composition: bool,
233    /// Test-only label for the panel, read with `debug_bounds`.
234    ///
235    /// Not a v3 prop: naming the laid-out panel beats wrapping it, because a
236    /// wrapper between the positioner and the menu would measure and cap
237    /// while the real panel kept its natural size underneath.
238    panel_debug_label: Option<&'static str>,
239    /// The `sx` slot, refined over the root style at the end of render.
240    sx: Option<Box<gpui::StyleRefinement>>,
241    recipes: Vec<SharedString>,
242}
243
244impl Menu {
245    /// Creates a menu from an element id and its items.
246    pub fn new(id: impl Into<gpui::ElementId>, items: Vec<MenuItem>) -> Self {
247        Self {
248            exiting: false,
249            deferred: true,
250            panel_bounds: None,
251            focus_first: None,
252            on_back: None,
253            item_content: None,
254            indicator_content: None,
255            id: id.into(),
256            items,
257            selected_key: None,
258            selection_mode: SelectionMode::None,
259            selected_keys: Vec::new(),
260            default_selected_keys: Vec::new(),
261            selection_is_controlled: false,
262            disallow_empty_selection: false,
263            disabled_keys: Vec::new(),
264            indicator: IndicatorKind::default(),
265            on_selection_change: None,
266            on_action: None,
267            row_hover_bg: None,
268            row_hover_foreground: None,
269            radius: None,
270            panel_min_width: None,
271            panel_max_width: None,
272            panel_max_height: None,
273            row_height: None,
274            row_padding_x: None,
275            row_padding_y: None,
276            row_text_size: None,
277            row_gap: None,
278            panel_padding: None,
279            panel_gap: None,
280            separator_inset: None,
281            separator_thickness: None,
282            animate_entry: true,
283            animate_entry_is_set: false,
284            focus_handle: None,
285            on_dismiss: None,
286            overlay_token: None,
287            dropdown_composition: false,
288            panel_debug_label: None,
289            sx: None,
290            recipes: Vec::new(),
291        }
292    }
293
294    /// What to run when the menu closes: an item activation, Escape, or a
295    /// press outside the panel.
296    ///
297    /// Not a v3 prop: v3's `Dropdown.Menu` is inside the `Dropdown` that owns
298    /// `isOpen`, and React Aria's `useOverlay` closes it from there. Standalone
299    /// callers remove the menu in this callback. The `bool` is whether to return
300    /// the focus to the trigger — see the field docs for why a key pick passes
301    /// `false`.
302    pub fn on_dismiss(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
303        self.on_dismiss = Some(std::rc::Rc::new(f));
304        self
305    }
306
307    /// Overrides the panel min width in pixels, including submenus.
308    /// Unset preserves the stock metric.
309    pub fn panel_min_width(mut self, value: impl Into<Pixels>) -> Self {
310        self.panel_min_width = Some(value.into());
311        self
312    }
313
314    /// Overrides the panel max width in pixels, including submenus.
315    /// Unset preserves the stock metric.
316    pub fn panel_max_width(mut self, value: impl Into<Pixels>) -> Self {
317        self.panel_max_width = Some(value.into());
318        self
319    }
320
321    /// Overrides the panel max height in pixels, including submenus.
322    /// Unset preserves the stock metric.
323    pub fn panel_max_height(mut self, value: impl Into<Pixels>) -> Self {
324        self.panel_max_height = Some(value.into());
325        self
326    }
327
328    /// Overrides the row minimum height in pixels, including submenus.
329    /// Described rows may grow to fit their content.
330    /// Unset preserves the stock metric.
331    pub fn row_height(mut self, value: impl Into<Pixels>) -> Self {
332        self.row_height = Some(value.into());
333        self
334    }
335
336    /// Overrides the row padding x in pixels, including submenus.
337    /// Unset preserves the stock metric.
338    pub fn row_padding_x(mut self, value: impl Into<Pixels>) -> Self {
339        self.row_padding_x = Some(value.into());
340        self
341    }
342
343    /// Overrides the row padding y in pixels, including submenus.
344    /// Unset preserves the stock metric.
345    pub fn row_padding_y(mut self, value: impl Into<Pixels>) -> Self {
346        self.row_padding_y = Some(value.into());
347        self
348    }
349
350    /// Overrides the row text size in pixels, including submenus.
351    /// Unset preserves the stock metric.
352    pub fn row_text_size(mut self, value: impl Into<Pixels>) -> Self {
353        self.row_text_size = Some(value.into());
354        self
355    }
356
357    /// Overrides the row gap in pixels, including submenus.
358    /// Unset preserves the stock metric.
359    pub fn row_gap(mut self, value: impl Into<Pixels>) -> Self {
360        self.row_gap = Some(value.into());
361        self
362    }
363
364    /// Overrides the panel padding in pixels, including submenus.
365    /// Unset preserves the stock metric.
366    pub fn panel_padding(mut self, value: impl Into<Pixels>) -> Self {
367        self.panel_padding = Some(value.into());
368        self
369    }
370
371    /// Space between panel entries (default 2px), including submenus.
372    /// `row_gap` independently controls spacing inside each item.
373    pub fn panel_gap(mut self, gap: impl Into<Pixels>) -> Self {
374        self.panel_gap = Some(gap.into());
375        self
376    }
377
378    /// An absolute horizontal inset on each edge of a
379    /// [`MenuItem::Separator`], including submenus.
380    ///
381    /// Unset, the rule takes v3's own `ms-[3%] w-[94%]`: a proportional inset,
382    /// centred in the panel's content box. Set, that is replaced by the same
383    /// number of pixels on each edge whatever the panel's width — which is what
384    /// a panel sitting beside a platform menu needs, AppKit's own separator
385    /// being inset 15pt on each side inside wider panel bounds.
386    pub fn separator_inset(mut self, inset: impl Into<Pixels>) -> Self {
387        self.separator_inset = Some(inset.into());
388        self
389    }
390
391    /// Thickness of a [`MenuItem::Separator`], including submenus. Unset keeps
392    /// the theme's hairline border width.
393    pub fn separator_thickness(mut self, thickness: impl Into<Pixels>) -> Self {
394        self.separator_thickness = Some(thickness.into());
395        self
396    }
397
398    /// Whether the panel and its submenus play their entry animation (default true).
399    /// Exit animation and keyboard behavior are unchanged.
400    pub fn animate_entry(mut self, animate: bool) -> Self {
401        self.animate_entry = animate;
402        self.animate_entry_is_set = true;
403        self
404    }
405
406    /// Named theme overlay from [`herogpui_theme::ComponentThemes::menu`].
407    /// Stackable; a missing name adds no override.
408    pub fn recipe(mut self, name: impl Into<SharedString>) -> Self {
409        self.recipes.push(name.into());
410        self
411    }
412
413    /// Supplies the root menu's focus handle. Submenus own independent handles
414    /// and return focus here on Left/Escape. The menu still focuses on entry.
415    pub fn focus_handle(mut self, handle: gpui::FocusHandle) -> Self {
416        self.focus_handle = Some(handle);
417        self
418    }
419
420    pub(crate) fn overlay_token(mut self, token: crate::util::OverlayToken) -> Self {
421        self.overlay_token = Some(token);
422        self
423    }
424
425    pub(crate) fn dropdown_composition(mut self) -> Self {
426        self.dropdown_composition = true;
427        self
428    }
429
430    /// Labels the panel for behavior tests (`debug_bounds`).
431    ///
432    /// Not a v3 prop: see the field docs for why tests name the panel instead
433    /// of wrapping it.
434    pub(crate) fn panel_debug_label(mut self, label: &'static str) -> Self {
435        self.panel_debug_label = Some(label);
436        self
437    }
438
439    /// The element id every piece of this menu's state is keyed by.
440    ///
441    /// Not a v3 prop -- gpui needs an explicit id on a stateful element, and
442    /// two menus that share one key share their focus, their cursor and their
443    /// typeahead.
444    pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
445        self.id = id.into();
446        self
447    }
448
449    /// Plays the menu's exit instead of its entry.
450    ///
451    /// Not a v3 prop: v3's menu leaves the tree with a `[data-exiting]`
452    /// attribute, and this is the flag that stands in for it.
453    pub fn exiting(mut self, v: bool) -> Self {
454        self.exiting = v;
455        self
456    }
457
458    pub(crate) fn embedded(mut self, panel_bounds: PanelBounds) -> Self {
459        self.deferred = false;
460        self.panel_bounds = Some(panel_bounds);
461        self
462    }
463
464    pub(crate) fn focus_first(mut self, state: gpui::Entity<bool>) -> Self {
465        self.focus_first = Some(state);
466        self
467    }
468
469    pub(crate) fn on_back(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
470        self.on_back = Some(std::sync::Arc::new(f));
471        self
472    }
473
474    /// `children` on `Dropdown.Item` — replaces an item's label.
475    ///
476    /// The closure receives the item's key and the row's state: `isSelected`,
477    /// `isIndeterminate`, `isFocused`, `isPressed` and `isDisabled`, which are
478    /// the values v3 passes into the same render prop. The press is a frame
479    /// behind the pointer or activation key, because gpui reports it to a handler.
480    pub fn item_content(
481        mut self,
482        render: impl Fn(&SharedString, crate::util::InteractiveState) -> AnyElement + 'static,
483    ) -> Self {
484        self.item_content = Some(std::sync::Arc::new(render));
485        self
486    }
487
488    /// `children` on `Dropdown.ItemIndicator` — replaces the built-in mark.
489    pub fn indicator_content(
490        mut self,
491        render: impl Fn(&SharedString, bool, bool) -> AnyElement + 'static,
492    ) -> Self {
493        self.indicator_content = Some(std::sync::Arc::new(render));
494        self
495    }
496
497    /// `type` on `Dropdown.ItemIndicator` — a check mark or a dot.
498    pub fn indicator(mut self, kind: IndicatorKind) -> Self {
499        self.indicator = kind;
500        self
501    }
502
503    /// The fill a hovered menu row takes, in place of `--default`.
504    pub fn row_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
505        self.row_hover_bg = Some(color.into());
506        self
507    }
508
509    /// Paired highlight text/icon color for hovered, focused and open-submenu
510    /// rows. Disabled rows retain their disabled treatment. Forwarded to submenus.
511    pub fn row_hover_foreground(mut self, color: impl Into<gpui::Hsla>) -> Self {
512        self.row_hover_foreground = Some(color.into());
513        self
514    }
515
516    /// The panel's corner radius, in place of the owning `container_radius`
517    /// helper. The panel's entry zoom interpolates the same value, so both
518    /// follow the override.
519    ///
520    /// Internal: only [`Dropdown`] reaches it, by forwarding its own
521    /// [`Dropdown::radius`], so the standalone menu keeps the helper. Not a v3
522    /// prop; the removed v2 `radius` prop is prohibited and this is a
523    /// per-component repository extension.
524    pub(crate) fn radius(mut self, radius: impl Into<Pixels>) -> Self {
525        self.radius = Some(radius.into());
526        self
527    }
528
529    /// `selectionMode` — `None` (the default) makes items pure actions.
530    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
531        self.selection_mode = mode;
532        self
533    }
534
535    /// `selectedKeys` — the controlled selection.
536    pub fn selected_keys(
537        mut self,
538        keys: impl IntoIterator<Item = impl Into<SharedString>>,
539    ) -> Self {
540        self.selected_keys = keys.into_iter().map(Into::into).collect();
541        self.selection_is_controlled = true;
542        self
543    }
544
545    /// `defaultSelectedKeys` — seeds the menu's own selection state.
546    pub fn default_selected_keys(
547        mut self,
548        keys: impl IntoIterator<Item = impl Into<SharedString>>,
549    ) -> Self {
550        self.default_selected_keys = keys.into_iter().map(Into::into).collect();
551        self
552    }
553
554    /// `disallowEmptySelection` — prevents removing the last selected item.
555    pub fn disallow_empty_selection(mut self, value: bool) -> Self {
556        self.disallow_empty_selection = value;
557        self
558    }
559
560    /// `disabledKeys` — items that cannot be activated.
561    pub fn disabled_keys(
562        mut self,
563        keys: impl IntoIterator<Item = impl Into<SharedString>>,
564    ) -> Self {
565        self.disabled_keys = keys.into_iter().map(Into::into).collect();
566        self
567    }
568
569    /// `onSelectionChange` — the whole selection after an item is activated.
570    pub fn on_selection_change(
571        mut self,
572        f: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
573    ) -> Self {
574        self.on_selection_change = Some(std::sync::Arc::new(f));
575        self
576    }
577
578    /// `onAction` — an item was activated, independent of any selection.
579    pub fn on_action(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
580        self.on_action = Some(std::sync::Arc::new(f));
581        self
582    }
583
584    /// Sets the key of the selected item.
585    pub fn selected_key(mut self, key: impl Into<SharedString>) -> Self {
586        self.selected_key = Some(key.into());
587        self
588    }
589
590    /// The one slot for caller-owned low-level styling: GPUI's styling methods
591    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
592    /// applied to the menu's root element after every value the composition
593    /// and the active theme chose, so they win. The panel inside paints its
594    /// own chrome, so this reaches the surface it floats in, not the panel.
595    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
596        crate::util::refine_sx(&mut self.sx, style);
597        self
598    }
599}
600
601impl RenderOnce for Menu {
602    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
603        let base_id = self.id.clone();
604        let overlay_token = if let Some(token) = self.overlay_token.clone() {
605            Some(token)
606        } else if self.on_dismiss.is_some() {
607            let (_, token) = crate::util::overlay_scope(
608                window,
609                cx,
610                element_id::scoped(&base_id, "overlay"),
611                true,
612                self.exiting,
613            );
614            Some(token)
615        } else {
616            None
617        };
618        let (selected_keys, selection_own) = crate::util::controlled(
619            window,
620            cx,
621            element_id::scoped(&base_id, "selected"),
622            self.selection_is_controlled
623                .then(|| self.selected_keys.clone()),
624            self.default_selected_keys.clone(),
625        );
626        self.selected_keys = selected_keys;
627        // Which submenu is open, if any -- held as the open child's own
628        // `ElementId` (`{id}-sub-{key}`), which is also the id the child menu
629        // renders under. `use_keyed_state` takes `cx` mutably, so it precedes
630        // everything that borrows the theme.
631        let submenu_state =
632            window.use_keyed_state(element_id::scoped(&base_id, "submenu"), cx, |_, _| {
633                None::<gpui::ElementId>
634            });
635        let mut submenu_open = submenu_state.read(cx).clone();
636        let submenu_focus =
637            window.use_keyed_state(element_id::scoped(&base_id, "submenu-focus"), cx, |_, _| {
638                false
639            });
640        let focus_first = self
641            .focus_first
642            .as_ref()
643            .is_some_and(|state| *state.read(cx));
644        let dismiss = self.on_dismiss.clone().map(|cb| {
645            let submenu_state = submenu_state.clone();
646            let submenu_focus = submenu_focus.clone();
647            std::rc::Rc::new(move |refocus: &bool, window: &mut Window, cx: &mut App| {
648                submenu_state.update(cx, |value, cx| {
649                    if value.is_some() {
650                        *value = None;
651                        cx.notify();
652                    }
653                });
654                submenu_focus.update(cx, |value, _| *value = false);
655                cb(refocus, window, cx);
656            }) as OnDismiss
657        });
658        // The keyboard's own state: which row it is on, the handle that receives
659        // the keys, and the letters typed so far.
660        let focus_handle = self.focus_handle.clone().unwrap_or_else(|| {
661            window
662                .use_keyed_state(element_id::scoped(&base_id, "focus"), cx, |_, cx| {
663                    cx.focus_handle().tab_stop(true)
664                })
665                .read(cx)
666                .clone()
667        });
668        let cursor = window.use_keyed_state(element_id::scoped(&base_id, "cursor"), cx, |_, _| {
669            None::<usize>
670        });
671        let mut cursor_at = *cursor.read(cx);
672        // `.dropdown__popover` is `overflow-y-auto`, and React Aria keeps the
673        // focused row in view. `use_keyed_state` takes `cx` mutably, so the
674        // handle precedes the theme.
675        let menu_scroll =
676            window.use_keyed_state(element_id::scoped(&base_id, "scroll"), cx, |_, _| {
677                gpui::ScrollHandle::new()
678            });
679        let menu_scroll_now = menu_scroll.read(cx).clone();
680        let typed = window.use_keyed_state(element_id::scoped(&base_id, "typed"), cx, |_, _| {
681            crate::list_nav::Typeahead::default()
682        });
683        // One hover/press slot per item, for an `item_content` closure. The
684        // slots exist only when the closure is set: `track_interaction`'s
685        // handlers cost a frame of state, and the closure is the only reader
686        // (the press v3's `Dropdown.Item` render props document).
687        let interaction: Vec<crate::util::Interaction> =
688            if self.item_content.is_some() || self.row_hover_foreground.is_some() {
689                (0..self.items.len())
690                    .map(|i| {
691                        crate::util::interaction(
692                            element_id::scoped(
693                                &element_id::indexed(&base_id, "item", i),
694                                "interaction",
695                            ),
696                            window,
697                            cx,
698                        )
699                    })
700                    .collect()
701            } else {
702                Vec::new()
703            };
704        // A menu takes focus when it opens, which is what makes the arrows work
705        // without a click first. The one-shot re-arms while the menu plays its
706        // exit, so a menu that reopens after a dismissal -- a pick or Escape
707        // hands the focus back to the trigger -- is keyboard-driven again.
708        let autofocus = element_id::scoped(&base_id, "autofocus");
709        if self.exiting {
710            let done = window.use_keyed_state(autofocus, cx, |_, _| false);
711            done.update(cx, |d, _| *d = false);
712            // A trigger toggle or a controlled close shuts the menu without
713            // running `dismiss`, so an open child would paint full-size
714            // beside its exiting parent and greet the next open. Exiting is
715            // the close every path funnels through, which is where the child
716            // goes quiet.
717            submenu_state.update(cx, |value, cx| {
718                if value.is_some() {
719                    *value = None;
720                    cx.notify();
721                }
722            });
723            submenu_focus.update(cx, |value, _| *value = false);
724            submenu_open = None;
725        } else if focus_first {
726            window.focus(&focus_handle, cx);
727        } else if self.on_back.is_none() {
728            crate::util::focus_once(window, cx, autofocus, &focus_handle);
729        }
730
731        // The rows a keyboard can land on -- an item that is not disabled -- and
732        // the text a typed letter searches.
733        let stops: Vec<usize> = self
734            .items
735            .iter()
736            .enumerate()
737            .filter(|(_, item)| match item {
738                MenuItem::Item { key, .. } => !self.disabled_keys.contains(key),
739                _ => false,
740            })
741            .map(|(i, _)| i)
742            .collect();
743        if let Some(stale) = cursor_at.filter(|index| !stops.contains(index)) {
744            cursor_at = stops
745                .iter()
746                .copied()
747                .find(|index| *index > stale)
748                .or_else(|| stops.iter().rev().copied().find(|index| *index < stale))
749                .or_else(|| stops.first().copied());
750            cursor.update(cx, |value, cx| {
751                *value = cursor_at;
752                cx.notify();
753            });
754        }
755        if focus_first {
756            if let Some(first) = stops.first().copied() {
757                cursor.update(cx, |value, cx| {
758                    *value = Some(first);
759                    cx.notify();
760                });
761                cursor_at = Some(first);
762            }
763            if let Some(state) = &self.focus_first {
764                state.update(cx, |value, _| *value = false);
765            }
766        }
767        let labels: Vec<String> = self
768            .items
769            .iter()
770            .map(|item| match item {
771                MenuItem::Item { label, .. } => label.to_string(),
772                _ => String::new(),
773            })
774            .collect();
775        let item_keys: Vec<SharedString> = self
776            .items
777            .iter()
778            .map(|item| match item {
779                MenuItem::Item { key, .. } => key.clone(),
780                _ => SharedString::default(),
781            })
782            .collect();
783        // Whether each row is a submenu trigger. Such a row opens a child
784        // panel instead of ending the menu, so activating it must not close
785        // the parent -- React Aria returns before the close for a trigger.
786        let item_has_submenu: Vec<bool> = self
787            .items
788            .iter()
789            .map(|item| match item {
790                MenuItem::Item { submenu, .. } => !submenu.is_empty(),
791                _ => false,
792            })
793            .collect();
794        // Whether each row hands its interaction to its own content. Enter and
795        // Space must leave such a row alone: the hosted control answers them.
796        let item_is_interactive: Vec<bool> = self
797            .items
798            .iter()
799            .map(|item| match item {
800                MenuItem::Item {
801                    is_interactive,
802                    submenu,
803                    ..
804                } => *is_interactive && submenu.is_empty(),
805                _ => false,
806            })
807            .collect();
808        let item_bounds = self
809            .items
810            .iter()
811            .map(|item| match item {
812                MenuItem::Item { key, submenu, .. } if !submenu.is_empty() => {
813                    Some(window.use_keyed_state(
814                        element_id::scoped(
815                            &element_id::scoped(&element_id::scoped(&base_id, "item"), key.clone()),
816                            "bounds",
817                        ),
818                        cx,
819                        |_, _| None::<Bounds<Pixels>>,
820                    ))
821                }
822                _ => None,
823            })
824            .collect::<Vec<_>>();
825        let all_panel_bounds = self
826            .panel_bounds
827            .clone()
828            .unwrap_or_else(|| std::rc::Rc::new(std::cell::RefCell::new(Vec::new())));
829        // The union Vec's lifetime is one frame by construction: the top-level
830        // menu allocates it here, in its own render, and gpui re-renders every
831        // frame the panel is mounted. Both canvases below push once per frame
832        // and the outside-press listener captured in this same render reads it,
833        // so the union is always exactly this frame's composite panels -- no
834        // stale bounds can outlive the frame that drew them. Never hoist this
835        // Rc into keyed state: a Vec that survived frames would accumulate
836        // every panel position it ever had and swallow outside presses near
837        // old panel locations.
838
839        let colors = cx.colors();
840        let menu_theme = cx.theme().components.menu.resolve(&self.recipes);
841        self.panel_min_width = self.panel_min_width.or(menu_theme.panel_min_width);
842        self.panel_max_width = self.panel_max_width.or(menu_theme.panel_max_width);
843        self.panel_max_height = self.panel_max_height.or(menu_theme.panel_max_height);
844        self.panel_padding = self.panel_padding.or(menu_theme.panel_padding);
845        self.panel_gap = self.panel_gap.or(menu_theme.panel_gap);
846        self.row_height = self.row_height.or(menu_theme.row_height);
847        self.row_padding_x = self.row_padding_x.or(menu_theme.row_padding_x);
848        self.row_padding_y = self.row_padding_y.or(menu_theme.row_padding_y);
849        self.row_text_size = self.row_text_size.or(menu_theme.row_text_size);
850        self.row_gap = self.row_gap.or(menu_theme.row_gap);
851        self.radius = self.radius.or(menu_theme.radius);
852        self.separator_inset = self.separator_inset.or(menu_theme.separator_inset);
853        self.separator_thickness = self.separator_thickness.or(menu_theme.separator_thickness);
854        if !self.animate_entry_is_set {
855            if let Some(animate) = menu_theme.animate_entry {
856                self.animate_entry = animate;
857            }
858        }
859        if self.row_hover_bg.is_none() {
860            self.row_hover_bg = menu_theme.row_hover_bg.map(|color| color.resolve(colors));
861        }
862        if self.row_hover_foreground.is_none() {
863            self.row_hover_foreground = menu_theme
864                .row_hover_foreground
865                .map(|color| color.resolve(colors));
866        }
867        let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);
868        let dropdown_composition = self.dropdown_composition;
869        // The panel's entry zoom interpolates the panel's own radius, so one
870        // binding feeds both the painted shape and the animation.
871        let radius = self
872            .radius
873            .unwrap_or_else(|| crate::util::container_radius(cx));
874
875        let row_height = self.row_height.unwrap_or(px(36.));
876        let row_padding_x = self.row_padding_x.unwrap_or(if dropdown_composition {
877            px(10.)
878        } else {
879            px(8.)
880        });
881        let row_text_size = self.row_text_size.unwrap_or(px(14.));
882        let row_gap = self.row_gap.unwrap_or(px(12.));
883        let separator_inset = self.separator_inset;
884        let separator_thickness = self
885            .separator_thickness
886            .unwrap_or_else(|| cx.layout().border_width);
887        let panel_padding =
888            self.panel_padding
889                .unwrap_or(if dropdown_composition { px(6.) } else { px(4.) });
890        let panel = gpui::div()
891            .relative()
892            .flex()
893            .flex_col()
894            // `.dropdown__popover` is `md:min-w-55` (220px) and the menu inside
895            // it is `gap-0.5 p-1` -- `.dropdown__menu` overrides `.menu`'s
896            // `gap-1` with half a step.
897            .min_w(self.panel_min_width.unwrap_or(px(220.)))
898            // `max-w-[48svw]`. Without a ceiling a described row's copy set
899            // the menu's width outright, so a long description could widen the
900            // popover across half the window.
901            .max_w(self.panel_max_width.unwrap_or(window.viewport_size().width * 0.48))
902            .gap(self.panel_gap.unwrap_or(px(2.)))
903            .p(panel_padding)
904            .bg(colors.overlay.background)
905            .rounded(radius)
906            .shadow(cx.layout().overlay_shadow.clone());
907        let mut panel = panel
908            // A long menu scrolls rather than being clipped, and gpui needs an
909            // id for that. Pinned `.dropdown__popover` owns the overflow
910            // (`overflow-y-auto`) while the menu inside is `overflow-clip`, and
911            // React Aria caps the popover at the space the viewport leaves
912            // (`calculatePosition`'s `getMaxHeight`). Inside a
913            // height-constraining positioner -- the dropdown root and each
914            // submenu popover -- the panel carries a viewport-relative bound so
915            // the positioner's capped pass sizes it, and a short menu keeps its
916            // natural height. A standalone menu keeps the old window share.
917            .id(element_id::scoped(&base_id, "list"))
918            .overflow_y_scroll()
919            .track_scroll(&menu_scroll_now)
920            .track_focus(&focus_handle)
921            .key_context("Menu");
922        // `menu/menu.js` renders the RAC `Menu`, and
923        // `react-aria/dist/private/menu/useMenu.js` is a flat `role: 'menu'` on
924        // the list element — which is this panel, the element that holds the
925        // rows and the keyboard focus. Upstream names it through
926        // `aria-labelledby` pointing at the trigger and warns when neither
927        // `aria-label` nor `aria-labelledby` is supplied; the port has no id
928        // graph and `Menu` has no label prop in v3's API, so it is unnamed.
929        panel = panel.a11y(a11y::Role::Menu);
930        if dropdown_composition || !self.deferred {
931            // The surface already carries the positioner's bound; the panel
932            // stretches to the surface's resolved height. A percentage `max_h`
933            // cannot tunnel through the auto-height surface -- it would keep
934            // its natural height with padding-only scroll room -- while flex
935            // stretch follows the resolved size.
936            panel = panel.self_stretch();
937        } else {
938            panel = panel.max_h(window.viewport_size().height * 0.6);
939        }
940        if let Some(max_height) = self.panel_max_height {
941            panel = panel.max_h(max_height);
942        }
943        if let Some(label) = self.panel_debug_label {
944            panel = panel.debug_selector(move || label.to_owned());
945        }
946
947        // v3 gives a floating panel no border: it is `bg-overlay shadow-overlay`
948        // and a radius, and dark mode's inset hairline is what separates the
949        // panel from the page.
950        if let Some(hairline) = cx.layout().overlay_hairline {
951            panel = panel
952                .border(cx.layout().border_width)
953                .border_color(hairline);
954        }
955
956        if !stops.is_empty() {
957            let held = cursor.clone();
958            let stops_for_keys = stops;
959            let typed_keys = typed;
960            let on_action = self.on_action.clone();
961            let on_selection_change = self.on_selection_change.clone();
962            let selection_own_for_keys = selection_own.clone();
963            let key_scroll = menu_scroll_now;
964            let mode = self.selection_mode;
965            let disallow_empty = self.disallow_empty_selection;
966            let selected_now = self.selected_keys.clone();
967            let keys = item_keys;
968            let has_submenu = item_has_submenu;
969            let is_interactive_for_keys = item_is_interactive;
970            let submenu_open_for_keys = submenu_state.clone();
971            let submenu_focus_for_keys = submenu_focus.clone();
972            let submenu_base_for_keys = base_id.clone();
973            let on_back = self.on_back.clone();
974            let local_submenu = submenu_state.clone();
975            let local_submenu_focus = submenu_focus.clone();
976            let dismiss = dismiss.clone();
977            let interaction_for_keys = interaction.clone();
978            panel = panel.on_key_down(move |event, window, cx| {
979                let key = event.keystroke.key.as_str();
980                let from = (*held.read(cx)).filter(|index| stops_for_keys.contains(index));
981                if key == "left" {
982                    if let Some(cb) = &on_back {
983                        local_submenu.update(cx, |value, cx| {
984                            if value.is_some() {
985                                *value = None;
986                                cx.notify();
987                            }
988                        });
989                        local_submenu_focus.update(cx, |value, _| *value = false);
990                        cb(window, cx);
991                        cx.stop_propagation();
992                    }
993                    return;
994                }
995                if key == "right" {
996                    let Some(i) = from else {
997                        return;
998                    };
999                    if has_submenu.get(i).copied().unwrap_or(false) {
1000                        let Some(item_key) = keys.get(i) else {
1001                            return;
1002                        };
1003                        let open_key = element_id::scoped(
1004                            &element_id::scoped(&submenu_base_for_keys, "sub"),
1005                            item_key.clone(),
1006                        );
1007                        submenu_open_for_keys.update(cx, |value, cx| {
1008                            *value = Some(open_key);
1009                            cx.notify();
1010                        });
1011                        submenu_focus_for_keys.update(cx, |value, _| *value = true);
1012                        cx.stop_propagation();
1013                    }
1014                    return;
1015                }
1016                // Pinned React Aria 3.51.0 binds PageUp/PageDown through the
1017                // menu's `useSelectableCollection`, which only runs while the
1018                // panel is mounted: a closed trigger answers no page key at
1019                // all. The handlers also require `manager.focusedKey != null`
1020                // -- a mouse-opened menu has a null cursor until an arrow (or
1021                // a keyboard open's focus-first) seats one, and the page keys
1022                // must stay inert until then. With a cursor the list is
1023                // non-scrollable -- HeroUI v3.2.4 puts the overflow scrolling
1024                // on the Popover while the Menu element is `overflow-clip` --
1025                // so a page takes the enabled end: `stops` already omits
1026                // disabled rows, whatever the menu's length or scroll state.
1027                let page_move = match key {
1028                    "pagedown" if from.is_some() => stops_for_keys.last().copied(),
1029                    "pageup" if from.is_some() => stops_for_keys.first().copied(),
1030                    _ => None,
1031                }
1032                .filter(|next| Some(*next) != from);
1033                match page_move.map_or_else(
1034                    || crate::list_nav::resolve(&stops_for_keys, from, key, false),
1035                    crate::list_nav::Move::To,
1036                ) {
1037                    crate::list_nav::Move::To(next) => {
1038                        held.update(cx, |v, cx| {
1039                            *v = Some(next);
1040                            cx.notify();
1041                        });
1042                        // Keep the focused row on screen: a highlight that walks
1043                        // out of the panel reads as the arrows having stopped.
1044                        key_scroll.scroll_to_item(next);
1045                    }
1046                    crate::list_nav::Move::Activate => {
1047                        let Some(i) = from else {
1048                            return;
1049                        };
1050                        let Some(item_key) = keys.get(i).cloned() else {
1051                            return;
1052                        };
1053                        // An interactive row is not a button: the element its
1054                        // `item_content` drew owns Enter and Space, exactly as
1055                        // it owns the pointer.
1056                        if is_interactive_for_keys.get(i).copied().unwrap_or(false) {
1057                            return;
1058                        }
1059                        if let Some(slot) = interaction_for_keys.get(i) {
1060                            crate::util::begin_keyboard_press(slot, event, window, cx);
1061                        }
1062                        let has_submenu = has_submenu[i];
1063                        // A submenu trigger opens its child; it is neither a
1064                        // selection nor a menu-level action in React Aria.
1065                        if has_submenu {
1066                            let open_key = element_id::scoped(
1067                                &element_id::scoped(&submenu_base_for_keys, "sub"),
1068                                item_key,
1069                            );
1070                            submenu_open_for_keys.update(cx, |value, cx| {
1071                                *value = Some(open_key);
1072                                cx.notify();
1073                            });
1074                            submenu_focus_for_keys.update(cx, |value, _| *value = true);
1075                            return;
1076                        }
1077                        if crate::selection::reports_changes(mode) {
1078                            let next = crate::selection::next_selection(
1079                                &selected_now,
1080                                &item_key,
1081                                mode,
1082                                disallow_empty,
1083                            );
1084                            let blocked_last_removal = disallow_empty
1085                                && selected_now.len() == 1
1086                                && selected_now.contains(&item_key);
1087                            if !blocked_last_removal {
1088                                if let Some(held) = &selection_own_for_keys {
1089                                    held.update(cx, |value, cx| {
1090                                        *value = next.clone();
1091                                        cx.notify();
1092                                    });
1093                                }
1094                                if let Some(cb) = &on_selection_change {
1095                                    cb(&next, window, cx);
1096                                }
1097                            }
1098                        }
1099                        if let Some(cb) = &on_action {
1100                            cb(&item_key, window, cx);
1101                        }
1102                        // React Aria always closes for Enter. Space stays open
1103                        // only in multiple mode, so another item can be ticked.
1104                        // The focus is deliberately not sent back to the
1105                        // trigger from a key: gpui activates a focused element
1106                        // on key up, which would reopen the menu.
1107                        if key == "enter" || mode != SelectionMode::Multiple {
1108                            if let Some(cb) = &dismiss {
1109                                cb(&false, window, cx);
1110                            }
1111                        }
1112                    }
1113                    crate::list_nav::Move::Ignore => {
1114                        if !crate::list_nav::is_typeahead_key(key) {
1115                            return;
1116                        }
1117                        let now = web_time::Instant::now();
1118                        let (query, repeat) = typed_keys.update(cx, |t, _| {
1119                            let query = t.push(key, now);
1120                            (query, t.is_repeat())
1121                        });
1122                        if let Some(found) = crate::list_nav::typeahead(
1123                            &labels,
1124                            &stops_for_keys,
1125                            from,
1126                            &query,
1127                            repeat,
1128                        ) {
1129                            held.update(cx, |v, cx| {
1130                                *v = Some(found);
1131                                cx.notify();
1132                            });
1133                        }
1134                    }
1135                }
1136            });
1137        }
1138
1139        let mut open_submenu = None;
1140        for (i, item) in self.items.into_iter().enumerate() {
1141            match item {
1142                MenuItem::Separator => {
1143                    // `menu.css:8-11` and `dropdown.css:127-130` both inset the
1144                    // rule: `[data-slot="separator"] { @apply ms-[3%] w-[94%] }`,
1145                    // centred inside the panel's content box. `list_box.rs`
1146                    // already ports the identical rule; this panel used to draw
1147                    // the band full bleed.
1148                    //
1149                    // `separator_inset` replaces the proportional inset with an
1150                    // absolute one on each edge, for a panel that has to sit
1151                    // beside a platform menu whose own rule is a fixed inset
1152                    // rather than a share of the width.
1153                    panel = panel.child(match separator_inset {
1154                        Some(inset) => gpui::div().w_full().my(px(4.)).px(inset).child(
1155                            gpui::div()
1156                                .w_full()
1157                                .h(separator_thickness)
1158                                .bg(colors.separator),
1159                        ),
1160                        None => gpui::div()
1161                            .my(px(4.))
1162                            .mx(gpui::relative(0.03))
1163                            .w(gpui::relative(0.94))
1164                            .h(separator_thickness)
1165                            .bg(colors.separator),
1166                    });
1167                }
1168                MenuItem::SectionLabel(label) => {
1169                    panel = panel.child(
1170                        gpui::div()
1171                            .px(px(8.))
1172                            .pt(px(6.))
1173                            .pb(px(4.))
1174                            .text_size(px(12.))
1175                            .line_height(px(16.))
1176                            .font_weight(gpui::FontWeight::MEDIUM)
1177                            .text_color(colors.muted)
1178                            .child(label.to_string()),
1179                    );
1180                }
1181                MenuItem::Item {
1182                    key,
1183                    label,
1184                    shortcut,
1185                    icon,
1186                    is_danger,
1187                    description,
1188                    submenu,
1189                    is_interactive,
1190                } => {
1191                    // Either the single controlled key or membership of the
1192                    // selection set marks an item.
1193                    let is_selected = self.selected_key.as_ref() == Some(&key)
1194                        || self.selected_keys.contains(&key);
1195                    let is_item_disabled = self.disabled_keys.contains(&key);
1196                    let has_submenu = !submenu.is_empty();
1197                    // A submenu trigger already skips the row click; the flag
1198                    // only has meaning on a plain row.
1199                    let row_is_interactive = is_interactive && !has_submenu;
1200                    let open_key =
1201                        element_id::scoped(&element_id::scoped(&base_id, "sub"), key.clone());
1202                    let highlighted = !is_item_disabled
1203                        && (cursor_at == Some(i)
1204                            || submenu_open.as_ref() == Some(&open_key)
1205                            || interaction.get(i).is_some_and(|slot| slot.read(cx).0));
1206                    let highlight_foreground = self.row_hover_foreground.filter(|_| highlighted);
1207                    let text_color = if let Some(color) = highlight_foreground {
1208                        color
1209                    } else if is_item_disabled {
1210                        colors.muted
1211                    } else if is_danger {
1212                        colors.danger.color
1213                    } else {
1214                        colors.foreground
1215                    };
1216                    let mut row = gpui::div()
1217                        .id(element_id::indexed(&base_id, "item", i))
1218                        .relative()
1219                        .flex()
1220                        // `.menu-item` is `w-full`: the row takes the menu's
1221                        // width rather than its own content's.
1222                        .w_full()
1223                        .items_center()
1224                        .gap(row_gap)
1225                        .px(row_padding_x)
1226                        .rounded(crate::util::soft_radius(cx))
1227                        .text_size(row_text_size)
1228                        .line_height(px(20.))
1229                        .text_color(text_color);
1230                    if let Some(recorded_item_bounds) = item_bounds[i].clone() {
1231                        row = row.child(
1232                            gpui::canvas(
1233                                move |bounds, _, cx| {
1234                                    recorded_item_bounds.update(cx, |value, cx| {
1235                                        if value.as_ref() != Some(&bounds) {
1236                                            *value = Some(bounds);
1237                                            cx.notify();
1238                                        }
1239                                    });
1240                                    bounds
1241                                },
1242                                |_, _, _, _| {},
1243                            )
1244                            .absolute()
1245                            .inset_0(),
1246                        );
1247                    }
1248                    // `.menu-item` is `min-h-9 py-1.5`; a described row grows
1249                    // past the minimum instead of clipping its second line.
1250                    row = row
1251                        .min_h(row_height)
1252                        .py(self.row_padding_y.unwrap_or(px(6.)));
1253                    // `react-aria/dist/private/menu/useMenuItem.js` decides the
1254                    // row's role in one place: `let role = 'menuitem'`, and
1255                    // then, *only when the row is not a submenu trigger*,
1256                    // `menuitemradio` in single-selection mode and
1257                    // `menuitemcheckbox` in multiple. `aria-checked` follows the
1258                    // same guard (`selectionMode !== 'none' && !isTrigger`), and
1259                    // a submenu trigger takes `aria-expanded` instead. Its
1260                    // `aria-haspopup` and `aria-controls` companions have no
1261                    // gpui builder (see `crate::a11y`).
1262                    let item_role = match (has_submenu, self.selection_mode) {
1263                        (true, _) | (false, SelectionMode::None) => a11y::Role::MenuItem,
1264                        (false, SelectionMode::Single) => a11y::Role::MenuItemRadio,
1265                        (false, SelectionMode::Multiple) => a11y::Role::MenuItemCheckBox,
1266                    };
1267                    // `aria-describedby` joins the description node and the
1268                    // keyboard-shortcut node, in that order.
1269                    let described = match (&description, &shortcut) {
1270                        (None, None) => None,
1271                        (Some(d), None) => Some(d.clone()),
1272                        (None, Some(k)) => Some(k.clone()),
1273                        (Some(d), Some(k)) => Some(SharedString::from(format!("{d} {k}"))),
1274                    };
1275                    row = row.a11y_named(
1276                        item_role,
1277                        &a11y::Name::labelled(label.clone()).described(described),
1278                    );
1279                    if has_submenu {
1280                        let open_key =
1281                            element_id::scoped(&element_id::scoped(&base_id, "sub"), key.clone());
1282                        row = row.a11y_expanded(submenu_open.as_ref() == Some(&open_key));
1283                    } else if self.selection_mode != SelectionMode::None {
1284                        row = row.a11y_checked(is_selected, false);
1285                    }
1286                    if is_item_disabled {
1287                        // `status-disabled` is `--disabled-opacity`; the muted
1288                        // text alone was this port's own idea of the state.
1289                        row = row.opacity(cx.layout().disabled_opacity);
1290                    } else {
1291                        row = crate::util::cursor_interactive(row, cx);
1292                        // `.menu-item:hover` fills with `bg-default`, the full
1293                        // token, not the soft wash.
1294                        row = row.hover(move |s| s.bg(row_hover_bg));
1295                        let pointer_cursor = cursor.clone();
1296                        let pointer_focus = focus_handle.clone();
1297                        row = row.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
1298                            window.focus(&pointer_focus, cx);
1299                            pointer_cursor.update(cx, |value, cx| {
1300                                *value = Some(i);
1301                                cx.notify();
1302                            });
1303                        });
1304                        let hover_cursor = cursor.clone();
1305                        let hover_focus = focus_handle.clone();
1306                        row = row.on_mouse_move(move |_, window, cx| {
1307                            if crate::util::focus_visible(cx) || *hover_cursor.read(cx) == Some(i) {
1308                                return;
1309                            }
1310                            window.focus(&hover_focus, cx);
1311                            hover_cursor.update(cx, |value, cx| {
1312                                *value = Some(i);
1313                                cx.notify();
1314                            });
1315                        });
1316                    }
1317                    row = when_selected(row, is_selected, sem_primary(cx));
1318                    if highlighted && self.row_hover_foreground.is_some() {
1319                        row = row.bg(row_hover_bg).text_color(text_color);
1320                    }
1321                    // `.menu-item` takes `status-focused` on the row the keyboard
1322                    // is on -- a ring, not a border, which would shift the row.
1323                    // Pointer hover seats the cursor for the next arrow; it
1324                    // must not paint the ring. The ring rides as an overlay
1325                    // child so its corner is concentric with the row's
1326                    // `soft_radius`, which a spread shadow cannot be: the
1327                    // shadow keeps the row's own radius at a band two pixels
1328                    // wider, and needs a blur to paint at all.
1329                    row = crate::util::with_focus_ring_overlay(
1330                        row,
1331                        crate::util::shows_focus_ring(cursor_at == Some(i), cx),
1332                        true,
1333                        crate::util::soft_radius(cx),
1334                        Vec::new(),
1335                        cx,
1336                    );
1337
1338                    // HeroUI passes React Aria's raw MenuItem state to the
1339                    // indicator. The pinned 1.20.0 state has no indeterminate
1340                    // member, so the documented value is always falsy.
1341                    let is_indeterminate = false;
1342                    let indicator_content = if let Some(render) = &self.indicator_content {
1343                        Some(render(&key, is_selected, is_indeterminate))
1344                    } else if is_selected && self.selection_mode != SelectionMode::None {
1345                        Some(match self.indicator {
1346                            IndicatorKind::Checkmark => gpui::svg()
1347                                .size(px(13.))
1348                                .path(icons::CHECK)
1349                                // svg() never inherits text colour.
1350                                .text_color(highlight_foreground.unwrap_or_else(|| sem_primary(cx)))
1351                                .into_any_element(),
1352                            IndicatorKind::Dot => gpui::div()
1353                                .size(px(6.))
1354                                .rounded_full()
1355                                .bg(highlight_foreground.unwrap_or_else(|| sem_primary(cx)))
1356                                .into_any_element(),
1357                        })
1358                    } else {
1359                        None
1360                    };
1361                    let mut indicator = if self.indicator_content.is_some()
1362                        || self.selection_mode != SelectionMode::None
1363                    {
1364                        let mut cell = gpui::div()
1365                            .size(px(16.))
1366                            .flex_none()
1367                            .flex()
1368                            .items_center()
1369                            .justify_center();
1370                        // The row's normal gap is 12px. v3 positions the 16px
1371                        // cell 4px from its content, so cancel the extra 8px.
1372                        cell = if has_submenu {
1373                            cell.ml(px(-8.))
1374                        } else {
1375                            cell.mr(px(-8.))
1376                        };
1377                        if let Some(content) = indicator_content {
1378                            cell = cell.child(content);
1379                        }
1380                        Some(cell.into_any_element())
1381                    } else {
1382                        None
1383                    };
1384                    // The indicator owns v3's leading 16px cell. Submenu rows
1385                    // move it beside the trailing submenu chevron instead.
1386                    if !has_submenu {
1387                        if let Some(content) = indicator.take() {
1388                            row = row.child(content);
1389                        }
1390                    }
1391                    if let Some(icon_path) = icon {
1392                        row = row.child(
1393                            gpui::svg()
1394                                // `.menu-item__indicator` is `size-4`.
1395                                .size(px(16.))
1396                                .path(icon_path)
1397                                .text_color(text_color),
1398                        );
1399                    }
1400                    // `children` on `Dropdown.Item` is a render function in
1401                    // v3, handed the row's state.
1402                    row = row.child(
1403                        gpui::div().flex().flex_col().flex_1().min_w_0().child(
1404                            match &self.item_content {
1405                                Some(render) => {
1406                                    // The slot's press is a frame behind the
1407                                    // pointer, because gpui reports it to a handler
1408                                    // rather than to the render that draws it. v3's
1409                                    // `Dropdown.Item` render-props table lists no
1410                                    // `isHovered`, so the hover the slot also
1411                                    // tracks is not handed over; a row is focused
1412                                    // when the keyboard cursor is on it.
1413                                    let (_, recorded_press) = interaction
1414                                        .get(i)
1415                                        .map(|slot| *slot.read(cx))
1416                                        .unwrap_or_default();
1417                                    render(
1418                                        &key,
1419                                        crate::util::InteractiveState {
1420                                            is_hovered: false,
1421                                            is_pressed: !is_item_disabled && recorded_press,
1422                                            is_focused: cursor_at == Some(i),
1423                                            is_focus_visible: cursor_at == Some(i)
1424                                                && crate::util::focus_visible(cx),
1425                                            is_selected,
1426                                            is_disabled: is_item_disabled,
1427                                            is_pending: false,
1428                                            is_indeterminate,
1429                                        },
1430                                    )
1431                                }
1432                                None => match &description {
1433                                    // `Label` over `Description`, which is how v3
1434                                    // composes a described item.
1435                                    Some(text) => gpui::div()
1436                                        .flex()
1437                                        .flex_col()
1438                                        .min_w_0()
1439                                        .child(
1440                                            gpui::div()
1441                                                .font_weight(gpui::FontWeight::MEDIUM)
1442                                                .child(label.to_string()),
1443                                        )
1444                                        .child(
1445                                            gpui::div()
1446                                            // A described row composes a
1447                                            // `Description`, which is `text-xs`.
1448                                            .text_size(px(12.))
1449                                            .line_height(px(16.))
1450                                            .text_color(highlight_foreground.unwrap_or(colors.muted))
1451                                            // `[data-slot="description"]` is
1452                                            // `text-wrap`, so its box is the
1453                                            // column's width, not its own
1454                                            // content's.
1455                                            .w_full()
1456                                            .child(text.to_string()),
1457                                        )
1458                                        .into_any_element(),
1459                                    None => gpui::div()
1460                                        .font_weight(gpui::FontWeight::MEDIUM)
1461                                        .child(label.to_string())
1462                                        .into_any_element(),
1463                                },
1464                            },
1465                        ),
1466                    );
1467                    // The slot's hover and press handlers keep the press the
1468                    // closure reads current. Disabled rows expose idle state.
1469                    if !is_item_disabled {
1470                        if let Some(slot) = interaction.get(i) {
1471                            row = crate::util::track_interaction(row, slot);
1472                        }
1473                    }
1474                    if let Some(sc) = shortcut {
1475                        row = row.child(
1476                            crate::kbd::Kbd::new()
1477                                .variant(crate::kbd::KbdVariant::Light)
1478                                .map(|kbd| match highlight_foreground {
1479                                    Some(color) => kbd.sx(move |el| el.text_color(color)),
1480                                    None => kbd,
1481                                })
1482                                .child(sc.to_string()),
1483                        );
1484                    }
1485                    // `Dropdown.SubmenuIndicator` — the chevron that says a row
1486                    // opens another panel.
1487                    if has_submenu {
1488                        row = row.child(
1489                            gpui::svg()
1490                                .size(px(13.))
1491                                .path(icons::CHEVRON_RIGHT)
1492                                .text_color(highlight_foreground.unwrap_or(colors.muted)),
1493                        );
1494                        if let Some(content) = indicator {
1495                            row = row.child(content);
1496                        }
1497                    }
1498
1499                    // `.menu-item[data-pressed]` is `scale(0.98)`. The press
1500                    // wrap comes after every visual child: children added
1501                    // after `pressed` land on the slot and fight the skin for
1502                    // width. The row is `w-full`, so its slot is too.
1503                    // The 98% press scale shrinks every child, a hosted
1504                    // control included, and it is keyed off a row press the
1505                    // interactive row no longer claims.
1506                    if !is_item_disabled && !row_is_interactive {
1507                        row = crate::anim::pressed(
1508                            row,
1509                            crate::anim::PressBox {
1510                                height: row_height,
1511                                padding_x: Some(row_padding_x),
1512                                width: None,
1513                                min_width: None,
1514                                text_size: row_text_size,
1515                                line_height: px(20.),
1516                                gap: row_gap,
1517                                radius: crate::util::soft_radius(cx),
1518                                shrink_x: false,
1519                                scale: crate::anim::PRESSED_SCALE_SUBTLE,
1520                            },
1521                            cx,
1522                        );
1523                    }
1524
1525                    if !is_item_disabled && !has_submenu && !row_is_interactive {
1526                        let on_action = self.on_action.clone();
1527                        let on_selection_change = self.on_selection_change.clone();
1528                        let selection_own = selection_own.clone();
1529                        let dismiss = dismiss.clone();
1530                        // Attached even with no callback to run, because v3's
1531                        // close happens on the click, not in a handler.
1532                        let key2 = key.clone();
1533                        let mode = self.selection_mode;
1534                        let disallow_empty = self.disallow_empty_selection;
1535                        let current = self.selected_keys.clone();
1536                        row = row.on_click(move |_, window, cx| {
1537                            if crate::selection::reports_changes(mode) {
1538                                let next = crate::selection::next_selection(
1539                                    &current,
1540                                    &key2,
1541                                    mode,
1542                                    disallow_empty,
1543                                );
1544                                let blocked_last_removal =
1545                                    disallow_empty && current.len() == 1 && current.contains(&key2);
1546                                if !blocked_last_removal {
1547                                    if let Some(held) = &selection_own {
1548                                        held.update(cx, |value, cx| {
1549                                            *value = next.clone();
1550                                            cx.notify();
1551                                        });
1552                                    }
1553                                    if let Some(cb) = &on_selection_change {
1554                                        cb(&next, window, cx);
1555                                    }
1556                                }
1557                            }
1558                            if let Some(cb) = &on_action {
1559                                cb(&key2, window, cx);
1560                            }
1561                            // A pointer pick stays open only in multiple mode.
1562                            // The trigger gets focus back from a click; only a
1563                            // keyboard key-up cannot safely refocus it.
1564                            if mode != SelectionMode::Multiple {
1565                                if let Some(cb) = &dismiss {
1566                                    cb(&true, window, cx);
1567                                }
1568                            }
1569                        });
1570                    }
1571
1572                    // `Dropdown.SubmenuTrigger`: the child panel is anchored to
1573                    // the row and opens while the row is hovered. gpui paints in
1574                    // tree order, so it goes through `util::floating` like every
1575                    // other floating surface.
1576                    if has_submenu {
1577                        let open_key =
1578                            element_id::scoped(&element_id::scoped(&base_id, "sub"), key.clone());
1579                        let is_sub_open = submenu_open.as_ref() == Some(&open_key);
1580                        let held = submenu_state.clone();
1581                        let hover_focus = submenu_focus.clone();
1582                        let open_key2 = open_key.clone();
1583                        // The hover that opens the child panel lives on the
1584                        // wrapper, not the row: `track_interaction` above has
1585                        // claimed the row's single `on_hover` when an
1586                        // `item_content` closure is set, and gpui refuses a
1587                        // second listener on one element.
1588                        let mut slot = gpui::div()
1589                            .id(element_id::scoped(&open_key, "wrap"))
1590                            .relative()
1591                            .child(row);
1592                        if !is_item_disabled {
1593                            let click_held = submenu_state.clone();
1594                            let click_focus = submenu_focus.clone();
1595                            let click_key = open_key.clone();
1596                            slot = slot
1597                                .on_hover(move |hovered, _window, cx| {
1598                                    if *hovered {
1599                                        held.update(cx, |value, cx| {
1600                                            if value.as_ref() != Some(&open_key2) {
1601                                                *value = Some(open_key2.clone());
1602                                                cx.notify();
1603                                            }
1604                                        });
1605                                        hover_focus.update(cx, |value, _| *value = false);
1606                                    }
1607                                })
1608                                .on_click(move |_, _, cx| {
1609                                    click_held.update(cx, |value, cx| {
1610                                        *value = Some(click_key.clone());
1611                                        cx.notify();
1612                                    });
1613                                    click_focus.update(cx, |value, _| *value = false);
1614                                });
1615                        }
1616                        if is_sub_open {
1617                            open_submenu = Some((i, open_key, submenu));
1618                        }
1619                        panel = panel.child(slot);
1620                    } else {
1621                        panel = panel.child(row);
1622                    }
1623                }
1624            }
1625        }
1626
1627        // The flex surface's rectangular hull includes blank space between
1628        // unequal-height panels. The outside listener lives on the root panel
1629        // and checks the union of the actual parent and descendant bounds.
1630        if self.deferred {
1631            if let Some(cb) = dismiss.clone() {
1632                if open_submenu.is_some() {
1633                    // A submenu is a sibling of the parent panel. Keep the
1634                    // union-boundary check so its real bounds remain inside
1635                    // the menu surface; the shared token still gates Escape.
1636                    let panel_union = all_panel_bounds.clone();
1637                    if let Some(token) = overlay_token.clone() {
1638                        panel = crate::util::dismiss_on_press_outside_with_token_event(
1639                            panel,
1640                            token,
1641                            move |event, window, cx| {
1642                                let inside = panel_union.borrow().iter().any(|bounds| {
1643                                    event.position.x >= bounds.origin.x
1644                                        && event.position.x <= bounds.origin.x + bounds.size.width
1645                                        && event.position.y >= bounds.origin.y
1646                                        && event.position.y <= bounds.origin.y + bounds.size.height
1647                                });
1648                                if inside {
1649                                    crate::util::DismissResult::Declined
1650                                } else {
1651                                    cb(&true, window, cx);
1652                                    crate::util::DismissResult::Handled
1653                                }
1654                            },
1655                        );
1656                    }
1657                } else if let Some(token) = overlay_token.clone() {
1658                    // The explicit token gates a simple menu against any
1659                    // overlay above it.
1660                    panel = crate::util::dismiss_on_press_outside_with_token(
1661                        panel,
1662                        token,
1663                        move |window, cx| {
1664                            cb(&true, window, cx);
1665                            crate::util::DismissResult::Handled
1666                        },
1667                    );
1668                }
1669            }
1670        }
1671
1672        // The zoom grows the panel's own vertical padding, so the border box
1673        // the positioner places IS the painted panel: at rest the animated
1674        // padding equals the panel's natural padding (`p-1.5` under the
1675        // dropdown composition, `p-1` otherwise) and adds nothing, and
1676        // mid-animation only the internal padding flexes, never the origin.
1677        // Animating the surface instead would hang its `py` between the
1678        // placed box and the painted panel (one RAC gap plus ~6px).
1679        let zoom = crate::anim::ZoomBox::panel(panel_padding, radius);
1680        let panel = if self.exiting {
1681            crate::anim::exiting(
1682                panel,
1683                element_id::scoped(&base_id, "panel-out"),
1684                zoom,
1685                crate::anim::Motion::LIST_OUT,
1686                cx,
1687            )
1688        } else if self.animate_entry {
1689            crate::anim::entering_zoom(
1690                panel,
1691                element_id::scoped(&base_id, "panel"),
1692                zoom,
1693                crate::anim::Motion::POPOVER_IN,
1694                cx,
1695            )
1696        } else {
1697            panel.into_any_element()
1698        };
1699
1700        // Parent and child menus share one deferred surface. The submenu is its
1701        // own popover positioned against its row -- pinned RAC 1.20.0 renders
1702        // each submenu in a separate `Popover` with `placement: 'end top'` --
1703        // so it is a sibling of the parent's overflow scroller, never clipped
1704        // by it, and it flips and caps against the viewport on its own. The
1705        // positioner element itself is absolute, so it takes no flex space.
1706        // The surface carries the positioner's relative bound through to the
1707        // panel: `layout_as_root` only offers definite space, and the cap
1708        // resolves through `max_h_full` at every level -- a bare flex surface
1709        // would size to its content and shield the panel, leaving it at its
1710        // natural height with padding-only scroll room.
1711        let mut surface = gpui::div()
1712            .relative()
1713            .flex()
1714            .items_start()
1715            .gap(px(4.))
1716            .max_h_full()
1717            .child(panel);
1718        if let Some((index, submenu_id, submenu)) = open_submenu {
1719            // The row canvas records these bounds every frame, so the bridge
1720            // carries last frame's row rect and the positioner settles the
1721            // same frame the submenu opens.
1722            let row_trigger = std::rc::Rc::new(std::cell::Cell::new(
1723                item_bounds[index]
1724                    .as_ref()
1725                    .and_then(|bounds| bounds.read(cx).to_owned()),
1726            ));
1727            let mut sub = Menu::new(submenu_id.clone(), submenu)
1728                .id(element_id::scoped(&submenu_id, "menu"))
1729                .panel_debug_label("dropdown-submenu")
1730                .indicator(self.indicator)
1731                .disabled_keys(self.disabled_keys)
1732                .embedded(all_panel_bounds.clone())
1733                .focus_first(submenu_focus.clone());
1734            sub.panel_min_width = self.panel_min_width;
1735            sub.panel_max_width = self.panel_max_width;
1736            sub.panel_max_height = self.panel_max_height;
1737            sub.row_height = self.row_height;
1738            sub.row_padding_x = self.row_padding_x;
1739            sub.row_padding_y = self.row_padding_y;
1740            sub.row_text_size = self.row_text_size;
1741            sub.row_gap = self.row_gap;
1742            sub.panel_padding = self.panel_padding;
1743            sub.panel_gap = self.panel_gap;
1744            sub.separator_inset = self.separator_inset;
1745            sub.separator_thickness = self.separator_thickness;
1746            sub.animate_entry = self.animate_entry;
1747            sub.row_hover_bg = self.row_hover_bg;
1748            sub.row_hover_foreground = self.row_hover_foreground;
1749            sub.radius = self.radius;
1750            sub.recipes = self.recipes.clone();
1751            sub.animate_entry_is_set = self.animate_entry_is_set;
1752            sub.item_content = self.item_content.clone();
1753            sub.indicator_content = self.indicator_content.clone();
1754            if let Some(token) = overlay_token.clone() {
1755                sub = sub.overlay_token(token);
1756            }
1757            let close_state = submenu_state;
1758            let close_focus_state = submenu_focus;
1759            let parent_focus = focus_handle.clone();
1760            sub = sub.on_back(move |window, cx| {
1761                close_state.update(cx, |value, cx| {
1762                    *value = None;
1763                    cx.notify();
1764                });
1765                close_focus_state.update(cx, |value, _| *value = false);
1766                window.focus(&parent_focus, cx);
1767            });
1768            if let Some(cb) = self.on_action.clone() {
1769                sub = sub.on_action(move |key, window, cx| cb(key, window, cx));
1770            }
1771            if let Some(cb) = dismiss.clone() {
1772                sub = sub.on_dismiss(move |refocus, window, cx| cb(refocus, window, cx));
1773            }
1774            surface = surface.child(crate::popover::scrollable_submenu_popover(row_trigger, sub));
1775        }
1776
1777        // The union outside-press check reads this frame's panel frames. The
1778        // canvas lives on the surface, not the panel: the panel owns the
1779        // vertical scroll container, so a canvas inside it reports scrolled
1780        // content-space bounds after a wheel (its origin walks up with the
1781        // rows) and a press on a scrolled-into-view row reads as outside.
1782        // The surface never scrolls -- its only other child is the absolute
1783        // submenu positioner, which takes no flex space -- so its frame
1784        // coincides with the panel's.
1785        let registered_panel_bounds = all_panel_bounds;
1786        surface = surface.child(
1787            gpui::canvas(
1788                move |bounds, _, _| {
1789                    registered_panel_bounds.borrow_mut().push(bounds);
1790                    bounds
1791                },
1792                |_, _, _, _| {},
1793            )
1794            .absolute()
1795            .inset_0(),
1796        );
1797
1798        // Escape bubbles from the focused descendant to this root surface.
1799        let surface = if self.deferred {
1800            match dismiss {
1801                Some(cb) => match overlay_token {
1802                    Some(token) => crate::util::dismiss_on_escape_with_token(
1803                        surface,
1804                        token,
1805                        move |window, cx| {
1806                            cb(&true, window, cx);
1807                            crate::util::DismissResult::Handled
1808                        },
1809                    ),
1810                    None => surface,
1811                },
1812                None => surface,
1813            }
1814        } else {
1815            surface
1816        };
1817        let surface = crate::util::apply_sx(surface, &self.sx);
1818
1819        if self.deferred {
1820            crate::util::floating(surface).into_any_element()
1821        } else {
1822            surface.into_any_element()
1823        }
1824    }
1825}
1826
1827fn sem_primary(cx: &App) -> gpui::Hsla {
1828    cx.colors().accent.color
1829}
1830
1831fn when_selected(
1832    el: gpui::Stateful<gpui::Div>,
1833    selected: bool,
1834    color: gpui::Hsla,
1835) -> gpui::Stateful<gpui::Div> {
1836    if selected {
1837        el.bg(color.alpha(0.14)).text_color(color)
1838    } else {
1839        el
1840    }
1841}
1842
1843/// `trigger` — what opens the menu.
1844#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
1845pub enum DropdownTrigger {
1846    /// A press opens it, which is v3's default.
1847    #[default]
1848    Press,
1849    /// A press held for the theme's `long_press_ms` opens it (500ms by
1850    /// default, matching React Aria).
1851    LongPress,
1852}
1853
1854/// Dropdown wrapper: trigger + floating menu panel (`Dropdown/DropdownTrigger/
1855/// DropdownMenu` composition).
1856#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
1857#[derive(IntoElement)]
1858pub struct Dropdown {
1859    /// Keys this dropdown's own state; see [`Dropdown::id`].
1860    id: gpui::ElementId,
1861    trigger: AnyElement,
1862    /// `trigger` — press (the default) or long press.
1863    trigger_kind: DropdownTrigger,
1864    /// `isOpen` — `None` leaves the component holding the flag, seeded from
1865    /// `defaultOpen`.
1866    is_open: Option<bool>,
1867    default_open: bool,
1868    on_open_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
1869    items: Vec<MenuItem>,
1870    item_content: Option<ItemContent>,
1871    indicator_content: Option<ItemIndicatorContent>,
1872    selection_mode: SelectionMode,
1873    selected_keys: Vec<SharedString>,
1874    default_selected_keys: Vec<SharedString>,
1875    selection_is_controlled: bool,
1876    disallow_empty_selection: bool,
1877    disabled_keys: Vec<SharedString>,
1878    indicator: IndicatorKind,
1879    on_selection_change: Option<OnSelectionChange>,
1880    on_action: Option<OnSelect>,
1881    /// The fill a hovered menu row takes, in place of `--default`.
1882    row_hover_bg: Option<gpui::Hsla>,
1883    /// The panel's corner radius, forwarded onto the menu that paints it.
1884    radius: Option<Pixels>,
1885    placement: DropdownPlacement,
1886    /// The `sx` slot, refined over the root style at the end of render.
1887    sx: Option<Box<gpui::StyleRefinement>>,
1888    recipes: Vec<SharedString>,
1889}
1890
1891/// `placement` on `Dropdown.Popover`.
1892///
1893/// Shares the one placement vocabulary with the pickers and popover; it
1894/// previously offered only the two bottom-aligned values.
1895pub use herogpui_core::Placement as DropdownPlacement;
1896
1897impl Dropdown {
1898    /// The element id this dropdown's state is keyed by.
1899    ///
1900    /// Not a v3 prop. It matters more here than it looks: with one shared key,
1901    /// pressing any trigger on a page opened *every* menu on it, because they
1902    /// were all reading the same uncontrolled open flag.
1903    pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
1904        self.id = id.into();
1905        self
1906    }
1907
1908    /// `onOpenChange` — reports the open state the trigger moves to.
1909    pub fn on_open_change(
1910        mut self,
1911        handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
1912    ) -> Self {
1913        self.on_open_change = Some(std::sync::Arc::new(handler));
1914        self
1915    }
1916
1917    /// `isOpen` — also accepted positionally by [`Dropdown::new`].
1918    pub fn is_open(mut self, v: bool) -> Self {
1919        self.is_open = Some(v);
1920        self
1921    }
1922    /// `defaultOpen` — the uncontrolled initial state.
1923    ///
1924    /// Only consulted when `is_open` is not supplied; the component then owns
1925    /// the flag and its trigger toggles it.
1926    pub fn default_open(mut self, v: bool) -> Self {
1927        self.default_open = v;
1928        self
1929    }
1930
1931    /// An uncontrolled dropdown: the menu holds its own open state, seeded
1932    /// from [`Dropdown::default_open`], and the trigger toggles it.
1933    /// `trigger` — `Press` (default) or `LongPress`.
1934    pub fn trigger(mut self, kind: DropdownTrigger) -> Self {
1935        self.trigger_kind = kind;
1936        self
1937    }
1938
1939    /// Creates a dropdown that owns its open state.
1940    pub fn uncontrolled(
1941        id: impl Into<gpui::ElementId>,
1942        trigger: impl IntoElement,
1943        items: Vec<MenuItem>,
1944    ) -> Self {
1945        let mut dd = Self::new(id, trigger, items, false);
1946        dd.is_open = None;
1947        dd
1948    }
1949
1950    /// Creates a dropdown whose open state is supplied by the caller.
1951    pub fn new(
1952        id: impl Into<gpui::ElementId>,
1953        trigger: impl IntoElement,
1954        items: Vec<MenuItem>,
1955        is_open: bool,
1956    ) -> Self {
1957        Self {
1958            id: id.into(),
1959            trigger: trigger.into_any_element(),
1960            trigger_kind: DropdownTrigger::default(),
1961            is_open: Some(is_open),
1962            default_open: false,
1963            on_open_change: None,
1964            items,
1965            item_content: None,
1966            indicator_content: None,
1967            selection_mode: SelectionMode::None,
1968            selected_keys: Vec::new(),
1969            default_selected_keys: Vec::new(),
1970            selection_is_controlled: false,
1971            disallow_empty_selection: false,
1972            disabled_keys: Vec::new(),
1973            indicator: IndicatorKind::default(),
1974            on_selection_change: None,
1975            on_action: None,
1976            row_hover_bg: None,
1977            radius: None,
1978            placement: DropdownPlacement::BottomStart,
1979            sx: None,
1980            recipes: Vec::new(),
1981        }
1982    }
1983
1984    /// Named theme overlay forwarded onto the painted [`Menu`].
1985    /// Stackable; a missing name adds no override.
1986    pub fn recipe(mut self, name: impl Into<SharedString>) -> Self {
1987        self.recipes.push(name.into());
1988        self
1989    }
1990
1991    /// `type` on `Dropdown.ItemIndicator`.
1992    pub fn indicator(mut self, kind: IndicatorKind) -> Self {
1993        self.indicator = kind;
1994        self
1995    }
1996
1997    /// The fill a hovered menu row takes, in place of `--default`. v3 tints
1998    /// the row with a class; this names the colour.
1999    pub fn row_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
2000        self.row_hover_bg = Some(color.into());
2001        self
2002    }
2003
2004    /// The panel's corner radius, in place of the owning `container_radius`
2005    /// helper. The panel's entry zoom interpolates the same value, so both
2006    /// follow the override. Not a v3 prop; the removed v2 `radius` prop is
2007    /// prohibited and this is a per-component repository extension.
2008    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
2009        self.radius = Some(radius.into());
2010        self
2011    }
2012
2013    /// `children` on `Dropdown.Item` — replaces each item's label with a
2014    /// render closure receiving its key and interactive state.
2015    pub fn item_content(
2016        mut self,
2017        render: impl Fn(&SharedString, crate::util::InteractiveState) -> AnyElement + 'static,
2018    ) -> Self {
2019        self.item_content = Some(std::sync::Arc::new(render));
2020        self
2021    }
2022
2023    /// `children` on `Dropdown.ItemIndicator` — replaces the built-in mark.
2024    pub fn indicator_content(
2025        mut self,
2026        render: impl Fn(&SharedString, bool, bool) -> AnyElement + 'static,
2027    ) -> Self {
2028        self.indicator_content = Some(std::sync::Arc::new(render));
2029        self
2030    }
2031
2032    /// `selectionMode` on `Dropdown.Menu`.
2033    pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
2034        self.selection_mode = mode;
2035        self
2036    }
2037
2038    /// `selectedKeys` on `Dropdown.Menu`.
2039    pub fn selected_keys(
2040        mut self,
2041        keys: impl IntoIterator<Item = impl Into<SharedString>>,
2042    ) -> Self {
2043        self.selected_keys = keys.into_iter().map(Into::into).collect();
2044        self.selection_is_controlled = true;
2045        self
2046    }
2047
2048    /// `defaultSelectedKeys` on `Dropdown.Menu` — seeds uncontrolled selection.
2049    pub fn default_selected_keys(
2050        mut self,
2051        keys: impl IntoIterator<Item = impl Into<SharedString>>,
2052    ) -> Self {
2053        self.default_selected_keys = keys.into_iter().map(Into::into).collect();
2054        self
2055    }
2056
2057    /// `disallowEmptySelection` on `Dropdown.Menu`.
2058    pub fn disallow_empty_selection(mut self, value: bool) -> Self {
2059        self.disallow_empty_selection = value;
2060        self
2061    }
2062
2063    /// `disabledKeys` on `Dropdown.Menu`.
2064    pub fn disabled_keys(
2065        mut self,
2066        keys: impl IntoIterator<Item = impl Into<SharedString>>,
2067    ) -> Self {
2068        self.disabled_keys = keys.into_iter().map(Into::into).collect();
2069        self
2070    }
2071
2072    /// `onSelectionChange` on `Dropdown.Menu`.
2073    pub fn on_selection_change(
2074        mut self,
2075        f: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
2076    ) -> Self {
2077        self.on_selection_change = Some(std::sync::Arc::new(f));
2078        self
2079    }
2080
2081    /// `onAction` on `Dropdown.Menu`.
2082    pub fn on_action(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
2083        self.on_action = Some(std::sync::Arc::new(f));
2084        self
2085    }
2086
2087    /// Sets where the menu opens relative to the trigger.
2088    pub fn placement(mut self, p: DropdownPlacement) -> Self {
2089        self.placement = p;
2090        self
2091    }
2092
2093    /// The one slot for caller-owned low-level styling: GPUI's styling methods
2094    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
2095    /// applied to the dropdown's root element after every value the
2096    /// composition and the active theme chose, so they win. The trigger is
2097    /// the caller's own element and the panel paints its own chrome, so this
2098    /// reaches the wrapper they sit in.
2099    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
2100        crate::util::refine_sx(&mut self.sx, style);
2101        self
2102    }
2103}
2104
2105impl RenderOnce for Dropdown {
2106    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
2107        let _ = icons::CHEVRON_DOWN;
2108        let wrap_base_id = self.id.clone();
2109
2110        // `isOpen` wins; without it the menu holds the flag itself, which is
2111        // what `defaultOpen` promises. See `Dropdown::uncontrolled`.
2112        let (is_open, open_own) = crate::util::controlled(
2113            window,
2114            cx,
2115            element_id::scoped(&wrap_base_id, "open"),
2116            self.is_open,
2117            self.default_open,
2118        );
2119        let (selected_keys, selection_own) = crate::util::controlled(
2120            window,
2121            cx,
2122            element_id::scoped(&wrap_base_id, "selected"),
2123            self.selection_is_controlled
2124                .then(|| self.selected_keys.clone()),
2125            self.default_selected_keys.clone(),
2126        );
2127        self.selected_keys = selected_keys;
2128        // `overlay_scope` takes `cx` mutably too, so it goes here.
2129        let (phase, overlay_token) = crate::util::overlay_scope(
2130            window,
2131            cx,
2132            element_id::scoped(&wrap_base_id, "phase"),
2133            is_open,
2134            true,
2135        );
2136        let focus_first = window.use_keyed_state(
2137            element_id::scoped(&wrap_base_id, "focus-first"),
2138            cx,
2139            |_, _| false,
2140        );
2141
2142        // `trigger="longPress"` needs to know whether the button is still down
2143        // when the timer fires, so the press is a piece of state rather than a
2144        // local.
2145        let holding =
2146            window.use_keyed_state(element_id::scoped(&wrap_base_id, "holding"), cx, |_, _| {
2147                false
2148            });
2149
2150        // Where the focus goes when the menu closes. React Aria hands it back
2151        // to the trigger, and the trigger element is the caller's, so the
2152        // wrapper holds the handle. It is deliberately *not* a tab stop: gpui
2153        // keeps any tracked handle in the tab order, so Tab carries on from here
2154        // instead of starting the page over.
2155        let trigger_focus = window.use_keyed_state(
2156            element_id::scoped(&wrap_base_id, "trigger-focus"),
2157            cx,
2158            |_, cx| cx.focus_handle(),
2159        );
2160        let trigger_handle = trigger_focus.read(cx).clone();
2161        let anchor_bounds = window
2162            .use_keyed_state(
2163                element_id::scoped(&wrap_base_id, "anchor-bounds"),
2164                cx,
2165                |_, _| std::rc::Rc::new(std::cell::Cell::new(None::<Bounds<Pixels>>)),
2166            )
2167            .read(cx)
2168            .clone();
2169        let mut trigger_wrap = gpui::div()
2170            .id(element_id::scoped(&wrap_base_id, "trigger"))
2171            .track_focus(&trigger_handle)
2172            .cursor(crate::util::interactive_cursor(cx));
2173        let dismiss_own = open_own.clone();
2174        let on_open_change = self.on_open_change.clone();
2175        if on_open_change.is_some() || open_own.is_some() {
2176            let next_open = !is_open;
2177            let own = open_own;
2178            match self.trigger_kind {
2179                DropdownTrigger::Press => {
2180                    let key_own = own.clone();
2181                    let key_open_change = on_open_change.clone();
2182                    let key_focus_first = focus_first.clone();
2183                    trigger_wrap = trigger_wrap.on_key_down(move |event, window, cx| {
2184                        let key = event.keystroke.key.as_str();
2185                        if is_open || (key != "enter" && key != "space") {
2186                            return;
2187                        }
2188                        key_focus_first.update(cx, |focus, _| *focus = true);
2189                        if let Some(held) = &key_own {
2190                            held.update(cx, |value, cx| {
2191                                *value = true;
2192                                cx.notify();
2193                            });
2194                        }
2195                        if let Some(cb) = &key_open_change {
2196                            cb(&true, window, cx);
2197                        }
2198                        cx.stop_propagation();
2199                    });
2200                    let focus_first = focus_first.clone();
2201                    trigger_wrap = trigger_wrap.on_click(move |ev: &ClickEvent, w, cx| {
2202                        focus_first.update(cx, |focus, _| {
2203                            *focus = matches!(ev, ClickEvent::Keyboard(_));
2204                        });
2205                        // Uncontrolled: flip our own copy, or the trigger would
2206                        // be inert without a caller handler.
2207                        if let Some(held) = &own {
2208                            held.update(cx, |v, cx| {
2209                                *v = next_open;
2210                                cx.notify();
2211                            });
2212                        }
2213                        if let Some(cb) = &on_open_change {
2214                            cb(&next_open, w, cx);
2215                        }
2216                    });
2217                }
2218                DropdownTrigger::LongPress => {
2219                    let up_holding = holding.clone();
2220                    let pointer_focus_first = focus_first.clone();
2221                    trigger_wrap = trigger_wrap
2222                        .on_mouse_down(gpui::MouseButton::Left, {
2223                            let holding = holding;
2224                            move |_, window, cx| {
2225                                pointer_focus_first.update(cx, |focus, _| *focus = false);
2226                                holding.update(cx, |v, _| *v = true);
2227                                let holding = holding.clone();
2228                                let own = own.clone();
2229                                let on_open_change = on_open_change.clone();
2230                                let long_press_ms = cx.layout().long_press_ms;
2231                                // Open only if the button is still down when the
2232                                // timer expires; a quick click leaves it shut.
2233                                // `window.spawn` rather than `cx.spawn`: the
2234                                // callback needs a `Window`, and only a window
2235                                // async context can hand one back.
2236                                window
2237                                    .spawn(cx, async move |cx| {
2238                                        cx.background_executor()
2239                                            .timer(std::time::Duration::from_millis(long_press_ms))
2240                                            .await;
2241                                        cx.update(|window, cx| {
2242                                            if !*holding.read(cx) {
2243                                                return;
2244                                            }
2245                                            if let Some(held) = &own {
2246                                                held.update(cx, |v, cx| {
2247                                                    *v = true;
2248                                                    cx.notify();
2249                                                });
2250                                            }
2251                                            if let Some(cb) = &on_open_change {
2252                                                cb(&true, window, cx);
2253                                            }
2254                                        })
2255                                        .ok();
2256                                    })
2257                                    .detach();
2258                            }
2259                        })
2260                        .on_mouse_up(gpui::MouseButton::Left, move |_, _window, cx| {
2261                            up_holding.update(cx, |v, _| *v = false);
2262                        });
2263                }
2264            }
2265        }
2266
2267        // A flex column with `items_start` keeps the trigger at its natural
2268        // width; a plain block root would stretch it (gpui divs are
2269        // Display::Block, so a block-level flex child fills the line). The
2270        // trigger is measured the way RAC's `useOverlayPosition` positions
2271        // against the trigger rect -- the measure element only records the
2272        // bounds the popover below reads to flip and cap the panel.
2273        let trigger = crate::popover::PopoverTriggerMeasure::new(
2274            trigger_wrap.child(self.trigger),
2275            anchor_bounds.clone(),
2276        );
2277        let mut root = gpui::div()
2278            .relative()
2279            .flex()
2280            .flex_col()
2281            // `.dropdown` is `flex flex-col gap-1`.
2282            .gap(px(4.))
2283            .items_start()
2284            .child(trigger);
2285
2286        // v3 keeps a closing menu on screen for its `[data-exiting]` run.
2287        if phase != crate::util::OverlayPhase::Closed {
2288            let mut menu = Menu::new(
2289                element_id::scoped(&wrap_base_id, "menu-content"),
2290                self.items,
2291            )
2292            .id(element_id::scoped(&wrap_base_id, "menu"))
2293            .dropdown_composition()
2294            .focus_first(focus_first)
2295            .exiting(phase == crate::util::OverlayPhase::Exiting)
2296            .selection_mode(self.selection_mode)
2297            .selected_keys(self.selected_keys.clone())
2298            .disallow_empty_selection(self.disallow_empty_selection)
2299            .disabled_keys(self.disabled_keys.clone())
2300            .indicator(self.indicator);
2301            menu.item_content = self.item_content.clone();
2302            menu.indicator_content = self.indicator_content.clone();
2303            menu.recipes = self.recipes.clone();
2304            if let Some(row_hover_bg) = self.row_hover_bg {
2305                menu = menu.row_hover_bg(row_hover_bg);
2306            }
2307            if let Some(radius) = self.radius {
2308                menu = menu.radius(radius);
2309            }
2310            menu = menu.overlay_token(overlay_token);
2311            if let Some(on_action) = self.on_action.clone() {
2312                menu = menu.on_action(move |k, w, cx| on_action(k, w, cx));
2313            }
2314            let selection_cb = self.on_selection_change.clone();
2315            if selection_cb.is_some() || selection_own.is_some() {
2316                menu = menu.on_selection_change(move |keys, w, cx| {
2317                    if let Some(held) = &selection_own {
2318                        held.update(cx, |value, cx| {
2319                            *value = keys.to_vec();
2320                            cx.notify();
2321                        });
2322                    }
2323                    if let Some(cb) = &selection_cb {
2324                        cb(keys, w, cx);
2325                    }
2326                });
2327            }
2328            let dismiss_cb = self.on_open_change.clone();
2329            if dismiss_cb.is_some() || dismiss_own.is_some() {
2330                let back_to_trigger = trigger_handle;
2331                menu = menu.on_dismiss(move |refocus, window, cx| {
2332                    if let Some(held) = &dismiss_own {
2333                        held.update(cx, |v, cx| {
2334                            *v = false;
2335                            cx.notify();
2336                        });
2337                    }
2338                    if let Some(cb) = &dismiss_cb {
2339                        cb(&false, window, cx);
2340                    }
2341                    // The menu held the focus for its arrows; hand it back.
2342                    // An Enter pick runs this inside the key event, where gpui
2343                    // would activate the trigger on key up and reopen the menu
2344                    // -- the keyboard path asks for no refocus for that reason.
2345                    if *refocus {
2346                        window.focus(&back_to_trigger, cx);
2347                    }
2348                });
2349            }
2350            // RAC renders the menu in a `Popover` against the trigger: an 8px
2351            // gap (`offset ?? 8` in pinned RAC 1.20.0's `Popover`, which `Menu`
2352            // passes no offset to), flipping to the side with more room when
2353            // the menu cannot fit and capping at the available viewport height
2354            // past the 12px container padding. The shared `scrollable_popover`
2355            // owns that contract -- including `Left`/`Right`, which the old
2356            // cross-axis-only shift left to overflow -- and the panel's
2357            // `max_h_full` keeps short menus at their natural height. The menu
2358            // itself is the positioner's child: a wrapper between them would
2359            // measure and cap while the panel kept its natural size (and its
2360            // scroll range would collapse to the padding).
2361            root = root.child(crate::util::floating(crate::popover::scrollable_popover(
2362                anchor_bounds,
2363                self.placement,
2364                menu.panel_debug_label("dropdown-menu"),
2365            )));
2366        }
2367
2368        crate::util::apply_sx(root, &self.sx)
2369    }
2370}
2371
2372// The pinned `.menu-item:hover` fills with `bg-default`, the full token.
2373// `soft()` is a lighter wash that looks plausible on screen, so the check is
2374// mechanical.
2375#[cfg(test)]
2376mod hover_tokens {
2377    #[test]
2378    fn menu_rows_hover_the_full_default() {
2379        // Scan the implementation only.
2380        let source = include_str!("dropdown.rs")
2381            .split("#[cfg(test)]")
2382            .next()
2383            .expect("the implementation section is always present");
2384        assert!(
2385            source
2386                .contains("let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);"),
2387            "menu rows must default to the full `bg-default` and honor the \
2388             named override (pinned `.menu-item:hover`)"
2389        );
2390        assert!(
2391            source.contains("row = row.hover(move |s| s.bg(row_hover_bg));"),
2392            "the row hover must consume the resolved fill"
2393        );
2394        assert!(
2395            !source.contains("colors.default.soft()"),
2396            "no menu surface may hover a soft token"
2397        );
2398    }
2399}
2400
2401crate::util::impl_component_styled!(Menu, Dropdown);