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

### Fixed

- **`Combobox` opens on focus even when its options have not arrived yet.**
The focus handler was `open = filtered.length > 0`, so a click into the
field opened the list only if options were already present — and nothing
reopened it when they arrived later. For a static list that is invisible;
for the remote-search shape, where a consumer replaces `options` with each
debounced response, it is a race between the click and the first response.
The user who loses it clicks into the field, sees nothing, and only typing
or ArrowDown recovers.

Found the expensive way: the consuming app's impersonation picker passed
its browser test locally on every run (the response wins by milliseconds)
and failed all three CI retries (the runner is slow enough that the click
wins). Focus now sets the open flag unconditionally; the listbox itself is
still gated on having options or an `empty-message` to show, so an open
flag over a truly empty list renders nothing. The `RemoteOptions` story
pins the sequence — focus first, options later, no typing — and fails on
the previous handler.

One visible behavior change besides the fix: a combobox whose options are
present now also shows an `empty-message` on focus when the typed text
filters everything out, where before that message only appeared after
typing. No call site in the consuming app depended on the old behavior.

## v1.19.0

Four findings from the app that adopted 1.18.0, three of them acted on and one
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.0",
"version": "1.19.1",
"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
58 changes: 57 additions & 1 deletion src/components/molecules/Combobox.stories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import Combobox from './Combobox.vue';
import Field from './Field.vue';

Expand Down Expand Up @@ -55,3 +55,59 @@ export const Default: Story = {
await expect(input).toHaveValue('e_brand_new');
},
};

/**
* Options that arrive AFTER the field is focused — the remote-search shape,
* where a consumer replaces `options` with each debounced response.
*
* This pins the focus handler opening unconditionally. With the previous
* `open = filtered.length > 0` on focus, a click that landed before the first
* response found an empty list, left the dropdown closed, and nothing ever
* reopened it when the options arrived — the user saw a combobox that showed
* nothing until they typed. Locally the response tends to win that race, which
* is exactly why it shipped: the failure needed a slow network or a loaded CI
* runner to show itself.
*/
export const RemoteOptions: Story = {
render: () => ({
components: { Combobox, Field },
setup: () => {
const value = ref('');
const options = ref<{ value: string; label: string }[]>([]);

// Long enough that the play function's click below reliably beats
// it — the point is focus-before-options, not a realistic latency.
onMounted(() => {
setTimeout(() => {
options.value = cabinets;
}, 600);
});

return { value, options };
},
template: `
<div class="w-80 pb-48">
<Field label="File cabinet" name="cabinet" hint="Options load remotely.">
<Combobox v-model="value" name="cabinet" :options="options" placeholder="e_invoices" />
</Field>
</div>`,
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole('combobox');

// Focus while the list is still empty: no options, no empty-message,
// so nothing may render yet — an open flag alone must not paint a box.
await userEvent.click(input);
await expect(canvas.queryByRole('listbox')).not.toBeInTheDocument();

// The options land ~600ms later. No typing, no ArrowDown, no second
// click — the already-focused field must show them on its own.
const listbox = await canvas.findByRole('listbox', {}, { timeout: 3000 });
await expect(within(listbox).getAllByRole('option')).toHaveLength(cabinets.length);

// And the late-arriving list is live, not just visible.
await userEvent.keyboard('{ArrowDown}{Enter}');
await expect(input).toHaveValue('e_invoices');
},
};
13 changes: 12 additions & 1 deletion src/components/molecules/Combobox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ const emit = defineEmits<{
const { describedBy } = useFieldA11y(props);

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

// Focus opens UNCONDITIONALLY (`@focus="open = true"` below), not only when
// options are already present. The list itself stays gated on having something
// to show (see the `v-if` on the listbox), so an open flag over an empty,
// message-less list renders nothing — but it is what lets options that arrive
// AFTER focus appear at all. With `open = filtered.length > 0` on focus, a
// consumer feeding options from a remote search lost that race whenever the
// response landed after the click, and the closed list never reopened: the
// user clicked into the field, saw nothing, and only typing or ArrowDown
// would recover. Measured in the consuming app's CI, where the runner is slow
// enough that the click reliably beat the response.
const open = ref(false);
const listId = `${props.name ?? 'combobox'}-listbox`;

Expand Down Expand Up @@ -125,7 +136,7 @@ useClickOutside(root, close, open);
:class="classes"
@input="onInput"
@keydown="onKeydown"
@focus="open = filtered.length > 0"
@focus="open = true"
>

<ul
Expand Down
Loading