Skip to content

Commit 5a5b7d2

Browse files
authored
Merge pull request #30 from codebar-ag/feat/combobox-clearable
Let a Combobox be cleared in one gesture
2 parents d0e183d + f500164 commit 5a5b7d2

4 files changed

Lines changed: 126 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,43 @@ All notable changes to `@codebar-ag/storybook`.
55
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
66
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## v1.22.0
9+
10+
### Added
11+
12+
- **`Combobox` accepts `clearable`** (plus `clear-label`, default "Clear
13+
value"), matching the prop `SearchableSelect` gained in v1.21.0. While the
14+
field holds anything, an ✕ sits at its right end; pressing it emits
15+
`update:modelValue` with `''`, focuses the field and leaves the suggestion
16+
list open.
17+
18+
v1.21.0's entry below argued this sibling did not need one, because
19+
"`Combobox` is free text that can be erased". True, but not in one gesture:
20+
erasing means selecting the field's contents by hand and deleting them, and
21+
the amount to erase is unbounded because picking a suggestion **overwrites
22+
the text wholesale**. The consuming app made that concrete — a field where
23+
one option inserts a placeholder token and everything else is typed prose, so
24+
the two gestures a user alternates between are "pick" and "start over". A
25+
select and its searchable sibling both offer a one-press exit; the third
26+
control being the odd one out is the inconsistency, not the fix.
27+
28+
Three details differ from `SearchableSelect`'s, all forced by the trigger
29+
being an `<input>` rather than a `<button>`:
30+
31+
- The ✕ sits at `right-0`, not `right-7`: a `Combobox` has no chevron to
32+
sit beside.
33+
- Visibility is gated on **`modelValue` alone**, not on an option's label
34+
resolving. Here the typed text *is* the value, so there is never a state
35+
where something is stored and nothing is on screen — the gate that
36+
`SearchableSelect` needs for options still in flight has no meaning.
37+
- The ✕ prevents its own `mousedown`. Without it the press blurs the field
38+
before the click lands, and the blur races the focus handoff that follows.
39+
40+
Padding is `pr-10` only while the ✕ is rendered, so a `Combobox` without the
41+
prop keeps aligning with the `Input` atom beside it. The `Clearable` story
42+
pins the loop: clear → model `''`, field empty and focused, ✕ gone, full list
43+
open; type again → ✕ back.
44+
845
## v1.21.0
946

1047
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codebar-ag/storybook",
3-
"version": "1.21.0",
3+
"version": "1.22.0",
44
"description": "codebar-ag DocuHub — shared Vue 3 + Tailwind v4 design-system atoms and tokens, documented in Storybook.",
55
"license": "MIT",
66
"author": "codebar Solutions AG",

src/components/molecules/Combobox.stories.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,42 @@ export const Default: Story = {
5656
},
5757
};
5858

59+
export const Clearable: Story = {
60+
render: () => ({
61+
components: { Combobox, Field },
62+
setup: () => ({ value: ref('e_invoices'), cabinets }),
63+
template: `
64+
<div class="w-80 pb-48">
65+
<Field label="File cabinet" name="cabinet" hint="Optional — leave empty to search every cabinet.">
66+
<Combobox v-model="value" name="cabinet" :options="cabinets" placeholder="Every cabinet" clearable clear-label="Clear file cabinet" />
67+
</Field>
68+
<p data-testid="value">value: {{ value === '' ? '(empty)' : value }}</p>
69+
</div>`,
70+
}),
71+
play: async ({ canvasElement }) => {
72+
const canvas = within(canvasElement);
73+
const input = canvas.getByRole('combobox');
74+
await expect(input).toHaveValue('e_invoices');
75+
76+
// Clearing empties the model, restores the placeholder, and hands
77+
// focus to the field the ✕ it was on has just unmounted beside.
78+
await userEvent.click(canvas.getByRole('button', { name: 'Clear file cabinet' }));
79+
await expect(canvas.getByTestId('value')).toHaveTextContent('value: (empty)');
80+
await expect(input).toHaveValue('');
81+
await expect(canvas.queryByRole('button', { name: 'Clear file cabinet' })).not.toBeInTheDocument();
82+
await waitFor(() => expect(input).toHaveFocus());
83+
84+
// An empty field filters nothing, so clearing leaves the whole list
85+
// open to pick from — clearing is a step towards another value.
86+
const listbox = await canvas.findByRole('listbox');
87+
await expect(within(listbox).getAllByRole('option')).toHaveLength(5);
88+
89+
// Free text brings the ✕ back, not only a picked suggestion.
90+
await userEvent.type(input, 'e_brand_new');
91+
await expect(canvas.getByRole('button', { name: 'Clear file cabinet' })).toBeInTheDocument();
92+
},
93+
};
94+
5995
/**
6096
* Options that arrive AFTER the field is focused — the remote-search shape,
6197
* where a consumer replaces `options` with each debounced response.

src/components/molecules/Combobox.vue

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { formControlClasses } from '../../helpers/formControlClasses';
88
import { useClickOutside } from '../../composables/useClickOutside';
99
import { useFieldA11y } from '../../composables/useFieldA11y';
1010
import { useListNavigation } from '../../composables/useListNavigation';
11+
import Icon from '../atoms/Icon.vue';
1112
import 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
2837
const 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<{
4556
const { describedBy } = useFieldA11y(props);
4657
4758
const 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
85103
function 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+
90119
function 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

Comments
 (0)