@@ -8,6 +8,7 @@ import { formControlClasses } from '../../helpers/formControlClasses';
88import { useClickOutside } from ' ../../composables/useClickOutside' ;
99import { useFieldA11y } from ' ../../composables/useFieldA11y' ;
1010import { useListNavigation } from ' ../../composables/useListNavigation' ;
11+ import Icon from ' ../atoms/Icon.vue' ;
1112import type { SelectOption } from ' ../atoms/Select.vue' ;
1213
1314/**
@@ -23,6 +24,14 @@ export interface ComboboxProps<T extends string | number = string | number> {
2324 placeholder? : string | null ;
2425 invalid? : boolean ;
2526 emptyMessage? : string | null ;
27+ /**
28+ * Show an ✕ at the right end while the field holds anything, so a value
29+ * can be emptied in one gesture. Selecting a suggestion overwrites the
30+ * text wholesale, so without it the only way back to empty is to select
31+ * the field's contents by hand and delete them.
32+ */
33+ clearable? : boolean ;
34+ clearLabel? : string ;
2635}
2736
2837const props = withDefaults (
@@ -34,6 +43,8 @@ const props = withDefaults(
3443 placeholder: null ,
3544 invalid: false ,
3645 emptyMessage: null ,
46+ clearable: false ,
47+ clearLabel: ' Clear value' ,
3748 },
3849);
3950
@@ -45,6 +56,7 @@ const emit = defineEmits<{
4556const { describedBy } = useFieldA11y (props );
4657
4758const root = ref <HTMLElement | null >(null );
59+ const field = ref <HTMLInputElement | null >(null );
4860
4961// Focus opens UNCONDITIONALLY (`@focus="open = true"` below), not only when
5062// options are already present. The list itself stays gated on having something
@@ -80,13 +92,30 @@ const { activeIndex, setActive, onKeydown: onListKeydown } = useListNavigation(
8092 },
8193);
8294
83- const classes = computed (() => cx (formControlClasses (props .invalid , ' px-3.5 h-11' )));
95+ const showClear = computed (() => props .clearable && props .modelValue !== ' ' );
96+
97+ // `pr-10` only while the ✕ is there: padding held unconditionally would
98+ // indent every non-clearable Combobox against the Input atom it sits beside.
99+ const classes = computed (() =>
100+ cx (formControlClasses (props .invalid , showClear .value ? ' pl-3.5 pr-10 h-11' : ' px-3.5 h-11' )),
101+ );
84102
85103function close(): void {
86104 open .value = false ;
87105 setActive (- 1 );
88106}
89107
108+ function clearValue(): void {
109+ emit (' update:modelValue' , ' ' );
110+ setActive (- 1 );
111+ // The ✕ unmounts with the value it cleared; without a handoff, focus falls
112+ // to <body> and a keyboard user starts over from the page top. Focusing the
113+ // field also matches what clearing is FOR — entering something else — and
114+ // leaves the list open, which is what focus does here anyway.
115+ field .value ?.focus ();
116+ open .value = true ;
117+ }
118+
90119function selectOption(opt : SelectOption <T >): void {
91120 emit (' update:modelValue' , opt .label );
92121 emit (' select' , opt );
@@ -119,6 +148,7 @@ useClickOutside(root, close, open);
119148 >
120149 <input
121150 :id =" name ?? undefined"
151+ ref =" field"
122152 type =" text"
123153 role =" combobox"
124154 aria-autocomplete =" list"
@@ -139,6 +169,27 @@ useClickOutside(root, close, open);
139169 @focus =" open = true"
140170 >
141171
172+ <!-- Sibling of the field, not a child: an <input> is void and cannot
173+ contain anything. Absolutely positioned into its right end, full
174+ control height, so the hit area is 28px × the control. `mousedown` is
175+ prevented so the click does not blur the field on its way in — the
176+ blur would land before the click and the handoff below would fight
177+ it. -->
178+ <button
179+ v-if =" showClear"
180+ type =" button"
181+ :aria-label =" clearLabel"
182+ class =" absolute inset-y-0 right-0 flex w-9 items-center justify-center text-muted transition hover:text-ink"
183+ @mousedown.prevent
184+ @click =" clearValue"
185+ >
186+ <Icon
187+ name="x"
188+ size="sm"
189+ class="size-3.5 shrink-0"
190+ />
191+ </button >
192+
142193 <ul
143194 v-if =" open && (filtered.length > 0 || emptyMessage !== null)"
144195 :id =" listId"
0 commit comments