Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ All notable changes to `@codebar-ag/storybook`.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## v1.22.0

### Added

- **`Combobox` accepts `clearable`** (plus `clear-label`, default "Clear
value"), matching the prop `SearchableSelect` gained in v1.21.0. While the
field holds anything, an ✕ sits at its right end; pressing it emits
`update:modelValue` with `''`, focuses the field and leaves the suggestion
list open.

v1.21.0's entry below argued this sibling did not need one, because
"`Combobox` is free text that can be erased". True, but not in one gesture:
erasing means selecting the field's contents by hand and deleting them, and
the amount to erase is unbounded because picking a suggestion **overwrites
the text wholesale**. The consuming app made that concrete — a field where
one option inserts a placeholder token and everything else is typed prose, so
the two gestures a user alternates between are "pick" and "start over". A
select and its searchable sibling both offer a one-press exit; the third
control being the odd one out is the inconsistency, not the fix.

Three details differ from `SearchableSelect`'s, all forced by the trigger
being an `<input>` rather than a `<button>`:

- The ✕ sits at `right-0`, not `right-7`: a `Combobox` has no chevron to
sit beside.
- Visibility is gated on **`modelValue` alone**, not on an option's label
resolving. Here the typed text *is* the value, so there is never a state
where something is stored and nothing is on screen — the gate that
`SearchableSelect` needs for options still in flight has no meaning.
- The ✕ prevents its own `mousedown`. Without it the press blurs the field
before the click lands, and the blur races the focus handoff that follows.

Padding is `pr-10` only while the ✕ is rendered, so a `Combobox` without the
prop keeps aligning with the `Input` atom beside it. The `Clearable` story
pins the loop: clear → model `''`, field empty and focused, ✕ gone, full list
open; type again → ✕ back.

## v1.21.0

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codebar-ag/storybook",
"version": "1.21.0",
"version": "1.22.0",
"description": "codebar-ag DocuHub — shared Vue 3 + Tailwind v4 design-system atoms and tokens, documented in Storybook.",
"license": "MIT",
"author": "codebar Solutions AG",
Expand Down
36 changes: 36 additions & 0 deletions src/components/molecules/Combobox.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,42 @@ export const Default: Story = {
},
};

export const Clearable: Story = {
render: () => ({
components: { Combobox, Field },
setup: () => ({ value: ref('e_invoices'), cabinets }),
template: `
<div class="w-80 pb-48">
<Field label="File cabinet" name="cabinet" hint="Optional — leave empty to search every cabinet.">
<Combobox v-model="value" name="cabinet" :options="cabinets" placeholder="Every cabinet" clearable clear-label="Clear file cabinet" />
</Field>
<p data-testid="value">value: {{ value === '' ? '(empty)' : value }}</p>
</div>`,
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('combobox');
await expect(input).toHaveValue('e_invoices');

// Clearing empties the model, restores the placeholder, and hands
// focus to the field the ✕ it was on has just unmounted beside.
await userEvent.click(canvas.getByRole('button', { name: 'Clear file cabinet' }));
await expect(canvas.getByTestId('value')).toHaveTextContent('value: (empty)');
await expect(input).toHaveValue('');
await expect(canvas.queryByRole('button', { name: 'Clear file cabinet' })).not.toBeInTheDocument();
await waitFor(() => expect(input).toHaveFocus());

// An empty field filters nothing, so clearing leaves the whole list
// open to pick from — clearing is a step towards another value.
const listbox = await canvas.findByRole('listbox');
await expect(within(listbox).getAllByRole('option')).toHaveLength(5);

// Free text brings the ✕ back, not only a picked suggestion.
await userEvent.type(input, 'e_brand_new');
await expect(canvas.getByRole('button', { name: 'Clear file cabinet' })).toBeInTheDocument();
},
};

/**
* Options that arrive AFTER the field is focused — the remote-search shape,
* where a consumer replaces `options` with each debounced response.
Expand Down
53 changes: 52 additions & 1 deletion src/components/molecules/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { formControlClasses } from '../../helpers/formControlClasses';
import { useClickOutside } from '../../composables/useClickOutside';
import { useFieldA11y } from '../../composables/useFieldA11y';
import { useListNavigation } from '../../composables/useListNavigation';
import Icon from '../atoms/Icon.vue';
import type { SelectOption } from '../atoms/Select.vue';

/**
Expand All @@ -23,6 +24,14 @@ export interface ComboboxProps<T extends string | number = string | number> {
placeholder?: string | null;
invalid?: boolean;
emptyMessage?: string | null;
/**
* Show an ✕ at the right end while the field holds anything, so a value
* can be emptied in one gesture. Selecting a suggestion overwrites the
* text wholesale, so without it the only way back to empty is to select
* the field's contents by hand and delete them.
*/
clearable?: boolean;
clearLabel?: string;
}

const props = withDefaults(
Expand All @@ -34,6 +43,8 @@ const props = withDefaults(
placeholder: null,
invalid: false,
emptyMessage: null,
clearable: false,
clearLabel: 'Clear value',
},
);

Expand All @@ -45,6 +56,7 @@ const emit = defineEmits<{
const { describedBy } = useFieldA11y(props);

const root = ref<HTMLElement | null>(null);
const field = ref<HTMLInputElement | null>(null);

// Focus opens UNCONDITIONALLY (`@focus="open = true"` below), not only when
// options are already present. The list itself stays gated on having something
Expand Down Expand Up @@ -80,13 +92,30 @@ const { activeIndex, setActive, onKeydown: onListKeydown } = useListNavigation(
},
);

const classes = computed(() => cx(formControlClasses(props.invalid, 'px-3.5 h-11')));
const showClear = computed(() => props.clearable && props.modelValue !== '');

// `pr-10` only while the ✕ is there: padding held unconditionally would
// indent every non-clearable Combobox against the Input atom it sits beside.
const classes = computed(() =>
cx(formControlClasses(props.invalid, showClear.value ? 'pl-3.5 pr-10 h-11' : 'px-3.5 h-11')),
);

function close(): void {
open.value = false;
setActive(-1);
}

function clearValue(): void {
emit('update:modelValue', '');
setActive(-1);
// The ✕ unmounts with the value it cleared; without a handoff, focus falls
// to <body> and a keyboard user starts over from the page top. Focusing the
// field also matches what clearing is FOR — entering something else — and
// leaves the list open, which is what focus does here anyway.
field.value?.focus();
open.value = true;
}

function selectOption(opt: SelectOption<T>): void {
emit('update:modelValue', opt.label);
emit('select', opt);
Expand Down Expand Up @@ -119,6 +148,7 @@ useClickOutside(root, close, open);
>
<input
:id="name ?? undefined"
ref="field"
type="text"
role="combobox"
aria-autocomplete="list"
Expand All @@ -139,6 +169,27 @@ useClickOutside(root, close, open);
@focus="open = true"
>

<!-- Sibling of the field, not a child: an <input> is void and cannot
contain anything. Absolutely positioned into its right end, full
control height, so the hit area is 28px × the control. `mousedown` is
prevented so the click does not blur the field on its way in — the
blur would land before the click and the handoff below would fight
it. -->
<button
v-if="showClear"
type="button"
:aria-label="clearLabel"
class="absolute inset-y-0 right-0 flex w-9 items-center justify-center text-muted transition hover:text-ink"
@mousedown.prevent
@click="clearValue"
>
<Icon
name="x"
size="sm"
class="size-3.5 shrink-0"
/>
</button>

<ul
v-if="open && (filtered.length > 0 || emptyMessage !== null)"
:id="listId"
Expand Down
Loading