Skip to content

Commit a8b7ab6

Browse files
authored
APP-3302: add multiple prop to <SearchableSelect> (#478)
1 parent 3db8f34 commit a8b7ab6

File tree

7 files changed

+189
-35
lines changed

7 files changed

+189
-35
lines changed

.vscode/settings.json

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"activedescendant",
2828
"combobox",
2929
"listbox",
30+
"multiselectable",
3031
"radiobox",
3132
"unstub",
3233
"viamrobotics"

packages/core/src/lib/icon/icons.ts

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const paths = {
2929
cancel: MDI.mdiCancel,
3030
'check-circle': MDI.mdiCheckCircle,
3131
check: MDI.mdiCheck,
32+
'checkbox-blank-outline': MDI.mdiCheckboxBlankOutline,
33+
'checkbox-marked': MDI.mdiCheckboxMarked,
3234
'chevron-double-up': MDI.mdiChevronDoubleUp,
3335
'chevron-down': MDI.mdiChevronDown,
3436
'chevron-left': MDI.mdiChevronLeft,

packages/core/src/lib/select/__tests__/searchable-select.spec.ts

+104-2
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import type { ComponentProps } from 'svelte';
66
import { SearchableSelect as Subject, InputStates } from '$lib';
77

88
const onChange = vi.fn();
9+
const onMultiChange = vi.fn();
910
const onFocus = vi.fn();
1011
const onBlur = vi.fn();
1112

1213
const renderSubject = (props: Partial<ComponentProps<Subject>> = {}) => {
1314
return render(Subject, {
1415
options: ['hello from', 'the other side'],
1516
onChange,
17+
onMultiChange,
1618
onFocus,
1719
onBlur,
1820
...props,
@@ -33,8 +35,8 @@ const getResults = (): {
3335
return { search, button, list, options };
3436
};
3537

36-
describe('combobox list', () => {
37-
it('controls a listbox', () => {
38+
describe('SearchableSelect', () => {
39+
it('is a combobox that controls a listbox', () => {
3840
renderSubject();
3941

4042
const { search, button, list } = getResults();
@@ -43,6 +45,7 @@ describe('combobox list', () => {
4345
expect(button).toHaveAttribute('aria-controls', list.id);
4446
expect(search).toHaveAttribute('aria-controls', list.id);
4547
expect(search).toHaveAttribute('aria-autocomplete', 'list');
48+
expect(search).not.toHaveAttribute('aria-multiselectable');
4649
});
4750

4851
it('has a placeholder', () => {
@@ -421,6 +424,31 @@ describe('combobox list', () => {
421424
expect(options[0]).toHaveAttribute('aria-selected', 'true');
422425
});
423426

427+
it('selects visually focused option with space', async () => {
428+
const user = userEvent.setup();
429+
renderSubject();
430+
431+
const { search } = getResults();
432+
await user.click(search);
433+
await user.keyboard('{ArrowDown} ');
434+
435+
expect(onChange).toHaveBeenCalledWith('hello from');
436+
expect(search).toHaveValue('hello from');
437+
expect(search).toHaveAttribute('aria-expanded', 'false');
438+
});
439+
440+
it('types with space when visual focus on search', async () => {
441+
const user = userEvent.setup();
442+
renderSubject();
443+
444+
const { search } = getResults();
445+
await user.click(search);
446+
await user.keyboard(' ');
447+
448+
expect(onChange).not.toHaveBeenCalled();
449+
expect(search).toHaveValue(' ');
450+
});
451+
424452
it('sets cursor with home and end', async () => {
425453
const user = userEvent.setup();
426454
renderSubject();
@@ -471,4 +499,78 @@ describe('combobox list', () => {
471499

472500
expect(options[0]).toHaveAttribute('aria-selected', 'true');
473501
});
502+
503+
describe('multiple mode', () => {
504+
it('can select multiple options without closing', async () => {
505+
const user = userEvent.setup();
506+
renderSubject({ multiple: true });
507+
508+
const { search, options } = getResults();
509+
510+
expect(search).toHaveAttribute('aria-multiselectable', 'true');
511+
512+
await user.click(search);
513+
514+
// TODO(mc, 2024-02-03): replace .click with userEvent
515+
// https://github.com/testing-library/user-event/issues/1119
516+
await act(() => options[0]?.click());
517+
expect(onMultiChange).toHaveBeenCalledWith(['hello from']);
518+
expect(search).toHaveFocus();
519+
expect(search).toHaveValue('');
520+
expect(search).toHaveAttribute('aria-expanded', 'true');
521+
expect(options[0]).toHaveAttribute('aria-checked', 'true');
522+
expect(options[1]).toHaveAttribute('aria-checked', 'false');
523+
524+
await act(() => options[1]?.click());
525+
expect(onMultiChange).toHaveBeenCalledWith([
526+
'hello from',
527+
'the other side',
528+
]);
529+
expect(search).toHaveFocus();
530+
expect(search).toHaveValue('');
531+
expect(search).toHaveAttribute('aria-expanded', 'true');
532+
expect(options[0]).toHaveAttribute('aria-checked', 'true');
533+
expect(options[1]).toHaveAttribute('aria-checked', 'true');
534+
});
535+
536+
it('can select unselect with the mouse', async () => {
537+
const user = userEvent.setup();
538+
renderSubject({ multiple: true });
539+
540+
const { search, options } = getResults();
541+
542+
await user.click(search);
543+
544+
// TODO(mc, 2024-02-03): replace .click with userEvent
545+
// https://github.com/testing-library/user-event/issues/1119
546+
await act(() => options[0]?.click());
547+
expect(onMultiChange).toHaveBeenCalledWith(['hello from']);
548+
await act(() => options[0]?.click());
549+
expect(onMultiChange).toHaveBeenCalledWith([]);
550+
});
551+
552+
it('resets search input on select', async () => {
553+
const user = userEvent.setup();
554+
renderSubject({ multiple: true });
555+
556+
const { search } = getResults();
557+
await user.click(search);
558+
559+
await user.type(search, 'hello{Enter}');
560+
561+
expect(onMultiChange).toHaveBeenCalledWith(['hello from']);
562+
expect(search).toHaveValue('');
563+
});
564+
565+
it('closes menu on blur', async () => {
566+
const user = userEvent.setup();
567+
renderSubject({ multiple: true });
568+
569+
const { search } = getResults();
570+
await user.click(search);
571+
await user.keyboard('{Tab}');
572+
573+
expect(search).toHaveAttribute('aria-expanded', 'false');
574+
});
575+
});
474576
});

packages/core/src/lib/select/searchable-select.svelte

+71-22
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export let options: string[];
2727
/** The value of the search input or the currently selected option, if any. */
2828
export let value = '';
2929
30+
/** All selected values, if `multiple` is `true`. */
31+
export let values: string[] = [];
32+
3033
/** The placeholder of the input. */
3134
export let placeholder = '';
3235
@@ -38,6 +41,9 @@ export let placeholder = '';
3841
*/
3942
export let exclusive: boolean | ((value: string) => boolean) = false;
4043
44+
/** Multiple selections allowed. */
45+
export let multiple: boolean | undefined = undefined;
46+
4147
/** Input is disabled. */
4248
export let disabled = false;
4349
@@ -53,9 +59,17 @@ export let otherOptionPrefix = '';
5359
/** Error message ID, if any. */
5460
export let errorID = '';
5561
56-
/** Notify the parent of a value change, after Enter key or blur. */
62+
/**
63+
* Notify the parent of a value change, after Enter key or blur.
64+
*
65+
* Only used if `multiple` is `false` (default)
66+
*/
5767
export let onChange: ((value: string) => unknown) | undefined = undefined;
5868
69+
/** Notify the parent of a value change, if `multiple` is `true` */
70+
export let onMultiChange: ((values: string[]) => unknown) | undefined =
71+
undefined;
72+
5973
/** Notify the parent of focus. */
6074
export let onFocus: ((event: FocusEvent) => unknown) | undefined = undefined;
6175
@@ -110,6 +124,34 @@ $: if (typeof activeElement?.scrollIntoView === 'function') {
110124
activeElement.scrollIntoView({ block: 'nearest' });
111125
}
112126
127+
const handleSingleSelect = (selectedValue: string | undefined) => {
128+
const fallback = exclusive && !valueInSearch ? '' : value;
129+
const nextValue = selectedValue ?? fallback;
130+
131+
if (nextValue !== previousValue) {
132+
setMenuState(CLOSED);
133+
134+
value = nextValue;
135+
previousValue = nextValue;
136+
onChange?.(nextValue);
137+
}
138+
};
139+
140+
const handleMultiSelect = (selectedValue: string | undefined) => {
141+
if (!selectedValue) {
142+
return;
143+
}
144+
145+
values = values.includes(selectedValue)
146+
? values.filter((val) => val !== selectedValue)
147+
: [...values, selectedValue];
148+
149+
value = '';
150+
onMultiChange?.(values);
151+
};
152+
153+
$: handleSelect = multiple ? handleMultiSelect : handleSingleSelect;
154+
113155
const setMenuState = (nextMenuState: MenuState) => {
114156
menuState = disabled ? CLOSED : nextMenuState;
115157
};
@@ -125,20 +167,8 @@ const handleFocus = (event: FocusEvent) => {
125167
126168
const handleBlur = (event: FocusEvent) => {
127169
handleSelect(autoSelectOption?.option);
128-
onBlur?.(event);
129-
};
130-
131-
const handleSelect = (selectedValue: string | undefined) => {
132-
const fallback = exclusive && !valueInSearch ? '' : value;
133-
const nextValue = selectedValue ?? fallback;
134-
135170
setMenuState(CLOSED);
136-
137-
if (nextValue !== previousValue) {
138-
value = nextValue;
139-
previousValue = nextValue;
140-
onChange?.(nextValue);
141-
}
171+
onBlur?.(event);
142172
};
143173
144174
const handleButtonClick = () => {
@@ -150,6 +180,15 @@ const handleKeydown = createHandleKey({
150180
Enter: () => {
151181
handleSelect(autoSelectOption?.option);
152182
},
183+
' ': {
184+
handler: (event) => {
185+
if (menuState === FOCUS_ITEM) {
186+
handleSelect(autoSelectOption?.option);
187+
event.preventDefault();
188+
}
189+
},
190+
preventDefault: false,
191+
},
153192
Escape: () => {
154193
if (menuState === CLOSED) {
155194
value = '';
@@ -198,6 +237,7 @@ const handleKeydown = createHandleKey({
198237
isFocused={menuState === FOCUS_ITEM ? false : undefined}
199238
cx={[{ 'caret-transparent': menuState === FOCUS_ITEM }, inputCx]}
200239
aria-autocomplete="list"
240+
aria-multiselectable={multiple}
201241
aria-activedescendant={activeID}
202242
aria-errormessage={errorID}
203243
on:focus={handleFocus}
@@ -221,7 +261,9 @@ const handleKeydown = createHandleKey({
221261
class="max-h-36 flex-col overflow-y-auto border border-gray-9 bg-white py-1 shadow-sm"
222262
>
223263
{#each allOptions as { option, highlight } (option)}
224-
{@const isSelected = activeOption?.option === option}
264+
{@const isActive = activeOption?.option === option}
265+
{@const isSelected = multiple ? false : isActive}
266+
{@const isChecked = multiple ? values.includes(option) : undefined}
225267
{@const isOther = otherOption?.option === option}
226268

227269
{#if isOther && allOptions.length > 1}
@@ -236,31 +278,38 @@ const handleKeydown = createHandleKey({
236278
-->
237279
<li
238280
role="option"
239-
id={isSelected ? activeID : undefined}
281+
id={isActive ? activeID : undefined}
240282
aria-selected={isSelected}
283+
aria-checked={isChecked}
241284
aria-label={isOther
242285
? [otherOptionPrefix, option].filter(Boolean).join(' ')
243286
: option}
244287
class={cx(
245-
'flex h-7.5 w-full cursor-pointer items-center justify-start px-2.5 text-xs',
246-
isSelected ? 'bg-light' : 'hover:bg-light'
288+
'flex h-7.5 w-full cursor-pointer items-center justify-start text-xs',
289+
multiple ? 'pl-2 pr-2.5' : 'px-2.5',
290+
isActive ? 'bg-light' : 'hover:bg-light'
247291
)}
248292
on:pointerdown|preventDefault
249293
on:mousedown|preventDefault
250294
on:click={() => handleSelect(option)}
251295
bind:this={optionElements[option]}
252296
>
297+
{#if multiple}
298+
<Icon
299+
cx={['mr-1 shrink-0', !isChecked && 'text-gray-6']}
300+
name={isChecked ? 'checkbox-marked' : 'checkbox-blank-outline'}
301+
/>
302+
{/if}
253303
{#if isOther}
254304
<Icon
255305
cx="mr-1 shrink-0 text-gray-6"
256306
name="plus"
257307
/>
258308
{/if}
259-
<p class="truncate">
309+
<p class="truncate whitespace-pre">
260310
{#if highlight !== undefined}
261-
<span class="whitespace-pre">{highlight[0]}</span>
262-
<span class="whitespace-pre bg-yellow-100">{highlight[1]}</span>
263-
<span class="whitespace-pre">{highlight[2]}</span>
311+
{@const [prefix, match, suffix] = highlight}
312+
{prefix}<span class="bg-yellow-100">{match}</span>{suffix}
264313
{:else if isOther && otherOptionPrefix}
265314
{otherOptionPrefix} {option}
266315
{:else}

packages/core/src/routes/+page.svelte

+1
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,7 @@ const onHoverDelayMsInput = (event: Event) => {
13011301
]}
13021302
placeholder="Reducing Select"
13031303
sort="reduce"
1304+
multiple
13041305
/>
13051306
</div>
13061307

packages/storybook/src/stories/select.mdx

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,5 @@ import { Multiselect } from '@viamrobotics/prime-core';
4141
```
4242

4343
<Canvas>
44-
<Story of={SelectStories.Multi} />
44+
<Story of={SelectStories.SearchableWithMultiple} />
4545
</Canvas>

0 commit comments

Comments
 (0)