diff --git a/CHANGELOG.md b/CHANGELOG.md index 06072a7..e2afc18 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). +## 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 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", 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" >