herogpui_components/tag_group.rs
1//! TagGroup — port of `@heroui/tag-group` (v3).
2//!
3//! A focusable list of tags with optional selection and removal. Mirrors the
4//! React API: `selectionMode`, `selectedKeys`, `disabledKeys`, `isDisabled`,
5//! `onRemove`, `onSelectionChange`, `size` and the `default | surface` variant.
6
7use std::collections::HashSet;
8use std::sync::Arc;
9
10use gpui::{
11 div, prelude::*, px, AnyElement, App, ElementId, InteractiveElement, IntoElement, Pixels,
12 RenderOnce, SharedString, Styled, Window,
13};
14use herogpui_core::{element_id, SelectionMode, Size};
15use herogpui_theme::ActiveTheme;
16
17use crate::{
18 a11y::{self, A11y as _},
19 icons, EscapeKeyBehavior,
20};
21
22/// Visual variant of the tags in a group.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub enum TagVariant {
25 /// Filled with `default`.
26 #[default]
27 Default,
28 /// Flat on the surface.
29 Surface,
30}
31
32impl TagVariant {
33 /// Every variant, in display order.
34 pub const ALL: [TagVariant; 2] = [TagVariant::Default, TagVariant::Surface];
35
36 /// The display name of this variant.
37 pub fn label(self) -> &'static str {
38 match self {
39 TagVariant::Default => "Default",
40 TagVariant::Surface => "Surface",
41 }
42 }
43}
44
45/// One tag in a [`TagGroup`].
46#[must_use = "builder methods return a new value; pass the tag to its group"]
47#[derive(Clone)]
48pub struct Tag {
49 key: SharedString,
50 label: SharedString,
51 icon: Option<SharedString>,
52 remove_content: Option<Arc<dyn Fn() -> AnyElement + 'static>>,
53 is_disabled: bool,
54}
55
56impl Tag {
57 /// Creates a tag from a key and a label.
58 pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
59 Self {
60 key: key.into(),
61 label: label.into(),
62 icon: None,
63 remove_content: None,
64 is_disabled: false,
65 }
66 }
67
68 /// Sets the icon asset path shown in the tag.
69 pub fn icon(mut self, path: impl Into<SharedString>) -> Self {
70 self.icon = Some(path.into());
71 self
72 }
73
74 /// `Tag.RemoveButton` children, replacing the default close glyph.
75 pub fn remove_content(mut self, render: impl Fn() -> AnyElement + 'static) -> Self {
76 self.remove_content = Some(Arc::new(render));
77 self
78 }
79
80 /// Sets whether the tag is disabled (v3 `isDisabled`).
81 pub fn is_disabled(mut self, v: bool) -> Self {
82 self.is_disabled = v;
83 self
84 }
85
86 /// Returns the tag's key.
87 pub fn key(&self) -> &SharedString {
88 &self.key
89 }
90}
91
92type OnSelectionChange = Arc<dyn Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static>;
93type OnRemove = Arc<dyn Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static>;
94
95/// Pinned React Stately `useMultipleSelectionState`'s anchor record: where a
96/// Shift extension reaches from, how far the last one went, and whether the
97/// selection is the raw `all` a `selectAll` produced.
98#[derive(Clone, Debug, Default)]
99struct TagSelectionRange {
100 anchor: Option<SharedString>,
101 current: Option<SharedString>,
102 is_all: bool,
103}
104
105impl TagSelectionRange {
106 /// Seats the range on `target`: the anchor stays whatever a first
107 /// extension chose, the cursor lands on `target`, and a raw `all` ends.
108 fn seat(&mut self, target: SharedString) {
109 if self.anchor.is_none() {
110 self.anchor = Some(target.clone());
111 }
112 self.current = Some(target);
113 self.is_all = false;
114 }
115}
116
117/// The selection after extending to `target` from `range`'s anchor.
118///
119/// The old anchor..current range is *replaced* by anchor..target, so extending
120/// backwards shrinks again; a raw `all` collapses to the new key; a first
121/// extension without an anchor selects from the target itself, which is what
122/// the pinned SelectionManager does when nothing anchors it yet. Only
123/// `selectable` keys enter the range, so disabled tags are skipped.
124fn extend_selection_range(
125 current: &HashSet<SharedString>,
126 collection: &[SharedString],
127 selectable: &HashSet<SharedString>,
128 range: &TagSelectionRange,
129 target: &SharedString,
130) -> HashSet<SharedString> {
131 if range.is_all {
132 return HashSet::from([target.clone()]);
133 }
134 let anchor = range.anchor.as_ref().unwrap_or(target);
135 let previous = range.current.as_ref().unwrap_or(target);
136 let anchor_at = collection.iter().position(|key| key == anchor);
137 let previous_at = collection.iter().position(|key| key == previous);
138 let target_at = collection.iter().position(|key| key == target);
139 let between = |from: Option<usize>, to: Option<usize>| {
140 from.zip(to)
141 .map(|(from, to)| if from <= to { from..=to } else { to..=from })
142 };
143 let mut next = current.clone();
144 if let Some(previous_range) = between(anchor_at, previous_at) {
145 for index in previous_range {
146 next.remove(&collection[index]);
147 }
148 }
149 if let Some(target_range) = between(anchor_at, target_at) {
150 for index in target_range {
151 let key = &collection[index];
152 if selectable.contains(key) {
153 next.insert(key.clone());
154 }
155 }
156 }
157 next
158}
159
160/// Pinned React Aria 3.51.0 `useSelectableCollection` registers Home and End
161/// only for the chords each platform's handler admits: none, Shift, Alt, and
162/// Alt+Shift on macOS -- no Meta or Control handler exists -- and none,
163/// Shift, Control, and Control+Shift on Windows and Linux. The upstream
164/// matcher reads exactly the browser's canonical modifier flags -- Alt,
165/// Control, Meta, Shift -- so GPUI's `function` flag is ignored here: a
166/// browser exposes no Fn state for it to read, so vetoing on the flag would
167/// claim a pinned guard that does not exist, and the framework delivers an
168/// Fn-bearing press with every matched modifier flag still false. A chord
169/// outside the registration is entirely inert: no focus move, no selection,
170/// no preventDefault. `macos` is simulated explicitly so every platform's
171/// unit tests can prove both maps.
172fn home_end_registered(modifiers: gpui::Modifiers, macos: bool) -> bool {
173 if macos {
174 !modifiers.control && !modifiers.platform
175 } else {
176 !modifiers.alt && !modifiers.platform
177 }
178}
179
180/// Pinned `useSelectableCollection` (`isCtrlKeyPressed`): a Shift move
181/// extends the range on the collection's navigation keys, while Home and
182/// End extend only from Control+Shift on Windows and Linux. macOS registers
183/// no Home/End extension at all -- its Shift and Alt+Shift chords move the
184/// focus alone -- so the platform is an explicit bool rather than a `cfg!`.
185fn shift_home_end_extends(key_name: &str, control: bool, macos: bool) -> bool {
186 !matches!(key_name, "home" | "end") || (!macos && control)
187}
188
189/// HeroUI TagGroup.
190#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
191#[derive(IntoElement)]
192pub struct TagGroup {
193 id: ElementId,
194 tags: Vec<Tag>,
195 label: Option<SharedString>,
196 description: Option<SharedString>,
197 selection_mode: SelectionMode,
198 selected_keys: HashSet<SharedString>,
199 default_selected_keys: HashSet<SharedString>,
200 is_controlled: bool,
201 disallow_empty_selection: bool,
202 escape_key_behavior: EscapeKeyBehavior,
203 disabled_keys: HashSet<SharedString>,
204 is_disabled: bool,
205 size: Size,
206 /// The tag chip's corner radius, in place of the size step's radius. The
207 /// remove button inside stays a circle.
208 radius: Option<Pixels>,
209 variant: TagVariant,
210 /// The fill a hovered selectable tag takes, in place of the variant's
211 /// hover colour.
212 hover_bg: Option<gpui::Hsla>,
213 /// The fill a hovered remove button takes, in place of
214 /// `--default-hover`.
215 remove_hover_bg: Option<gpui::Hsla>,
216 /// `Tag`'s `children`-as-a-function: handed the interactive state and drawn
217 /// in place of the label.
218 tag_content: Option<Arc<dyn Fn(&Tag, crate::util::InteractiveState) -> AnyElement + 'static>>,
219 /// Shown in place of the list when `tags` is empty.
220 empty_state: Option<SharedString>,
221 on_selection_change: Option<OnSelectionChange>,
222 on_remove: Option<OnRemove>,
223 /// Expands the root to the available width.
224 full_width: bool,
225 /// The `sx` slot, refined over the root style at the end of render.
226 sx: Option<Box<gpui::StyleRefinement>>,
227}
228
229impl TagGroup {
230 /// Creates a tag group from an element id and its tags.
231 pub fn new(id: impl Into<ElementId>, tags: Vec<Tag>) -> Self {
232 Self {
233 id: id.into(),
234 tags,
235 tag_content: None,
236 label: None,
237 description: None,
238 selection_mode: SelectionMode::None,
239 selected_keys: HashSet::new(),
240 default_selected_keys: HashSet::new(),
241 is_controlled: false,
242 disallow_empty_selection: false,
243 escape_key_behavior: EscapeKeyBehavior::ClearSelection,
244 disabled_keys: HashSet::new(),
245 is_disabled: false,
246 size: Size::Md,
247 radius: None,
248 variant: TagVariant::Default,
249 hover_bg: None,
250 remove_hover_bg: None,
251 empty_state: None,
252 on_selection_change: None,
253 on_remove: None,
254 full_width: false,
255 sx: None,
256 }
257 }
258
259 /// Sets the group label.
260 pub fn label(mut self, text: impl Into<SharedString>) -> Self {
261 self.label = Some(text.into());
262 self
263 }
264
265 /// Sets the group description.
266 pub fn description(mut self, text: impl Into<SharedString>) -> Self {
267 self.description = Some(text.into());
268 self
269 }
270
271 /// Sets the selection mode (v3 `selectionMode`).
272 pub fn selection_mode(mut self, mode: SelectionMode) -> Self {
273 self.selection_mode = mode;
274 self
275 }
276
277 /// Sets the selected keys (v3 `selectedKeys`), making the selection controlled.
278 pub fn selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
279 self.selected_keys = keys.into_iter().collect();
280 self.is_controlled = true;
281 self
282 }
283
284 /// `defaultSelectedKeys` — seeds the group's own selection state.
285 pub fn default_selected_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
286 self.default_selected_keys = keys.into_iter().collect();
287 self
288 }
289
290 /// Prevents selection from becoming empty through a tag toggle or Escape.
291 pub fn disallow_empty_selection(mut self, v: bool) -> Self {
292 self.disallow_empty_selection = v;
293 self
294 }
295
296 /// `escapeKeyBehavior` — whether unmodified Escape clears selection.
297 pub fn escape_key_behavior(mut self, behavior: EscapeKeyBehavior) -> Self {
298 self.escape_key_behavior = behavior;
299 self
300 }
301
302 /// Sets the keys of tags that are disabled (v3 `disabledKeys`).
303 pub fn disabled_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
304 self.disabled_keys = keys.into_iter().collect();
305 self
306 }
307
308 /// Sets whether the whole group is disabled.
309 pub fn is_disabled(mut self, v: bool) -> Self {
310 self.is_disabled = v;
311 self
312 }
313
314 /// Sets the size (v3 `size`).
315 pub fn size(mut self, size: Size) -> Self {
316 self.size = size;
317 self
318 }
319
320 /// The tag chip's corner radius, in place of the size step's radius. The
321 /// step (`rounded-xl`, `rounded-2xl` on `Lg`) stays the fallback, and the
322 /// remove button inside a chip is an inner part that stays a circle. Not a
323 /// v3 prop; the removed v2 `radius` prop is prohibited and this is a
324 /// per-component repository extension.
325 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
326 self.radius = Some(radius.into());
327 self
328 }
329
330 /// v3's render function for a tag's children, handed `isHovered`,
331 /// `isPressed`, `isFocused`, `isFocusVisible` and `isSelected` -- and the tag
332 /// itself, which the closure needs to know what it is drawing.
333 ///
334 /// The hover and the press are a frame behind the pointer: gpui reports both
335 /// to a handler, not to the render that draws them.
336 pub fn tag_content(
337 mut self,
338 render: impl Fn(&Tag, crate::util::InteractiveState) -> AnyElement + 'static,
339 ) -> Self {
340 self.tag_content = Some(Arc::new(render));
341 self
342 }
343
344 /// Sets the visual variant (v3 `variant`).
345 pub fn variant(mut self, variant: TagVariant) -> Self {
346 self.variant = variant;
347 self
348 }
349
350 /// The fill a hovered selectable tag takes, in place of the variant's
351 /// hover colour. Resting colours (including a selected tag's soft accent)
352 /// are unchanged.
353 pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
354 self.hover_bg = Some(color.into());
355 self
356 }
357
358 /// The fill a hovered remove button takes, in place of
359 /// `--default-hover`.
360 pub fn remove_hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
361 self.remove_hover_bg = Some(color.into());
362 self
363 }
364
365 /// `fullWidth` — expands the root to the available width without
366 /// redistributing the children.
367 pub fn full_width(mut self, v: bool) -> Self {
368 self.full_width = v;
369 self
370 }
371
372 /// The one slot for caller-owned low-level styling: GPUI's styling methods
373 /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
374 /// applied to the group's root element after every value the size, the
375 /// variant and the active theme chose, so they win.
376 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
377 crate::util::refine_sx(&mut self.sx, style);
378 self
379 }
380
381 /// `TagGroup.List` renders this when there is nothing to show.
382 pub fn empty_state(mut self, text: impl Into<SharedString>) -> Self {
383 self.empty_state = Some(text.into());
384 self
385 }
386
387 /// Sets the handler called with the new set of selected keys (v3 `onSelectionChange`).
388 pub fn on_selection_change(
389 mut self,
390 handler: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
391 ) -> Self {
392 self.on_selection_change = Some(Arc::new(handler));
393 self
394 }
395
396 /// Adds a remove button to every tag. The button reports its own key;
397 /// Delete or Backspace reports the whole selection when the focused tag is
398 /// selected, and otherwise reports only the focused key.
399 pub fn on_remove(
400 mut self,
401 handler: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
402 ) -> Self {
403 self.on_remove = Some(Arc::new(handler));
404 self
405 }
406
407 /// `(px, py, text, leading)` from `.tag--sm` / `--md` / `--lg`.
408 ///
409 /// v3 gives a tag no height: it is padding around one line, which is why
410 /// this returns a vertical padding rather than the box it used to force.
411 fn metrics(size: Size) -> (Pixels, Pixels, Pixels, Pixels) {
412 match size {
413 Size::Sm => (px(8.), px(2.), px(12.), px(16.)),
414 Size::Md => (px(8.), px(4.), px(12.), px(16.)),
415 Size::Lg => (px(10.), px(6.), px(14.), px(20.)),
416 }
417 }
418
419 /// `rounded-xl` on `.tag`, `rounded-2xl` on `.tag--lg`. Named for the step
420 /// rather than for what it returns, because [`TagGroup::radius`] is the
421 /// builder that replaces its value.
422 fn step_radius(size: Size, cx: &App) -> Pixels {
423 match size {
424 Size::Sm | Size::Md => crate::util::small_radius(cx),
425 Size::Lg => crate::util::soft_radius(cx),
426 }
427 }
428}
429
430impl RenderOnce for TagGroup {
431 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
432 // A tag group is *one* tab stop: React Aria roves the tabindex, so Tab
433 // enters the group once and the arrows move inside it. Which tag claims
434 // the handle is held here, because a handle's `tab_stop` is fixed where
435 // the handle is made. `use_keyed_state` takes `cx` mutably, so both
436 // precede the theme.
437 let group_focus =
438 crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
439 let cursor =
440 window.use_keyed_state(element_id::scoped(&self.id, "cursor"), cx, |_, _| 0usize);
441 // The Shift-range anchor lives beside the cursor, keyed off the same
442 // instance id so two groups never share an anchor.
443 let selection_range =
444 window.use_keyed_state(element_id::scoped(&self.id, "range"), cx, |_, _| {
445 TagSelectionRange::default()
446 });
447 let (selected_keys, selection_own) = crate::util::controlled(
448 window,
449 cx,
450 element_id::scoped(&self.id, "selected"),
451 self.is_controlled.then(|| self.selected_keys.clone()),
452 self.default_selected_keys.clone(),
453 );
454 self.selected_keys = selected_keys;
455 // Removing a tag shortens the list and disabled tags take no focus, so
456 // the stop lands on the first enabled tag at or after the cursor.
457 let enabled: Vec<usize> = self
458 .tags
459 .iter()
460 .enumerate()
461 .filter(|(_, t)| {
462 !(self.is_disabled || t.is_disabled || self.disabled_keys.contains(&t.key))
463 })
464 .map(|(i, _)| i)
465 .collect();
466 let at = *cursor.read(cx);
467 let cursor_index = enabled
468 .iter()
469 .copied()
470 .find(|i| *i >= at)
471 .or_else(|| enabled.first().copied());
472 let enabled_keys: HashSet<SharedString> = enabled
473 .iter()
474 .map(|index| self.tags[*index].key.clone())
475 .collect();
476 // The collection order the range is resolved against — every tag's
477 // key, including disabled ones: disabled keys keep their collection
478 // positions so range span traversal preserves indexes, while the
479 // `selectable` filter keeps their insertions out of the range.
480 let collection_keys: Vec<SharedString> =
481 self.tags.iter().map(|tag| tag.key.clone()).collect();
482 let owns_focus = window.is_window_active() && group_focus.is_focused(window);
483 // One hover/press slot per tag. The slot is also the source for the
484 // pinned 100ms background transition, so plain tags and tags with a
485 // `tag_content` render prop take exactly the same hover path. Keeping
486 // the slot on the stable element lets the animated fill change its id
487 // without dropping the interaction listener or focus state.
488 let interaction: Vec<crate::util::Interaction> = (0..self.tags.len())
489 .map(|index| {
490 crate::util::interaction(
491 element_id::scoped(&element_id::indexed(&self.id, "tag", index), "interaction"),
492 window,
493 cx,
494 )
495 })
496 .collect();
497 for (index, slot) in interaction.iter().enumerate() {
498 let tag = &self.tags[index];
499 if (self.is_disabled || tag.is_disabled || self.disabled_keys.contains(&tag.key))
500 && *slot.read(cx) != (false, false)
501 {
502 slot.update(cx, |state, _| *state = (false, false));
503 }
504 }
505 let remove_focus_handles = if self.on_remove.is_some() {
506 self.tags
507 .iter()
508 .map(|tag| {
509 crate::util::tab_stop_handle(
510 element_id::scoped(
511 &element_id::scoped(
512 &element_id::scoped(&self.id, "tag"),
513 tag.key.clone(),
514 ),
515 "remove-focus",
516 ),
517 window,
518 cx,
519 )
520 })
521 .collect()
522 } else {
523 Vec::new()
524 };
525 let ring_visible = crate::util::focus_visible(cx);
526 // The hover animation mutably borrows `cx` while it registers keyed
527 // state. Keep a snapshot of the semantic tokens so that borrow does
528 // not span that call; theme values are immutable for this render.
529 let colors = cx.colors().clone();
530 let disabled_opacity = cx.layout().disabled_opacity;
531 let (pad_x, pad_y, text_size, leading) = Self::metrics(self.size);
532 // The size step stays the fallback; an instance radius replaces it on
533 // the chip alone — the remove button inside stays a circle.
534 let tag_radius = self
535 .radius
536 .unwrap_or_else(|| Self::step_radius(self.size, cx));
537
538 // `.tag-group` is `flex flex-col gap-1`: the label, the list and the
539 // description.
540 let mut root = div().relative().flex().flex_col().gap(px(4.));
541 if self.full_width {
542 root = root.w_full();
543 }
544
545 if let Some(label) = &self.label {
546 root = root.child(
547 div()
548 .text_size(px(14.))
549 .line_height(px(20.))
550 .font_weight(gpui::FontWeight::MEDIUM)
551 .text_color(colors.foreground)
552 .child(label.to_string()),
553 );
554 }
555
556 if self.tags.is_empty() {
557 let text = self
558 .empty_state
559 .unwrap_or_else(|| SharedString::from("No tags"));
560 root = root.child(
561 div()
562 .id(element_id::scoped(&self.id, "list"))
563 // `react-aria/dist/private/tag/useTagGroup.mjs` chooses
564 // the list's role by whether the collection is empty:
565 // `role: state.collection.size ? 'grid' : 'group'`. This
566 // is the empty half. Its `aria-live`/`aria-atomic`/
567 // `aria-relevant` neighbours are recorded omissions —
568 // gpui exposes no live-region builder at all.
569 .a11y_named(a11y::Role::Group, &a11y::Name::maybe(self.label.clone()))
570 // `.empty-state` is `p-2 text-sm text-muted`.
571 .p(px(8.))
572 .text_size(px(14.))
573 .line_height(px(20.))
574 .text_color(colors.muted)
575 .child(text.to_string()),
576 );
577 return crate::util::apply_sx(root, &self.sx);
578 }
579
580 let mut list = div().relative().flex().flex_row().flex_wrap().gap(px(6.));
581
582 for (index, tag) in self.tags.iter().enumerate() {
583 let disabled =
584 self.is_disabled || tag.is_disabled || self.disabled_keys.contains(&tag.key);
585 let selected = self.selected_keys.contains(&tag.key);
586 let selectable = self.selection_mode != SelectionMode::None;
587 let interactive = selectable && !disabled;
588 let tag_foreground = if selected {
589 colors.accent.soft_foreground(colors.foreground)
590 } else {
591 match self.variant {
592 TagVariant::Default => colors.default.foreground,
593 TagVariant::Surface => colors.surface.foreground,
594 }
595 };
596
597 let mut chip = div()
598 .id(element_id::indexed(&self.id, "tag", index))
599 // A `Tag` is a grid-list item: `useTag.mjs` builds on
600 // `.../gridlist/useGridListItem.mjs`, which is `role: 'row'`
601 // with `'aria-label': node['aria-label'] || node.textValue`
602 // and `'aria-selected': canSelectItem ? isSelected :
603 // undefined`. The `role: 'gridcell'` node that hook also
604 // returns has no counterpart element here — this port draws
605 // the tag's contents straight into the row — and its
606 // `aria-disabled` has no gpui builder.
607 .a11y_named(a11y::Role::Row, &a11y::Name::labelled(tag.label.clone()))
608 .when(selectable, |c| c.a11y_selected(selected))
609 // One handle roves the group and a cursor picks the tag it
610 // stands on, which is upstream's virtual focus; gpui states
611 // that relation on the descendant.
612 .when(cursor_index == Some(index), |c| c.a11y_active_descendant())
613 .when(!disabled && cursor_index == Some(index), |c| {
614 c.track_focus(&group_focus)
615 })
616 .flex()
617 .flex_row()
618 .items_center()
619 .gap(px(4.))
620 .px(pad_x)
621 .py(pad_y)
622 .rounded(tag_radius)
623 .text_size(text_size)
624 .line_height(leading)
625 .font_weight(gpui::FontWeight::MEDIUM)
626 .whitespace_nowrap();
627
628 chip = if selected {
629 chip.bg(colors.accent.soft()).text_color(tag_foreground)
630 } else {
631 match self.variant {
632 TagVariant::Default => chip.bg(colors.default.color).text_color(tag_foreground),
633 TagVariant::Surface => chip
634 .bg(colors.surface.background)
635 .text_color(tag_foreground),
636 }
637 };
638
639 if disabled {
640 chip = chip.opacity(disabled_opacity);
641 } else {
642 let hover = self.hover_bg.unwrap_or_else(|| {
643 if selected {
644 colors.accent.soft_hover()
645 } else {
646 match self.variant {
647 TagVariant::Default => colors.default.hover(),
648 TagVariant::Surface => colors.surface.hover(),
649 }
650 }
651 });
652 chip = chip.cursor(crate::util::interactive_cursor(cx));
653 // `.tag` declares `background-color 100ms var(--ease-smooth)`.
654 // Keep the chip itself as the stable hover listener and put
655 // only the interpolated fill in a rounded child. This avoids
656 // the keyed animation wrapper resetting the tag's focus and
657 // render-prop state, and the child inherits the same radius so
658 // a mid-transition frame cannot leak square corners.
659 if let Some(slot) = interaction.get(index) {
660 chip = crate::anim::hover_fade_with_duration_and_easing(
661 chip,
662 element_id::scoped(&element_id::indexed(&self.id, "tag", index), "fade"),
663 (
664 if selected {
665 colors.accent.soft()
666 } else {
667 match self.variant {
668 TagVariant::Default => colors.default.color,
669 TagVariant::Surface => colors.surface.background,
670 }
671 },
672 hover,
673 ),
674 Some(slot),
675 None,
676 move |fill| fill.rounded(tag_radius),
677 Some(100),
678 crate::anim::HoverFadeEasing::EaseSmooth,
679 window,
680 cx,
681 );
682 }
683 }
684
685 if let Some(path) = &tag.icon {
686 chip = chip.child(
687 gpui::svg()
688 .size(px(12.))
689 .path(path.clone())
690 .flex_shrink_0()
691 .text_color(tag_foreground),
692 );
693 }
694
695 chip = match &self.tag_content {
696 Some(render) => {
697 let (is_hovered, is_pressed) = if interactive {
698 interaction
699 .get(index)
700 .map(|slot| *slot.read(cx))
701 .unwrap_or_default()
702 } else {
703 (false, false)
704 };
705 let focused = !disabled && owns_focus && cursor_index == Some(index);
706 chip.child(render(
707 tag,
708 crate::util::InteractiveState {
709 is_hovered,
710 is_pressed,
711 is_focused: focused,
712 is_focus_visible: focused && ring_visible,
713 is_selected: selected,
714 is_disabled: disabled,
715 is_pending: false,
716 is_indeterminate: false,
717 },
718 ))
719 }
720 None => chip.child(tag.label.to_string()),
721 };
722 if !disabled {
723 if let Some(slot) = interaction.get(index) {
724 chip = crate::util::track_interaction(chip, slot);
725 }
726 }
727
728 if let Some(on_remove) = self.on_remove.clone() {
729 let key = tag.key.clone();
730 let remove_content = tag.remove_content.as_ref().map_or_else(
731 || {
732 gpui::svg()
733 .size(px(12.))
734 .path(icons::CLOSE)
735 .text_color(tag_foreground)
736 .into_any_element()
737 },
738 |render| render(),
739 );
740 // `.tag__remove-button` is `size-3`.
741 let mut remove_visual = div()
742 .size(px(12.))
743 .rounded_full()
744 .flex()
745 .items_center()
746 .justify_center()
747 .flex_shrink_0()
748 .child(remove_content);
749 let mut close = div()
750 .id(element_id::scoped(
751 &element_id::indexed(&self.id, "tag", index),
752 "remove",
753 ))
754 // `useTag.mjs`'s `removeButtonProps` is
755 // `'aria-label': stringFormatter.format(
756 // 'removeButtonLabel')` — `Remove` in the pinned en-US
757 // strings (`react-aria/dist/private/intl/tag/en-US.mjs`)
758 // — plus an `aria-labelledby` pointing at the button and
759 // the row together, which needs the id graph gpui does
760 // not have. The button itself is an RAC `Button`.
761 .a11y_named(a11y::Role::Button, &a11y::Name::labelled(crate::i18n::ui_string(crate::i18n::UiString::Remove, cx)))
762 .group("tag-remove")
763 .flex()
764 .items_center()
765 .justify_center()
766 // v3.2.5's `touch-target` extends 6px on each side of
767 // the glyph. A 12px parent retains the layout footprint.
768 .size(px(24.))
769 .absolute()
770 .top(px(-6.))
771 .left(px(-6.))
772 .flex_shrink_0();
773 if !disabled {
774 let hover_bg = self.remove_hover_bg.unwrap_or(colors.default.hover());
775 let remove_focus = &remove_focus_handles[index];
776 let focus_for_remove = group_focus.clone();
777 let cursor_for_remove = cursor.clone();
778 remove_visual =
779 remove_visual.group_hover("tag-remove", move |s| s.bg(hover_bg));
780 close = close
781 .track_focus(remove_focus)
782 .cursor(crate::util::interactive_cursor(cx))
783 // React Aria's grid-list stops row key handling while
784 // a child button owns the focus, except for Tab.
785 .on_key_down(|event, _, cx| {
786 if event.keystroke.key != "tab" {
787 cx.stop_propagation();
788 }
789 })
790 // The press belongs to the button. Stopping it here
791 // keeps the tag body's mouse-down -- which seats the
792 // group's focus and cursor -- out of a remove press.
793 .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| {
794 cx.stop_propagation();
795 })
796 .on_click(move |_, window, cx| {
797 // The remove button is an action inside a
798 // selectable tag. Its press belongs to the button,
799 // never to the tag behind it.
800 cx.stop_propagation();
801 on_remove(&HashSet::from([key.clone()]), window, cx);
802 // Pinned `useSelectableItem` only isolates the
803 // child's press -- DOM focus goes to the button.
804 // This port seats the owning tag itself because
805 // the report-only Rust model has no persisting
806 // native child and keyboard continuity needs a
807 // stable roving target. Removal only reports;
808 // the selection is not this click's to change.
809 window.focus(&focus_for_remove, cx);
810 cursor_for_remove.update(cx, |v, cx| {
811 *v = index;
812 cx.notify();
813 });
814 });
815 // The ring rides the 12px glyph rather than the 24px
816 // touch target that owns the focus handle: the glyph is
817 // the element carrying `rounded_full`, and the ring is
818 // concentric only with the shape it is drawn around. A
819 // ring on the touch target would circle a footprint the
820 // pinned sheet never paints. `rounded_full` on a 12px box
821 // resolves to a 6px radius.
822 remove_visual = crate::util::ring_overlay_if_focused(
823 remove_visual,
824 remove_focus,
825 true,
826 px(6.),
827 Vec::new(),
828 window,
829 cx,
830 );
831 close = crate::util::record_focus_bounds(close, remove_focus, window, cx);
832 }
833 chip = chip.child(
834 div()
835 .relative()
836 .size(px(12.))
837 .flex_shrink_0()
838 .child(close.child(remove_visual)),
839 );
840 }
841
842 // React Aria's TagGroup: the arrows move between tags. Delete or
843 // Backspace removes the selection when the focused tag belongs to
844 // it, and otherwise removes only the focused tag.
845 if !disabled {
846 let stops = enabled.clone();
847 let moved = cursor.clone();
848 let remove = self.on_remove.clone();
849 let key_for_remove = tag.key.clone();
850 let mode = self.selection_mode;
851 let disallow_empty = self.disallow_empty_selection;
852 let escape_key_behavior = self.escape_key_behavior;
853 let selected_now = self.selected_keys.clone();
854 let selectable_keys = enabled_keys.clone();
855 let collection_for_keys = collection_keys.clone();
856 let range_for_keys = selection_range.clone();
857 let on_selection_change = self.on_selection_change.clone();
858 let selection_own_for_keys = selection_own.clone();
859 let range_for_all = selection_range.clone();
860 let range_for_escape = selection_range.clone();
861 chip = chip.on_key_down(move |event, window, cx| {
862 let key_name = event.keystroke.key.as_str();
863 // Pinned React Aria 3.51 routes TagGroup through
864 // `useSelectableCollection`, including Mod+A select-all.
865 if key_name == "a"
866 && event.keystroke.modifiers.secondary()
867 && !event.keystroke.modifiers.shift
868 && !event.keystroke.modifiers.alt
869 && !event.keystroke.modifiers.function
870 && if cfg!(target_os = "macos") {
871 !event.keystroke.modifiers.control
872 } else {
873 !event.keystroke.modifiers.platform
874 }
875 && mode == SelectionMode::Multiple
876 {
877 let all_selected =
878 selectable_keys.iter().all(|key| selected_now.contains(key));
879 if !all_selected {
880 if let Some(held) = &selection_own_for_keys {
881 held.update(cx, |value, cx| {
882 *value = selectable_keys.clone();
883 cx.notify();
884 });
885 }
886 if let Some(change) = &on_selection_change {
887 change(&selectable_keys, window, cx);
888 }
889 // The raw `all`: the next Shift move collapses to
890 // its target instead of extending across
891 // everything. A redundant Mod+A over an already
892 // complete selection is idempotent and keeps the
893 // anchor, like pinned `SelectionManager::selectAll`.
894 range_for_all.update(cx, |range, _| {
895 *range = TagSelectionRange {
896 is_all: true,
897 ..TagSelectionRange::default()
898 };
899 });
900 }
901 cx.stop_propagation();
902 return;
903 }
904 // `useSelectableCollection` also clears a nonempty
905 // selection on unmodified Escape by default.
906 if key_name == "escape"
907 && !event.keystroke.modifiers.modified()
908 && escape_key_behavior == EscapeKeyBehavior::ClearSelection
909 && crate::selection::reports_changes(mode)
910 && !disallow_empty
911 && !selected_now.is_empty()
912 {
913 let next = HashSet::new();
914 if let Some(held) = &selection_own_for_keys {
915 held.update(cx, |value, cx| {
916 *value = next.clone();
917 cx.notify();
918 });
919 }
920 if let Some(change) = &on_selection_change {
921 change(&next, window, cx);
922 }
923 range_for_escape.update(cx, |range, _| {
924 *range = TagSelectionRange::default();
925 });
926 cx.stop_propagation();
927 return;
928 }
929 match key_name {
930 "delete" | "backspace" => {
931 if let Some(cb) = &remove {
932 cx.stop_propagation();
933 if selected_now.contains(&key_for_remove) {
934 cb(&selected_now, window, cx);
935 } else {
936 cb(&HashSet::from([key_for_remove.clone()]), window, cx);
937 }
938 }
939 }
940 key @ ("left" | "right" | "home" | "end") => {
941 // The pinned registrations install no Home/End
942 // handler for an unregistered chord -- Cmd- or
943 // Ctrl-bearing on macOS, Alt- or platform-bearing
944 // elsewhere -- so the whole event stays inert: no
945 // focus move, no selection, no preventDefault,
946 // and no consumption of the key either.
947 if matches!(key, "home" | "end")
948 && !home_end_registered(
949 event.keystroke.modifiers,
950 cfg!(target_os = "macos"),
951 )
952 {
953 return;
954 }
955 let key = match key {
956 "right" => "down",
957 "left" => "up",
958 other => other,
959 };
960 // React Aria gives TagGroup a horizontal
961 // keyboard delegate. The list owns its axis
962 // and Home/End; Up/Down fall through to an
963 // enclosing scroller.
964 cx.stop_propagation();
965 let crate::list_nav::Move::To(next) =
966 crate::list_nav::resolve(&stops, Some(index), key, true)
967 else {
968 return;
969 };
970 // Pinned `useSelectableCollection`: Shift extends a
971 // multiple selection from the anchor with no other
972 // chord, so plain Shift navigation is exact. A
973 // wrap-to-self move or a Home/End
974 // already at its target still extends: pinned
975 // `extendSelection` replaces the anchor..target
976 // range even when the cursor does not move, and
977 // the unchanged-selection guard below is what
978 // keeps true no-ops silent.
979 let modifiers = event.keystroke.modifiers;
980 let exact_shift_navigation = if cfg!(target_os = "macos") {
981 !modifiers.control && !modifiers.platform && !modifiers.function
982 } else {
983 !modifiers.alt && !modifiers.platform && !modifiers.function
984 };
985 let extends_selection = modifiers.shift
986 && mode == SelectionMode::Multiple
987 && exact_shift_navigation
988 && shift_home_end_extends(
989 key_name,
990 modifiers.control,
991 cfg!(target_os = "macos"),
992 );
993 if extends_selection {
994 if let Some(target) = collection_for_keys.get(next) {
995 let range = range_for_keys.read(cx).clone();
996 let next_selection = extend_selection_range(
997 &selected_now,
998 &collection_for_keys,
999 &selectable_keys,
1000 &range,
1001 target,
1002 );
1003 range_for_keys
1004 .update(cx, |range, _| range.seat(target.clone()));
1005 if next_selection != selected_now {
1006 if let Some(held) = &selection_own_for_keys {
1007 held.update(cx, |value, cx| {
1008 *value = next_selection.clone();
1009 cx.notify();
1010 });
1011 }
1012 if let Some(change) = &on_selection_change {
1013 change(&next_selection, window, cx);
1014 }
1015 }
1016 }
1017 }
1018 // No refocusing: the next render has the tag
1019 // at `next` claim the group's handle, so the
1020 // focus goes with it.
1021 moved.update(cx, |v, cx| {
1022 *v = next;
1023 cx.notify();
1024 });
1025 }
1026 _ => {}
1027 }
1028 });
1029 // React Aria seats a collection on pointer-down: pressing a
1030 // tag's body moves the roving cursor to it and takes the
1031 // group's focus, so the arrows and Space answer the tag the
1032 // user pressed with no Tab first. A child that prevented the
1033 // press -- the remove button -- keeps the body out of it, and
1034 // preventing the default here is the other half of the seat:
1035 // gpui's own focus-on-press for `track_focus` elements works
1036 // the same way, so an ancestor's -- the app root's -- press
1037 // focus cannot steal the handle back in the same dispatch.
1038 let focus_for_seat = group_focus.clone();
1039 let cursor_for_seat = cursor.clone();
1040 chip = chip.on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
1041 if window.default_prevented() {
1042 return;
1043 }
1044 window.focus(&focus_for_seat, cx);
1045 window.prevent_default();
1046 cursor_for_seat.update(cx, |v, cx| {
1047 *v = index;
1048 cx.notify();
1049 });
1050 });
1051 }
1052
1053 if selectable && !disabled {
1054 let key = tag.key.clone();
1055 let mode = self.selection_mode;
1056 let disallow_empty = self.disallow_empty_selection;
1057 let current = self.selected_keys.clone();
1058 let on_change = self.on_selection_change.clone();
1059 let selection_own = selection_own.clone();
1060 let collection_for_click = collection_keys.clone();
1061 let selectable_for_click = enabled_keys.clone();
1062 let range_for_click = selection_range.clone();
1063 chip = chip.on_click(move |ev, window, cx| {
1064 let was_selected = current.contains(&key);
1065 // A Shift click extends from the anchor in multiple mode;
1066 // an ordinary click toggles and re-anchors instead.
1067 let extends_selection = ev.modifiers().shift && mode == SelectionMode::Multiple;
1068 let next = if extends_selection {
1069 let range = range_for_click.read(cx).clone();
1070 extend_selection_range(
1071 ¤t,
1072 &collection_for_click,
1073 &selectable_for_click,
1074 &range,
1075 &key,
1076 )
1077 } else {
1078 match mode {
1079 SelectionMode::None => current.clone(),
1080 SelectionMode::Single => {
1081 if current.contains(&key) && !disallow_empty {
1082 HashSet::new()
1083 } else {
1084 HashSet::from([key.clone()])
1085 }
1086 }
1087 SelectionMode::Multiple => {
1088 let mut set = current.clone();
1089 if set.remove(&key) {
1090 if disallow_empty && set.is_empty() {
1091 set.insert(key.clone());
1092 }
1093 } else {
1094 set.insert(key.clone());
1095 }
1096 set
1097 }
1098 }
1099 };
1100 // A controlled selection only reports; the owner's prop
1101 // stays in charge until it feeds the value back.
1102 if next != current {
1103 if let Some(held) = &selection_own {
1104 held.update(cx, |value, cx| {
1105 *value = next.clone();
1106 cx.notify();
1107 });
1108 }
1109 if let Some(change) = &on_change {
1110 change(&next, window, cx);
1111 }
1112 }
1113 if extends_selection {
1114 range_for_click.update(cx, |range, _| range.seat(key.clone()));
1115 } else if mode == SelectionMode::Multiple && !was_selected {
1116 range_for_click.update(cx, |range, _| {
1117 range.anchor = Some(key.clone());
1118 range.current = Some(key.clone());
1119 range.is_all = false;
1120 });
1121 } else if mode == SelectionMode::Multiple {
1122 range_for_click.update(cx, |range, _| {
1123 if range.is_all {
1124 *range = TagSelectionRange::default();
1125 }
1126 });
1127 }
1128 });
1129 }
1130
1131 // `.tag:focus-visible` is `status-focused`.
1132 let mut chip = crate::util::with_focus_ring_overlay(
1133 chip,
1134 !disabled && ring_visible && cursor_index == Some(index) && owns_focus,
1135 true,
1136 tag_radius,
1137 Vec::new(),
1138 cx,
1139 );
1140 if !disabled && cursor_index == Some(index) {
1141 chip = crate::util::record_focus_bounds(chip, &group_focus, window, cx);
1142 }
1143 list = list.child(chip);
1144 }
1145
1146 // The id and the role go on at the end rather than in the chain
1147 // above: `.shots/design_audit.py` reads `.tag-group__list`'s gap with
1148 // a regex spelling `let mut list = div().relative().flex()...` with
1149 // no room between the calls, and it correctly reported the line as
1150 // unreadable when they were spliced in.
1151 //
1152 // This is the populated half of `useTagGroup.mjs`'s role switch
1153 // (`role: state.collection.size ? 'grid' : 'group'`); the grid comes
1154 // from `useGridList` underneath it, and RAC's `TagGroup.mjs` spreads
1155 // those props onto the `TagList` unmodified.
1156 root = root.child(
1157 list.id(element_id::scoped(&self.id, "list"))
1158 .a11y_named(a11y::Role::Grid, &a11y::Name::maybe(self.label.clone())),
1159 );
1160
1161 if let Some(description) = &self.description {
1162 root = root.child(
1163 div()
1164 .p(px(4.))
1165 .text_size(px(12.))
1166 .line_height(px(16.))
1167 .text_color(colors.muted)
1168 .child(description.to_string()),
1169 );
1170 }
1171
1172 root = crate::util::apply_sx(root, &self.sx);
1173 root
1174 }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::*;
1180
1181 /// The Home/End gate takes the platform as an explicit bool, so this
1182 /// truth table is free of `cfg!` and mechanically proves both maps from
1183 /// any host: no macOS chord ever extends -- Shift and Alt+Shift move the
1184 /// focus alone -- while Windows and Linux extend exactly from
1185 /// Control+Shift.
1186 #[test]
1187 fn shift_home_end_extends_only_from_control_outside_macos() {
1188 for key in ["home", "end"] {
1189 assert!(
1190 !shift_home_end_extends(key, true, true),
1191 "macOS registers no Home/End extension"
1192 );
1193 assert!(!shift_home_end_extends(key, false, true));
1194 assert!(
1195 shift_home_end_extends(key, true, false),
1196 "Control+Shift+{key} must extend on Windows and Linux"
1197 );
1198 assert!(
1199 !shift_home_end_extends(key, false, false),
1200 "plain Shift+{key} must only move the focus"
1201 );
1202 }
1203 }
1204
1205 /// The horizontal delegate's arrows never consult the Home/End gate:
1206 /// their forbidden extra chords are rejected earlier, by
1207 /// `exact_shift_navigation`.
1208 #[test]
1209 fn shift_navigation_keys_do_not_consult_the_home_end_gate() {
1210 for key in ["left", "right"] {
1211 assert!(shift_home_end_extends(key, false, true));
1212 assert!(shift_home_end_extends(key, true, false));
1213 }
1214 }
1215
1216 /// The registration gate takes `Modifiers`, so the pinned chord map can
1217 /// be spelled out: macOS registers none, Shift, Alt, and Alt+Shift and
1218 /// every Control- or Meta-bearing chord is entirely inert, while
1219 /// Windows and Linux register none, Shift, Control, and Control+Shift
1220 /// and reject every Alt- or Meta-bearing chord. The upstream matcher
1221 /// sees only the browser's Alt/Control/Meta/Shift flags, so GPUI's
1222 /// `function` flag is ignored: `fn` stays registered on both maps, and
1223 /// it never rescues a chord the platform itself rejects.
1224 #[test]
1225 fn home_end_registration_matches_the_pinned_chord_map() {
1226 let none = gpui::Modifiers::none();
1227 let shift = gpui::Modifiers {
1228 shift: true,
1229 ..none
1230 };
1231 let alt = gpui::Modifiers { alt: true, ..none };
1232 let alt_shift = gpui::Modifiers { shift: true, ..alt };
1233 let function = gpui::Modifiers {
1234 function: true,
1235 ..none
1236 };
1237 let function_alt = gpui::Modifiers {
1238 alt: true,
1239 ..function
1240 };
1241 for modifiers in [none, shift, alt, alt_shift, function, function_alt] {
1242 assert!(
1243 home_end_registered(modifiers, true),
1244 "macOS must register {modifiers:?}"
1245 );
1246 }
1247 let control = gpui::Modifiers {
1248 control: true,
1249 ..none
1250 };
1251 let control_shift = gpui::Modifiers {
1252 shift: true,
1253 ..control
1254 };
1255 let platform = gpui::Modifiers {
1256 platform: true,
1257 ..none
1258 };
1259 let platform_shift = gpui::Modifiers {
1260 shift: true,
1261 ..platform
1262 };
1263 for modifiers in [control, control_shift, platform, platform_shift] {
1264 assert!(
1265 !home_end_registered(modifiers, true),
1266 "macOS must not register {modifiers:?}"
1267 );
1268 }
1269 for modifiers in [none, shift, control, control_shift, function] {
1270 assert!(
1271 home_end_registered(modifiers, false),
1272 "Windows and Linux must register {modifiers:?}"
1273 );
1274 }
1275 for modifiers in [alt, alt_shift, function_alt, platform, platform_shift] {
1276 assert!(
1277 !home_end_registered(modifiers, false),
1278 "Windows and Linux must not register {modifiers:?}"
1279 );
1280 }
1281 }
1282
1283 /// The keystroke spellings real events hand the gate: `ctrl` parses to
1284 /// the Control field the Windows/Linux registration admits and macOS
1285 /// vetoes, `cmd` to the platform field macOS vetoes, `alt-shift` to
1286 /// the chord that stays registered (focus-only) on macOS alone, and
1287 /// `fn` to the flag the browser matcher never sees, so it registers
1288 /// exactly like the bare key on both maps.
1289 #[test]
1290 fn keystroke_spellings_reach_the_registration_gate() {
1291 let ctrl_shift_home = gpui::Keystroke::parse("ctrl-shift-home").unwrap();
1292 assert!(home_end_registered(ctrl_shift_home.modifiers, false));
1293 assert!(!home_end_registered(ctrl_shift_home.modifiers, true));
1294 let cmd_shift_home = gpui::Keystroke::parse("cmd-shift-home").unwrap();
1295 assert!(!home_end_registered(cmd_shift_home.modifiers, true));
1296 let alt_shift_end = gpui::Keystroke::parse("alt-shift-end").unwrap();
1297 assert!(home_end_registered(alt_shift_end.modifiers, true));
1298 assert!(!home_end_registered(alt_shift_end.modifiers, false));
1299 let fn_home = gpui::Keystroke::parse("fn-home").unwrap();
1300 assert!(fn_home.modifiers.function);
1301 assert!(home_end_registered(fn_home.modifiers, true));
1302 assert!(home_end_registered(fn_home.modifiers, false));
1303 }
1304}
1305
1306crate::util::impl_component_styled!(TagGroup);