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 ¤t,
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);