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: ` +