herogpui_components/autocomplete.rs
1//! Autocomplete — port of `@heroui/autocomplete`.
2//!
3//! v3's Autocomplete is a **Select whose popover holds a search field**, not a
4//! text field with suggestions under it. `autocomplete.css` says so directly:
5//! `.autocomplete__trigger` is a field-shaped box (`min-h-9 rounded-field
6//! bg-field px-3 shadow-field`) holding `.autocomplete__value` and a chevron
7//! `.autocomplete__indicator`, and `.autocomplete__popover` stacks
8//! `[data-slot="search-field"]` above the list. The text field *with* a list is
9//! the [`crate::combo_box::ComboBox`], which is a separate component with its
10//! own stylesheet.
11//!
12//! This port had it the other way round -- an
13//! [`Input`](crate::input::Input) with a suggestion panel
14//! -- which drew none of that sheet and left the trigger nothing to show the
15//! selection in. The trigger draws the selection now, and the popover searches.
16//!
17//! [`InputState`] therefore backs the **search field inside the popover**, which
18//! is what `Autocomplete.Filter`'s `inputValue` and `onInputChange` address. The
19//! selection is a set of item keys, held by `value` / `defaultValue`.
20//!
21//! Pinned v3.2.4 / React Aria Components 1.20.0 keep a stable `Key` separate
22//! from each item's `textValue`: `value` / `defaultValue` / `disabledKeys`,
23//! the selection callbacks and the form value address items by key, while
24//! filtering and the visible text use the label. Items are therefore
25//! [`crate::PickerItem`]s; using a label as the key made duplicate labels alias
26//! each other's selection, disabled state and row identity.
27//!
28//! Pinned react-stately 3.49.0's `useSelectState` holds `selectedKeys` as a
29//! JavaScript `Set`, which iterates in insertion order: `selectedItems`,
30//! `selectedText`, the selection callbacks and the form value all follow the
31//! *selection's* order, not the collection's. The selection is therefore an
32//! ordered unique key list, and toggling removes in place or appends.
33
34use std::{
35 cell::{Cell, RefCell},
36 collections::HashMap,
37 rc::Rc,
38 time::Duration,
39};
40
41use gpui::{
42 prelude::*, px, AnimationExt, App, Entity, IntoElement, Pixels, RenderOnce, SharedString,
43 StatefulInteractiveElement, Styled, Window,
44};
45use herogpui_core::{element_id, FieldVariant, Placement, SelectionMode};
46use herogpui_theme::ActiveTheme;
47
48use crate::{
49 a11y::{self, A11y as _},
50 icons,
51 input::{InputState, SearchField},
52 matches::{empty_matches, MatchesCache},
53 picker_item::PickerItem,
54 selection::{normalize_selection, toggle_key},
55 util,
56};
57
58type OnSelectionChange = std::sync::Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
59type AutocompleteFormState = Rc<RefCell<crate::form::LiveFormFieldState>>;
60
61/// `.autocomplete__clear-button:not([data-empty="true"])` fades in over
62/// 150ms with `--ease-smooth`; clearing itself remains an immediate hide.
63const CLEAR_OPACITY_TRANSITION_MS: u64 = 150;
64
65thread_local! {
66 static AUTOCOMPLETE_FORM_STATES: RefCell<
67 HashMap<u64, std::rc::Weak<RefCell<crate::form::LiveFormFieldState>>>,
68 > = RefCell::new(HashMap::new());
69}
70
71fn autocomplete_form_state(entity_id: u64) -> AutocompleteFormState {
72 AUTOCOMPLETE_FORM_STATES.with(|states| {
73 let mut states = states.borrow_mut();
74 if let Some(state) = states.get(&entity_id).and_then(|state| state.upgrade()) {
75 return state;
76 }
77 let state = Rc::new(RefCell::new(crate::form::LiveFormFieldState {
78 value: crate::form::FormValue::Keys(Vec::new()),
79 is_invalid: false,
80 is_successful: true,
81 focus: None,
82 restore: None,
83 }));
84 states.insert(entity_id, Rc::downgrade(&state));
85 state
86 })
87}
88
89fn form_selection_value(selected: &[SharedString]) -> crate::form::FormValue {
90 crate::form::FormValue::Keys(selected.to_vec())
91}
92
93/// The rows the popover's list shows for `query`: the custom filter's own
94/// decision — including what an empty query means — or the default
95/// case-insensitive substring match. Filtering reads the items' labels, never
96/// their keys.
97fn compute_matches(
98 items: &[PickerItem],
99 query: &str,
100 max_items: usize,
101 filter: Option<&std::sync::Arc<dyn Fn(&str, &str) -> bool + 'static>>,
102) -> Vec<PickerItem> {
103 if let Some(filter) = filter {
104 return items
105 .iter()
106 .filter(|item| filter(item.label(), query))
107 .take(max_items)
108 .cloned()
109 .collect();
110 }
111 if query.is_empty() {
112 return items.iter().take(max_items).cloned().collect();
113 }
114 let lowered = query.to_lowercase();
115 items
116 .iter()
117 .filter(|it| it.label().to_lowercase().contains(&lowered))
118 .take(max_items)
119 .cloned()
120 .collect()
121}
122
123fn sync_form_state(
124 state: &AutocompleteFormState,
125 selected: &[SharedString],
126 is_disabled: bool,
127 is_invalid: bool,
128) {
129 let mut state = state.borrow_mut();
130 state.value = form_selection_value(selected);
131 state.is_successful = !is_disabled;
132 state.is_invalid = is_invalid;
133}
134
135/// HeroUI Autocomplete.
136#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
137#[derive(IntoElement)]
138pub struct Autocomplete {
139 /// `name` — the name this control submits under; read back by
140 /// [`Self::form_field`].
141 name: Option<SharedString>,
142 /// The search field's text state — `Autocomplete.Filter`'s `inputValue`.
143 state: Entity<InputState>,
144 items: Vec<PickerItem>,
145 max_items: usize,
146 /// `ListLayout`'s `rowHeight`, which virtualizes the popover list.
147 row_height: Option<Pixels>,
148 /// Replaces the list rows' `px-2.5` horizontal padding.
149 row_padding_x: Option<Pixels>,
150 /// Replaces the list rows' `py-1.5` vertical padding.
151 row_padding_y: Option<Pixels>,
152 /// The fill a hovered row takes, in place of `--default`.
153 row_hover_bg: Option<gpui::Hsla>,
154 /// The trigger's hover endpoint, in place of the variant's hover token.
155 trigger_hover_bg: Option<gpui::Hsla>,
156 /// The clear button's hover fill, in place of `--default-hover`.
157 clear_hover_bg: Option<gpui::Hsla>,
158 /// The family the option rows are drawn with; unset keeps the
159 /// inherited family. A detached popover does not inherit the trigger's
160 /// font.
161 row_font_family: Option<SharedString>,
162 /// The trigger's and filter field's family; unset keeps the inherited one.
163 font_family: Option<SharedString>,
164 /// The corner radius of the detached panel, in place of the owning
165 /// `container_radius` helper.
166 radius: Option<Pixels>,
167 label: Option<SharedString>,
168 placeholder: Option<SharedString>,
169 description: Option<SharedString>,
170 error_message: Option<SharedString>,
171 variant: FieldVariant,
172 full_width: bool,
173 /// Optional trigger geometry/chrome overrides; defaults are the stock box.
174 field: util::FieldBox,
175 is_disabled: bool,
176 is_read_only: bool,
177 is_invalid: bool,
178 is_required: bool,
179 /// `disabledKeys` — suggestions that render but cannot be chosen,
180 /// addressed by item key so a disabled item never disables its
181 /// same-label sibling.
182 disabled_keys: std::collections::HashSet<SharedString>,
183 /// `shouldFocusWrap` — whether the arrow keys wrap at the ends of the list.
184 should_focus_wrap: bool,
185 /// `ListBox.Section` — a heading above the item with this key.
186 sections: Vec<(SharedString, SharedString)>,
187 /// `Autocomplete.Indicator` — replaces the trigger chevron. The closure is
188 /// handed whether the popover is open.
189 indicator: Option<Box<dyn Fn(bool) -> gpui::AnyElement + 'static>>,
190 /// Composed `ListBox.ItemIndicator` — draws the selection tick. The closure
191 /// is handed whether the row is selected.
192 item_indicator: Option<Box<dyn Fn(bool) -> gpui::AnyElement + 'static>>,
193 /// `Autocomplete.Value` — draws the trigger's value.
194 value_content: Option<Box<dyn Fn(util::SelectionValue<'_>) -> gpui::AnyElement + 'static>>,
195 /// `allowsEmptyCollection` — whether the autocomplete may function when
196 /// the collection has no items at all. It is the `useSelectState` open
197 /// gate, not a close-on-filtered-empty flag: filtering an open popover
198 /// to zero keeps it mounted with the empty state either way.
199 allows_empty_collection: bool,
200 selection_mode: SelectionMode,
201 /// The selection as ordered unique item keys — react-stately 3.49.0's
202 /// `selectedKeys` is a JS `Set`, which iterates in insertion order, so
203 /// callbacks, the form value and the trigger all follow the order the
204 /// keys were picked (or the owner listed) in.
205 selected_keys: Vec<SharedString>,
206 /// Whether the caller drives the selection. An unset `value` is not an empty
207 /// controlled selection: without this flag every uncontrolled Autocomplete
208 /// would hand its own clicks back to a set nobody owns, and picking an item
209 /// would do nothing.
210 is_controlled: bool,
211 /// `defaultValue` — set it to hand this component its own selection.
212 default_value: Option<Vec<SharedString>>,
213 on_selection_change_all:
214 Option<std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>>,
215 /// `isOpen`. `None` lets the trigger own it, seeded from `defaultOpen`.
216 is_open: Option<bool>,
217 default_open: bool,
218 placement: Placement,
219 on_open_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
220 on_selection_change: Option<OnSelectionChange>,
221 /// `filter` — decides whether an item matches the query. Defaults to a
222 /// case-insensitive substring test.
223 filter: Option<std::sync::Arc<dyn Fn(&str, &str) -> bool + 'static>>,
224 input_value: Option<String>,
225 on_input_change: Option<std::sync::Arc<dyn Fn(&str, &mut Window, &mut App) + 'static>>,
226 on_clear: Option<std::sync::Arc<dyn Fn(&mut Window, &mut App) + 'static>>,
227 form_state: AutocompleteFormState,
228 /// The `sx` slot, refined over the root style at the end of render.
229 sx: Option<Box<gpui::StyleRefinement>>,
230}
231
232impl Autocomplete {
233 /// `selectionMode`
234 pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
235 self.selection_mode = mode;
236 self
237 }
238
239 /// `defaultValue` — the uncontrolled initial selection.
240 ///
241 /// Supplying it hands the component its own selection set, seeded once;
242 /// [`Self::value`] is the controlled spelling. The listed order is the
243 /// selection's order, exactly as the owner listed it.
244 pub fn default_value(
245 mut self,
246 keys: impl IntoIterator<Item = impl Into<SharedString>>,
247 ) -> Self {
248 self.default_value = Some(keys.into_iter().map(Into::into).collect());
249 self
250 }
251
252 /// `value` — the controlled selection, as item keys. The listed order is
253 /// the owner's order and is preserved everywhere the selection is read.
254 pub fn value(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
255 self.selected_keys = keys.into_iter().map(Into::into).collect();
256 self.is_controlled = true;
257 self
258 }
259
260 /// The `ListBox`'s spelling of [`Self::value`], for a caller that has a set.
261 pub fn selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
262 self.selected_keys = keys.into_iter().collect();
263 self.is_controlled = true;
264 self
265 }
266
267 /// `onChange`'s complete `Key | Key[] | null` domain as a selection slice.
268 /// Single selection reports zero or one key; multiple reports every key.
269 pub fn on_selection_change_all(
270 mut self,
271 handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
272 ) -> Self {
273 self.on_selection_change_all = Some(std::sync::Arc::new(handler));
274 self
275 }
276
277 /// `placement` on `Autocomplete.Popover`.
278 pub fn placement(mut self, placement: Placement) -> Self {
279 self.placement = placement;
280 self
281 }
282
283 /// `defaultOpen` — the popover starts open.
284 pub fn default_open(mut self, v: bool) -> Self {
285 self.default_open = v;
286 self
287 }
288
289 /// `isOpen` — the controlled popover state.
290 pub fn is_open(mut self, v: bool) -> Self {
291 self.is_open = Some(v);
292 self
293 }
294
295 /// `onOpenChange`
296 pub fn on_open_change(
297 mut self,
298 handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
299 ) -> Self {
300 self.on_open_change = Some(std::sync::Arc::new(handler));
301 self
302 }
303
304 /// `filter` on `Autocomplete.Filter` — replaces the default
305 /// case-insensitive substring match.
306 ///
307 /// Called as `filter(item_label, input)`: v3 filters on the item's
308 /// `textValue`, not on its key.
309 pub fn filter(mut self, f: impl Fn(&str, &str) -> bool + 'static) -> Self {
310 self.filter = Some(std::sync::Arc::new(f));
311 self
312 }
313
314 /// `inputValue` on `Autocomplete.Filter` — the controlled search text.
315 ///
316 /// Unlike the bound [`InputState`] this does not write through, so a caller
317 /// can hold the query itself.
318 pub fn input_value(mut self, value: impl Into<String>) -> Self {
319 self.input_value = Some(value.into());
320 self
321 }
322
323 /// `onInputChange` on `Autocomplete.Filter` — every keystroke in the search
324 /// field.
325 pub fn on_input_change(mut self, f: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
326 self.on_input_change = Some(std::sync::Arc::new(f));
327 self
328 }
329
330 /// `onClear` — called after `Autocomplete.ClearButton` clears selection.
331 /// The button's clearing behavior does not depend on this callback.
332 pub fn on_clear(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
333 self.on_clear = Some(std::sync::Arc::new(f));
334 self
335 }
336
337 /// Pick-only single-key convenience callback.
338 ///
339 /// Use [`Self::on_selection_change_all`] for v3's complete `onChange`
340 /// domain, including multiple selection and the empty value from clear.
341 pub fn on_change(
342 self,
343 handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
344 ) -> Self {
345 self.on_selection_change(handler)
346 }
347
348 /// Creates an autocomplete over `items`, with `state` holding the input text.
349 pub fn new(state: Entity<InputState>, items: Vec<PickerItem>) -> Self {
350 let form_state = autocomplete_form_state(state.entity_id().as_u64());
351 Self {
352 name: None,
353 state,
354 items,
355 max_items: 100,
356 row_height: None,
357 row_padding_x: None,
358 row_padding_y: None,
359 row_hover_bg: None,
360 trigger_hover_bg: None,
361 clear_hover_bg: None,
362 row_font_family: None,
363 font_family: None,
364 radius: None,
365 field: util::FieldBox::default(),
366 label: None,
367 placeholder: None,
368 description: None,
369 error_message: None,
370 variant: FieldVariant::Primary,
371 full_width: false,
372 is_disabled: false,
373 is_read_only: false,
374 is_invalid: false,
375 is_required: false,
376 disabled_keys: std::collections::HashSet::new(),
377 should_focus_wrap: false,
378 sections: Vec::new(),
379 indicator: None,
380 item_indicator: None,
381 value_content: None,
382 allows_empty_collection: false,
383 selection_mode: SelectionMode::Single,
384 selected_keys: Vec::new(),
385 is_controlled: false,
386 default_value: None,
387 on_selection_change_all: None,
388 filter: None,
389 input_value: None,
390 on_input_change: None,
391 on_clear: None,
392 is_open: None,
393 default_open: false,
394 placement: Placement::BottomStart,
395 on_open_change: None,
396 on_selection_change: None,
397 form_state,
398 sx: None,
399 }
400 }
401
402 /// `name` — the name this control submits under.
403 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
404 self.name = Some(name.into());
405 self
406 }
407
408 /// The `Form` field this control submits, when it has a `name`.
409 ///
410 /// v3 discovers a field through the DOM; gpui gives a child no way to reach
411 /// its ancestor, so the control hands the pair over instead. The live
412 /// selection survives the next `Autocomplete::new` because it is keyed by
413 /// the search-field entity, the way DateField keys its form state. A
414 /// disabled control stays registered and is omitted from FormData.
415 ///
416 /// ```
417 /// # use gpui::{prelude::*, Window};
418 /// # use herogpui_components::{Autocomplete, Form, InputState, PickerItem};
419 /// # struct Demo;
420 /// # impl Render for Demo {
421 /// # fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
422 /// # let form = Form::new();
423 /// # let state = cx.new(|cx| InputState::new(cx));
424 /// # let control = Autocomplete::new(state, vec![PickerItem::new("rust", "Rust")])
425 /// # .name("lang");
426 /// let field = control.form_field();
427 /// form.field(field.unwrap()).child(control)
428 /// # }
429 /// # }
430 /// # let mut tcx = gpui::TestAppContext::single();
431 /// # tcx.update(herogpui_theme::ThemeProvider::init);
432 /// # let _ = tcx.add_window_view(|_, _| Demo);
433 /// ```
434 pub fn form_field(&self) -> Option<crate::form::FormField> {
435 let name = self.name.clone()?;
436 {
437 let mut state = self.form_state.borrow_mut();
438 state.is_successful = !self.is_disabled;
439 state.is_invalid = self.is_invalid || self.error_message.is_some();
440 }
441 Some(
442 crate::form::FormField::live(name, self.form_state.clone())
443 .is_required(self.is_required),
444 )
445 }
446
447 /// `ListLayout`'s `rowHeight` -- and what virtualizes the popover list.
448 ///
449 /// v3 wraps the list in `<Virtualizer layout={ListLayout}>` inside
450 /// `Autocomplete.Popover`; gpui's `uniform_list` builds only the rows in
451 /// view, and it can do that because every row is this tall.
452 pub fn row_height(mut self, h: impl Into<Pixels>) -> Self {
453 self.row_height = Some(h.into());
454 self
455 }
456
457 /// Sets the maximum number of suggestions shown; values below 1 are treated as 1.
458 pub fn max_items(mut self, n: usize) -> Self {
459 self.max_items = n.max(1);
460 self
461 }
462
463 /// Sets the label shown above the field.
464 pub fn label(mut self, l: impl Into<SharedString>) -> Self {
465 self.label = Some(l.into());
466 self
467 }
468
469 /// Sets the input placeholder.
470 pub fn placeholder(mut self, p: impl Into<SharedString>) -> Self {
471 self.placeholder = Some(p.into());
472 self
473 }
474
475 /// Sets the description shown below the field.
476 pub fn description(mut self, text: impl Into<SharedString>) -> Self {
477 self.description = Some(text.into());
478 self
479 }
480
481 /// Sets the error message shown when the field is invalid.
482 pub fn error_message(mut self, text: impl Into<SharedString>) -> Self {
483 self.error_message = Some(text.into());
484 self
485 }
486
487 /// Sets the field variant.
488 pub fn variant(mut self, variant: FieldVariant) -> Self {
489 self.variant = variant;
490 self
491 }
492
493 /// Sets whether the field fills the available width.
494 pub fn full_width(mut self, v: bool) -> Self {
495 self.full_width = v;
496 self
497 }
498
499 /// Fixes the trigger box at `h`. Unset keeps the 36px `min-h-9`; content
500 /// taller than an explicit height overflows the box.
501 pub fn height(mut self, h: impl Into<Pixels>) -> Self {
502 self.field.height = Some(h.into());
503 self
504 }
505
506 /// Replaces the trigger's `px-3` horizontal padding. The trailing 28px
507 /// keeps its room for the indicator.
508 pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
509 self.field.padding_x = Some(p.into());
510 self
511 }
512
513 /// Renders the trigger with no background, border, field shadow, ring,
514 /// focus fill or hover fill, for a caller painting around it. The list
515 /// still opens and selects.
516 pub fn is_bare(mut self, v: bool) -> Self {
517 self.field.is_bare = v;
518 self.field.is_bare_is_set = true;
519 self
520 }
521
522 /// Shows or hides only the trigger's visual focus ring. The trigger stays
523 /// keyboard focusable and the list still opens when set to `false`.
524 pub fn focus_ring(mut self, v: bool) -> Self {
525 self.field.focus_ring = Some(v);
526 self
527 }
528
529 /// Replaces the list rows' `px-2.5` horizontal padding.
530 pub fn row_padding_x(mut self, p: impl Into<Pixels>) -> Self {
531 self.row_padding_x = Some(p.into());
532 self
533 }
534
535 /// Replaces the list rows' `py-1.5` vertical padding.
536 pub fn row_padding_y(mut self, p: impl Into<Pixels>) -> Self {
537 self.row_padding_y = Some(p.into());
538 self
539 }
540
541 /// The trigger's fill while hovered, in place of `--field-hover`
542 /// (`--default-hover` on the secondary variant). The 150ms ease-smooth
543 /// fade, the border hover and the clear button's suppression are
544 /// unchanged; a disabled or bare trigger does not hover. Not a v3 prop:
545 /// v3 tints the trigger with a class.
546 pub fn trigger_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
547 self.trigger_hover_bg = Some(color.into());
548 self
549 }
550
551 /// The clear button's fill while hovered, in place of `--default-hover`.
552 /// Its press scale is unchanged. Not a v3 prop: v3 tints the button with
553 /// a class.
554 pub fn clear_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
555 self.clear_hover_bg = Some(color.into());
556 self
557 }
558
559 /// The fill a hovered row takes, in place of `--default`.
560 pub fn row_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
561 self.row_hover_bg = Some(color.into());
562 self
563 }
564
565 /// The family the trigger's value and placeholder, and the popover's
566 /// filter field (query, placeholder and caret measurement), are drawn
567 /// with; unset keeps the inherited family. The detached rows take
568 /// [`Autocomplete::row_font_family`] instead. Not a v3 prop; v3 sets it
569 /// with a class.
570 pub fn font_family(mut self, family: impl Into<SharedString>) -> Self {
571 self.font_family = Some(family.into());
572 self
573 }
574
575 /// The family the option rows are drawn with; unset keeps the inherited
576 /// family. A detached popover does not inherit the trigger's font.
577 pub fn row_font_family(mut self, family: impl Into<SharedString>) -> Self {
578 self.row_font_family = Some(family.into());
579 self
580 }
581
582 /// The corner radius of the detached panel, in place of the owning
583 /// `container_radius` helper. The panel's entry zoom interpolates the same
584 /// value, so both follow the override. Not a v3 prop; the removed v2
585 /// `radius` prop is prohibited and this is a per-component repository
586 /// extension.
587 ///
588 /// The trigger is a field box of its own, painted by the shared field
589 /// chrome — `--field-radius`, not this value.
590 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
591 self.radius = Some(radius.into());
592 self
593 }
594
595 /// The one slot for caller-owned low-level styling: GPUI's styling methods
596 /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
597 /// applied to the autocomplete's root element after every value the
598 /// variant and the active theme chose, so they win. The trigger paints its
599 /// own chrome, so this reaches the box that chrome sits in, not the chrome.
600 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
601 util::refine_sx(&mut self.sx, style);
602 self
603 }
604
605 /// Sets whether the field is disabled (`isDisabled`).
606 pub fn is_disabled(mut self, v: bool) -> Self {
607 self.is_disabled = v;
608 self
609 }
610
611 /// A read-only Autocomplete shows its selection and does not open. Pinned
612 /// v3 puts no read-only gate on the clear button part (only `disabled`),
613 /// so a read-only control keeps a working clear button.
614 pub fn is_read_only(mut self, v: bool) -> Self {
615 self.is_read_only = v;
616 self
617 }
618
619 /// Sets whether the field is invalid (`isInvalid`).
620 pub fn is_invalid(mut self, v: bool) -> Self {
621 self.is_invalid = v;
622 self
623 }
624
625 /// Sets whether the field is required (`isRequired`).
626 pub fn is_required(mut self, v: bool) -> Self {
627 self.is_required = v;
628 self
629 }
630
631 /// `disabledKeys` — keys of the suggestions that render but cannot be
632 /// chosen. Disabled state is per key, so one of two same-label items can
633 /// be disabled alone.
634 pub fn disabled_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
635 self.disabled_keys = keys.into_iter().collect();
636 self
637 }
638
639 /// `shouldFocusWrap` — whether the arrow keys wrap at the ends of the list.
640 pub fn should_focus_wrap(mut self, v: bool) -> Self {
641 self.should_focus_wrap = v;
642 self
643 }
644
645 /// `ListBox.Section` — a heading rendered above the item with this key.
646 pub fn section_before(
647 mut self,
648 item: impl Into<SharedString>,
649 label: impl Into<SharedString>,
650 ) -> Self {
651 self.sections.push((item.into(), label.into()));
652 self
653 }
654
655 /// `Autocomplete.Indicator` — draw the trigger indicator yourself.
656 ///
657 /// The closure receives the current open state, which is the GPUI analog of
658 /// v3's `data-open` attribute on this part.
659 pub fn indicator(mut self, render: impl Fn(bool) -> gpui::AnyElement + 'static) -> Self {
660 self.indicator = Some(Box::new(render));
661 self
662 }
663
664 /// Composed `ListBox.ItemIndicator` — draw the selected row tick yourself.
665 pub fn item_indicator(mut self, render: impl Fn(bool) -> gpui::AnyElement + 'static) -> Self {
666 self.item_indicator = Some(Box::new(render));
667 self
668 }
669
670 /// `Autocomplete.Value` — draw the trigger's value yourself.
671 ///
672 /// The closure is handed the render props v3 passes into
673 /// `<Autocomplete.Value>{({defaultChildren, isPlaceholder, selectedItems,
674 /// selectedText}) => …}`, so a multiple selection can be drawn as tags.
675 pub fn value_content(
676 mut self,
677 render: impl Fn(util::SelectionValue<'_>) -> gpui::AnyElement + 'static,
678 ) -> Self {
679 self.value_content = Some(Box::new(render));
680 self
681 }
682
683 /// `allowsEmptyCollection` — whether the autocomplete may function with a
684 /// collection that has no items at all (v3: *"When true, the autocomplete
685 /// can function even with no items."*).
686 ///
687 /// react-stately 3.49.0's `useSelectState` reads it as the `open`/`toggle`
688 /// gate: a truly empty collection refuses to open without it. Filtering an
689 /// open popover to zero is a different layer (the ListBox's empty-state
690 /// slot), so this is not a close-on-filtered-empty flag.
691 pub fn allows_empty_collection(mut self, v: bool) -> Self {
692 self.allows_empty_collection = v;
693 self
694 }
695
696 /// Sets the handler called with the chosen suggestion (`onSelectionChange`).
697 pub fn on_selection_change(
698 mut self,
699 f: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
700 ) -> Self {
701 self.on_selection_change = Some(std::sync::Arc::new(f));
702 self
703 }
704}
705
706type OnOpenChange = std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
707type OnSelectionChangeAll =
708 std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>;
709
710/// The controlled halves `render` resolves first: the frame's ids and shared
711/// collection, the selection and the open flag. `controlled` takes `cx`
712/// mutably, so these precede every other read.
713struct AutoControlled {
714 base: String,
715 base_id: gpui::ElementId,
716 items: Rc<[PickerItem]>,
717 multiple: bool,
718 selection_own: Option<Entity<Vec<SharedString>>>,
719 open: bool,
720 open_own: Option<Entity<bool>>,
721 overlay_phase: util::OverlayPhase,
722 dismissal_token: util::OverlayToken,
723}
724
725/// The clear button's keyed interaction and derived flags.
726struct AutoClear {
727 clear_slot: util::Interaction,
728 clear_active: bool,
729 clear_hovered: bool,
730 clear_pressed: bool,
731 reduce_motion: bool,
732 clear_opacity: crate::anim::Tween<f32>,
733}
734
735/// Everything one Autocomplete frame shares between its painted parts: the
736/// controlled state, the keyed handles, the resolved matches and cursor, the
737/// clear button's state and the theme tokens. `render` resolves it once in
738/// [`Autocomplete::frame`]; the trigger, its clear button, the key handler
739/// and the popover all read this one copy.
740struct AutoFrame {
741 base: String,
742 base_id: gpui::ElementId,
743 items: Rc<[PickerItem]>,
744 multiple: bool,
745 selection_own: Option<Entity<Vec<SharedString>>>,
746 open: bool,
747 open_own: Option<Entity<bool>>,
748 overlay_phase: util::OverlayPhase,
749 dismissal_token: util::OverlayToken,
750 resolved_placement: Rc<Cell<Option<Placement>>>,
751 entry_placement: Placement,
752 blur_scope: gpui::FocusHandle,
753 focus_handle: Option<gpui::FocusHandle>,
754 cursor: Entity<Option<SharedString>>,
755 list_scroll_now: gpui::UniformListScrollHandle,
756 panel_scroll_now: gpui::ScrollHandle,
757 query_edit: Entity<Option<bool>>,
758 plain_edit_key: Entity<bool>,
759 clear: AutoClear,
760 raw_query: String,
761 matches: Rc<[PickerItem]>,
762 cursor_at: Option<usize>,
763 anchor_bounds: Rc<Cell<Option<gpui::Bounds<Pixels>>>>,
764 colors: herogpui_theme::ThemeColors,
765 layout: herogpui_theme::LayoutTheme,
766 is_invalid: bool,
767 can_open: bool,
768 toggle_allowed: bool,
769 trigger_pressed: Rc<Cell<bool>>,
770}
771
772impl RenderOnce for Autocomplete {
773 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
774 // One shared collection for the frame: the `'static` panel and event
775 // closures clone the `Rc`, never the rows.
776 let items: Rc<[PickerItem]> = std::mem::take(&mut self.items).into();
777 let base = format!("autocomplete-{}", self.state.entity_id().as_u64());
778 // The same identity as `base`, kept as structure for the ids that key
779 // state rather than name a debug selector.
780 let base_id =
781 gpui::ElementId::named_usize("autocomplete", self.state.entity_id().as_u64() as usize);
782 // `Autocomplete.Filter.inputValue` is controlled state. Keep the bound
783 // search field on the owner's value while still reporting proposed
784 // edits through `onInputChange`.
785 if let Some(input_value) = self.input_value.clone() {
786 let current = self.state.read(cx).value().to_owned();
787 if current != input_value {
788 self.state.update(cx, |state, cx| {
789 state.set_value(input_value);
790 cx.notify();
791 });
792 }
793 }
794 // `defaultValue` opts into the component holding its own selection;
795 // `controlled` takes `cx` mutably, so it precedes the theme tokens.
796 // Both spellings normalize to the `Set` shape first: duplicates
797 // collapse to their first insertion, and single mode keeps one key.
798 // A controlled order is the owner's order — nothing is sorted.
799 let multiple = self.selection_mode == SelectionMode::Multiple;
800 self.selected_keys = normalize_selection(self.selected_keys.clone(), multiple);
801 let (selection, selection_own) = util::controlled(
802 window,
803 cx,
804 element_id::scoped(&base_id, "selection"),
805 self.is_controlled.then(|| self.selected_keys.clone()),
806 normalize_selection(self.default_value.clone().unwrap_or_default(), multiple),
807 );
808 self.selected_keys = selection;
809
810 // `isOpen` / `defaultOpen`: the trigger owns the popover, the way
811 // `.autocomplete__trigger` does in v3 -- the old port opened on focus,
812 // which is a ComboBox's behaviour, not this one's.
813 let (is_open, open_own) = util::controlled(
814 window,
815 cx,
816 element_id::scoped(&base_id, "open"),
817 self.is_open,
818 self.default_open,
819 );
820 let open = is_open && !self.is_disabled;
821 let (overlay_phase, dismissal_token) = util::overlay_scope(
822 window,
823 cx,
824 element_id::scoped(&base_id, "overlay"),
825 open,
826 true,
827 );
828 let frame = self.frame(
829 AutoControlled {
830 base,
831 base_id,
832 items,
833 multiple,
834 selection_own,
835 open,
836 open_own,
837 overlay_phase,
838 dismissal_token,
839 },
840 window,
841 cx,
842 );
843
844 // --- the trigger ----------------------------------------------------
845 let mut field = self.trigger_field(&frame, window, cx);
846 let (value_slot, selected_text) = self.trigger_value(&frame, cx);
847 // `@heroui/react/dist/components/autocomplete/autocomplete.js` builds
848 // this trigger from RAC `Select` + `Button` — v3's Autocomplete is a
849 // Select whose popover holds a search field, not a ComboBox — so
850 // `react-aria/.../select/useSelect.mjs` decides its contract through
851 // `useMenuTrigger({type: 'listbox'})`: a native `<button>` with
852 // `'aria-haspopup': 'listbox'`, `'aria-expanded': isOpen`,
853 // `'aria-controls'`, named by its label and the drawn value. Only the
854 // role, the name and `aria-expanded` have gpui builders.
855 //
856 // Stated here rather than at the head of the chain because
857 // `selected_text` is what names it, and that is not known until the
858 // value slot is resolved.
859 field = field
860 .a11y_named(
861 a11y::Role::Button,
862 &a11y::Name::maybe(self.label.clone())
863 .described(Some(SharedString::from(selected_text))),
864 )
865 .a11y_expanded(frame.open);
866 field = field.child(value_slot);
867 field = field.child(self.clear_button(&frame, cx));
868 field = self.trigger_indicator_slot(field, &frame, window, cx);
869 field = self.trigger_toggle(field, &frame);
870 if let Some(handle) = &frame.focus_handle {
871 field = util::record_focus_bounds(field, handle, window, cx);
872 }
873
874 // The popup anchors to the trigger bounds -- not to the
875 // label-to-description wrapper root -- the way RAC's
876 // `useOverlayPosition` positions against the trigger rect.
877 // `scrollable_field_popover` below reads these bounds to flip and
878 // cap the panel; the measure element itself only records them.
879 let field = crate::popover::PopoverTriggerMeasure::new(field, frame.anchor_bounds.clone());
880
881 let mut root = self.field_root(field, &frame);
882 if frame.can_open {
883 root = self.root_keys(root, &frame);
884 }
885 root = self.root_escape(root, &frame);
886
887 // --- the popover ----------------------------------------------------
888 // The popover's presence is the Select's open state and nothing else.
889 // Filtering happens inside `Autocomplete.Filter`, which prunes only
890 // the ListBox's rows. At zero this port draws the "No results found"
891 // empty state used by v3's examples; `allowsEmptyCollection` is not a
892 // close-on-filtered-empty flag. The panel carries its own
893 // outside-press dismissal, so there is nothing to attach to the root
894 // when it is unmounted.
895 if frame.overlay_phase != util::OverlayPhase::Closed {
896 root = root.child(self.popover(frame, cx));
897 }
898
899 util::apply_sx(root, &self.sx)
900 }
901}
902
903impl Autocomplete {
904 /// Resolves the frame's keyed handles, matches and cursor on top of the
905 /// controlled state.
906 fn frame(&self, controlled: AutoControlled, window: &mut Window, cx: &mut App) -> AutoFrame {
907 let AutoControlled {
908 ref base_id,
909 ref items,
910 open,
911 ref open_own,
912 ..
913 } = controlled;
914 // Field popover placement can flip during prepaint. Feed the resolved
915 // physical side back into the next entry frame, just like Popover.
916 let (resolved_placement, entry_placement) =
917 crate::popover::field_placement_feedback(window, cx, base_id, self.placement);
918
919 // `usePopover` closes when focus leaves the trigger-plus-panel scope.
920 // Unlike Escape, blur leaves focus on its destination.
921 let blur_close_own = open_own.clone();
922 let blur_open_change = self.on_open_change.clone();
923 let blur_scope = util::close_on_blur(window, cx, base_id, open, move |window, cx| {
924 if let Some(held) = &blur_close_own {
925 held.update(cx, |v, cx| {
926 *v = false;
927 cx.notify();
928 });
929 }
930 if let Some(cb) = &blur_open_change {
931 cb(&false, window, cx);
932 }
933 });
934
935 // The trigger is what holds focus, so the open list can be walked with
936 // the arrows. A disabled control leaves the tab order.
937 let focus_handle = if self.is_disabled {
938 None
939 } else {
940 Some(util::tab_stop_handle(
941 element_id::scoped(base_id, "focus"),
942 window,
943 cx,
944 ))
945 };
946 // Which row the keyboard is on, held as the item's *key* so the cursor
947 // stays on the same item when the query filters or the caller reorders
948 // the collection.
949 let cursor = window.use_keyed_state(element_id::scoped(base_id, "cursor"), cx, |_, _| {
950 None::<SharedString>
951 });
952 // React Aria keeps the focused row in view, and v3's list is
953 // `overflow-y-auto`. The virtual list has its own handle kind, a
954 // scrolling div the other. `use_keyed_state` takes `cx` mutably, so both
955 // precede the theme tokens.
956 let list_scroll =
957 window.use_keyed_state(element_id::scoped(base_id, "list-scroll"), cx, |_, _| {
958 gpui::UniformListScrollHandle::new()
959 });
960 let panel_scroll =
961 window.use_keyed_state(element_id::scoped(base_id, "panel-scroll"), cx, |_, _| {
962 gpui::ScrollHandle::new()
963 });
964 let list_scroll_now = list_scroll.read(cx).clone();
965 let panel_scroll_now = panel_scroll.read(cx).clone();
966 // v3 writes `<SearchField autoFocus>` inside `Autocomplete.Filter`, so
967 // the query field takes the focus as the popover opens -- once per
968 // opening, or it would take the focus back on every frame.
969 let autofocused =
970 window.use_keyed_state(element_id::scoped(base_id, "autofocus"), cx, |_, _| false);
971 // SearchField's text callback and the bubbling key event cooperate to
972 // classify the pending edit; see the block after `matches` below.
973 let query_edit =
974 window.use_keyed_state(element_id::scoped(base_id, "query-edit"), cx, |_, _| {
975 None::<bool>
976 });
977 let plain_edit_key =
978 window.use_keyed_state(element_id::scoped(base_id, "plain-edit-key"), cx, |_, _| {
979 false
980 });
981 let clear = self.clear_state(base_id, window, cx);
982 let search_focus = self.state.read(cx).focus_handle.clone();
983 if open && !*autofocused.read(cx) {
984 window.focus(&search_focus, cx);
985 autofocused.update(cx, |v, _| *v = true);
986 } else if !open && *autofocused.read(cx) {
987 autofocused.update(cx, |v, _| *v = false);
988 }
989
990 // A controlled `inputValue` wins over whatever the search field holds.
991 let raw_query = match &self.input_value {
992 Some(v) => v.clone(),
993 None => self.state.read(cx).value().to_owned(),
994 };
995
996 let matches = self.matches(&controlled, &raw_query, &query_edit, window, cx);
997 // The stored cursor is the focused item's key; the row it lands on is
998 // wherever that key sits in the filtered collection now.
999 let cursor_at = cursor
1000 .read(cx)
1001 .as_ref()
1002 .and_then(|k| matches.iter().position(|it| it.key() == k));
1003
1004 // Forward typing while the popover is open puts the collection cursor
1005 // on its first enabled filtered row. react-aria 3.51.0 does this from
1006 // `useAutocomplete.onChange` only for forward input types, and clears
1007 // virtual focus for deletion, paste and history edits. GPUI exposes
1008 // neither a DOM input type nor one combined callback, so the actual
1009 // SearchField change and its bubbling unmodified character key mark
1010 // the edit together. A controlled prop update fires neither and cannot
1011 // masquerade as typing.
1012 if let Some(forward) = *query_edit.read(cx) {
1013 let next = if open && forward {
1014 matches
1015 .iter()
1016 .position(|item| !self.disabled_keys.contains(item.key()))
1017 } else {
1018 None
1019 };
1020 if cursor_at != next {
1021 let next_key = next.and_then(|i| matches.get(i)).map(|it| it.key().clone());
1022 cursor.update(cx, |v, cx| {
1023 *v = next_key;
1024 cx.notify();
1025 });
1026 if let Some(next) = next {
1027 if self.row_height.is_some() {
1028 list_scroll_now.scroll_to_item(next, gpui::ScrollStrategy::Center);
1029 } else {
1030 panel_scroll_now.scroll_to_item(next);
1031 }
1032 }
1033 }
1034 query_edit.update(cx, |v, cx| {
1035 *v = None;
1036 cx.notify();
1037 });
1038 }
1039 if *plain_edit_key.read(cx) {
1040 plain_edit_key.update(cx, |v, _| *v = false);
1041 }
1042
1043 let anchor_bounds: Rc<Cell<Option<gpui::Bounds<Pixels>>>> = window
1044 .use_keyed_state(element_id::scoped(base_id, "anchor-bounds"), cx, |_, _| {
1045 Rc::new(Cell::new(None))
1046 })
1047 .read(cx)
1048 .clone();
1049
1050 // The theme tokens borrow `cx`, so they are copied out only after the
1051 // keyed-state updates above.
1052 let colors = cx.colors().clone();
1053 let layout = cx.layout().clone();
1054
1055 let is_invalid = self.is_invalid || self.error_message.is_some();
1056 self.sync_form(&controlled, is_invalid, &focus_handle);
1057 let can_open = !self.is_disabled;
1058 // Whether the trigger's open acts are allowed at all: react-stately
1059 // 3.49.0's `useSelectState` guards `open`/`toggle` — *"Don't open if
1060 // the collection is empty"* — and v3's Autocomplete root is a RAC
1061 // `Select`, whose trigger calls `state.toggle()`. A collection with no
1062 // items therefore refuses every trigger open/toggle unless
1063 // `allowsEmptyCollection` lets the autocomplete function with no
1064 // items. This is the *unfiltered* collection: a query that prunes an
1065 // open popover to zero never reaches this gate.
1066 let toggle_allowed = self.allows_empty_collection || !items.is_empty();
1067
1068 // Whether the pointer went down on the trigger (or on the clear
1069 // button inside it). The panel's outside-press dismissal treats the
1070 // trigger as outside its own bounds, so a press on an *open* popover's
1071 // trigger would dismiss it on the mouse-down *and* toggle it back open
1072 // through the trigger's own click on the mouse-up -- one press, two
1073 // contradictory reports. The trigger's capture-phase handler runs
1074 // before the panel's `on_mouse_down_out` in the same dispatch, so the
1075 // dismissal can see it and leave the close to the trigger's click.
1076 let trigger_pressed = Rc::new(Cell::new(false));
1077 let AutoControlled {
1078 base,
1079 base_id,
1080 items,
1081 multiple,
1082 selection_own,
1083 open,
1084 open_own,
1085 overlay_phase,
1086 dismissal_token,
1087 } = controlled;
1088 AutoFrame {
1089 base,
1090 base_id,
1091 items,
1092 multiple,
1093 selection_own,
1094 open,
1095 open_own,
1096 overlay_phase,
1097 dismissal_token,
1098 resolved_placement,
1099 entry_placement,
1100 blur_scope,
1101 focus_handle,
1102 cursor,
1103 list_scroll_now,
1104 panel_scroll_now,
1105 query_edit,
1106 plain_edit_key,
1107 clear,
1108 raw_query,
1109 matches,
1110 cursor_at,
1111 anchor_bounds,
1112 colors,
1113 layout,
1114 is_invalid,
1115 can_open,
1116 toggle_allowed,
1117 trigger_pressed,
1118 }
1119 }
1120
1121 /// The clear button's keyed (hovered, pressed) slot and its fade.
1122 fn clear_state(
1123 &self,
1124 base_id: &gpui::ElementId,
1125 window: &mut Window,
1126 cx: &mut App,
1127 ) -> AutoClear {
1128 // The pinned trigger hover carries
1129 // `:not(:has(.autocomplete__clear-button:hover))`: while the pointer is
1130 // on the clear button inside the trigger, the trigger's own hover fill
1131 // is suppressed so the affordance does not double-hover the whole
1132 // field. gpui 0.2.2 has no `:has` analog and a parent hitbox stays
1133 // hovered while a child's is, so the clear button feeds its own hover
1134 // into this keyed (hovered, pressed) slot (`on_hover` dispatches with
1135 // the moved position, before the next paint) and the trigger's
1136 // refinement reads it. The same slot carries the press, for the pinned
1137 // `:active, &[data-pressed] { transform: scale(0.93) }`.
1138 let clear_slot = util::interaction(element_id::scoped(base_id, "clear-ix"), window, cx);
1139 // The slot is read before the theme tokens for the same reason the
1140 // other keyed states are: the normalization below takes `cx` mutably.
1141 // `.autocomplete__clear-button` stays mounted for as long as it is
1142 // composed: pinned v3 gates the part only through `disabled={isDisabled}`
1143 // and its own `data-empty` (`pointer-events-none opacity-0`), and
1144 // neither the part nor pinned react-stately 3.49.0's
1145 // `selectionManager.setSelectedKeys` knows a read-only gate (RAC
1146 // 1.20.0's `Select` has no `isReadOnly` at all), so a read-only
1147 // control keeps a working clear button.
1148 let clear_empty = self.selected_keys.is_empty();
1149 let clear_active = !clear_empty && !self.is_disabled;
1150 // A hover or press recorded on the button outlives the listener that
1151 // would clear it when the button goes inert (cleared, disabled): the
1152 // pointer can leave and the selection can flip with no event reaching
1153 // the detached handler. The inert frames normalize the slot back to
1154 // rest, so a stale flag cannot survive a clear-and-reselect.
1155 if !clear_active && *clear_slot.read(cx) != (false, false) {
1156 clear_slot.update(cx, |state, _| *state = (false, false));
1157 }
1158 let (clear_hovered, clear_pressed) = if clear_active {
1159 *clear_slot.read(cx)
1160 } else {
1161 (false, false)
1162 };
1163 let reduce_motion = ActiveTheme::reduce_motion(cx);
1164 let mut clear_opacity = crate::anim::Tween::keyed(
1165 base_id,
1166 "clear-opacity",
1167 if clear_empty { 0.0 } else { 1.0 },
1168 window,
1169 cx,
1170 );
1171 // HeroUI hides the clear button immediately when the selection is
1172 // emptied, but lets a newly visible button fade in. Keeping the
1173 // transition on a listener-free child preserves the stable 20px hit
1174 // target and lets a clear/reselect reversal resume from its painted
1175 // opacity.
1176 if clear_empty {
1177 clear_opacity.settle();
1178 } else {
1179 clear_opacity.snap_if_reduced(reduce_motion);
1180 }
1181 AutoClear {
1182 clear_slot,
1183 clear_active,
1184 clear_hovered,
1185 clear_pressed,
1186 reduce_motion,
1187 clear_opacity,
1188 }
1189 }
1190
1191 /// The rows the list draws this frame, or none while nothing consumes
1192 /// them.
1193 fn matches(
1194 &self,
1195 controlled: &AutoControlled,
1196 raw_query: &str,
1197 query_edit: &Entity<Option<bool>>,
1198 window: &mut Window,
1199 cx: &mut App,
1200 ) -> Rc<[PickerItem]> {
1201 let AutoControlled {
1202 ref base_id,
1203 ref items,
1204 overlay_phase,
1205 ..
1206 } = *controlled;
1207 let overlay_active = overlay_phase != util::OverlayPhase::Closed;
1208 // The list starts unfiltered: v3's popover shows the whole collection
1209 // until something is typed into the search field. Closed and idle
1210 // frames draw no rows, so they skip the match work entirely; a
1211 // consuming frame with a typed query shares one cached list until the
1212 // query, the collection or the cap changes. An empty query copies the
1213 // capped prefix directly: no per-row matching runs for it, so the
1214 // cache's element-by-element key comparison would cost more than the
1215 // work it saves. A custom filter owns the whole decision, including
1216 // what an empty query means, and its configuration cannot join a
1217 // cache key — its results are never cached and it only runs while the
1218 // matches are consumed.
1219 let matches_cache =
1220 window.use_keyed_state(element_id::scoped(base_id, "matches"), cx, |_, _| {
1221 MatchesCache::default()
1222 });
1223 let consume_matches = overlay_active || query_edit.read(cx).is_some();
1224 let matches: Rc<[PickerItem]> = if !consume_matches {
1225 empty_matches()
1226 } else {
1227 let filter = self.filter.clone();
1228 match &filter {
1229 Some(f) => Rc::from(compute_matches(items, raw_query, self.max_items, Some(f))),
1230 None if raw_query.is_empty() => {
1231 Rc::from(compute_matches(items, raw_query, self.max_items, None))
1232 }
1233 None => matches_cache.update(cx, |cache, _| {
1234 cache.get(items.clone(), raw_query, self.max_items, |items| {
1235 compute_matches(items, raw_query, self.max_items, None)
1236 })
1237 }),
1238 }
1239 };
1240 matches
1241 }
1242
1243 /// Mirrors the selection into the live form state and installs the
1244 /// reset that restores the default selection.
1245 fn sync_form(
1246 &self,
1247 controlled: &AutoControlled,
1248 is_invalid: bool,
1249 focus_handle: &Option<gpui::FocusHandle>,
1250 ) {
1251 let AutoControlled {
1252 multiple,
1253 ref selection_own,
1254 ..
1255 } = *controlled;
1256 sync_form_state(
1257 &self.form_state,
1258 &self.selected_keys,
1259 self.is_disabled,
1260 is_invalid,
1261 );
1262 self.form_state.borrow_mut().focus = focus_handle.clone();
1263 let restore_own = selection_own.clone();
1264 let restore_state = Rc::downgrade(&self.form_state);
1265 let restore_default =
1266 normalize_selection(self.default_value.clone().unwrap_or_default(), multiple);
1267 let restore_all = self.on_selection_change_all.clone();
1268 self.form_state.borrow_mut().restore = (restore_own.is_some() || restore_all.is_some())
1269 .then(|| {
1270 util::shared(move |window: &mut Window, cx: &mut App| {
1271 if let Some(state) = restore_state.upgrade() {
1272 state.borrow_mut().value = form_selection_value(&restore_default);
1273 }
1274 if let Some(held) = &restore_own {
1275 let set = restore_default.clone();
1276 held.update(cx, |v, cx| {
1277 *v = set;
1278 cx.notify();
1279 });
1280 }
1281 if let Some(cb) = &restore_all {
1282 cb(&restore_default, window, cx);
1283 }
1284 }) as std::sync::Arc<dyn Fn(&mut Window, &mut App)>
1285 });
1286 }
1287
1288 /// The `.autocomplete__trigger` box: geometry, field chrome, focus ring
1289 /// and hover fade.
1290 fn trigger_field(
1291 &self,
1292 frame: &AutoFrame,
1293 window: &mut Window,
1294 cx: &mut App,
1295 ) -> gpui::Stateful<gpui::Div> {
1296 let AutoFrame {
1297 ref base,
1298 ref base_id,
1299 ref focus_handle,
1300 clear: AutoClear { clear_hovered, .. },
1301 ref colors,
1302 ref layout,
1303 is_invalid,
1304 ..
1305 } = *frame;
1306 let field_box = self.field;
1307 // `radius` overrides the detached panel only; the trigger is a field
1308 // box painted by the shared field chrome.
1309 let trigger_radius = util::field_radius(cx);
1310 // `.autocomplete__trigger` is `relative isolate inline-flex min-h-9
1311 // rounded-field border bg-field px-3 py-2 text-sm shadow-field`, plus
1312 // `pe-7` because the indicator sits inside it.
1313 let mut field = gpui::div()
1314 .id(element_id::scoped(base_id, "trigger"))
1315 .when_some(self.font_family.clone(), |field, family| field.font_family(family))
1316 // Headless probe: the decision that gates the hover refinement
1317 // above, so a test can drive real hover coordinates and read the
1318 // rendered state without painted-color access.
1319 .debug_selector({
1320 let base = base.clone();
1321 move || format!("{base}-trigger-suppressed-{clear_hovered}")
1322 })
1323 .relative()
1324 .flex()
1325 .items_center()
1326 .gap(px(8.))
1327 .min_h(field_box.resolved_height())
1328 .when_some(field_box.height, |el, h| el.h(h))
1329 .px(field_box.resolved_padding_x())
1330 .pr(px(28.))
1331 .text_size(util::FIELD_TEXT)
1332 .line_height(px(20.));
1333 if !field_box.is_bare {
1334 field = util::apply_field_chrome(
1335 field,
1336 self.variant,
1337 is_invalid,
1338 false,
1339 Some(trigger_radius),
1340 cx,
1341 );
1342 // `.autocomplete__trigger:focus-visible` is `status-focused` -- the
1343 // offset ring, not a field's flush one, which is why the chrome
1344 // above is not told about the focus.
1345 if field_box.focus_ring.unwrap_or(true) {
1346 if let Some(handle) = &focus_handle {
1347 // Overlay rather than spread shadow: the ring's corner is
1348 // then concentric with `trigger_radius` instead of
1349 // repeating the trigger's own radius four pixels out, and
1350 // its edge is crisp instead of blurred.
1351 field = util::ring_overlay_if_focused(
1352 field,
1353 handle,
1354 true,
1355 trigger_radius,
1356 Vec::new(),
1357 window,
1358 cx,
1359 );
1360 }
1361 }
1362 }
1363 if self.is_disabled {
1364 field = field.opacity(layout.disabled_opacity);
1365 } else {
1366 // The cursor is an affordance, not chrome: a bare trigger stays
1367 // clickable and keeps the themed pointer.
1368 field = field.cursor(util::interactive_cursor(cx));
1369 if !field_box.is_bare {
1370 let idle_bg = match self.variant {
1371 FieldVariant::Primary => colors.field.background,
1372 FieldVariant::Secondary => colors.default.color,
1373 };
1374 let hover_bg = self.trigger_hover_bg.unwrap_or(match self.variant {
1375 FieldVariant::Primary => colors.field.hover(),
1376 // `.autocomplete--secondary` hovers
1377 // `--autocomplete-trigger-bg-hover: var(--default-hover)`.
1378 FieldVariant::Secondary => colors.default.hover(),
1379 });
1380 let hover_border = colors.field.border_hover();
1381 // The trigger owns the stable focus/clear listeners. Animate
1382 // only its background fill with the pinned 150ms
1383 // `ease-smooth` curve; while the nested clear affordance is
1384 // hovered, suppress the parent endpoint just like v3's
1385 // `:not(:has(.autocomplete__clear-button:hover))` rule.
1386 field = crate::anim::hover_fade_with_duration_and_easing_suppressed(
1387 field,
1388 element_id::scoped(base_id, "trigger-hover-fade"),
1389 (idle_bg, hover_bg),
1390 None,
1391 (!clear_hovered).then_some(hover_border),
1392 clear_hovered,
1393 |fill| fill.rounded(trigger_radius),
1394 Some(150),
1395 crate::anim::HoverFadeEasing::EaseSmooth,
1396 window,
1397 cx,
1398 );
1399 }
1400 }
1401 if self.full_width {
1402 field = field.w_full();
1403 } else {
1404 // v3's trigger is `inline-flex` and every documented example sizes
1405 // it from the outside (`<Autocomplete className="w-[256px]">`).
1406 // There is no `className` here, so the trigger keeps a floor of its
1407 // own rather than collapsing onto the placeholder -- the same choice
1408 // `ComboBox` makes.
1409 field = field.min_w(px(180.));
1410 }
1411 field
1412 }
1413
1414 /// `.autocomplete__value`: the selection's text, or the caller's slot.
1415 fn trigger_value(&mut self, frame: &AutoFrame, cx: &App) -> (gpui::AnyElement, String) {
1416 let AutoFrame {
1417 ref items,
1418 ref colors,
1419 ..
1420 } = *frame;
1421 // --- `.autocomplete__value` -----------------------------------------
1422 // The trigger renders the selection in the selection set's own order —
1423 // pinned react-stately 3.49.0's `selectedKeys` is a JS `Set`, whose
1424 // iteration order is insertion order, so `selectedItems`, the render
1425 // props' keys and `selectedText` follow the pick (or owner) order, not
1426 // the collection's. Each key resolves to its item wherever that item
1427 // now sits; a key whose item is not in the collection (still loading)
1428 // renders nothing, which is what v3's `selectedItems` does too.
1429 let mut selected_items: Vec<SharedString> = Vec::new();
1430 let mut selected_indices: Vec<usize> = Vec::new();
1431 for key in &self.selected_keys {
1432 if let Some((index, item)) = items.iter().enumerate().find(|(_, it)| it.key() == key) {
1433 selected_items.push(item.label().clone());
1434 selected_indices.push(index);
1435 }
1436 }
1437 let selected_key_order = self.selected_keys.clone();
1438 // `selectedText` — v3 joins with locale-aware separators; without CLDR
1439 // data this is a comma and a space.
1440 let selected_text = selected_items
1441 .iter()
1442 .map(ToString::to_string)
1443 .collect::<Vec<_>>()
1444 .join(", ");
1445 let is_placeholder = selected_items.is_empty();
1446 let placeholder = self
1447 .placeholder
1448 .clone()
1449 // v3's own default for this prop.
1450 .unwrap_or_else(|| crate::i18n::ui_string(crate::i18n::UiString::SelectPlaceholder, cx));
1451 // `.autocomplete__value` is `flex-1 text-start text-sm
1452 // wrap-break-word`, and `text-field-placeholder` while nothing is
1453 // chosen. Keep the value slot's min-content floor released so a
1454 // narrow trigger grows vertically for a long selected label.
1455 let default_children = gpui::div()
1456 .flex_1()
1457 .min_w_0()
1458 .whitespace_normal()
1459 .text_size(util::FIELD_TEXT)
1460 .line_height(px(20.))
1461 .text_color(if is_placeholder {
1462 colors.field.placeholder
1463 } else {
1464 colors.field.foreground
1465 })
1466 .child(if is_placeholder {
1467 placeholder.to_string()
1468 } else {
1469 selected_text.clone()
1470 })
1471 .into_any_element();
1472 let value_slot = match self.value_content.take() {
1473 Some(render) => gpui::div()
1474 .flex_1()
1475 .min_w_0()
1476 .child(render(util::SelectionValue {
1477 selected_items: &selected_items,
1478 selected_indices: &selected_indices,
1479 selected_keys: Some(&selected_key_order),
1480 selected_text: &selected_text,
1481 is_placeholder,
1482 default_children,
1483 }))
1484 .into_any_element(),
1485 None => default_children,
1486 };
1487 (value_slot, selected_text)
1488 }
1489
1490 /// `.autocomplete__clear-button`: a stable 20px hit box over a scaled,
1491 /// fading visual.
1492 fn clear_button(&self, frame: &AutoFrame, cx: &App) -> gpui::Stateful<gpui::Div> {
1493 let AutoFrame {
1494 ref base,
1495 ref base_id,
1496 ref selection_own,
1497 clear:
1498 AutoClear {
1499 ref clear_slot,
1500 clear_active,
1501 clear_pressed,
1502 reduce_motion,
1503 ref clear_opacity,
1504 ..
1505 },
1506 ref colors,
1507 ..
1508 } = *frame;
1509 // `.autocomplete__clear-button` — mounted whenever the trigger is:
1510 // `data-empty` only makes it invisible and pointer-inert, and
1511 // `disabled={isDisabled}` only disables it. The pinned part is a
1512 // 20px `rounded-xl p-1` box holding the `size-3.5` (14px) glyph, and
1513 // `:active, &[data-pressed]` scales the whole button to 0.93 about
1514 // its center. gpui 0.2.2 cannot scale a div, so the 20px hit box
1515 // stays put for the pointer and a centered *visual* box carries the
1516 // scale while pressed.
1517 let clear_scale = if clear_pressed { 0.93 } else { 1. };
1518 let clear_visual = px(20. * clear_scale);
1519 let clear_glyph = px(14. * clear_scale);
1520 let clear_radius = px(f32::from(util::small_radius(cx)) * clear_scale);
1521 // `.autocomplete__clear-button:hover` fills with `bg-default-hover`,
1522 // the role-hover mix -- not the lighter soft-hover wash.
1523 let hover_bg = self.clear_hover_bg.unwrap_or(colors.default.hover());
1524 let clear_visual = gpui::div()
1525 .debug_selector({
1526 let base = base.clone();
1527 move || format!("{base}-clear-visual")
1528 })
1529 .flex()
1530 .items_center()
1531 .justify_center()
1532 .flex_shrink_0()
1533 .size(clear_visual)
1534 .rounded(clear_radius)
1535 .when(clear_active, |el| el.hover(move |st| st.bg(hover_bg)))
1536 .child(
1537 gpui::svg()
1538 .size(clear_glyph)
1539 .path(icons::CLOSE)
1540 .text_color(colors.muted),
1541 );
1542 let clear_visual = if clear_opacity.animates(reduce_motion) {
1543 let from = clear_opacity.from();
1544 let to = clear_opacity.target();
1545 let value = clear_opacity.value();
1546 clear_visual
1547 .with_animation(
1548 element_id::indexed(base_id, "clear-opacity", clear_opacity.generation()),
1549 gpui::Animation::new(Duration::from_millis(CLEAR_OPACITY_TRANSITION_MS))
1550 .with_easing(crate::anim::ease_smooth()),
1551 move |visual, delta| {
1552 let next = from + (to - from) * delta;
1553 value.set(next);
1554 visual.opacity(next)
1555 },
1556 )
1557 .into_any_element()
1558 } else {
1559 clear_opacity.settle();
1560 clear_visual
1561 .opacity(clear_opacity.target())
1562 .into_any_element()
1563 };
1564 let mut clear = gpui::div()
1565 .id(element_id::scoped(base_id, "clear"))
1566 // `autocomplete.js` hard-codes `"aria-label": "Clear selection"`
1567 // on this RAC `Button`, beside an `aria-hidden` that only hides
1568 // it while the selection is empty — and gpui has no
1569 // `aria-hidden`, so the port leaves the empty button in the tree
1570 // where upstream removes it from it.
1571 .a11y_named(
1572 a11y::Role::Button,
1573 &a11y::Name::labelled(crate::i18n::ui_string(
1574 crate::i18n::UiString::ClearSelection,
1575 cx,
1576 )),
1577 )
1578 // `.autocomplete__clear-button` is `h-6 w-6`
1579 // and then `size-5`, so 20px, `rounded-xl` and `p-1`
1580 // -- with the pinned `size-3.5` (14px) glyph inside.
1581 .size(px(20.))
1582 .p(px(4.))
1583 .rounded(util::small_radius(cx))
1584 .relative()
1585 .flex()
1586 .items_center()
1587 .justify_center()
1588 .flex_shrink_0()
1589 .when(clear_active, |el| el.cursor(util::interactive_cursor(cx)))
1590 .debug_selector({
1591 let base = base.clone();
1592 move || format!("{base}-clear")
1593 })
1594 .child(clear_visual);
1595 if clear_active {
1596 let own = selection_own.clone();
1597 let selection_cb = self.on_selection_change_all.clone();
1598 let clear_cb = self.on_clear.clone();
1599 let clear_form_state = self.form_state.clone();
1600 clear = util::track_interaction(clear, clear_slot).on_click(move |_, window, cx| {
1601 // The button sits *inside* the trigger, so gpui
1602 // dispatches its click up to the trigger's own
1603 // `on_click` too -- and clearing is not an open
1604 // gesture (React Aria's trigger press is
1605 // pointer-bound, so a bubbled DOM click is inert
1606 // there).
1607 cx.stop_propagation();
1608 // Uncontrolled: drop our own selection too, or the
1609 // button would clear nothing.
1610 if let Some(held) = &own {
1611 held.update(cx, |v, cx| {
1612 v.clear();
1613 cx.notify();
1614 });
1615 clear_form_state.borrow_mut().value = crate::form::FormValue::Keys(Vec::new());
1616 }
1617 if let Some(cb) = &selection_cb {
1618 cb(&[], window, cx);
1619 }
1620 if let Some(cb) = &clear_cb {
1621 cb(window, cx);
1622 }
1623 });
1624 }
1625 clear
1626 }
1627
1628 /// The absolute `.autocomplete__indicator` end slot.
1629 fn trigger_indicator_slot(
1630 &mut self,
1631 mut field: gpui::Stateful<gpui::Div>,
1632 frame: &AutoFrame,
1633 window: &mut Window,
1634 cx: &mut App,
1635 ) -> gpui::Stateful<gpui::Div> {
1636 let AutoFrame {
1637 ref base_id,
1638 open,
1639 ref colors,
1640 ..
1641 } = *frame;
1642 // `.autocomplete__indicator` is `absolute inset-y-0 end-2 my-auto`, and
1643 // its glyph is `size-4`. HeroUI keeps one down-chevron in the tree and
1644 // rotates it over 150ms. Caller content still receives the live open
1645 // state and remains caller-owned.
1646 let trigger_indicator = match self.indicator.take() {
1647 Some(render) => render(open),
1648 None => crate::anim::rotating_indicator_with_duration(
1649 &element_id::scoped(base_id, "trigger-indicator"),
1650 open,
1651 gpui::svg()
1652 .size(util::FIELD_ICON)
1653 .path(icons::CHEVRON_DOWN)
1654 .text_color(colors.field.placeholder),
1655 150,
1656 window,
1657 cx,
1658 ),
1659 };
1660 field = field.child(
1661 gpui::div()
1662 .absolute()
1663 .right(px(8.))
1664 .top_0()
1665 .bottom_0()
1666 .flex()
1667 .items_center()
1668 .justify_center()
1669 .text_color(colors.field.placeholder)
1670 .child(trigger_indicator),
1671 );
1672 field
1673 }
1674
1675 /// The trigger's press: toggles the popover and reports the change.
1676 fn trigger_toggle(
1677 &self,
1678 mut field: gpui::Stateful<gpui::Div>,
1679 frame: &AutoFrame,
1680 ) -> gpui::Stateful<gpui::Div> {
1681 let AutoFrame {
1682 open,
1683 ref open_own,
1684 ref focus_handle,
1685 can_open,
1686 toggle_allowed,
1687 ref trigger_pressed,
1688 ..
1689 } = *frame;
1690 // Clicking the trigger opens and closes the popover. The toggle is
1691 // the `useSelectState.toggle()` act: an empty collection without the
1692 // prop refuses it in *both* directions, and the refusal reports
1693 // nothing (the guard sits before `triggerState.toggle()`, so
1694 // `onOpenChange` never fires).
1695 if can_open {
1696 let own = open_own.clone();
1697 let cb = self.on_open_change.clone();
1698 let was_open = open;
1699 let pressed = trigger_pressed.clone();
1700 let may_toggle = toggle_allowed;
1701 field = field
1702 .capture_any_mouse_down(move |_, _, cx| {
1703 pressed.set(true);
1704 let pressed = pressed.clone();
1705 cx.defer(move |_| pressed.set(false));
1706 })
1707 .when_some(focus_handle.as_ref(), |el, handle| el.track_focus(handle))
1708 .on_click(move |_, window, cx| {
1709 if !may_toggle {
1710 return;
1711 }
1712 if let Some(held) = &own {
1713 held.update(cx, |v, cx| {
1714 *v = !was_open;
1715 cx.notify();
1716 });
1717 }
1718 if let Some(cb) = &cb {
1719 cb(&!was_open, window, cx);
1720 }
1721 });
1722 }
1723 field
1724 }
1725
1726 /// The `.autocomplete` wrapper column and the root that scopes blur.
1727 fn field_root(&self, field: impl IntoElement, frame: &AutoFrame) -> gpui::Div {
1728 let AutoFrame {
1729 ref blur_scope,
1730 is_invalid,
1731 ..
1732 } = *frame;
1733 // --- the wrapper: `.autocomplete` is `flex flex-col gap-1` -----------
1734 let mut wrapper = gpui::div().flex().flex_col().gap(px(4.)).w_full();
1735 if let Some(label) = &self.label {
1736 wrapper = wrapper.child(
1737 crate::field::Label::new(label.clone())
1738 .is_required(self.is_required)
1739 .is_disabled(self.is_disabled)
1740 .is_invalid(is_invalid),
1741 );
1742 }
1743 wrapper = wrapper.child(field);
1744 if is_invalid {
1745 if let Some(message) = &self.error_message {
1746 wrapper = wrapper.child(crate::field::ErrorMessage::new(message.clone()));
1747 }
1748 } else if let Some(desc) = &self.description {
1749 wrapper = wrapper.child(crate::field::Description::new(desc.clone()));
1750 }
1751
1752 let mut root = gpui::div().relative().child(wrapper);
1753 root = if self.full_width {
1754 root.w_full()
1755 } else {
1756 root.max_w(px(320.))
1757 };
1758 // The blur scope spans this one root, so a focus move between the
1759 // trigger and the search field inside the panel stays inside it.
1760 root = root.track_focus(blur_scope);
1761 root
1762 }
1763
1764 /// The root's key handler: the list walk while the search field holds
1765 /// the focus, and the closed trigger's Down/Up open.
1766 fn root_keys(&self, root: gpui::Div, frame: &AutoFrame) -> gpui::Div {
1767 let AutoFrame {
1768 multiple,
1769 ref selection_own,
1770 open,
1771 ref open_own,
1772 ref cursor,
1773 ref list_scroll_now,
1774 ref panel_scroll_now,
1775 ref query_edit,
1776 ref plain_edit_key,
1777 ref matches,
1778 toggle_allowed,
1779 ..
1780 } = *frame;
1781 let stops: Vec<usize> = (0..matches.len())
1782 .filter(|i| {
1783 matches
1784 .get(*i)
1785 .is_some_and(|item| !self.disabled_keys.contains(item.key()))
1786 })
1787 .collect();
1788 let held = cursor.clone();
1789 let key_query_edit = query_edit.clone();
1790 let key_plain_edit = plain_edit_key.clone();
1791 let wrap = self.should_focus_wrap;
1792 let virtual_rows = self.row_height.is_some();
1793 let page_row_height = self.row_height;
1794 let key_list_scroll = list_scroll_now.clone();
1795 let key_panel_scroll = panel_scroll_now.clone();
1796 let key_page_stops = stops.clone();
1797 let rows = matches.clone();
1798 let key_open_own = open_own.clone();
1799 let key_open_change = self.on_open_change.clone();
1800 let may_open = toggle_allowed;
1801 let on_change_all = self.on_selection_change_all.clone();
1802 let on_change_one = self.on_selection_change.clone();
1803 let key_selection_own = selection_own.clone();
1804 let key_form_state = self.form_state.clone();
1805 let selected_now = self.selected_keys.clone();
1806 let was_open = open;
1807 let handler = AutoKeys {
1808 stops,
1809 held,
1810 key_query_edit,
1811 key_plain_edit,
1812 wrap,
1813 virtual_rows,
1814 page_row_height,
1815 key_list_scroll,
1816 key_panel_scroll,
1817 key_page_stops,
1818 rows,
1819 key_open_own,
1820 key_open_change,
1821 may_open,
1822 on_change_all,
1823 on_change_one,
1824 key_selection_own,
1825 key_form_state,
1826 selected_now,
1827 was_open,
1828 multiple,
1829 };
1830 root.on_key_down(move |event, window, cx| handler.on_key_down(event, window, cx))
1831 }
1832
1833 /// Escape closes the popover and hands the focus back to the trigger.
1834 fn root_escape(&self, mut root: gpui::Div, frame: &AutoFrame) -> gpui::Div {
1835 let AutoFrame {
1836 ref open_own,
1837 ref dismissal_token,
1838 ref focus_handle,
1839 ..
1840 } = *frame;
1841 let escape_own = open_own.clone();
1842 let escape_cb = self.on_open_change.clone();
1843 let escape_focus = focus_handle.clone();
1844 root =
1845 util::dismiss_on_escape_with_token(root, dismissal_token.clone(), move |window, cx| {
1846 if let Some(held) = &escape_own {
1847 held.update(cx, |v, cx| {
1848 *v = false;
1849 cx.notify();
1850 });
1851 }
1852 if let Some(cb) = &escape_cb {
1853 cb(&false, window, cx);
1854 }
1855 if let Some(handle) = &escape_focus {
1856 window.focus(handle, cx);
1857 }
1858 util::DismissResult::Handled
1859 });
1860 root
1861 }
1862
1863 /// The popover's clipping surface: `bg-overlay`, the panel radius, the
1864 /// dark-mode hairline and the overlay shadow.
1865 fn panel_surface(&self, base: &str, radius: Pixels, frame: &AutoFrame) -> gpui::Div {
1866 let AutoFrame {
1867 ref colors,
1868 ref layout,
1869 ..
1870 } = *frame;
1871 let panel_selector = format!("{base}-panel");
1872 gpui::div()
1873 .w_full()
1874 .flex()
1875 .flex_col()
1876 // `.autocomplete__popover` is `p-0 pt-2`: the search field and
1877 // the list bring their own padding.
1878 .pt(px(8.))
1879 .bg(colors.overlay.background)
1880 .rounded(radius)
1881 // v3 gives a floating panel no border: `.popover` and friends are
1882 // `bg-overlay shadow-overlay` and a radius, and dark mode's
1883 // inset hairline is what separates the panel from the page.
1884 .when_some(layout.overlay_hairline, |el, hairline| {
1885 el.border(layout.border_width).border_color(hairline)
1886 })
1887 .shadow(layout.overlay_shadow.clone())
1888 .debug_selector(move || panel_selector)
1889 // `.autocomplete__popover` is `overflow-hidden
1890 // overscroll-contain`: the panel clips while the inner list
1891 // owns the scrolling, and a wheel over it never reaches the
1892 // page behind. RAC caps the popover at the available viewport
1893 // height past a 12px inset; the positioner below re-lays the
1894 // panel out with that cap, so the panel carries a
1895 // viewport-relative bound rather than a fixed one.
1896 .max_h_full()
1897 .overflow_hidden()
1898 .occlude()
1899 }
1900
1901 /// The popover's search header: v3's `[data-slot="search-field"]`.
1902 fn search_row(&self, frame: &AutoFrame) -> gpui::Div {
1903 let AutoFrame {
1904 ref base,
1905 ref raw_query,
1906 ref query_edit,
1907 ref plain_edit_key,
1908 ..
1909 } = *frame;
1910 // The search field: v3's `[data-slot="search-field"]` inside the
1911 // popover is `shrink-0 px-3 py-1`, and `variant="secondary"` so it
1912 // reads as part of the panel rather than as a second field.
1913 let query_before_edit = raw_query.clone();
1914 let edit_query = query_edit.clone();
1915 let edit_key = plain_edit_key.clone();
1916 let input_change = self.on_input_change.clone();
1917 let search = SearchField::new(self.state.clone());
1918 let search = match self.font_family.clone() {
1919 Some(family) => search.font_family(family),
1920 None => search,
1921 };
1922 let search = search
1923 .variant(FieldVariant::Secondary)
1924 .placeholder("Search...")
1925 .is_read_only(self.is_read_only)
1926 .on_change(move |text, window, cx| {
1927 if text != query_before_edit {
1928 let forward = *edit_key.read(cx);
1929 edit_query.update(cx, |edit, cx| {
1930 *edit = Some(forward);
1931 cx.notify();
1932 });
1933 }
1934 if let Some(cb) = &input_change {
1935 cb(text, window, cx);
1936 }
1937 });
1938 gpui::div()
1939 .flex_shrink_0()
1940 .px(px(12.))
1941 .py(px(4.))
1942 .debug_selector({
1943 let base = base.clone();
1944 move || format!("{base}-search")
1945 })
1946 .child(search)
1947 }
1948
1949 /// The popover: surface, dismissal, search header, rows and motion.
1950 fn popover(&mut self, frame: AutoFrame, cx: &mut App) -> gpui::Deferred {
1951 // The entry zoom interpolates the panel's own radius, so one
1952 // binding feeds both the painted shape and the animation.
1953 let radius = self.radius.unwrap_or_else(|| util::container_radius(cx));
1954 let panel = self.panel_surface(&frame.base, radius, &frame);
1955 let search = self.search_row(&frame);
1956 let AutoFrame {
1957 base,
1958 base_id,
1959 multiple,
1960 selection_own,
1961 open_own,
1962 overlay_phase,
1963 dismissal_token,
1964 resolved_placement,
1965 entry_placement,
1966 focus_handle,
1967 list_scroll_now,
1968 panel_scroll_now,
1969 matches,
1970 cursor_at,
1971 anchor_bounds,
1972 colors,
1973 layout,
1974 trigger_pressed,
1975 ..
1976 } = frame;
1977 let overlay_exiting = overlay_phase == util::OverlayPhase::Exiting;
1978 // React Aria dismisses the popover on a press outside it; Escape is
1979 // read by the key handler above. A press that started on the
1980 // trigger (or its clear button) is not an outside press: the
1981 // trigger's own click owns the close, and the click only fires
1982 // because the down was not stolen as a dismissal.
1983 let dismiss_own = open_own.clone();
1984 let dismiss_cb = self.on_open_change.clone();
1985 let mut panel =
1986 util::dismiss_on_press_outside_with_token(panel, dismissal_token, move |window, cx| {
1987 if trigger_pressed.get() {
1988 return util::DismissResult::Declined;
1989 }
1990 if let Some(held) = &dismiss_own {
1991 held.update(cx, |v, cx| {
1992 *v = false;
1993 cx.notify();
1994 });
1995 }
1996 if let Some(cb) = &dismiss_cb {
1997 cb(&false, window, cx);
1998 }
1999 util::DismissResult::Handled
2000 });
2001
2002 panel = panel.child(search);
2003
2004 // Everything a row reads, owned: `uniform_list`'s callback is
2005 // `'static` and runs again on every scroll, so it cannot borrow
2006 // `self` or the theme -- and one row builder for both paths is what
2007 // keeps a virtual list drawing the same row as a short one.
2008 let matches_len = matches.len();
2009 // `useOption` adds `aria-posinset`/`aria-setsize` only
2010 // `if (isVirtualized)`; `row_height` is what windows this list.
2011 let row_virtualized = self.row_height.is_some();
2012 let rows = matches.clone();
2013 let sections = self.sections.clone();
2014 let row_disabled_keys = self.disabled_keys.clone();
2015 let row_selected_keys = self.selected_keys.clone();
2016 let indicator: Option<Rc<dyn Fn(bool) -> gpui::AnyElement>> =
2017 self.item_indicator.take().map(Rc::from);
2018 let on_change_all = self.on_selection_change_all.clone();
2019 let on_change_one = self.on_selection_change.clone();
2020 let row_selection_own = selection_own;
2021 let row_form_state = self.form_state.clone();
2022 let row_open_own = open_own;
2023 let row_open_change = self.on_open_change.clone();
2024 let row_trigger_focus = focus_handle;
2025 let base_row = format!("{base}-list");
2026 let base_row_id = element_id::scoped(&base_id, "list");
2027 let row_disabled_opacity = layout.disabled_opacity;
2028 // v3's `EmptyState` inside the popover is `text-center text-sm
2029 // text-overlay-foreground/60`. Copied out here because the row
2030 // builder below takes `cx` mutably, which ends the theme borrow.
2031 let mut empty_fg = colors.overlay.foreground;
2032 empty_fg.a *= 0.6;
2033 let row_padding_x = self.row_padding_x.unwrap_or(px(10.));
2034 let row_font_family = self.row_font_family.clone();
2035 let row_padding_y = self.row_padding_y.unwrap_or(px(6.));
2036 let rows = AutoRows {
2037 base_row,
2038 base_row_id,
2039 rows,
2040 sections,
2041 row_disabled_keys,
2042 row_selected_keys,
2043 indicator,
2044 on_change_all,
2045 on_change_one,
2046 row_selection_own,
2047 row_form_state,
2048 row_open_own,
2049 row_open_change,
2050 row_trigger_focus,
2051 colors,
2052 row_hover_bg: self.row_hover_bg,
2053 row_disabled_opacity,
2054 row_padding_x,
2055 row_font_family,
2056 row_padding_y,
2057 overlay_exiting,
2058 cursor_at,
2059 row_virtualized,
2060 matches_len,
2061 multiple,
2062 };
2063
2064 // The list: `[data-slot="list-box"]` inside the popover is
2065 // `max-h-[320px] min-h-0 p-1.5 overflow-y-auto`. The search header
2066 // above stays fixed (`shrink-0`) while this list shrinks with the
2067 // capped panel and owns the scrolling -- the outer panel only
2068 // clips (`overflow-hidden`).
2069 panel = panel.child(self.rows_list(
2070 rows,
2071 &base,
2072 &base_id,
2073 &list_scroll_now,
2074 &panel_scroll_now,
2075 cx,
2076 ));
2077
2078 if matches.is_empty() {
2079 panel = panel.child(
2080 gpui::div()
2081 .w_full()
2082 .px(px(12.))
2083 .py(px(12.))
2084 .text_center()
2085 .text_size(util::FIELD_TEXT)
2086 .line_height(px(20.))
2087 .text_color(empty_fg)
2088 // "No results found" in en-US.
2089 .child(crate::i18n::ui_string(crate::i18n::UiString::NoResults, cx)),
2090 );
2091 }
2092
2093 let (slide_x, slide_y) = crate::popover::placement_entry_offset(entry_placement);
2094 let zoom = crate::anim::ZoomBox::panel(px(6.), radius);
2095 let zoom = crate::anim::ZoomBox {
2096 slide_x: (slide_x != 0.0).then(|| px(slide_x)),
2097 slide_y: (slide_y != 0.0).then(|| px(slide_y)),
2098 ..zoom
2099 };
2100 let panel = if overlay_phase == util::OverlayPhase::Exiting {
2101 crate::anim::exiting(
2102 panel,
2103 element_id::scoped(&base_id, "panel-out"),
2104 zoom,
2105 crate::anim::Motion::FLUID_OUT,
2106 cx,
2107 )
2108 } else {
2109 crate::anim::entering_zoom(
2110 panel,
2111 element_id::scoped(&base_id, "panel"),
2112 zoom,
2113 crate::anim::Motion::FLUID_IN,
2114 cx,
2115 )
2116 };
2117 // RAC positions the popover against the trigger with an 8px gap,
2118 // flips it when the other side has more room, and caps it at the
2119 // available viewport height past a 12px inset -- which
2120 // `scrollable_field_popover` reads from the measured trigger
2121 // bounds above.
2122 util::floating(
2123 crate::popover::scrollable_field_popover_with_resolved_placement(
2124 anchor_bounds,
2125 self.placement,
2126 Some(resolved_placement),
2127 panel,
2128 ),
2129 )
2130 }
2131
2132 /// The `[data-slot="list-box"]` rows: a windowed list under
2133 /// `row_height`, a scrolling column otherwise.
2134 fn rows_list(
2135 &self,
2136 rows: AutoRows,
2137 base: &str,
2138 base_id: &gpui::ElementId,
2139 list_scroll_now: &gpui::UniformListScrollHandle,
2140 panel_scroll_now: &gpui::ScrollHandle,
2141 cx: &mut App,
2142 ) -> gpui::AnyElement {
2143 let matches_len = rows.matches_len;
2144 match self.row_height {
2145 // Virtual: only the rows in view are built, which is what makes
2146 // a thousand options affordable. The list itself is the scroll
2147 // container: `Infer` sizes it from its rows -- the full
2148 // natural height on the positioner's measure pass (so the
2149 // flip sees the real extent, like upstream's `overlaySize`),
2150 // capped to the available height on the capped pass -- while
2151 // `max-h-[320px]` keeps the roomy-window height at the
2152 // upstream maximum and `min-h-0` lets it shrink with the
2153 // panel. A fixed inner height would strand rows outside a
2154 // capped panel.
2155 Some(row_height) => {
2156 let rows_selector = format!("{base}-rows");
2157 // The virtual half of the same `[data-slot="list-box"]`
2158 // the branch below draws. `gpui::uniform_list` returns a
2159 // `UniformList`, which is not a
2160 // `StatefulInteractiveElement` and so cannot carry a role
2161 // however many ids it has; the role goes on a wrapper that
2162 // adds no box of its own — `flex flex-col min-h-0` around
2163 // a `w-full` child lays out exactly as the child did.
2164 gpui::div()
2165 .id(element_id::scoped(base_id, "list"))
2166 .a11y(a11y::Role::ListBox)
2167 .a11y_orientation(herogpui_core::Orientation::Vertical)
2168 .flex()
2169 .flex_col()
2170 .w_full()
2171 .min_h_0()
2172 .child(
2173 gpui::uniform_list(
2174 element_id::scoped(base_id, "rows"),
2175 matches_len,
2176 move |range, _window, cx| {
2177 range
2178 .map(|i| rows.row(i, Some(row_height), cx))
2179 .collect::<Vec<_>>()
2180 },
2181 )
2182 .track_scroll(list_scroll_now)
2183 .with_sizing_behavior(gpui::ListSizingBehavior::Infer)
2184 .w_full()
2185 .max_h(px(320.))
2186 .min_h_0()
2187 .p(px(6.))
2188 .debug_selector(move || rows_selector),
2189 )
2190 .into_any_element()
2191 }
2192 None => {
2193 let list_selector = format!("{base}-list-scroll");
2194 let mut list = gpui::div()
2195 .id(element_id::scoped(base_id, "list"))
2196 // `[data-slot="list-box"]` is RAC's `ListBox`, i.e.
2197 // `useListBox.mjs`'s literal `role: 'listbox'` with
2198 // `'aria-orientation'` defaulting to vertical. The
2199 // search field above it is outside the list upstream
2200 // too, which is why the role is here and not on the
2201 // popover panel.
2202 .a11y(a11y::Role::ListBox)
2203 .a11y_orientation(herogpui_core::Orientation::Vertical)
2204 .debug_selector(move || list_selector)
2205 .flex()
2206 .flex_col()
2207 .w_full()
2208 .p(px(6.))
2209 .max_h(px(320.))
2210 .min_h_0()
2211 .overflow_y_scroll()
2212 .track_scroll(panel_scroll_now);
2213 for index in 0..matches_len {
2214 list = list.child(rows.row(index, None, cx));
2215 }
2216 list.into_any_element()
2217 }
2218 }
2219 }
2220}
2221
2222/// The root's key handler, holding everything it reads. The handler is
2223/// `'static`, so it owns copies of the frame's state rather than borrowing
2224/// the Autocomplete.
2225struct AutoKeys {
2226 stops: Vec<usize>,
2227 held: Entity<Option<SharedString>>,
2228 key_query_edit: Entity<Option<bool>>,
2229 key_plain_edit: Entity<bool>,
2230 wrap: bool,
2231 virtual_rows: bool,
2232 page_row_height: Option<Pixels>,
2233 key_list_scroll: gpui::UniformListScrollHandle,
2234 key_panel_scroll: gpui::ScrollHandle,
2235 key_page_stops: Vec<usize>,
2236 rows: Rc<[PickerItem]>,
2237 key_open_own: Option<Entity<bool>>,
2238 key_open_change: Option<OnOpenChange>,
2239 may_open: bool,
2240 on_change_all: Option<OnSelectionChangeAll>,
2241 on_change_one: Option<OnSelectionChange>,
2242 key_selection_own: Option<Entity<Vec<SharedString>>>,
2243 key_form_state: AutocompleteFormState,
2244 selected_now: Vec<SharedString>,
2245 was_open: bool,
2246 multiple: bool,
2247}
2248
2249impl AutoKeys {
2250 fn on_key_down(&self, event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut App) {
2251 let Self {
2252 ref stops,
2253 ref held,
2254 ref key_query_edit,
2255 ref key_plain_edit,
2256 wrap,
2257 page_row_height,
2258 ref key_list_scroll,
2259 ref key_panel_scroll,
2260 ref key_page_stops,
2261 ref rows,
2262 ref key_open_own,
2263 ref key_open_change,
2264 may_open,
2265 was_open,
2266 ..
2267 } = *self;
2268 let key = event.keystroke.key.as_str();
2269 let modifiers = event.keystroke.modifiers;
2270 let mut chars = key.chars();
2271 let plain_insert = was_open
2272 && (key == "space"
2273 || matches!(
2274 (chars.next(), chars.next()),
2275 (Some(ch), None) if !ch.is_control()
2276 ))
2277 && !modifiers.control
2278 && !modifiers.alt
2279 && !modifiers.platform
2280 && !modifiers.function;
2281 key_plain_edit.update(cx, |v, cx| {
2282 if *v != plain_insert {
2283 *v = plain_insert;
2284 cx.notify();
2285 }
2286 });
2287 if plain_insert {
2288 key_query_edit.update(cx, |edit, cx| {
2289 if edit.is_some() {
2290 *edit = Some(true);
2291 cx.notify();
2292 }
2293 });
2294 }
2295 if !was_open {
2296 // Closed: Down and Up open it. Enter and Space are *not*
2297 // handled here -- the trigger has a click listener and gpui
2298 // fires those for a focused element, so answering them again
2299 // would open and close the popover in one keystroke.
2300 if matches!(key, "down" | "up") {
2301 // The keyboard open is the same
2302 // `useSelectState.open()` act: an empty collection
2303 // without `allowsEmptyCollection` refuses it and
2304 // reports nothing.
2305 if !may_open {
2306 return;
2307 }
2308 if let Some(held) = &key_open_own {
2309 held.update(cx, |v, cx| {
2310 *v = true;
2311 cx.notify();
2312 });
2313 }
2314 if let Some(cb) = &key_open_change {
2315 cb(&true, window, cx);
2316 }
2317 }
2318 return;
2319 }
2320 // The focused search field owns inserted characters. In
2321 // particular, the shared list navigator treats Space as an
2322 // activation key, but Autocomplete must insert it into the
2323 // query rather than select the current virtual row.
2324 if plain_insert || key == "space" {
2325 return;
2326 }
2327 // The held cursor is the focused item's key; resolve it to the
2328 // row it occupies in the filtered collection now.
2329 let from = held
2330 .read(cx)
2331 .as_ref()
2332 .and_then(|k| rows.iter().position(|it| it.key() == k));
2333 // Pinned React Aria 3.51.0 binds PageUp/PageDown through the
2334 // listbox's `useSelectableCollection`, which the closed branch
2335 // above never reaches. Those handlers require
2336 // `manager.focusedKey != null` -- a mouse-opened, selection-less
2337 // Autocomplete has a null cursor and must answer nothing until
2338 // an arrow establishes one.
2339 //
2340 // With a cursor this popup pages by viewport, unlike the
2341 // Select/ComboBox/Dropdown popups: `autocomplete.css` styles
2342 // the composed `[data-slot="list-box"]` itself
2343 // `max-h-[320px] min-h-0 overflow-y-auto`, so the list element
2344 // is its own scroller and pinned `ListKeyboardDelegate` walks
2345 // enabled rows from the cursor until one crosses a
2346 // one-viewport boundary, taking the enabled end only when the
2347 // walk runs out. The default rows are laid out, so the
2348 // boundary reads real `ScrollHandle` rects (the plain ListBox
2349 // shape); a `row_height` list is uniform and pages by
2350 // whole-row steps across its *actual* laid-out viewport: the
2351 // panel caps together with the positioner, so the 320px
2352 // upstream maximum is only the roomy-window height, never the
2353 // paging ruler. The step reads the virtual list's own
2354 // `UniformListScrollHandle` viewport bounds -- the pinned
2355 // handle's `base_handle.bounds()` -- so a capped panel pages
2356 // by what it shows.
2357 let page_target = |from: usize| -> Option<usize> {
2358 if let Some(row_height) = page_row_height {
2359 let viewport_height =
2360 f32::from(key_list_scroll.0.borrow().base_handle.bounds().size.height);
2361 if viewport_height <= 0. {
2362 return None;
2363 }
2364 let step =
2365 ((viewport_height / f32::from(row_height)).ceil() as usize).saturating_sub(1);
2366 let boundary = match key {
2367 "pagedown" => (from + step).min(rows.len().saturating_sub(1)),
2368 "pageup" => from.saturating_sub(step),
2369 _ => return None,
2370 };
2371 return match key {
2372 "pagedown" => key_page_stops
2373 .iter()
2374 .copied()
2375 .find(|stop| *stop >= boundary)
2376 .or_else(|| key_page_stops.last().copied()),
2377 "pageup" => key_page_stops
2378 .iter()
2379 .rev()
2380 .copied()
2381 .find(|stop| *stop <= boundary)
2382 .or_else(|| key_page_stops.first().copied()),
2383 _ => None,
2384 };
2385 }
2386 let current = key_panel_scroll.bounds_for_item(from)?;
2387 let viewport_height = key_panel_scroll.bounds().size.height;
2388 let target = match key {
2389 "pagedown" => current.top() - current.size.height + viewport_height,
2390 "pageup" => current.top() + current.size.height - viewport_height,
2391 _ => return None,
2392 };
2393 match key {
2394 "pagedown" => key_page_stops
2395 .iter()
2396 .copied()
2397 .filter(|stop| *stop >= from)
2398 .find(|stop| {
2399 key_panel_scroll
2400 .bounds_for_item(*stop)
2401 .is_some_and(|bounds| bounds.top() >= target)
2402 })
2403 .or_else(|| key_page_stops.last().copied()),
2404 "pageup" => key_page_stops
2405 .iter()
2406 .rev()
2407 .copied()
2408 .filter(|stop| *stop <= from)
2409 .find(|stop| {
2410 key_panel_scroll
2411 .bounds_for_item(*stop)
2412 .is_some_and(|bounds| bounds.top() <= target)
2413 })
2414 .or_else(|| key_page_stops.first().copied()),
2415 _ => None,
2416 }
2417 };
2418 let page_move = from.and_then(page_target);
2419 let page_move = page_move.filter(|next| Some(*next) != from);
2420 let next_move = page_move.map_or_else(
2421 || crate::list_nav::resolve(stops, from, key, wrap),
2422 crate::list_nav::Move::To,
2423 );
2424 self.apply_move(next_move, from, window, cx);
2425 }
2426
2427 /// The open list's answer to a resolved move: walk the cursor, or take
2428 /// the cursor row.
2429 fn apply_move(
2430 &self,
2431 next_move: crate::list_nav::Move,
2432 from: Option<usize>,
2433 window: &mut Window,
2434 cx: &mut App,
2435 ) {
2436 let Self {
2437 ref held,
2438 virtual_rows,
2439 ref key_list_scroll,
2440 ref key_panel_scroll,
2441 ref rows,
2442 ref key_open_own,
2443 ref key_open_change,
2444 ref on_change_all,
2445 ref on_change_one,
2446 ref key_selection_own,
2447 ref key_form_state,
2448 ref selected_now,
2449 multiple,
2450 ..
2451 } = *self;
2452 match next_move {
2453 crate::list_nav::Move::To(next) => {
2454 let next_key = rows.get(next).map(|item| item.key().clone());
2455 held.update(cx, |v, cx| {
2456 *v = next_key;
2457 cx.notify();
2458 });
2459 if virtual_rows {
2460 key_list_scroll.scroll_to_item(next, gpui::ScrollStrategy::Center);
2461 } else {
2462 key_panel_scroll.scroll_to_item(next);
2463 }
2464 }
2465 crate::list_nav::Move::Activate => {
2466 let Some(item) = from.and_then(|i| rows.get(i)) else {
2467 return;
2468 };
2469 let item_key = item.key().clone();
2470 let mut next = selected_now.clone();
2471 if multiple {
2472 toggle_key(&mut next, &item_key);
2473 } else {
2474 next.clear();
2475 next.push(item_key.clone());
2476 }
2477 if let Some(own) = &key_selection_own {
2478 let set = next.clone();
2479 own.update(cx, |v, cx| {
2480 *v = set;
2481 cx.notify();
2482 });
2483 key_form_state.borrow_mut().value = form_selection_value(&next);
2484 }
2485 if let Some(cb) = &on_change_one {
2486 cb(&item_key, window, cx);
2487 }
2488 if let Some(cb) = &on_change_all {
2489 cb(&next, window, cx);
2490 }
2491 // A single selection closes the popover, as v3's does;
2492 // a multiple one stays open for the next pick.
2493 if !multiple {
2494 if let Some(own) = &key_open_own {
2495 own.update(cx, |v, cx| {
2496 *v = false;
2497 cx.notify();
2498 });
2499 }
2500 if let Some(cb) = &key_open_change {
2501 cb(&false, window, cx);
2502 }
2503 // The focus is *not* moved back to the trigger here.
2504 // gpui activates a focused element on Enter, so
2505 // focusing the trigger inside this very keystroke
2506 // fires its click listener and the popover reopens --
2507 // observed, not theorised.
2508 }
2509 }
2510 crate::list_nav::Move::Ignore => {}
2511 }
2512 }
2513}
2514
2515/// Everything an option row reads, owned: `uniform_list`'s callback is
2516/// `'static` and runs again on every scroll, so it cannot borrow the
2517/// Autocomplete or the theme -- and one row builder for both paths is what
2518/// keeps a virtual list drawing the same row as a short one.
2519struct AutoRows {
2520 base_row: String,
2521 base_row_id: gpui::ElementId,
2522 rows: Rc<[PickerItem]>,
2523 sections: Vec<(SharedString, SharedString)>,
2524 row_disabled_keys: std::collections::HashSet<SharedString>,
2525 row_selected_keys: Vec<SharedString>,
2526 indicator: Option<Rc<dyn Fn(bool) -> gpui::AnyElement>>,
2527 on_change_all: Option<OnSelectionChangeAll>,
2528 on_change_one: Option<OnSelectionChange>,
2529 row_selection_own: Option<Entity<Vec<SharedString>>>,
2530 row_form_state: AutocompleteFormState,
2531 row_open_own: Option<Entity<bool>>,
2532 row_open_change: Option<OnOpenChange>,
2533 row_trigger_focus: Option<gpui::FocusHandle>,
2534 colors: herogpui_theme::ThemeColors,
2535 row_hover_bg: Option<gpui::Hsla>,
2536 row_disabled_opacity: f32,
2537 row_padding_x: Pixels,
2538 row_font_family: Option<SharedString>,
2539 row_padding_y: Pixels,
2540 overlay_exiting: bool,
2541 cursor_at: Option<usize>,
2542 row_virtualized: bool,
2543 matches_len: usize,
2544 multiple: bool,
2545}
2546
2547impl AutoRows {
2548 /// One option row, with its section header when one precedes it.
2549 fn row(&self, index: usize, fixed_h: Option<Pixels>, cx: &mut App) -> gpui::AnyElement {
2550 let Self {
2551 ref base_row,
2552 ref base_row_id,
2553 ref rows,
2554 ref sections,
2555 ref row_disabled_keys,
2556 ref row_selected_keys,
2557 ref indicator,
2558 ref colors,
2559 row_disabled_opacity,
2560 row_padding_x,
2561 ref row_font_family,
2562 row_padding_y,
2563 overlay_exiting,
2564 cursor_at,
2565 row_virtualized,
2566 matches_len,
2567 ..
2568 } = *self;
2569 let row_muted = colors.muted;
2570 let row_fg = colors.foreground;
2571 let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);
2572 let row_focus = colors.focus;
2573 let row_accent = colors.accent.color;
2574 let base = base_row.as_str();
2575 let base_id = &base_row_id;
2576 let item = &rows[index];
2577 // A section header rides above the row it introduces, so the two
2578 // are one element -- a virtual row is one slot tall.
2579 let mut head: Vec<gpui::AnyElement> = Vec::new();
2580 let done = |head: Vec<gpui::AnyElement>, row: gpui::AnyElement| {
2581 gpui::div()
2582 .flex()
2583 .flex_col()
2584 .when_some(fixed_h, |el, h| el.h(h).w_full())
2585 .children(head)
2586 .child(row)
2587 .into_any_element()
2588 };
2589 // `ListBox.Section`'s `Header`, above the item it introduces.
2590 if let Some((_, label)) = sections.iter().find(|(at, _)| at == item.key()) {
2591 head.push(
2592 gpui::div()
2593 .px(px(8.))
2594 .pt(px(6.))
2595 .pb(px(4.))
2596 .text_size(px(12.))
2597 .line_height(px(16.))
2598 .font_weight(gpui::FontWeight::MEDIUM)
2599 .text_color(row_muted)
2600 .child(label.to_string())
2601 .into_any_element(),
2602 );
2603 }
2604 // The row's element id comes from the item's key, so two items
2605 // that share a label never share an interactive row.
2606 let item_disabled = row_disabled_keys.contains(item.key());
2607 let item_interactive = !item_disabled && !overlay_exiting;
2608 let row_selected = row_selected_keys.contains(item.key());
2609 let has_indicator_slot = indicator.is_some() || row_selected;
2610 let row_selector = format!("{base}-{}", item.key());
2611 let mut row = gpui::div()
2612 .id(element_id::scoped(
2613 &element_id::scoped(base_id, "opt"),
2614 item.key().clone(),
2615 ))
2616 // `useOption.mjs`: `role: 'option'` plus `'aria-selected'`
2617 // whenever the list selects at all. The search field keeps
2618 // the real focus while a cursor walks the rows, which is
2619 // upstream's virtual focus; gpui states that relation on
2620 // the descendant, so the row can carry it even though this
2621 // component does not own the field.
2622 .a11y_named(
2623 a11y::Role::ListBoxOption,
2624 &a11y::Name::labelled(item.label().clone()),
2625 )
2626 .a11y_selected(row_selected)
2627 .when(cursor_at == Some(index), |row| row.a11y_active_descendant())
2628 .when(row_virtualized, |row| {
2629 row.a11y_set_position(index, matches_len)
2630 })
2631 .debug_selector(move || row_selector)
2632 .flex()
2633 .items_center()
2634 .justify_between()
2635 .w_full()
2636 // Every menu row in v3 is a `.list-box-item`: `min-h-9
2637 // rounded-2xl py-1.5 gap-3` at `text-sm`, and the
2638 // Autocomplete's popover restates the padding as `px-2.5`.
2639 .min_h(util::FIELD_HEIGHT)
2640 .rounded(util::soft_radius(cx))
2641 .px(row_padding_x)
2642 .py(row_padding_y)
2643 .gap(px(12.))
2644 .text_size(util::FIELD_TEXT)
2645 .line_height(px(20.))
2646 // HeroUI's list-box item reserves `pe-7` whenever its
2647 // indicator slot is present; the indicator itself is
2648 // absolute at the inline end. Keeping it out of flex
2649 // flow prevents long labels from pushing the checkmark.
2650 .relative()
2651 .when(has_indicator_slot, |row| row.pr(px(28.)));
2652 if let Some(family) = row_font_family.clone() {
2653 row = row.font_family(family);
2654 }
2655
2656 if item_disabled {
2657 row = row.opacity(row_disabled_opacity);
2658 } else if item_interactive {
2659 row = row
2660 .cursor(util::interactive_cursor(cx))
2661 .hover(move |s| s.bg(row_hover_bg));
2662 }
2663 if row_selected {
2664 row = row.text_color(row_accent);
2665 } else {
2666 row = row.text_color(row_fg);
2667 }
2668 // `status-focused` on the row the keyboard is on.
2669 if util::shows_focus_ring(cursor_at == Some(index), cx) {
2670 row = row.border_2().border_color(row_focus);
2671 }
2672
2673 // HeroUI's ListBox.Item does not add an ellipsis rule. Keep
2674 // normal text flow in both natural and virtual rows; the
2675 // caller owns the fixed row geometry when `row_height` is
2676 // supplied, just as the upstream Virtualizer owns its
2677 // `rowHeight` layout.
2678 let label = gpui::div().flex_1().min_w_0().whitespace_normal();
2679 row = row.child(label.child(item.label().to_string()));
2680
2681 // The chosen rows are ticked, unless `ListBox.ItemIndicator` is
2682 // drawn by the caller.
2683 match &indicator {
2684 Some(render) => {
2685 row = row.child(
2686 gpui::div()
2687 .absolute()
2688 .top_0()
2689 .bottom_0()
2690 .right(px(8.))
2691 .w(px(16.))
2692 .flex()
2693 .items_center()
2694 .justify_center()
2695 .child(render(row_selected)),
2696 );
2697 }
2698 None if row_selected => {
2699 row = row.child(
2700 gpui::div()
2701 .absolute()
2702 .top_0()
2703 .bottom_0()
2704 .right(px(8.))
2705 .w(px(16.))
2706 .flex()
2707 .items_center()
2708 .justify_center()
2709 .child(
2710 gpui::svg()
2711 .size(px(13.))
2712 .path(icons::CHECK)
2713 .text_color(row_accent),
2714 ),
2715 );
2716 }
2717 None => {}
2718 }
2719
2720 if item_interactive {
2721 row = self.attach_pick(row, item.key().clone());
2722 }
2723
2724 done(head, row.into_any_element())
2725 }
2726
2727 /// The row's pointer pick: a toggle in multiple mode, a select-and-close
2728 /// that hands the focus back to the trigger in single mode.
2729 fn attach_pick(
2730 &self,
2731 mut row: gpui::Stateful<gpui::Div>,
2732 value: SharedString,
2733 ) -> gpui::Stateful<gpui::Div> {
2734 let Self {
2735 ref row_selected_keys,
2736 ref row_selection_own,
2737 ref row_form_state,
2738 ref on_change_all,
2739 ref on_change_one,
2740 ref row_open_own,
2741 ref row_open_change,
2742 ref row_trigger_focus,
2743 multiple,
2744 ..
2745 } = *self;
2746 let current = row_selected_keys.clone();
2747 let own = row_selection_own.clone();
2748 let row_form_state = row_form_state.clone();
2749 let cb_all = on_change_all.clone();
2750 let cb_one = on_change_one.clone();
2751 let open_own = row_open_own.clone();
2752 let open_cb = row_open_change.clone();
2753 let trigger_focus = row_trigger_focus.clone();
2754 row = row.on_click(move |_, window, cx| {
2755 let mut next = current.clone();
2756 if multiple {
2757 toggle_key(&mut next, &value);
2758 } else {
2759 next.clear();
2760 next.push(value.clone());
2761 }
2762 // Uncontrolled: keep the new set, or picking an item
2763 // would do nothing.
2764 if let Some(held) = &own {
2765 let set = next.clone();
2766 held.update(cx, |v, cx| {
2767 *v = set;
2768 cx.notify();
2769 });
2770 row_form_state.borrow_mut().value = form_selection_value(&next);
2771 }
2772 if let Some(cb) = &cb_one {
2773 cb(&value, window, cx);
2774 }
2775 if let Some(cb) = &cb_all {
2776 cb(&next, window, cx);
2777 }
2778 // A single selection closes the popover; a multiple one
2779 // stays open for the next pick.
2780 if !multiple {
2781 if let Some(held) = &open_own {
2782 held.update(cx, |v, cx| {
2783 *v = false;
2784 cx.notify();
2785 });
2786 }
2787 if let Some(cb) = &open_cb {
2788 cb(&false, window, cx);
2789 }
2790 if let Some(handle) = &trigger_focus {
2791 window.focus(handle, cx);
2792 }
2793 }
2794 });
2795 row
2796 }
2797}
2798
2799// The pinned `.autocomplete--secondary` hover fill is
2800// `--autocomplete-trigger-bg-hover: var(--default-hover)` and the popup rows
2801// fill with the full `bg-default`. The two accessors differ by one word and
2802// the wrong one still looks plausible on screen, so the check is mechanical.
2803#[cfg(test)]
2804mod hover_tokens {
2805 #[test]
2806 fn secondary_trigger_and_menu_rows_use_the_pinned_hover_tokens() {
2807 // Scan the implementation only.
2808 let source = include_str!("autocomplete.rs")
2809 .split("#[cfg(test)]")
2810 .next()
2811 .expect("the implementation section is always present");
2812 assert!(
2813 source.contains("FieldVariant::Secondary => colors.default.hover()"),
2814 "the secondary trigger hover must read `colors.default.hover()` \
2815 (pinned `--autocomplete-trigger-bg-hover: var(--default-hover)`)"
2816 );
2817 assert!(
2818 source
2819 .contains("let row_hover_bg = self.row_hover_bg.unwrap_or(colors.default.color);"),
2820 "the popup rows must hover the full `bg-default` \
2821 (pinned `.list-box-item:hover`)"
2822 );
2823 }
2824
2825 #[test]
2826 fn the_clear_button_hovers_the_role_hover_token() {
2827 // Scan the implementation only; this test's own text names the
2828 // forbidden accessor.
2829 let source = include_str!("autocomplete.rs")
2830 .split("#[cfg(test)]")
2831 .next()
2832 .expect("the implementation section is always present");
2833 assert!(
2834 source
2835 .contains("let hover_bg = self.clear_hover_bg.unwrap_or(colors.default.hover());"),
2836 "the clear button must hover `bg-default-hover` \
2837 (pinned `.autocomplete__clear-button:hover`)"
2838 );
2839 assert!(
2840 !source.contains("colors.default.soft_hover()"),
2841 "the clear button must not hover the lighter soft-hover wash"
2842 );
2843 }
2844
2845 #[test]
2846 fn the_trigger_hover_defers_to_a_hovered_clear_button() {
2847 // Painted-only claim, so it is pinned by source shape: headless tests
2848 // prove the wiring (real coordinates flip the keyed flag) but cannot
2849 // read the painted quad. The refinement must leave the trigger's
2850 // resting style untouched while the clear button is hovered, which is
2851 // what `:not(:has(.autocomplete__clear-button:hover))` does upstream.
2852 let source = include_str!("autocomplete.rs")
2853 .split("#[cfg(test)]")
2854 .next()
2855 .expect("the implementation section is always present");
2856 assert!(
2857 source.contains("hover_fade_with_duration_and_easing_suppressed")
2858 && source.contains("clear_hovered,"),
2859 "the trigger hover must be suppressed while the clear button is hovered \
2860 (pinned `.autocomplete__trigger:hover:not(\
2861 :has(.autocomplete__clear-button:hover))`)"
2862 );
2863 }
2864
2865 #[test]
2866 fn trigger_hover_uses_the_pinned_smooth_transition() {
2867 let source = include_str!("autocomplete.rs")
2868 .split("#[cfg(test)]")
2869 .next()
2870 .expect("the implementation section is always present");
2871 assert!(
2872 source.contains("Some(150)")
2873 && source.contains("HoverFadeEasing::EaseSmooth")
2874 && source.contains("colors.field.border_hover()"),
2875 "the trigger hover must animate its background while retaining the border endpoint"
2876 );
2877 }
2878
2879 #[test]
2880 fn clear_button_visibility_fades_in_on_a_listener_free_visual_child() {
2881 let source = include_str!("autocomplete.rs")
2882 .split("#[cfg(test)]")
2883 .next()
2884 .expect("the implementation section is always present");
2885 assert!(
2886 source.contains("const CLEAR_OPACITY_TRANSITION_MS: u64 = 150;"),
2887 "the clear button must retain HeroUI's 150ms visibility duration"
2888 );
2889 assert!(
2890 source.contains("\"clear-opacity\"")
2891 && source.contains("clear_opacity.animates(reduce_motion)"),
2892 "clear visibility must use keyed reduced-motion-aware state"
2893 );
2894 assert!(
2895 source.contains("clear_opacity.settle();") && source.contains(".with_animation("),
2896 "clearing must hide immediately while appearance animates on the visual child"
2897 );
2898 }
2899}
2900
2901crate::util::impl_component_styled!(Autocomplete);