Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[APP-4532] - Replace <SearchableSelect> with svelte-select #611

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@
},
"./prime.css": "./prime.css",
"./plugins": "./plugins.ts",
"./theme": "./theme.ts"
"./theme": "./src/lib/theme.ts"
},
"svelte": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"src/lib/theme.ts",
"theme.ts",
"plugins.ts",
"prime.css",
Expand Down Expand Up @@ -86,6 +87,7 @@
"publint": "^0.2.6",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
"svelte-select": "^5.8.3",
"tailwindcss": "^3.3.7",
"tslib": "^2.6.2",
"type-fest": "^4.8.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { act, render, screen, within } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import type { ComponentProps } from 'svelte';

import { SearchableSelect as Subject, InputStates } from '$lib';
import Subject, {
type AutocompleteInputItem,
} from '../autocomplete-input.svelte';
import { InputStates } from '../../input';

const onChange = vi.fn();
const onMultiChange = vi.fn();
const onFocus = vi.fn();
const onBlur = vi.fn();
const detailedOptions = [
const detailedOptions: AutocompleteInputItem[] = [
{
value: 'opt-1',
label: 'Gale',
Expand All @@ -29,7 +31,6 @@ const renderSubject = (props: Partial<ComponentProps<Subject>> = {}) => {
return render(Subject, {
options: stringOptions,
onChange,
onMultiChange,
onFocus,
onBlur,
...props,
Expand All @@ -38,28 +39,17 @@ const renderSubject = (props: Partial<ComponentProps<Subject>> = {}) => {

const getResults = (): {
search: HTMLElement;
button: HTMLElement;
list: HTMLElement | null;
options: HTMLElement[];
} => {
const search = screen.getByRole('combobox');
const button = screen.getByRole('button');
const search = screen.getByRole('textbox');
const list = screen.queryByRole('listbox');
const options = list ? within(list).queryAllByRole('option') : [];

return { search, button, list, options };
return { search, list, options };
};

describe('SearchableSelect', () => {
it('is a combobox that controls a listbox', () => {
renderSubject();

const { search } = getResults();

expect(search).toHaveAttribute('aria-autocomplete', 'list');
expect(search).not.toHaveAttribute('aria-multiselectable');
});

it('has a placeholder', () => {
renderSubject({ placeholder: "It's me" });

Expand Down Expand Up @@ -113,11 +103,10 @@ describe('SearchableSelect', () => {
const user = userEvent.setup();
renderSubject();

const { search, button, list } = getResults();
const { search, list } = getResults();

expect(list).not.toBeInTheDocument();
expect(search).toHaveAttribute('aria-expanded', 'false');
expect(button).toHaveAttribute('aria-expanded', 'false');

await user.keyboard('{Tab}');

Expand All @@ -126,22 +115,7 @@ describe('SearchableSelect', () => {
expect(onFocus).toHaveBeenCalledOnce();
expect(search).toHaveFocus();
expect(expandedList).toHaveAttribute('id', expect.any(String));
expect(button).toHaveAttribute('aria-controls', expandedList?.id);
expect(search).toHaveAttribute('aria-controls', expandedList?.id);
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(search).toHaveAttribute('aria-expanded', 'true');
});

it('expands the listbox on button click', async () => {
renderSubject();

const { search, button } = getResults();

// TODO(mc, 2024-02-03): replace button.click with userEvent
// https://github.com/testing-library/user-event/issues/1119
await act(() => button.click());

expect(search).toHaveFocus();
expect(search).toHaveAttribute('aria-expanded', 'true');
});

Expand Down Expand Up @@ -175,7 +149,7 @@ describe('SearchableSelect', () => {

it('closes the listbox if no options', async () => {
const user = userEvent.setup();
renderSubject({ exclusive: true, sort: 'reduce' });
renderSubject();

const { search } = getResults();
await user.type(search, 'asdf');
Expand All @@ -184,21 +158,6 @@ describe('SearchableSelect', () => {
expect(search).toHaveAttribute('aria-expanded', 'false');
});

it('closes the listbox on second button click', async () => {
renderSubject();

const { search, button } = getResults();

// 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');
expect(onChange).not.toHaveBeenCalled();
});

it('collapses the listbox on blur', async () => {
const user = userEvent.setup();
renderSubject();
Expand Down Expand Up @@ -362,7 +321,7 @@ describe('SearchableSelect', () => {

// Define new options
const newOptions = [
{ value: 'New Option 1' },
'New Option 1',
{ value: 'opt1', label: 'New Option 2' },
{ value: 'opt3', label: 'New Option 3', icon: 'apple' as const },
];
Expand All @@ -386,7 +345,6 @@ describe('SearchableSelect', () => {
const user = userEvent.setup();
renderSubject({
options: detailedOptions,
exclusive: true,
});

const { search } = getResults();
Expand All @@ -411,7 +369,6 @@ describe('SearchableSelect', () => {
const user = userEvent.setup();
renderSubject({
options: detailedOptions,
exclusive: true,
});

const { search } = getResults();
Expand Down Expand Up @@ -464,24 +421,10 @@ describe('SearchableSelect', () => {
expect(onChange).not.toHaveBeenCalled();
});

it('keeps last selected value if menu is closed with escape (non exclusive)', async () => {
it('keeps last selected value if menu is closed with escape', async () => {
const user = userEvent.setup();
renderSubject();

const { search } = getResults();
await user.type(search, 'testFoo{Enter}');
expect(onChange).toHaveBeenCalledWith('testFoo');
expect(search).toHaveValue('testFoo');
onChange.mockReset();
await user.type(search, 'ohNoIMeantToClickElsewhereOops{Escape}{Tab}');
expect(search).toHaveValue('testFoo');
expect(onChange).not.toHaveBeenCalled();
});

it('keeps last selected value if menu is closed with escape (exclusive)', async () => {
const user = userEvent.setup();
renderSubject({ exclusive: true });

const { search } = getResults();
await user.type(search, 'the other{Enter}');
expect(onChange).toHaveBeenCalledWith('the other side');
Expand All @@ -492,89 +435,6 @@ describe('SearchableSelect', () => {
expect(onChange).not.toHaveBeenCalled();
});

it('has an "other" option when not exclusive', async () => {
const user = userEvent.setup();
renderSubject();

const { search } = getResults();
await user.type(search, 'hello');
const { options } = getResults();

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('sets an "other" option as active when no search matches', async () => {
const user = userEvent.setup();
renderSubject();

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);
});

it('has no "other" option when value empty', async () => {
const user = userEvent.setup();
renderSubject();

const { search } = getResults();
await user.click(search);
const { options } = getResults();

expect(options).toHaveLength(2);
});

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);
});

it('has no "other" option when exclusive', async () => {
const user = userEvent.setup();
renderSubject({ exclusive: true });

const { search } = getResults();
await user.type(search, 'hello');
const { options } = getResults();

expect(options).toHaveLength(2);
});

it('has an "other" option when value matches exclusivity function', async () => {
const user = userEvent.setup();
renderSubject({ exclusive: (value: string) => value === 'hello' });

const { search } = getResults();
await user.type(search, 'hello');
const { options } = getResults();

expect(options).toHaveLength(3);
});

it('adds a prefix to the "other" option display text', async () => {
const user = userEvent.setup();
renderSubject({ otherOptionPrefix: 'You said:' });

const { search } = getResults();
await user.type(search, 'hello');
const { options } = getResults();

expect(options[2]).toHaveAccessibleName('You said: hello');
});

it('closes listbox on escape', async () => {
const user = userEvent.setup();
renderSubject();
Expand Down Expand Up @@ -723,84 +583,34 @@ describe('SearchableSelect', () => {
expect(options[0]).toHaveAttribute('aria-selected', 'true');
});

it('renders the icon and does not change the contents of the select when Enter is pressed twice', async () => {
it('does not change the contents of the select when Enter is pressed twice', async () => {
const user = userEvent.setup();
renderSubject({ options: detailedOptions });

const { search } = getResults();
await user.type(search, 'Gale{Enter}');
const searchIcon = screen.getByTestId('icon-viam-process');

expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('opt-1');
expect(onChange).toHaveBeenCalledWith(
new CustomEvent('change', { detail: 'opt-1' })
);
expect(search).toHaveValue('Gale');
expect(searchIcon).toBeInTheDocument();

await user.keyboard('{Enter}');

// Verify that onChange is not called again
expect(onChange).toHaveBeenCalledTimes(1);
expect(search).toHaveValue('Gale');
expect(searchIcon).toBeInTheDocument();
});

it('closes menu on blur', async () => {
const user = userEvent.setup();
renderSubject({ multiple: true });
renderSubject();

const { search } = getResults();
await user.click(search);
await user.keyboard('{Tab}');

expect(search).toHaveAttribute('aria-expanded', 'false');
});

describe('multiple mode', () => {
it('can select multiple options without closing', async () => {
const user = userEvent.setup();
renderSubject({ multiple: true });

const { search } = getResults();

expect(search).toHaveAttribute('aria-multiselectable', 'true');

await user.type(search, 'hello{Enter}');
const { options } = getResults();

expect(onMultiChange).toHaveBeenCalledWith(['hello from']);
expect(search).toHaveFocus();
expect(search).toHaveValue('');
expect(search).toHaveAttribute('aria-expanded', 'true');
expect(options[0]).toHaveAttribute('aria-checked', 'true');
expect(options[1]).toHaveAttribute('aria-checked', 'false');

await user.type(search, 'other{Enter}');

expect(onMultiChange).toHaveBeenCalledWith([
'hello from',
'the other side',
]);
expect(search).toHaveFocus();
expect(search).toHaveValue('');
expect(search).toHaveAttribute('aria-expanded', 'true');
expect(options[0]).toHaveAttribute('aria-checked', 'true');
expect(options[1]).toHaveAttribute('aria-checked', 'true');
});

it('can select and unselect with the mouse', async () => {
const user = userEvent.setup();
renderSubject({ multiple: true });

const { search } = getResults();
await user.click(search);
const { options } = getResults();

// TODO(mc, 2024-02-03): replace .click with userEvent
// https://github.com/testing-library/user-event/issues/1119
await act(() => options[0]?.click());
expect(onMultiChange).toHaveBeenCalledWith(['hello from']);
await act(() => options[0]?.click());
expect(onMultiChange).toHaveBeenCalledWith([]);
});
});
});
Loading