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