From 5fbfb4c730bed0dac013c5015f4b8feefc5dfe96 Mon Sep 17 00:00:00 2001 From: "ai@codebar" Date: Mon, 10 Aug 2026 04:53:29 +0200 Subject: [PATCH 1/2] Combobox: open on focus even when the options have not arrived yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, which is why the story pins the exact sequence. The consuming app's impersonation picker passed its browser test locally on every run — the response wins by milliseconds there — and failed all three CI retries, where the runner is slow enough that the click reliably wins. A race a fast machine can never lose is a race a test on a fast machine can never catch, so the RemoteOptions story manufactures it: options arrive 600ms after mount, the play function clicks first, and the listbox must appear with no typing, no ArrowDown and no second click. It fails on the previous handler (verified by inverting the fix) and also asserts the flag alone paints nothing while the list is still empty and message-less. Focus now sets the open flag unconditionally; the listbox itself is still gated on having options or an empty-message to show. One visible change besides the fix: a filter that matches nothing now shows the empty-message on refocus, where before the closed list hid it. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 27 +++++++++ src/components/molecules/Combobox.stories.ts | 58 +++++++++++++++++++- src/components/molecules/Combobox.vue | 13 ++++- 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06072a7..c82a896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). +## Unreleased + +### 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 diff --git a/src/components/molecules/Combobox.stories.ts b/src/components/molecules/Combobox.stories.ts index 5360bea..6b1ad69 100644 --- a/src/components/molecules/Combobox.stories.ts +++ b/src/components/molecules/Combobox.stories.ts @@ -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'; @@ -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: ` +
+ + + +
`, + }), + 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'); + }, +}; diff --git a/src/components/molecules/Combobox.vue b/src/components/molecules/Combobox.vue index dc807d8..967a632 100644 --- a/src/components/molecules/Combobox.vue +++ b/src/components/molecules/Combobox.vue @@ -45,6 +45,17 @@ const emit = defineEmits<{ const { describedBy } = useFieldA11y(props); const root = ref(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`; @@ -125,7 +136,7 @@ useClickOutside(root, close, open); :class="classes" @input="onInput" @keydown="onKeydown" - @focus="open = filtered.length > 0" + @focus="open = true" >
    Date: Mon, 10 Aug 2026 04:57:48 +0200 Subject: [PATCH 2/2] =?UTF-8?q?1.19.1=20=E2=80=94=20the=20number=20the=20v?= =?UTF-8?q?ersion=20gate=20asks=20for?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify:version refuses a package.json equal to an existing tag, and it is right to: v1.19.0 is spent whether or not its release succeeded. The CHANGELOG's Unreleased heading becomes the version for the same reason — this repo's entries are named at PR time, not at release time. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c82a896..e2afc18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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). -## Unreleased +## v1.19.1 ### Fixed diff --git a/package-lock.json b/package-lock.json index ef37047..fac5a2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codebar-ag/storybook", - "version": "1.19.0", + "version": "1.19.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codebar-ag/storybook", - "version": "1.19.0", + "version": "1.19.1", "license": "MIT", "dependencies": { "@fontsource/jetbrains-mono": "^5.3.0", diff --git a/package.json b/package.json index d818b05..f110541 100644 --- a/package.json +++ b/package.json @@ -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",