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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@ 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.21.0

### Added

- **`SearchableSelect` accepts `clearable`** (plus `clear-label` for the ✕'s
accessible name, default "Clear selection"). While something is selected, an
✕ appears between the label and the chevron; pressing it emits
`update:modelValue` with `''` and hands focus back to the trigger.

Without it the control is a trap for OPTIONAL values: it picks from a closed
set and only ever emits `option.value`, so once anything is picked there is
no gesture that returns to "nothing chosen". The consuming app hit this on
its data-source form — the store-dialog field is nullable all the way down
(`'nullable'` in the FormRequest, `?? null` on submit), but a user who
selected a dialog could never unselect it short of reloading the page. Its
sibling controls both already had an exit: the native `Select` renders a
selectable placeholder `<option value="">`, and `Combobox` is free text that
can be erased. This closes the gap for the third sibling; `required` fields
simply don't pass the prop.

Two deliberate choices, so call sites don't pay for what they don't use:

- Clearing emits **`''`, not `null`** — the same "nothing chosen" payload
the native `Select` produces when its placeholder is picked. Emitting
`null` would widen the payload type to `T | null` for every consumer,
forcing null-handling onto the majority of call sites whose select is not
clearable and can never receive it. (For a `T = string` site, `T | ''`
collapses to `string`: existing handlers type-check unchanged.)
- The ✕ is a **sibling of the trigger, not a child** — the trigger is
itself a `<button>`, and a button inside a button is invalid HTML that
browsers "repair" by splitting the elements apart. It is absolutely
positioned into the trigger's right end at the control's full height, and
only rendered while a selected option's label is actually on screen (a
`modelValue` whose options have not arrived yet shows the placeholder, and
there is nothing visible to clear).

The `Clearable` story pins the loop: clear → model `''`, placeholder back,
menu closed, focus on the trigger, ✕ gone; re-pick → ✕ back.

## v1.19.1

### Fixed
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.19.1",
"version": "1.21.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
47 changes: 47 additions & 0 deletions src/components/molecules/SearchableSelect.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,53 @@ export const Default: Story = {
},
};

export const Clearable: Story = {
render: () => ({
components: { SearchableSelect, Field },
setup: () => ({
dialog: ref('d_default'),
dialogs: [
{ value: 'd_default', label: 'Default store dialog' },
{ value: 'd_invoices', label: 'Invoice intake' },
],
}),
template: `
<div class="max-w-md pb-64">
<Field label="Store dialog" name="dialog" hint="Optional — leave empty to use the cabinet's default.">
<SearchableSelect
v-model="dialog"
:options="dialogs"
placeholder="Use default"
clearable
clear-label="Clear store dialog"
/>
</Field>
<p data-testid="value">value: {{ dialog === '' ? '(empty)' : dialog }}</p>
</div>`,
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole('combobox');
await expect(trigger).toHaveTextContent('Default store dialog');

// Clearing empties the model, restores the placeholder, keeps the
// menu closed, and hands focus back to the trigger — the ✕ it was on
// has just unmounted.
await userEvent.click(canvas.getByRole('button', { name: 'Clear store dialog' }));
await expect(canvas.getByTestId('value')).toHaveTextContent('value: (empty)');
await expect(trigger).toHaveTextContent('Use default');
await expect(canvas.queryByRole('listbox')).not.toBeInTheDocument();
await expect(canvas.queryByRole('button', { name: 'Clear store dialog' })).not.toBeInTheDocument();
await waitFor(() => expect(trigger).toHaveFocus());

// A fresh pick brings the ✕ back.
await userEvent.click(trigger);
await userEvent.click(await canvas.findByRole('option', { name: 'Invoice intake' }));
await expect(canvas.getByTestId('value')).toHaveTextContent('value: d_invoices');
await expect(canvas.getByRole('button', { name: 'Clear store dialog' })).toBeInTheDocument();
},
};

export const CabinetPicker: Story = {
render: () => ({
components: { SearchableSelect, Field },
Expand Down
51 changes: 49 additions & 2 deletions src/components/molecules/SearchableSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export interface SearchableSelectProps<T extends string | number = string | numb
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
/**
* Show an ✕ next to the chevron while something is selected, so an
* OPTIONAL selection can be undone. Without it the control is a trap:
* the set is closed, so once any option is picked there is no gesture
* that returns to "nothing chosen".
*/
clearable?: boolean;
clearLabel?: string;
}

const props = withDefaults(
Expand All @@ -30,20 +38,32 @@ const props = withDefaults(
placeholder: 'Choose…',
searchPlaceholder: 'Search…',
emptyMessage: 'No results',
clearable: false,
clearLabel: 'Clear selection',
},
);

const emit = defineEmits<{ 'update:modelValue': [value: T] }>();
// Clearing emits `''`, not `null` — the same "nothing chosen" payload the
// native Select atom produces when its placeholder option is picked. Emitting
// null instead would widen the payload type for every consumer, forcing null
// handling onto call sites whose select is not clearable and can never
// receive it.
const emit = defineEmits<{ 'update:modelValue': [value: T | ''] }>();

const open = ref(false);
const query = ref('');
const root = ref<HTMLElement | null>(null);
const trigger = ref<HTMLButtonElement | null>(null);

const selectedLabel = computed(() => {
const match = props.options.find((opt) => String(opt.value) === String(props.modelValue));
return match?.label ?? null;
});

// Gated on a resolved label, not on `modelValue` alone: while options are
// still loading there is nothing on screen that says what would be cleared.
const showClear = computed(() => props.clearable && selectedLabel.value !== null);

const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) {
Expand All @@ -56,6 +76,14 @@ const triggerClasses = computed(() =>
formControlClasses(false, 'flex items-center justify-between gap-2 px-3 min-h-9 text-sm font-medium text-left cursor-pointer hover:border-line-2'),
);

function clearSelection() {
emit('update:modelValue', '');
closeMenu();
// The ✕ unmounts with the selection it cleared; without a handoff, focus
// falls to <body> and a keyboard user starts over from the page top.
trigger.value?.focus();
}

// Keyboard core shared with Combobox: Arrow/Home/End/Enter/Escape over the
// filtered list.
const { activeIndex, setActive, onKeydown: onSearchKeydown } = useListNavigation(
Expand Down Expand Up @@ -113,6 +141,7 @@ watch(query, () => {
class="relative w-full"
>
<button
ref="trigger"
type="button"
role="combobox"
:aria-expanded="open"
Expand All @@ -123,7 +152,7 @@ watch(query, () => {
>
<span
class="truncate"
:class="selectedLabel ? 'text-ink' : 'text-muted'"
:class="[selectedLabel ? 'text-ink' : 'text-muted', showClear ? 'pr-6' : '']"
>{{ selectedLabel ?? placeholder }}</span>
<Icon
name="chevron-down"
Expand All @@ -132,6 +161,24 @@ watch(query, () => {
/>
</button>

<!-- Sibling of the trigger, not a child: the trigger is itself a <button>,
and a button inside a button is invalid HTML that browsers "repair" by
splitting the elements apart. Absolutely positioned into the trigger's
right end, full control height, so the hit area is 28px × the control. -->
<button
v-if="showClear"
type="button"
:aria-label="clearLabel"
class="absolute inset-y-0 right-7 flex w-7 items-center justify-center text-muted transition hover:text-ink"
@click="clearSelection"
>
<Icon
name="x"
size="sm"
class="size-3.5 shrink-0"
/>
</button>

<div
v-if="open"
class="absolute left-0 right-0 z-30 mt-1 rounded-surface border border-line bg-surface shadow-lg shadow-ink/5"
Expand Down
Loading