Conversation
Deploying ui with
|
| Latest commit: |
7f956f0
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://0f331210.ui-6d0.pages.dev |
| Branch Preview URL: | https://components-for-datavis-nitro.ui-6d0.pages.dev |
There was a problem hiding this comment.
Pull request overview
Adds new Dropdown capabilities to support DataVis NITRO UI needs (notably searchable and multi-select dropdown behaviors), along with supporting tests/stories and improved jsdom test environment setup.
Changes:
- Extend
Dropdownwith searchable filtering UI and multi-select (checkbox + select-all) behavior. - Add Vitest coverage for searchable and multi-select dropdown behaviors.
- Add a
localStoragemock + per-test cleanup in jsdom test setup.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/test/setup.ts |
Adds localStorage mocking logic and clears storage between tests. |
src/components/Dropdown/Dropdown.tsx |
Implements searchable dropdown rendering/filtering and multi-select + select-all support. |
src/components/Dropdown/Dropdown.test.tsx |
Adds unit tests covering search filtering, empty state, multi-select, and select-all behaviors. |
src/components/Dropdown/Dropdown.stories.tsx |
Adds Storybook examples for searchable and multi-select dropdown variants. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ( | ||
| typeof window.localStorage === 'undefined' || | ||
| typeof window.localStorage.getItem !== 'function' | ||
| ) { | ||
| const storage = new Map<string, string>(); | ||
| const localStorageMock: Storage = { | ||
| get length() { | ||
| return storage.size; | ||
| }, | ||
| clear: () => { | ||
| storage.clear(); | ||
| }, | ||
| getItem: (key: string) => storage.get(key) ?? null, | ||
| key: (index: number) => Array.from(storage.keys())[index] ?? null, | ||
| removeItem: (key: string) => { | ||
| storage.delete(key); | ||
| }, | ||
| setItem: (key: string, value: string) => { | ||
| storage.set(key, String(value)); | ||
| }, | ||
| }; | ||
|
|
||
| Object.defineProperty(window, 'localStorage', { | ||
| configurable: true, | ||
| value: localStorageMock, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Accessing window.localStorage can throw a SecurityError in jsdom when the origin is opaque (default about:blank). The current typeof window.localStorage... check will still evaluate window.localStorage and can fail before the mock is installed, and the unconditional window.localStorage.clear() in afterEach can throw for the same reason. Wrap the availability check in a try/catch (e.g., attempt a getItem), install the mock when access fails, and guard/try-catch the clear() in afterEach so the test setup is robust across environments.
| // Cleanup after each test case (e.g. clearing jsdom) | ||
| afterEach(() => { | ||
| window.localStorage.clear(); | ||
| cleanup(); |
There was a problem hiding this comment.
window.localStorage.clear() is called unconditionally. If localStorage is unavailable or throws in this jsdom configuration, this will fail the test run. After making the localStorage setup robust (e.g., via a try/catch mock), also guard this call (or wrap it in try/catch) so cleanup cannot throw.
| (isMultiSelectItem | ||
| ? dropdownContext.selectedValues.includes(itemValue) | ||
| : false); | ||
|
|
||
| const handleClick = React.useCallback( | ||
| (event: React.MouseEvent<HTMLButtonElement>) => { | ||
| if (isMultiSelectItem && itemValue && !disabled) { | ||
| dropdownContext.toggleSelectedValue(itemValue); |
There was a problem hiding this comment.
dropdownContext is nullable (DropdownContextValue | null), but dropdownContext.selectedValues and dropdownContext.toggleSelectedValue are accessed unconditionally. With strict TS this is a compile-time error, and at runtime it will crash if DropdownItem is ever rendered outside a DropdownContext.Provider (e.g., if someone uses DropdownItem standalone or in tests). Fix by guarding on dropdownContext before reading it (or throwing a clear error), and only treating the item as multi-select when dropdownContext?.multiSelect is true. Also ensure itemValue is a string before passing it to includes() to satisfy strict typing.
| (isMultiSelectItem | |
| ? dropdownContext.selectedValues.includes(itemValue) | |
| : false); | |
| const handleClick = React.useCallback( | |
| (event: React.MouseEvent<HTMLButtonElement>) => { | |
| if (isMultiSelectItem && itemValue && !disabled) { | |
| dropdownContext.toggleSelectedValue(itemValue); | |
| (isMultiSelectItem && dropdownContext && itemValue !== undefined | |
| ? dropdownContext.selectedValues.includes(itemValue) | |
| : false); | |
| const handleClick = React.useCallback( | |
| (event: React.MouseEvent<HTMLButtonElement>) => { | |
| if (isMultiSelectItem && itemValue && !disabled) { | |
| if (dropdownContext) { | |
| dropdownContext.toggleSelectedValue(itemValue); | |
| } |
| <div | ||
| id={menuId} | ||
| role="menu" | ||
| style={widthStyle} | ||
| className={cn( | ||
| 'absolute z-50 min-w-[12rem]', | ||
| 'rounded-xl border border-neutral-200 bg-white shadow-lg', | ||
| 'dark:border-neutral-700 dark:bg-neutral-800', | ||
| 'animate-in fade-in zoom-in-95 duration-100', | ||
| placementStyles[placement], | ||
| className | ||
| )} | ||
| > | ||
| {searchable && ( | ||
| <div className="border-b border-neutral-200 p-2 dark:border-neutral-700"> | ||
| <input | ||
| ref={searchInputRef} | ||
| type="search" | ||
| value={searchQuery} | ||
| onChange={(event) => setSearchQuery(event.target.value)} | ||
| placeholder={searchPlaceholder} | ||
| aria-label={searchAriaLabel} | ||
| className={cn( | ||
| inputVariants({ size: 'sm' }), | ||
| 'text-sm', | ||
| 'dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' | ||
| )} | ||
| /> | ||
| </div> |
There was a problem hiding this comment.
When searchable is enabled, an <input type="search"> is rendered inside the element with role="menu". Per ARIA menu patterns, a menu should generally contain only menuitem / menuitemcheckbox / menuitemradio (and separators), and embedding a textbox can confuse screen readers and keyboard expectations. Consider switching the popup role to a more appropriate pattern when searchable (e.g., role="listbox"/combobox, or role="dialog" with an internal list), or otherwise keep the search input outside the role="menu" container and ensure keyboard navigation remains accessible.
9f3aab1 to
18b4fc3
Compare
18b4fc3 to
7f956f0
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ( | ||
| dropdownContext && | ||
| isMultiSelectItem && | ||
| itemValue !== undefined && | ||
| !disabled | ||
| ) { | ||
| dropdownContext.toggleSelectedValue(itemValue); | ||
| onCheckedChange?.(!isChecked); |
There was a problem hiding this comment.
onCheckedChange is only fired when a DropdownContext exists and the item has a string value. If a consumer uses checked/indeterminate + onCheckedChange (which the props API now allows) without providing a value or without multiSelect on the parent, clicks will never call onCheckedChange, so the item can’t be toggled. Consider firing onCheckedChange whenever isMultiSelectItem and not disabled, and separately toggling context selection only when dropdownContext + itemValue are present.
| if ( | |
| dropdownContext && | |
| isMultiSelectItem && | |
| itemValue !== undefined && | |
| !disabled | |
| ) { | |
| dropdownContext.toggleSelectedValue(itemValue); | |
| onCheckedChange?.(!isChecked); | |
| if (disabled) { | |
| return; | |
| } | |
| const nextChecked = !isChecked; | |
| if (isMultiSelectItem) { | |
| onCheckedChange?.(nextChecked); | |
| } | |
| if (dropdownContext && itemValue !== undefined && isMultiSelectItem) { | |
| dropdownContext.toggleSelectedValue(itemValue); |
| type="button" | ||
| role={isMultiSelectItem ? 'menuitemcheckbox' : 'menuitem'} | ||
| aria-checked={ | ||
| isMultiSelectItem ? (indeterminate ? 'mixed' : isChecked) : undefined | ||
| } | ||
| disabled={disabled} |
There was a problem hiding this comment.
Because {...props} is spread after type, role, and aria-checked, a consumer can accidentally override these accessibility-critical attributes via props. To ensure consistent semantics, spread props earlier (before type/role/aria-*) or omit/lock down those attributes in DropdownItemProps (e.g., Omit role/type/onClick) so they can’t be overridden.
| <div | ||
| id={menuId} | ||
| role="menu" | ||
| style={widthStyle} |
There was a problem hiding this comment.
When searchable is enabled the menu renders an interactive <input type="search"> inside an element with role="menu". Per ARIA menu patterns, role="menu" is expected to contain only menuitem* descendants; mixing in a searchbox can lead to confusing screen reader/keyboard behavior. Consider switching to a combobox/listbox pattern for searchable dropdowns (or adjust roles/structure so the search input is not inside the menu role container).
I'm adding some UI widgets that are needed to support the parts of DataVis NITRO that aren't yet using mieweb/ui components — such as searchable dropdowns.