Skip to content

Commit 615c74d

Browse files
authored
Merge pull request #28 from codebar-ag/fix/combobox-opens-before-remote-options-arrive
Combobox: open on focus even when the options have not arrived yet
2 parents 87c4c75 + 46d19e0 commit 615c74d

5 files changed

Lines changed: 99 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,33 @@ 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.19.1
9+
10+
### Fixed
11+
12+
- **`Combobox` opens on focus even when its options have not arrived yet.**
13+
The focus handler was `open = filtered.length > 0`, so a click into the
14+
field opened the list only if options were already present — and nothing
15+
reopened it when they arrived later. For a static list that is invisible;
16+
for the remote-search shape, where a consumer replaces `options` with each
17+
debounced response, it is a race between the click and the first response.
18+
The user who loses it clicks into the field, sees nothing, and only typing
19+
or ArrowDown recovers.
20+
21+
Found the expensive way: the consuming app's impersonation picker passed
22+
its browser test locally on every run (the response wins by milliseconds)
23+
and failed all three CI retries (the runner is slow enough that the click
24+
wins). Focus now sets the open flag unconditionally; the listbox itself is
25+
still gated on having options or an `empty-message` to show, so an open
26+
flag over a truly empty list renders nothing. The `RemoteOptions` story
27+
pins the sequence — focus first, options later, no typing — and fails on
28+
the previous handler.
29+
30+
One visible behavior change besides the fix: a combobox whose options are
31+
present now also shows an `empty-message` on focus when the typed text
32+
filters everything out, where before that message only appeared after
33+
typing. No call site in the consuming app depended on the old behavior.
34+
835
## v1.19.0
936

1037
Four findings from the app that adopted 1.18.0, three of them acted on and one

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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.19.0",
3+
"version": "1.19.1",
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: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Meta, StoryObj } from '@storybook/vue3-vite';
22
import { expect, userEvent, waitFor, within } from 'storybook/test';
3-
import { ref } from 'vue';
3+
import { onMounted, ref } from 'vue';
44
import Combobox from './Combobox.vue';
55
import Field from './Field.vue';
66

@@ -55,3 +55,59 @@ export const Default: Story = {
5555
await expect(input).toHaveValue('e_brand_new');
5656
},
5757
};
58+
59+
/**
60+
* Options that arrive AFTER the field is focused — the remote-search shape,
61+
* where a consumer replaces `options` with each debounced response.
62+
*
63+
* This pins the focus handler opening unconditionally. With the previous
64+
* `open = filtered.length > 0` on focus, a click that landed before the first
65+
* response found an empty list, left the dropdown closed, and nothing ever
66+
* reopened it when the options arrived — the user saw a combobox that showed
67+
* nothing until they typed. Locally the response tends to win that race, which
68+
* is exactly why it shipped: the failure needed a slow network or a loaded CI
69+
* runner to show itself.
70+
*/
71+
export const RemoteOptions: Story = {
72+
render: () => ({
73+
components: { Combobox, Field },
74+
setup: () => {
75+
const value = ref('');
76+
const options = ref<{ value: string; label: string }[]>([]);
77+
78+
// Long enough that the play function's click below reliably beats
79+
// it — the point is focus-before-options, not a realistic latency.
80+
onMounted(() => {
81+
setTimeout(() => {
82+
options.value = cabinets;
83+
}, 600);
84+
});
85+
86+
return { value, options };
87+
},
88+
template: `
89+
<div class="w-80 pb-48">
90+
<Field label="File cabinet" name="cabinet" hint="Options load remotely.">
91+
<Combobox v-model="value" name="cabinet" :options="options" placeholder="e_invoices" />
92+
</Field>
93+
</div>`,
94+
}),
95+
play: async ({ canvasElement }) => {
96+
const canvas = within(canvasElement);
97+
const input = canvas.getByRole('combobox');
98+
99+
// Focus while the list is still empty: no options, no empty-message,
100+
// so nothing may render yet — an open flag alone must not paint a box.
101+
await userEvent.click(input);
102+
await expect(canvas.queryByRole('listbox')).not.toBeInTheDocument();
103+
104+
// The options land ~600ms later. No typing, no ArrowDown, no second
105+
// click — the already-focused field must show them on its own.
106+
const listbox = await canvas.findByRole('listbox', {}, { timeout: 3000 });
107+
await expect(within(listbox).getAllByRole('option')).toHaveLength(cabinets.length);
108+
109+
// And the late-arriving list is live, not just visible.
110+
await userEvent.keyboard('{ArrowDown}{Enter}');
111+
await expect(input).toHaveValue('e_invoices');
112+
},
113+
};

src/components/molecules/Combobox.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ const emit = defineEmits<{
4545
const { describedBy } = useFieldA11y(props);
4646
4747
const root = ref<HTMLElement | null>(null);
48+
49+
// Focus opens UNCONDITIONALLY (`@focus="open = true"` below), not only when
50+
// options are already present. The list itself stays gated on having something
51+
// to show (see the `v-if` on the listbox), so an open flag over an empty,
52+
// message-less list renders nothing — but it is what lets options that arrive
53+
// AFTER focus appear at all. With `open = filtered.length > 0` on focus, a
54+
// consumer feeding options from a remote search lost that race whenever the
55+
// response landed after the click, and the closed list never reopened: the
56+
// user clicked into the field, saw nothing, and only typing or ArrowDown
57+
// would recover. Measured in the consuming app's CI, where the runner is slow
58+
// enough that the click reliably beat the response.
4859
const open = ref(false);
4960
const listId = `${props.name ?? 'combobox'}-listbox`;
5061
@@ -125,7 +136,7 @@ useClickOutside(root, close, open);
125136
:class="classes"
126137
@input="onInput"
127138
@keydown="onKeydown"
128-
@focus="open = filtered.length > 0"
139+
@focus="open = true"
129140
>
130141

131142
<ul

0 commit comments

Comments
 (0)