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 10 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,10 +3,9 @@ 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 { AutocompleteInput as Subject, InputStates } from '$lib';

const onChange = vi.fn();
const onMultiChange = vi.fn();
const onFocus = vi.fn();
const onBlur = vi.fn();
const detailedOptions = [
Expand All @@ -29,7 +28,6 @@ const renderSubject = (props: Partial<ComponentProps<Subject>> = {}) => {
return render(Subject, {
options: stringOptions,
onChange,
onMultiChange,
onFocus,
onBlur,
...props,
Expand Down Expand Up @@ -175,7 +173,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 Down Expand Up @@ -362,7 +360,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 +384,6 @@ describe('SearchableSelect', () => {
const user = userEvent.setup();
renderSubject({
options: detailedOptions,
exclusive: true,
});

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

const { search } = getResults();
Expand Down Expand Up @@ -464,24 +460,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 +474,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 @@ -755,7 +654,7 @@ describe('SearchableSelect', () => {
expect(search).toHaveAttribute('aria-expanded', 'false');
});

describe('multiple mode', () => {
describe.skip('multiple mode', () => {
it('can select multiple options without closing', async () => {
const user = userEvent.setup();
renderSubject({ multiple: true });
Expand Down
158 changes: 158 additions & 0 deletions packages/core/src/lib/select/autocomplete-input.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<script lang="ts">
import cx from 'classnames';
import Select from 'svelte-select';
import type { ComponentProps, ComponentEvents } from 'svelte';
import { theme } from '../theme';
import type { IconName } from '../icon/icons';
import { InputStates, type InputState } from '../input';
import Progress from '../progress/progress.svelte';
import Icon from '../icon/icon.svelte';

type Events = ComponentEvents<Select>;

interface SelectItem {
value: string;
label: string;
description?: string;
icon?: string;
group?: string;
}

type Option = SelectItem | string;

type DisallowedProps = 'items' | 'showChevron' | 'hideEmptyState' | 'hasError';

interface $$Props extends Omit<ComponentProps<Select>, DisallowedProps> {
value?: string | string[];
options: Option[];
state?: InputState;
cx?: cx.Argument;
onBlur?: (event: Events['blur']['detail']) => void;
onChange?: (event: Events['change']) => void;
onClear?: (event: Events['clear']) => void;
onFilter?: (event: Events['filter']) => void;
onFocus?: (event: Events['focus']) => void;
onInput?: (event: Events['focus']) => void;
onSelect?: (event: Events['select']) => void;
}

export let options: $$Props['options'];
export let inputAttributes: $$Props['inputAttributes'] = undefined;
export let containerStyles: $$Props['containerStyles'] = '';
export let state: $$Props['state'] = InputStates.NONE;
export let onBlur: $$Props['onBlur'] = undefined;
export let onChange: $$Props['onChange'] = undefined;
export let onClear: $$Props['onClear'] = undefined;
export let onFilter: $$Props['onFilter'] = undefined;
export let onFocus: $$Props['onFocus'] = undefined;
export let onInput: $$Props['onInput'] = undefined;
export let onSelect: $$Props['onSelect'] = undefined;

let cxArgument: $$Props['cx'] = '';
export { cxArgument as cx };

let className: $$Props['class'] = '';
export { className as class };

$: normalizedOptions = options.map((option) => {
if (typeof option === 'string') {
return { value: option, label: option };
}

return option;
});

$: icons = normalizedOptions.map((option) => option.icon) as (
| IconName
| undefined
)[];

$: warnClasses = state === InputStates.WARN ? 'border-warning-bright' : '';

const { colors, borderColor } = theme.extend;
</script>

<Select
showChevron
hideEmptyState
containerStyles="padding-left: 0.375rem {containerStyles}"
class="{cx(cxArgument)} {className} {warnClasses}"
items={normalizedOptions}
inputAttributes={{ autocomplete: 'off', ...inputAttributes }}
hasError={state === InputStates.ERROR}
--border="1px solid {state === InputStates.WARN
? colors['warning-medium']
: borderColor.light}"
--border-hover="1px solid {colors['gray-6']}"
--border-focused="1px solid {colors['gray-9']}"
--background="#ffffff"
--border-radius="0"
--font-size="0.75rem"
--height="1.875rem"
--padding-left="0.5rem"
--padding-right="0.5rem"
--padding-top="0.375rem"
--padding-bottom="0.375rem"
--chevron-height="1rem"
--chevron-icon-colour={colors['gray-6']}
--chevron-icon-width="1rem"
--clear-icon-color={colors['gray-6']}
--clear-icon-width="1rem"
--item-height="auto"
--item-line-height="auto"
--item-first-border-radius="0"
--item-is-active-bg={colors['gray-8']}
--item-is-active-color="#fff"
--item-active-background="#f7f7f8"
--item-hover-bg="#f7f7f8"
--item-padding="0.5rem"
--disabled-border-color={colors['disabled-light']}
--disabled-background={colors['disabled-light']}
--error-border="1px solid {colors['danger-dark']}"
--list-shadow="none"
--list-border="1px solid {colors['gray-9']}"
--list-border-radius="0"
{...$$restProps}
on:blur={onBlur}
on:change={onChange}
on:clear={onClear}
on:filter={onFilter}
on:focus={onFocus}
on:input={onInput}
on:select={onSelect}
>
<div
slot="item"
let:item
let:index
>
{@const icon = icons[index]}
<div class="flex items-center gap-2">
{#if icon}
<Icon
cx="flex-shrink-0"
name={icon}
/>
{/if}
<div class="flex w-full flex-col flex-wrap overflow-x-hidden">
<div class="w-full min-w-0 text-wrap break-words">{item.label}</div>
{#if item.description}
<div class="break-word w-full min-w-0 text-wrap text-[0.6rem]">
{item.description}
</div>
{/if}
</div>
</div>
</div>
<Icon
slot="chevron-icon"
name="chevron-down"
cx="text-gray-6"
/>
<Icon
slot="clear-icon"
name="close"
cx="text-gray-6"
/>
<Progress slot="loading-icon" />
</Select>
2 changes: 1 addition & 1 deletion packages/core/src/lib/select/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { SortOptions, type SortOption } from './search';
export { default as SearchableSelect } from './searchable-select.svelte';
export { default as AutocompleteInput } from './autocomplete-input.svelte';
export { default as Multiselect } from './multiselect.svelte';
export { default as Select } from './select.svelte';
export { default as SelectInput } from './select-input.svelte';
Expand Down
Loading
Loading