fix(Dropdown): keep selected value inside input for searchable single select - #3405
fix(Dropdown): keep selected value inside input for searchable single select#3405rivka-ungar wants to merge 34 commits into
Conversation
… select The searchable single-select combobox previously forced the input value to null after selection and rendered the selected label as a visual overlay on top of an empty input. Screen readers read the input, not the overlay, so a selected combobox announced as "blank" — failing WCAG 2.1 SC 4.1.2 (Name, Role, Value, Level A). Stop overriding Downshift's default so the selected item's label lives inside the input and is exposed to assistive technologies. Supporting changes keep the component's existing behavior intact: - Seed initialInputValue with the selected label so a defaultValue/value is visible on mount (previously handled by the overlay). - Filter the list only on real user typing (InputChange); ignore the label Downshift writes into the input on selection/blur. - Reset the filter when the menu closes so reopening shows the full list, matching the prior behavior and the documented combobox pattern. The overlay in SingleSelectTrigger now naturally renders only for non-searchable single select (where inputValue stays null), so that path is unaffected. Multi-select uses a separate hook and is untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Code Review by Qodo
1. FlexProps defined in Flex.tsx
|
PR Summary by Qodofix(Dropdown): WCAG 4.1.2 — selected value in input, interactive chips, and a11y hardening
AI Description
Diagram
High-Level Assessment
Files changed (20)
|
|
📦 Bundle Size Analysis ✅ No bundle size changes detected. Unchanged Components
📊 Summary:
|
Comprehensive story page covering all searchable single-select variants: overview, sizes, states, default/controlled value, icons & avatars, groups, tooltips, clearable/max-height, and custom filter / empty message. The default-value story demonstrates the selected value living inside the input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 6edf9ba |
Single-page accessibility reference covering the selected-value-in-input behavior, WCAG criteria, keyboard interaction, screen reader output, and the accessibility-relevant props (naming, state, feedback). Excludes layout props like size that do not affect accessibility. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 3417b82 |
Convert the plain .md (which Storybook does not pick up) into an .mdx docs page for the Searchable single select group, with the live examples embedded. Trim to accessibility essentials: core selected-value-in-input behavior, WCAG 4.1.2, and the accessibility-relevant props. Dropped the generic keyboard and screen-reader sections and the broader WCAG list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 11b7f86 |
…single select The .selectedItem overlay was previously hidden for searchable mode only via the `!inputValue` guard, so it reappeared and coexisted with the input value whenever the input text was cleared while a selection remained. Gate the overlay on `!searchable` instead — for searchable single select the value lives inside the input, so the overlay must never render. Removes the now-dead `faded`/`hasSelected` classes and their styles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 127c22d |
…-off examples Add a clear "What changed" before/after section to the searchable single select accessibility page, and stories that demonstrate the text-only collapsed selected value: preselected start elements (icons/avatars), end elements (trailing icon, suffix/hint), and a custom valueRenderer that is not applied to the searchable selected display. Remove the Do/Don't section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit d0ea70b |
MDX parses {curly braces} as JS expressions; "{selected label}" is not valid
JS and broke the Storybook/Chromatic preview build with an acorn parse error.
Replace it with plain text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Looking for bugs?Check back in a few minutes. An AI review agent is analyzing this pull request. |
textInput: selected items are shown as a comma-separated summary in the input (WCAG 4.1.2 — exposes value to assistive technologies on focus). interactiveChips: chips stay visible alongside the input; keyboard nav via getSelectedItemProps — ArrowLeft/Right moves between chips, Backspace/Delete removes the focused chip, ArrowLeft from an empty input navigates to the last chip or the +N overflow badge. Chips overflow uses the existing useItemsOverflow hook for the +N count badge. Both modes keep the menu open on selection and support toggle (re-click to deselect). Default chip mode behavior is unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Storybook MDX page and stories for the textInput and interactiveChips props explaining how each addresses the WCAG 4.1.2 gap in default multi-select (empty input announced as blank by screen readers). Covers: - Side-by-side comparison of all three modes - textInput: comma-separated value in the input, exposed on mount, controlled usage, trade-offs - interactiveChips: keyboard navigation table, overflow (+N badge), trade-offs - Decision guide for choosing between modes - Common a11y props table (label, clearAriaLabel, error, etc.) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 4fcb1dc |
|
Code review by qodo was updated up to the latest commit 1769c1d |
| <div | ||
| className={styles.multiWrapper} | ||
| onKeyDown={e => { | ||
| if ( | ||
| e.key === "ArrowLeft" && | ||
| e.target instanceof HTMLInputElement && | ||
| !e.target.value && | ||
| overflowBadgeRef.current | ||
| ) { | ||
| overflowBadgeRef.current.focus(); | ||
| } | ||
| }} |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
…ipleSelection onStateChange UseMultipleSelectionStateChange has no selectedItem property — diff old vs new selectedItems array instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| /** | ||
| * The ARIA role of the flex container. | ||
| */ | ||
| role?: React.AriaRole; |
There was a problem hiding this comment.
1. flexprops not in .types.ts 📘 Rule violation ⚙ Maintainability
FlexProps is still declared in Flex.tsx even though a Flex.types.ts file exists, and this PR adds another prop (role) there. This breaks the required typing convention and makes the component harder to maintain consistently.
Agent Prompt
## Issue description
`FlexProps` is declared in `Flex.tsx` instead of the dedicated `Flex.types.ts` file, and this PR extends that in-file interface by adding `role`.
## Issue Context
The compliance rule requires component prop types to live in `*.types.ts` (and extend `VibeComponentProps`) to keep a predictable, maintainable structure.
## Fix Focus Areas
- packages/components/layout/src/Flex/Flex.tsx[9-70]
- packages/components/layout/src/Flex/Flex.types.ts[1-20]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| onIsOpenChange: ({ isOpen }) => { | ||
| // Reset the text filter when the menu closes so reopening always shows the full option list, | ||
| // even though the input keeps displaying the selected item's label. | ||
| if (!isOpen) { | ||
| filterOptions(""); | ||
| } | ||
| isOpen ? onMenuClose?.() : onMenuOpen?.(); | ||
| }, |
There was a problem hiding this comment.
4. Menu callbacks inverted 🐞 Bug ≡ Correctness
In both useDropdownCombobox and useDropdownMultiCombobox, onIsOpenChange calls onMenuClose when isOpen becomes true and onMenuOpen when it becomes false, so consumers observe swapped open/close events.
Agent Prompt
## Issue description
Searchable Dropdown hooks invert `onMenuOpen`/`onMenuClose` callbacks in `onIsOpenChange`, causing consumers to receive the wrong lifecycle events.
## Issue Context
Non-searchable hooks use `isOpen ? onMenuOpen : onMenuClose`, but the combobox-based hooks currently do the opposite.
## Fix Focus Areas
- packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts[67-74]
- packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts[100-104]
- packages/core/src/components/Dropdown/hooks/useDropdownSelect.ts[67-69]
- packages/core/src/components/Dropdown/hooks/useDropdownMultiSelect.ts[80-82]
## Suggested fix
Change both combobox hooks to:
- `if (isOpen) onMenuOpen?.(); else onMenuClose?.();`
Keep the filter-reset logic as-is; only fix the callback direction.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Code review by qodo was updated up to the latest commit 3f226c9 |
Each chip in the selected-items group is now a single button with aria-label "Remove <item>", instead of a labelled div wrapping a separate × button. Clicking (or Enter/Space) removes the chip; downshift keyboard nav props still merge onto the same button for interactiveChips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ummary Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 2a405f8 |
downshift's useCombobox sets aria-selected to mark a single tracked selectedItem, so in multi-select the chosen options were always aria-selected="false". Strip downshift's aria-selected in DropdownBaseList so BaseItem's value — derived from the full selectedItems list — is authoritative. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // interactiveChips: menu is always open, so isOpen would permanently suppress Backspace chip-nav. | ||
| // Instead suppress only when the input has text (Backspace should delete chars, not navigate chips). | ||
| const preventKeyAction = interactiveChips ? !!(inputValue && inputValue.length > 0) : isOpen; | ||
| const multipleSelectionDropdownProps = getDropdownProps ? getDropdownProps({ preventKeyAction }) : {}; |
There was a problem hiding this comment.
1. Interactivechips nav blocked 🐞 Bug ≡ Correctness
When interactiveChips is enabled, the selection summary is written into the combobox input value, making it non-empty whenever there are selections. DropdownInput then sets preventKeyAction=true based on inputValue.length, which prevents the documented ArrowLeft/Backspace-from-input chip navigation from ever being reachable after a selection exists.
Agent Prompt
## Issue description
`interactiveChips` is documented to support chip navigation from the input (ArrowLeft/Backspace), but the current implementation writes a non-empty selection summary into the input and then derives `preventKeyAction` from `inputValue.length`, effectively disabling the key-navigation path once any item is selected.
## Issue Context
- `useDropdownMultiCombobox` writes `buildSelectionSummary(...)` into the combobox input value on mount and on every selection change when `interactiveChips` is true.
- `DropdownInput` computes `preventKeyAction` as `interactiveChips ? (inputValue.length > 0) : isOpen`, so it becomes `true` as soon as the summary is present.
- `interactiveChips` is documented to allow ArrowLeft/Backspace from the input to move focus to the last chip.
## Fix approach (one of these, pick a consistent design)
1) **Implement explicit input->chip focus handling** when `interactiveChips` is true:
- Add an input `onKeyDown` handler (or extend existing ones) that intercepts `ArrowLeft`/`Backspace` and moves focus to the last visible chip (or overflow badge) based on caret position, then `preventDefault()`.
- Do not gate this behavior on `inputValue` being empty.
2) **Change `preventKeyAction` semantics for `interactiveChips`**:
- Stop deriving `preventKeyAction` from `inputValue.length` when `interactiveChips` is enabled; derive it from a more accurate “user is actively typing a filter” signal, or keep it `false` and ensure the intended key nav still works.
3) **If the intended trade-off is ‘no typing while selection exists’**:
- Make the input effectively non-editable while showing the summary (e.g., `readOnly` in that state), and ensure ArrowLeft/Backspace still moves focus to chips.
## Fix Focus Areas
- packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx[41-67]
- packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts[53-63]
- packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx[36-65]
- packages/core/src/components/Dropdown/Dropdown.types.ts[20-28]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Code review by qodo was updated up to the latest commit 25d217d |
The expand/collapse chevron is focusable and needs its own accessible name (WCAG 4.1.2). It was labelled by the menu, naming it after the option list. Point it at the visible label instead, falling back to the combobox input (searchable) or toggle button (non-searchable) when there is no label. Also fix the input/trigger no-label aria-label fallback, which set an element id string rather than the field's real aria-label. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit a47d3e0 |
… summary-in-value Replace the inaccessible chips approach (selection summary stuffed into the combobox value + each chip as a single "Remove <item>" button in a role=group) with the visually-hidden + aria-describedby announcement from PR #3351, reading out the selected values. Revert the chips to a chip + labelled × remove button. Keeping the selection out of the input value restores type-to-search and Backspace-to-chip removal, which the value summary had disabled. Update tests and the multi-select a11y doc (new dated section above the preserved history). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // Stable id for the visually hidden element that announces the current selection. | ||
| // Only needed for multi-select chips; single-select already keeps the value inside the input. | ||
| const selectedValueId = useRef(`dropdown-selected-${Math.random().toString(36).slice(2, 9)}`).current; | ||
| const selectedValueText = useMemo(() => (multi ? getSelectedValueText(selectedItems) : ""), [multi, selectedItems]); | ||
|
|
||
| // The combobox can be described by the helper text and/or the selection announcement. | ||
| const describedBy = | ||
| [helperTextId, selectedValueText ? selectedValueId : undefined].filter(Boolean).join(" ") || undefined; |
There was a problem hiding this comment.
2. Random describedby id 🐞 Bug ☼ Reliability
DropdownInput generates the hidden selection announcement element id using Math.random(), which is non-deterministic across SSR and client hydration. This can cause hydration mismatches and transiently break the aria-describedby linkage between the combobox and its description.
Agent Prompt
### Issue description
`DropdownInput` creates `selectedValueId` via `Math.random()` and uses it for the visually-hidden selection announcement referenced by `aria-describedby`. In SSR/hydration flows this produces different markup server vs client, causing hydration warnings and potentially breaking the accessibility reference until React reconciles.
### Issue Context
The repo explicitly supports SSR usage for `@vibe/core`, so DOM ids must be deterministic between server and client renders.
### Fix Focus Areas
- packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx[60-103]
- packages/core/README.md[44-57]
### Implementation notes
- Remove `Math.random()` from id creation.
- Prefer deriving the id from an existing stable identifier (e.g., the consumer-provided `id` from context): `const selectedValueId = id ? `${id}-selected-values` : undefined`.
- Only render the `<span id=...>` and include it in `aria-describedby` when `selectedValueId` is defined (or implement a deterministic counter-based id generator that is SSR-safe).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Code review by qodo was updated up to the latest commit 8b7517d |
Wrap the multi-select chips in role="group" with aria-label "selected items" so assistive tech presents them as a single named set. Add a test and a new dated section to the multi-select a11y doc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI Feedback 🧐A test triggered by this PR failed. Here is an AI-generated analysis of the failure:
|
| /** | ||
| * The ARIA role of the flex container. | ||
| */ | ||
| role?: React.AriaRole; |
There was a problem hiding this comment.
1. flexprops defined in flex.tsx 📘 Rule violation ⚙ Maintainability
The role prop was added to FlexProps inside Flex.tsx even though the component has a dedicated Flex.types.ts file. This violates the requirement that component prop interfaces live in *.types.ts, reducing consistency and maintainability.
Agent Prompt
## Issue description
`FlexProps` (including the newly added `role` prop) is declared in `Flex.tsx` instead of in `Flex.types.ts`, which breaks the standard typing convention for components.
## Issue Context
The Flex component already has `Flex.types.ts`, but it currently contains only type aliases. The props interface should be moved there and exported, and `Flex.tsx` should import it.
## Fix Focus Areas
- packages/components/layout/src/Flex/Flex.tsx[9-70]
- packages/components/layout/src/Flex/Flex.types.ts[1-20]
- packages/components/layout/src/Flex/index.ts[1-1]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| onMouseDown={onMouseDown} | ||
| style={overrideStyle} | ||
| aria-label={ariaLabel} | ||
| role={role} | ||
| > |
There was a problem hiding this comment.
2. flex root missing data-vibe 📘 Rule violation ◔ Observability
The Flex root element still does not include a [data-vibe] attribute, even as new props (role) are being added to the root. This blocks consistent component identification/instrumentation expected by the compliance rule.
Agent Prompt
## Issue description
Flex is required to render a root DOM element with a `[data-vibe]` attribute, but the root element props do not include it.
## Issue Context
The PR updates the root element props by adding `role={role}`; this is the right place to also add `data-vibe` to satisfy the required instrumentation/identification pattern.
## Fix Focus Areas
- packages/components/layout/src/Flex/Flex.tsx[140-162]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| onIsOpenChange: ({ isOpen }) => { | ||
| // Reset the text filter when the menu closes so reopening always shows the full option list, | ||
| // even though the input keeps displaying the selected item's label. | ||
| if (!isOpen) { | ||
| filterOptions(""); | ||
| } | ||
| isOpen ? onMenuClose?.() : onMenuOpen?.(); | ||
| }, |
There was a problem hiding this comment.
3. Menu callbacks inverted 🐞 Bug ≡ Correctness
In useDropdownCombobox/useDropdownMultiCombobox, onIsOpenChange calls onMenuClose when isOpen is true and onMenuOpen when isOpen is false, so consumers receive the opposite lifecycle notifications.
Agent Prompt
### Issue description
`onIsOpenChange` in the combobox hooks invokes `onMenuClose` when the menu opens and `onMenuOpen` when it closes.
### Issue Context
This is inconsistent with the non-combobox dropdown hooks and will cause consumer callbacks (analytics, form wiring, etc.) to fire on the wrong transition.
### Fix Focus Areas
- packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts[67-74]
- packages/core/src/components/Dropdown/hooks/useDropdownMultiCombobox.ts[80-84]
### Proposed change
- Replace `isOpen ? onMenuClose?.() : onMenuOpen?.();` with `isOpen ? onMenuOpen?.() : onMenuClose?.();` in both hooks (keep the filter-reset logic intact).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Code review by qodo was updated up to the latest commit ae04db8 |
Open the overflow dialog reliably on Enter (drop "enter" as a hide trigger so the keyup no longer toggles it shut), move focus to the first chip when it opens, and return focus to the +N counter when it is dismissed with Esc. Document the keyboard behavior alongside the InteractiveChipsOverflow story. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| disabled?: boolean; | ||
| readOnly?: boolean; | ||
| minVisibleCount?: number; | ||
| /** Extra props (tabIndex, onKeyDown, etc.) to spread on each visible chip container. */ | ||
| getChipContainerProps?: (item: Item, index: number) => Record<string, any>; | ||
| /** Ref forwarded to the +N overflow Chips element, for external keyboard focus management. */ | ||
| badgeRef?: React.Ref<HTMLDivElement>; | ||
| }; |
There was a problem hiding this comment.
1. multiselectedvaluesprops defined inline 📘 Rule violation ⚙ Maintainability
MultiSelectedValues prop types were expanded inside the implementation file instead of being defined in a dedicated *.types.ts file. This reduces consistency with the repo typing conventions and makes the component API harder to discover and maintain.
Agent Prompt
## Issue description
`MultiSelectedValues` props are declared and updated inline in `MultiSelectedValues.tsx` rather than being defined in a dedicated `*.types.ts` file, which conflicts with the repository typing convention.
## Issue Context
The PR adds new props (`getChipContainerProps`, `badgeRef`) to `MultiSelectedValues`, so this is a good time to align the component typing structure.
## Fix Focus Areas
- packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx[18-25]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| const DropdownInput = ({ | ||
| inputSize, | ||
| fullWidth, | ||
| onKeyDown: externalKeyDown, | ||
| inputRef: externalInputRef | ||
| }: { | ||
| inputSize?: "small" | "medium" | "large"; | ||
| fullWidth?: boolean; | ||
| onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>; | ||
| inputRef?: RefObject<HTMLInputElement>; | ||
| }) => { |
There was a problem hiding this comment.
2. dropdowninput props typed inline 📘 Rule violation ⚙ Maintainability
DropdownInput introduces/extends its props typing inline in the implementation file rather than using a colocated *.types.ts file. This diverges from the repo’s standardized typing pattern and complicates consistent reuse across the codebase.
Agent Prompt
## Issue description
`DropdownInput` defines its props type inline in `DropdownInput.tsx` (including newly added props like `onKeyDown` and `inputRef`) instead of using a dedicated `*.types.ts` file.
## Issue Context
This PR expands the component’s API surface (e.g., `onKeyDown`, `inputRef`) and updates accessibility behavior, making it an appropriate moment to standardize the typing location.
## Fix Focus Areas
- packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx[19-29]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| const renderTriggerContent = () => { | ||
| if (interactiveChips && searchable && !readOnly) { | ||
| if (selectedItems.length === 0) { | ||
| return <DropdownInput />; | ||
| } | ||
| return ( | ||
| <div | ||
| className={styles.multiWrapper} | ||
| onKeyDown={e => { | ||
| if ( | ||
| e.key === "ArrowLeft" && | ||
| e.target instanceof HTMLInputElement && | ||
| !e.target.value && | ||
| overflowBadgeRef.current | ||
| ) { | ||
| overflowBadgeRef.current.focus(); | ||
| } | ||
| }} | ||
| > | ||
| <MultiSelectedValues | ||
| disabled={disabled} | ||
| readOnly={readOnly} | ||
| selectedItems={selectedItems} | ||
| onRemove={item => contextOnOptionRemove?.(item)} | ||
| renderInput={() => <DropdownInput inputSize="small" fullWidth />} | ||
| getChipContainerProps={(item, index) => getSelectedItemProps?.({ selectedItem: item, index }) ?? {}} | ||
| badgeRef={overflowBadgeRef} | ||
| minVisibleCount={minVisibleCount} | ||
| /> |
There was a problem hiding this comment.
3. Disabled chips still focusable 🐞 Bug ≡ Correctness
interactiveChips is enabled even when disabled is true, so the input becomes non-focusable while chip containers may still receive keyboard-focus/handlers via getSelectedItemProps. This creates inconsistent disabled behavior and can leave focusable elements inside a disabled control.
Agent Prompt
### Issue description
When `interactiveChips` is enabled, the new interactive-chips branch activates for searchable multi-select even if the Dropdown is `disabled`. The input is disabled (not focusable), but chip containers may still become focusable/interactive via `getSelectedItemProps`, leading to inconsistent disabled semantics.
### Issue Context
- The interactive branch is gated by `interactiveChips && searchable && !readOnly`, but not `!disabled`.
- Chip containers spread `getSelectedItemProps` output, which is intended to add keyboard interaction props.
### Fix Focus Areas
- packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx[36-67]
- packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx[108-139]
- packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx[69-103]
### Suggested fix
- Add `!disabled` to the interactive branch condition, OR
- When `disabled`, pass `getChipContainerProps={undefined}` (or strip `tabIndex`/keydown handlers) so chips cannot receive focus while disabled.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
|
||
| addSelectedItem?: (item: Item) => void; | ||
| removeSelectedItem?: (item: Item) => void; | ||
| getSelectedItemProps?: (options: { selectedItem: any; index: number }) => Record<string, any>; |
There was a problem hiding this comment.
4. Selected item typed as any 🐞 Bug ⚙ Maintainability
DropdownContextProps defines getSelectedItemProps with selectedItem: any, which removes the generic Item type safety for the new interactive chips plumbing. This can mask type mismatches when consumers pass custom item shapes.
Agent Prompt
### Issue description
`getSelectedItemProps` is added to the Dropdown context but its signature uses `selectedItem: any`, weakening type checking across the context API.
### Issue Context
The context is already generic (`DropdownContextProps<Item extends BaseItemData<...>>`), so `selectedItem` can and should be typed as `Item`.
### Fix Focus Areas
- packages/core/src/components/Dropdown/context/DropdownContext.types.ts[76-83]
### Suggested fix
Change:
```ts
getSelectedItemProps?: (options: { selectedItem: any; index: number }) => Record<string, any>;
```
To:
```ts
getSelectedItemProps?: (options: { selectedItem: Item; index: number }) => Record<string, any>;
```
(Optionally also tighten the return type away from `any` if feasible.)
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Code review by qodo was updated up to the latest commit fab50a2 |
Referencing the listbox or the combobox input never produced a usable name (a textbox computes its name from its value, not its label). The shared chevron now uses aria-labelledby to the visible label when present, and otherwise a real aria-label string from aria-label / inputAriaLabel. Covers single and multi searchable. Update tests and the single/multi a11y docs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| expect(queryByTestId("dropdown-chip-opt1")).not.toBeInTheDocument(); | ||
| expect(getByTestId("dropdown-chip-opt3")).toBeInTheDocument(); |
There was a problem hiding this comment.
1. dropdown-chip-* test ids hardcoded 📘 Rule violation ⚙ Maintainability
The updated Dropdown tests use hard-coded data-testid strings (e.g. dropdown-chip-opt1) instead of the established test-id constants/patterns, reducing consistency and making tests more brittle to refactors. This violates the requirement to follow repository test ID patterns from the centralized constants.
Agent Prompt
## Issue description
Dropdown tests introduced/updated in this PR assert against hard-coded `data-testid` values (e.g. `dropdown-chip-opt1`). The compliance rule requires following established test-id patterns/constants to keep tests consistent and maintainable.
## Issue Context
The repo maintains centralized test-id constants in `packages/core/src/tests/constants.ts` (e.g. `ComponentDefaultTestId.DROPDOWN`, `ComponentDefaultTestId.CHIP`). Tests and/or component test-id generation should reuse these constants (or a derived, component-specific constant built from them) instead of embedding ad-hoc strings.
## Fix Focus Areas
- packages/core/src/components/Dropdown/__tests__/Dropdown.test.tsx[755-756]
- packages/core/src/tests/constants.ts[14-37]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| "aria-labelledby": label ? getLabelProps().id : undefined, | ||
| // When there is no visible label, the input must still have a name; fall back to the | ||
| // field's aria-label so the input — and the chevron that points at it — are named. | ||
| "aria-label": inputAriaLabel || (label ? undefined : ariaLabel), | ||
| "aria-describedby": describedBy, | ||
| // The menu is presented in a Dialog, so the combobox advertises a dialog popup. |
There was a problem hiding this comment.
2. Inputarialabel ignored 🐞 Bug ≡ Correctness
DropdownInput always sets aria-labelledby when label is present, so the input’s accessible name is forced to the label and inputAriaLabel cannot rename the inner combobox as documented. This breaks the documented accessibility API contract for consumers needing a distinct input name.
Agent Prompt
### Issue description
`DropdownInput` sets both `aria-labelledby` (when `label` exists) and `aria-label` (when `inputAriaLabel` exists). Because `aria-labelledby` takes precedence for accessible-name computation, `inputAriaLabel` becomes ineffective whenever a visible label is present.
### Issue Context
The docs explicitly state `inputAriaLabel` is "useful when the input needs a name distinct from the field label".
### Fix Focus Areas
- packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx[73-86]
- packages/docs/src/pages/components/Dropdown/DropdownSearchableSingleSelect.mdx[95-104]
### Proposed fix
Adjust name wiring so `inputAriaLabel` truly controls the input name:
- If `inputAriaLabel` is provided, do **not** set `aria-labelledby` on the input (or otherwise ensure the computed name uses `inputAriaLabel`).
- Otherwise, keep the current behavior: use `aria-labelledby` when `label` exists, and fall back to the field `aria-label` when it doesn’t.
Example:
```ts
const ariaLabelledBy = !inputAriaLabel && label ? getLabelProps().id : undefined;
const ariaLabel = inputAriaLabel ?? (label ? undefined : ariaLabelProp);
```
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| <div | ||
| className={styles.multiWrapper} | ||
| onKeyDown={e => { | ||
| if ( | ||
| e.key === "ArrowLeft" && | ||
| e.target instanceof HTMLInputElement && | ||
| !e.target.value && | ||
| overflowBadgeRef.current | ||
| ) { | ||
| overflowBadgeRef.current.focus(); | ||
| } | ||
| }} |
There was a problem hiding this comment.
3. Overflow arrowleft mismatch 🐞 Bug ≡ Correctness
With interactiveChips enabled and selections overflowing, ArrowLeft from the empty input moves focus to the +N overflow badge rather than the last visible chip, contradicting the documented keyboard behavior. This adds an extra navigation step and diverges from the component’s stated contract.
Agent Prompt
### Issue description
The `interactiveChips` documentation says ArrowLeft from the input moves focus to the last chip. With overflow, the current implementation focuses the `+N` badge first.
### Issue Context
- `MultiSelectTrigger` focuses `overflowBadgeRef` on ArrowLeft from an empty input.
- `MultiSelectedValues` has the necessary `itemRefs`/`visibleCount` to focus the last visible chip, but it does not handle ArrowLeft on the input wrapper.
### Fix Focus Areas
- packages/core/src/components/Dropdown/components/Trigger/MultiSelectTrigger.tsx[42-65]
- packages/core/src/components/Dropdown/components/MultiSelectedValues/MultiSelectedValues.tsx[158-213]
- packages/core/src/components/Dropdown/Dropdown.types.ts[23-28]
### Proposed fix
Move/implement the ArrowLeft handling closer to where chip refs exist:
1. Add an `onKeyDown` handler on the `renderInput` wrapper inside `MultiSelectedValues` that:
- checks for `e.key === 'ArrowLeft'` and that the actual input is empty,
- `e.preventDefault()` and focuses `itemRefs[visibleCount - 1].current`.
2. Remove (or narrow) the `MultiSelectTrigger` ArrowLeft handler that focuses the overflow badge.
This makes the behavior match the documented contract while still allowing access to the overflow badge via normal navigation.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Code review by qodo was updated up to the latest commit b78733a |
Focus never leaves the input, so Space would type a literal space and corrupt the filter text. On Space, when an option is highlighted (aria-activedescendant set — the user arrowed to it), prevent the default and select it via downshift's selectItem; while typing (no highlight) Space types normally. Handled natively in the getInputProps keydown for both single and multi. Tests + a11y docs added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge the multiple "What changed — 2026-06-30" subsections on the single- and multi-select accessibility pages into a single dated section with a list of all fixes. No content changes beyond the regrouping; older dated sections untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // Builds the screen-reader announcement of the current multi-select selection (the chip labels), | ||
| // surfaced to the combobox via aria-describedby + a visually hidden element. This is how the selected | ||
| // chips are made accessible without depending on the chip buttons themselves carrying the semantics. | ||
| function getSelectedValueText(selectedItems: BaseItemData[]): string { | ||
| return selectedItems | ||
| .map(item => item.label || item.value || "") | ||
| .filter(Boolean) | ||
| .join(", "); | ||
| } |
There was a problem hiding this comment.
1. Selection text drops 0 🐞 Bug ≡ Correctness
DropdownInput.getSelectedValueText uses truthy fallbacks (item.label || item.value || "" + filter(Boolean)), so a valid numeric value like 0 is treated as falsy and omitted from the selection announcement. This can cause the multi-select selection to be announced incorrectly (or as blank) for items with an empty/missing label and value 0.
Agent Prompt
## Issue description
`getSelectedValueText()` currently relies on truthiness (`||` + `filter(Boolean)`), which drops legitimate values like numeric `0` from the computed screen-reader announcement.
## Issue Context
- `BaseItemData.value` is `string | number`, so `0` is a valid value.
- The selection announcement is used as text content of a visually-hidden element referenced by `aria-describedby`.
## Fix Focus Areas
- packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx[9-17]
- packages/core/src/components/BaseItem/BaseItem.types.ts[60-69]
## Suggested fix
- Prefer label when present, but use nullish coalescing instead of `||`, and always stringify numeric values:
- e.g. `const text = item.label ?? String(item.value ?? "")`
- then filter using `text.length > 0` (not `Boolean(text)`), or keep `0` explicitly.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| getInputProps: (options?: Parameters<typeof getInputProps>[0]) => | ||
| getInputProps({ | ||
| ...options, | ||
| ref: inputRef, | ||
| onKeyDown: event => { |
There was a problem hiding this comment.
2. Inputref prop ignored 🐞 Bug ≡ Correctness
DropdownInput accepts an inputRef prop and passes it to Downshift via getInputProps, but useDropdownCombobox overwrites any provided ref with its internal inputRef. As a result, consumer refs never receive the input element in searchable single-select, breaking focus/measurement integrations.
Agent Prompt
## Issue description
`DropdownInput` now supports `inputRef`, but in the searchable single-select path the hook wrapper unconditionally sets `ref: inputRef`, overriding the ref provided by the caller. This makes `inputRef` ineffective.
## Issue Context
- `DropdownInput` passes a `ref` via `getInputProps({ ref: inputRef })`.
- `useDropdownCombobox` returns a wrapped `getInputProps` that always sets `ref: inputRef`.
## Fix Focus Areas
- packages/core/src/components/Dropdown/components/Trigger/DropdownInput.tsx[19-54]
- packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts[24-26]
- packages/core/src/components/Dropdown/hooks/useDropdownCombobox.ts[142-159]
## Suggested fix
- Merge the internal and external refs instead of overwriting:
- If `options?.ref` is a function, call it with the node.
- If it is a ref object, assign to `.current`.
- Always keep `inputRef.current` updated for internal focus management.
- Alternatively, change the hook API to accept an external ref and merge at the hook level.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Code review by qodo was updated up to the latest commit c7b27a7 |
Problem
The searchable single-select combobox forced the input value to
nullafter every selection and rendered the selected label as a visual overlay on top of an empty input.Screen readers read what's inside the input — not the overlay. So a combobox with an active selection announced as "Edit combo, collapsed, blank" (JAWS). The user had no way to know what they'd selected.
This fails WCAG 2.1 SC 4.1.2 — Name, Role, Value (Level A), which requires the current value of a form control to be programmatically determinable.
Fix
Stop overriding Downshift's default so the selected item's label lives inside the input, where assistive technologies can read it. The selected value is no longer a separate visual layer.
Supporting changes keep the component's existing behavior intact:
initialInputValueseeded with the selected label, so adefaultValue/controlledvalueis visible on mount (previously the overlay handled this).onInputValueChangefilters the list only on real user typing (InputChange), ignoring the label Downshift writes into the input on selection/blur.onIsOpenChangeresets the filter when the menu closes, so reopening shows the full option list — matching the component's prior behavior and the documented combobox pattern (same approach Chakra v3 / Ark UI recommend: reset the filter on open).The overlay in
SingleSelectTriggernow renders only for non-searchable single select (whereinputValuestaysnull), so that path is unaffected. Multi-select uses a separate hook and is untouched.Behavior parity
Aligned with Chakra v3 (Ark UI / Zag.js):
aria-selected+ activedescendant on selected option at openreset())Storybook
Adds a dedicated Components/Dropdown/Searchable single select page documenting all searchable single-select variants: overview, sizes, states, default/controlled value, icons & avatars, groups (sticky titles & dividers), tooltips, clearable / max-height, and custom filter / empty message. The default-value story demonstrates the selected value living inside the input.
Tests
searchable: false, since the indent-stripping overlay logic now applies only to non-searchable single select.Test plan
defaultValue/ controlledvalueshows the label in the input on mount🤖 Generated with Claude Code