From 2468474b64026c38be19a2e50772fdf224886b7d Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Fri, 5 Jan 2024 11:49:58 -0500 Subject: [PATCH] APP-3126: fix searchable select value and focus issues (#459) --- packages/core/package.json | 2 +- packages/core/src/lib/index.ts | 1 + packages/core/src/lib/keyboard.ts | 26 + .../__tests__/searchable-select.spec.ts | 558 ++++++++++++------ .../src/lib/select/searchable-select.svelte | 409 +++++++------ .../core/src/lib/select/select-input.svelte | 4 +- packages/core/src/routes/+page.svelte | 42 +- packages/storybook/src/stories/select.mdx | 4 + .../src/stories/select.stories.svelte | 23 +- 9 files changed, 669 insertions(+), 400 deletions(-) create mode 100644 packages/core/src/lib/keyboard.ts diff --git a/packages/core/package.json b/packages/core/package.json index 23f7a252..4204925c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@viamrobotics/prime-core", - "version": "0.0.76", + "version": "0.0.77", "publishConfig": { "access": "public" }, diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index e4a49577..645bd7ea 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -81,6 +81,7 @@ export { type TooltipVisibility, } from './tooltip'; +export * from './keyboard'; export { useTimeout } from './use-timeout'; export { uniqueId } from './unique-id'; export { default as VectorInput } from './vector-input.svelte'; diff --git a/packages/core/src/lib/keyboard.ts b/packages/core/src/lib/keyboard.ts new file mode 100644 index 00000000..fd77f282 --- /dev/null +++ b/packages/core/src/lib/keyboard.ts @@ -0,0 +1,26 @@ +export type KeyMap = Partial>; + +export type KeyEventHandler = (event: KeyboardEvent) => unknown; + +export interface KeyOptions { + handler: KeyEventHandler; + preventDefault?: boolean; +} + +export const createHandleKey = (keyMap: KeyMap) => { + return (event: KeyboardEvent): void => { + const options = keyMap[event.key]; + const handler = typeof options === 'function' ? options : options?.handler; + const preventDefault = + typeof options === 'object' ? options.preventDefault : true; + + if (typeof handler === 'function') { + handler(event); + + if (preventDefault) { + event.preventDefault(); + event.stopPropagation(); + } + } + }; +}; diff --git a/packages/core/src/lib/select/__tests__/searchable-select.spec.ts b/packages/core/src/lib/select/__tests__/searchable-select.spec.ts index b6b4c93c..e6a91e7a 100644 --- a/packages/core/src/lib/select/__tests__/searchable-select.spec.ts +++ b/packages/core/src/lib/select/__tests__/searchable-select.spec.ts @@ -1,282 +1,474 @@ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import SearchableSelect from '../searchable-select.svelte'; -import { cxTestArguments, cxTestResults } from '$lib/__tests__/cx-test'; +import { describe, expect, it, vi } from 'vitest'; +import { act, render, screen, within } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; +import type { ComponentProps } from 'svelte'; -describe('SearchableSelect', () => { - const options = [ - 'First Option', - 'Option 2', - 'C.) Option', - 'Something Else', - 'With A Whole Lot Of Parts', - ]; +import { SearchableSelect as Subject, InputStates } from '$lib'; - const common = { placeholder: 'Select an option', options }; +const onChange = vi.fn(); +const onFocus = vi.fn(); +const onBlur = vi.fn(); - it('Renders the select input', () => { - render(SearchableSelect, common); +const renderSubject = (props: Partial> = {}) => { + return render(Subject, { + options: ['hello from', 'the other side'], + onChange, + onFocus, + onBlur, + ...props, + }); +}; + +const getResults = (): { + search: HTMLElement; + button: HTMLElement; + list: HTMLElement; + options: HTMLElement[]; +} => { + const search = screen.getByRole('combobox'); + const button = screen.getByRole('button'); + const list = screen.getByRole('listbox'); + const options = within(list).queryAllByRole('option'); + + return { search, button, list, options }; +}; + +describe('combobox list', () => { + it('controls a listbox', () => { + renderSubject(); + + const { search, button, list } = getResults(); + + expect(list).toHaveAttribute('id', expect.any(String)); + expect(button).toHaveAttribute('aria-controls', list.id); + expect(search).toHaveAttribute('aria-controls', list.id); + expect(search).toHaveAttribute('aria-autocomplete', 'list'); + }); - const select = screen.getByPlaceholderText('Select an option'); + it('has a placeholder', () => { + renderSubject({ placeholder: "It's me" }); - expect(select).toHaveClass( - 'h-7.5 w-full grow appearance-none border py-1.5 pl-2 pr-1 text-xs leading-tight outline-none' - ); + expect(getResults().search).toBe(screen.getByPlaceholderText("It's me")); }); - it('Renders the select as disabled', () => { - render(SearchableSelect, { - ...common, + it.each([ + { + state: InputStates.NONE, + disabled: false, + classNames: ['border-light', 'bg-white', 'focus:border-gray-9'], + }, + { + state: InputStates.WARN, + disabled: false, + classNames: [ + 'border-warning-bright', + 'bg-white', + 'focus:outline-warning-bright', + ], + }, + { + state: InputStates.ERROR, + disabled: false, + classNames: [ + 'border-danger-dark', + 'bg-white', + 'focus:outline-danger-dark', + ], + }, + { + state: InputStates.NONE, disabled: true, - }); + classNames: [ + 'cursor-not-allowed', + 'border-disabled-light', + 'bg-disabled-light', + 'focus:border-disabled-dark', + ], + }, + ])( + 'displays state=$state, disabled=$disabled', + ({ state, disabled, classNames }) => { + renderSubject({ state, disabled }); + + expect(getResults().search).toHaveClass(...classNames); + } + ); + + it('expands the listbox on focus', async () => { + const user = userEvent.setup(); + renderSubject(); + + const { search, button, list } = getResults(); + + expect(list).toHaveClass('hidden'); + expect(search).toHaveAttribute('aria-expanded', 'false'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + + await user.keyboard('{Tab}'); + + expect(onFocus).toHaveBeenCalledOnce(); + expect(search).toHaveFocus(); + expect(list).not.toHaveClass('hidden'); + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(search).toHaveAttribute('aria-expanded', 'true'); + }); + + it('expands the listbox on button click', async () => { + renderSubject(); - const select = screen.getByPlaceholderText('Select an option'); + const { search, button } = getResults(); - expect(select).toHaveClass( - 'bg-disabled-light text-disabled-dark border-disabled-light cursor-not-allowed' - ); + // TODO(mc, 2024-02-03): replace button.click with userEvent + // https://github.com/testing-library/user-event/issues/1119 + await act(() => button.click()); - expect(select).toHaveAttribute('aria-disabled', 'true'); + expect(search).toHaveFocus(); + expect(search).toHaveAttribute('aria-expanded', 'true'); }); - it('Renders the select in the warn state', () => { - render(SearchableSelect, { - ...common, - state: 'warn', - }); + it('does not expand the listbox if disabled', async () => { + const user = userEvent.setup(); + renderSubject({ disabled: true }); - const select = screen.getByPlaceholderText('Select an option'); + const { search } = getResults(); + await user.keyboard('{Tab}'); - expect(select).toHaveClass( - 'border-warning-bright focus:outline-warning-bright focus:outline-[1.5px] focus:-outline-offset-1' - ); + expect(onFocus).toHaveBeenCalledOnce(); + expect(search).toHaveFocus(); + expect(search).toHaveAttribute('aria-expanded', 'false'); }); - it('Renders the select in the error state', () => { - render(SearchableSelect, { - ...common, - state: 'error', - }); + it('closes the listbox if no options', async () => { + const user = userEvent.setup(); + renderSubject({ exclusive: true, sort: 'reduce' }); - const select = screen.getByPlaceholderText('Select an option'); + const { search } = getResults(); + await user.type(search, 'asdf'); - expect(select).toHaveClass( - 'border-danger-dark focus:outline-danger-dark focus:outline-[1.5px] focus:-outline-offset-1' - ); + expect(search).toHaveFocus(); + expect(search).toHaveAttribute('aria-expanded', 'false'); }); - it('Renders the select heading', () => { - render(SearchableSelect, { - ...common, - heading: 'Test Heading', - }); + it('closes the listbox on second button click', async () => { + renderSubject(); - const heading = screen.getByText('Test Heading'); + const { search, button } = getResults(); - expect(heading).toHaveClass( - 'text-default flex flex-wrap py-1 pl-2 text-xs' - ); + // TODO(mc, 2024-02-03): replace button.click with userEvent + // https://github.com/testing-library/user-event/issues/1119 + await act(() => button.click()); + await act(() => button.click()); + + expect(search).toHaveFocus(); + expect(search).toHaveAttribute('aria-expanded', 'false'); }); - it('Renders the select button', async () => { - const onButtonClick = vi.fn(); - const { component } = render(SearchableSelect, { - ...common, - button: { text: 'Test Button', icon: 'alert' }, - }); + it('collapses the listbox on blur', async () => { + const user = userEvent.setup(); + renderSubject(); + + const { search } = getResults(); - component.$on('buttonclick', onButtonClick); + await user.keyboard('{Tab}{Tab}'); - const button = screen.getByText('Test Button'); + expect(onBlur).toHaveBeenCalledOnce(); + expect(search).not.toHaveFocus(); + expect(search).toHaveAttribute('aria-expanded', 'false'); + }); - expect(button).toHaveClass('pl-1.5'); - expect(button.parentElement).toHaveClass( - 'hover:bg-light border-light flex h-7.5 w-full items-center border-t px-2 py-1 text-xs' - ); + it('has options', () => { + renderSubject(); - await userEvent.click(button); + const { search, options } = getResults(); - expect(onButtonClick).toHaveBeenCalled(); + expect(options).toHaveLength(2); + expect(options[0]).toHaveAccessibleName('hello from'); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAccessibleName('the other side'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); + expect(search).not.toHaveAttribute('aria-activedescendant'); }); - it('Sorts results with a match at the start of a word', async () => { - render(SearchableSelect, common); - - const select: HTMLInputElement = - screen.getByPlaceholderText('Select an option'); - const menu = screen.getByRole('menu'); + it('selects a clicked option', async () => { + const user = userEvent.setup(); + renderSubject(); - select.focus(); - await userEvent.type(select, 'C.)'); + const { search, options } = getResults(); - expect(menu.children[0]?.textContent?.trim()).toBe('C.) Option'); + await user.click(search); + // TODO(mc, 2024-02-03): replace .click with userEvent + // https://github.com/testing-library/user-event/issues/1119 + await act(() => options[0]?.click()); - await userEvent.type(select, 'Opt'); + expect(search).toHaveFocus(); + expect(onChange).toHaveBeenCalledWith('hello from'); + expect(search).toHaveValue('hello from'); + expect(search).toHaveAttribute('aria-expanded', 'false'); + expect(search).not.toHaveAttribute('aria-activedescendant'); + }); - expect(menu.children[0]?.textContent?.trim()).toBe('First Option'); + it('auto-selects search result on Enter', async () => { + const user = userEvent.setup(); + renderSubject(); + + const { search } = getResults(); + await user.type(search, 'the other'); + const { options } = getResults(); + + expect(options[0]).toHaveAccessibleName('the other side'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[0]).toHaveAttribute('id', expect.any(String)); + expect(options[1]).toHaveAccessibleName('hello from'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).not.toHaveAttribute('id', options[0]?.id); + expect(search).toHaveAttribute('aria-activedescendant', options[0]?.id); + + await user.keyboard('{Enter}'); + + expect(onChange).toHaveBeenCalledWith('the other side'); + expect(search).toHaveValue('the other side'); + expect(search).toHaveAttribute('aria-expanded', 'false'); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); + expect(search).not.toHaveAttribute('aria-activedescendant'); }); - it('Sorts results with a match below matches at a start of the word', async () => { - render(SearchableSelect, common); + it('auto-selects search result on blur', async () => { + const user = userEvent.setup(); + renderSubject(); + + const { search } = getResults(); + await user.type(search, 'the other'); + await user.keyboard('{Tab}'); - const select: HTMLInputElement = - screen.getByPlaceholderText('Select an option'); - const menu = screen.getByRole('menu'); + expect(onChange).toHaveBeenCalledWith('the other side'); + }); - select.focus(); - await userEvent.type(select, 'l'); + it('does not send multiple change events on blur', async () => { + const user = userEvent.setup(); + renderSubject(); - expect(menu.children[0]?.textContent?.trim()).toBe( - 'With A Whole L ot Of Parts' - ); + await user.keyboard('{Tab}hello{Enter}{Tab}'); - expect(menu.children[1]?.textContent?.trim()).toBe('Something E l se'); + expect(onChange).toHaveBeenCalledOnce(); }); - it('Filters out options without a match when reduce is true', async () => { - render(SearchableSelect, { ...common, sort: 'reduce' }); + it('keeps input value if menu closed on blur', async () => { + const user = userEvent.setup(); + renderSubject(); + + const { search } = getResults(); + await user.type(search, 'the other'); + await user.keyboard('{Escape}{Tab}'); - const select: HTMLInputElement = - screen.getByPlaceholderText('Select an option'); - const menu = screen.getByRole('menu'); + expect(onChange).toHaveBeenCalledWith('the other'); + }); + + it('has an "other" option when not exclusive', async () => { + const user = userEvent.setup(); + renderSubject(); - select.focus(); - await userEvent.type(select, 'C.)'); + const { search } = getResults(); + await user.type(search, 'hello'); + const { options } = getResults(); - expect(menu.children[0]?.textContent?.trim()).toBe('C.) Option'); - expect(menu.children.length).toBe(1); + expect(options).toHaveLength(3); + expect(options[0]).toHaveAccessibleName('hello from'); + expect(options[1]).toHaveAccessibleName('the other side'); + expect(options[2]).toHaveAccessibleName('hello'); + expect(options[2]).toHaveAttribute('aria-selected', 'false'); }); - it('Just highlights but does not filter or sort when sort is off', async () => { - render(SearchableSelect, { ...common, sort: 'off' }); + it('sets an "other" option as active when no search matches', async () => { + const user = userEvent.setup(); + renderSubject(); - const select: HTMLInputElement = - screen.getByPlaceholderText('Select an option'); - const menu = screen.getByRole('menu'); + const { search } = getResults(); + await user.type(search, 'asdf'); + const { options } = getResults(); + + expect(options[2]).toHaveAccessibleName('asdf'); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + expect(search).toHaveAttribute('aria-activedescendant', options[2]?.id); + }); - select.focus(); - await userEvent.type(select, 'C.)'); + it('has no "other" option when value empty', () => { + renderSubject(); - expect(menu.children[0]?.textContent?.trim()).toBe('First Option'); - expect(menu.children[1]?.textContent?.trim()).toBe('Option 2'); - expect(menu.children[2]?.textContent?.trim()).toBe('C.) Option'); - expect(menu.children.length).toBe(5); + const { options } = getResults(); + + expect(options).toHaveLength(2); }); - it('Selects an option with click', async () => { - render(SearchableSelect, common); + it('has no "other" option when value matches', async () => { + const user = userEvent.setup(); + renderSubject(); + + const { search } = getResults(); + await user.type(search, 'hello from'); + const { options } = getResults(); + + expect(options).toHaveLength(2); + }); - const select: HTMLInputElement = - screen.getByPlaceholderText('Select an option'); + it('has no "other" option when exclusive', async () => { + const user = userEvent.setup(); + renderSubject({ exclusive: true }); - select.focus(); - await userEvent.click(screen.getAllByRole('menuitem')[2]!); + const { search } = getResults(); + await user.type(search, 'hello'); + const { options } = getResults(); - expect(select.value).toBe('C.) Option'); + expect(options).toHaveLength(2); }); - it('Navigates to and selects an option with enter', async () => { - render(SearchableSelect, common); + it('has an "other" option when value matches exclusivity function', async () => { + const user = userEvent.setup(); + renderSubject({ exclusive: (value: string) => value === 'hello' }); - const select: HTMLInputElement = - screen.getByPlaceholderText('Select an option'); + const { search } = getResults(); + await user.type(search, 'hello'); + const { options } = getResults(); - select.focus(); - await userEvent.keyboard('[ArrowDown]'); - await userEvent.keyboard('[ArrowDown]'); - await userEvent.keyboard('[ArrowDown]'); - await userEvent.keyboard('[Enter]'); + expect(options).toHaveLength(3); + }); + + it('adds a prefix to the "other" option display text', async () => { + const user = userEvent.setup(); + renderSubject({ otherOptionPrefix: 'You said:' }); - expect(select.value).toBe('C.) Option'); + const { search } = getResults(); + await user.type(search, 'hello'); + const { options } = getResults(); + + expect(options[2]).toHaveAccessibleName('You said: hello'); }); - it('Navigates through the list', async () => { - render(SearchableSelect, common); + it('empties input value if closed and exclusive', async () => { + const user = userEvent.setup(); + renderSubject({ exclusive: true }); - const select: HTMLInputElement = - screen.getByPlaceholderText('Select an option'); - const menuItems = screen.getAllByRole('menuitem'); + const { search } = getResults(); + await user.type(search, 'hello'); + await user.keyboard('{Escape}{Tab}'); - select.focus(); - await userEvent.keyboard('[ArrowDown]'); + expect(onChange).toHaveBeenCalledWith(''); + }); - expect(menuItems[0]).toHaveClass('bg-light'); + it('closes listbox on escape', async () => { + const user = userEvent.setup(); + renderSubject(); - await userEvent.keyboard('[ArrowDown]'); - await userEvent.keyboard('[ArrowDown]'); + const { search } = getResults(); + await user.type(search, 'the other'); + await user.keyboard('{Escape}'); - expect(menuItems[2]).toHaveClass('bg-light'); + expect(search).toHaveAttribute('aria-expanded', 'false'); + expect(search).toHaveValue('the other'); + }); - await userEvent.keyboard('[ArrowDown]'); - await userEvent.keyboard('[ArrowDown]'); + it('resets input after closing on escape', async () => { + const user = userEvent.setup(); + renderSubject(); - expect(menuItems[4]).toHaveClass('bg-light'); + const { search } = getResults(); - await userEvent.keyboard('[ArrowDown]'); + await user.type(search, 'the other'); + await user.keyboard('{Escape}{Escape}'); - expect(menuItems[0]).toHaveClass('bg-light'); + expect(search).toHaveValue(''); + }); - await userEvent.keyboard('[ArrowUp]'); + it('reopens listbox on more typing', async () => { + const user = userEvent.setup(); + renderSubject(); - expect(menuItems[4]).toHaveClass('bg-light'); + const { search, options } = getResults(); - await userEvent.keyboard('[ArrowUp]'); - await userEvent.keyboard('[ArrowUp]'); - await userEvent.keyboard('[Enter]'); + await user.type(search, 'the other'); + await user.keyboard('{Escape} side'); - expect(select.value).toBe('C.) Option'); + expect(search).toHaveAttribute('aria-expanded', 'true'); + expect(search).toHaveAttribute('aria-activedescendant', options[1]?.id); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); }); - it('Closes the menu on escape', async () => { - render(SearchableSelect, common); + it('moves visual focus to options on arrow keys', async () => { + const user = userEvent.setup(); + renderSubject(); - const select: HTMLInputElement = - screen.getByPlaceholderText('Select an option'); - const menu = screen.getByRole('menu'); - const menuItems = screen.getAllByRole('menuitem'); + const { search, options } = getResults(); + await user.keyboard('{Tab}'); - expect(menu.parentElement).toHaveClass('invisible'); + await user.keyboard('{ArrowDown}'); + expect(search).toHaveAttribute('aria-activedescendant', options[0]?.id); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); - select.focus(); - await userEvent.keyboard('[ArrowDown]'); + await user.keyboard('{ArrowDown}'); + expect(search).toHaveAttribute('aria-activedescendant', options[1]?.id); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); - expect(menuItems[0]).toHaveClass('bg-light'); + await user.keyboard('{ArrowDown}'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); - await userEvent.keyboard('[Escape]'); + await user.keyboard('{ArrowUp}'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); - expect(menu.parentElement).toHaveClass('invisible'); + await user.keyboard('{ArrowUp}'); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); }); - it('Closes the menu on tab', async () => { - render(SearchableSelect, common); + it('sets cursor with home and end', async () => { + const user = userEvent.setup(); + renderSubject(); - const select: HTMLInputElement = - screen.getByPlaceholderText('Select an option'); - const menu = screen.getByRole('menu'); - const menuItems = screen.getAllByRole('menuitem'); + const { search } = getResults(); + await user.type(search, 'e'); + await user.keyboard('{Home}h'); - expect(menu.parentElement).toHaveClass('invisible'); + expect(search).toHaveValue('he'); - select.focus(); - await userEvent.keyboard('[ArrowDown]'); + await user.keyboard('{End}llo'); + expect(search).toHaveValue('hello'); + }); - expect(menuItems[0]).toHaveClass('bg-light'); + it('opens listbox with alt+down arrow without changing selected state', async () => { + const user = userEvent.setup(); + renderSubject(); - await userEvent.keyboard('[Tab]'); + const { search, options } = getResults(); + await user.keyboard('{Tab}{Alt>}{ArrowDown}{/Alt}'); - expect(menu.parentElement).toHaveClass('invisible'); + expect(search).toHaveAttribute('aria-expanded', 'true'); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); }); - it('Renders with the passed cx classes', () => { - render(SearchableSelect, { - ...common, - cx: cxTestArguments, - }); + it.each(['{Home}', '{End}', '{ArrowRight}', '{ArrowLeft}'])( + 'moves visual focus to input and resets highlight with %s', + async (key) => { + const user = userEvent.setup(); + renderSubject(); + + const { search, options } = getResults(); + await user.type(search, 'hello'); + await user.keyboard(`{ArrowDown}${key}`); + + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + } + ); + + it('resets selected item on close', async () => { + const user = userEvent.setup(); + renderSubject(); + + const { search, options } = getResults(); + await user.type(search, 'hello'); + await user.keyboard('{Escape}{ArrowDown}'); - expect( - screen.getByPlaceholderText('Select an option').parentElement - ?.parentElement - ).toHaveClass(cxTestResults); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); }); }); diff --git a/packages/core/src/lib/select/searchable-select.svelte b/packages/core/src/lib/select/searchable-select.svelte index 685e72b9..79f091ec 100644 --- a/packages/core/src/lib/select/searchable-select.svelte +++ b/packages/core/src/lib/select/searchable-select.svelte @@ -1,241 +1,268 @@ - - -
+ - handleFocus(disabled)} - on:mousemove={() => ($isKeyboardControlling = false)} - on:click={() => ($isOpen ? close() : handleFocus(disabled))} - /> - - {#if !disabled} - - {#if heading} +
    + {#each allOptions as { option, highlight } (option)} + {@const isSelected = activeOption?.option === option} + {@const isOther = otherOption?.option === option} + + {#if isOther && allOptions.length > 1} - {/if} - - {#if searchedOptions.length > 0} - {#each searchedOptions as { highlight, option }, index (option)} -
  • - -
  • - {/each} - {:else} -
  • - No matching results -
  • - {/if} - - {#if button !== undefined} - handleOptionFocus(searchedOptions.length)} - on:keydown={(event) => handleKeyDown(event, true)} - > - {button.text} - + role="none" + class="mb-0.5 mt-[3px] border-b border-light" + /> {/if} - - {/if} -
+ +
  • handleSelect(option)} + bind:this={optionElements[option]} + > + {#if isOther} + + {/if} + {#if highlight !== undefined} + {highlight[0]} + {highlight[1]} + {highlight[2]} + {:else if isOther && otherOptionPrefix} + {otherOptionPrefix} {option} + {:else} + {option} + {/if} +
  • + {/each} + + + diff --git a/packages/core/src/lib/select/select-input.svelte b/packages/core/src/lib/select/select-input.svelte index bc7e490f..3f6ab712 100644 --- a/packages/core/src/lib/select/select-input.svelte +++ b/packages/core/src/lib/select/select-input.svelte @@ -37,7 +37,7 @@ $: warnClasses = isWarn && !disabled && cx( - 'border-warning-bright group-hover/select-input:outline-[1.5px] group-hover/select-input:-outline-offset-1 group-hover/select-input:outline-warning-bright', + 'border-warning-bright bg-white group-hover/select-input:outline-[1.5px] group-hover/select-input:-outline-offset-1 group-hover/select-input:outline-warning-bright', { 'focus:outline-[1.5px] focus:-outline-offset-1 focus:outline-warning-bright': isFocused !== false, @@ -48,7 +48,7 @@ $: errorClasses = isError && !disabled && cx( - 'border-danger-dark group-hover/select-input:outline-[1.5px] group-hover/select-input:-outline-offset-1 group-hover/select-input:outline-danger-dark', + 'border-danger-dark bg-white group-hover/select-input:outline-[1.5px] group-hover/select-input:-outline-offset-1 group-hover/select-input:outline-danger-dark', { 'focus:outline-[1.5px] focus:-outline-offset-1 focus:outline-danger-dark': isFocused !== false, diff --git a/packages/core/src/routes/+page.svelte b/packages/core/src/routes/+page.svelte index 43765915..21ef3dba 100644 --- a/packages/core/src/routes/+page.svelte +++ b/packages/core/src/routes/+page.svelte @@ -1245,41 +1245,31 @@ const onHoverDelayMsInput = (event: Event) => {
    { + onChange={(value) => { // eslint-disable-next-line no-console - console.log('SearchableSelect input', event); + console.log('Selected', value); }} /> -
    {
    diff --git a/packages/storybook/src/stories/select.mdx b/packages/storybook/src/stories/select.mdx index 25cd0aa3..87295811 100644 --- a/packages/storybook/src/stories/select.mdx +++ b/packages/storybook/src/stories/select.mdx @@ -28,6 +28,10 @@ import { SearchableSelect } from '@viamrobotics/prime-core'; + + + + # Multiselect A user input for selecting multiple options from a list that can be filtered via text search. diff --git a/packages/storybook/src/stories/select.stories.svelte b/packages/storybook/src/stories/select.stories.svelte index 3af41ddd..5886ab36 100644 --- a/packages/storybook/src/stories/select.stories.svelte +++ b/packages/storybook/src/stories/select.stories.svelte @@ -1,9 +1,11 @@ @@ -23,8 +25,11 @@ import { -
    +
    + +
    + + +