Skip to content

Commit 177e823

Browse files
authored
Merge pull request #1 from chaseweaver/master
feat: Allow 'multiple' Listbox options
2 parents f431f89 + 11532a1 commit 177e823

File tree

3 files changed

+98
-4
lines changed

3 files changed

+98
-4
lines changed

src/lib/components/listbox/Listbox.svelte

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
Open,
44
Closed,
55
}
6+
export enum ValueMode {
7+
Single,
8+
Multi,
9+
}
610
export type ListboxOptionDataRef = {
711
textValue: string;
812
disabled: boolean;
@@ -14,6 +18,7 @@
1418
listboxState: ListboxStates;
1519
value: unknown;
1620
orientation: "vertical" | "horizontal";
21+
mode: ValueMode;
1722
1823
labelRef: Writable<HTMLLabelElement | null>;
1924
buttonRef: Writable<HTMLButtonElement | null>;
@@ -61,6 +66,8 @@
6166
horizontal?: boolean;
6267
/** The selected value */
6368
value?: StateDefinition["value"];
69+
/** Whether the `Listbox` should allow mutliple selections */
70+
multiple?: boolean;
6471
};
6572
</script>
6673

@@ -89,6 +96,7 @@
8996
export let use: HTMLActionArray = [];
9097
export let disabled = false;
9198
export let horizontal = false;
99+
export let multiple = false;
92100
export let value: StateDefinition["value"];
93101
94102
/***** Events *****/
@@ -112,6 +120,9 @@
112120
let options: StateDefinition["options"] = [];
113121
let searchQuery: StateDefinition["searchQuery"] = "";
114122
let activeOptionIndex: StateDefinition["activeOptionIndex"] = null;
123+
let mode: StateDefinition["mode"] = multiple
124+
? ValueMode.Multi
125+
: ValueMode.Single;
115126
116127
let api = writable<StateDefinition>({
117128
listboxState,
@@ -124,6 +135,7 @@
124135
activeOptionIndex,
125136
disabled,
126137
orientation,
138+
mode,
127139
closeListbox() {
128140
if (disabled) return;
129141
if (listboxState === ListboxStates.Closed) return;
@@ -231,7 +243,25 @@
231243
},
232244
select(value: unknown) {
233245
if (disabled) return;
234-
dispatch("change", value);
246+
dispatch(
247+
"change",
248+
match(mode, {
249+
[ValueMode.Single]: () => value,
250+
[ValueMode.Multi]: () => {
251+
let copy = ($api.value as unknown[]).slice();
252+
let raw = value;
253+
254+
let idx = copy.indexOf(raw);
255+
if (idx === -1) {
256+
copy.push(raw);
257+
} else {
258+
copy.splice(idx, 1);
259+
}
260+
261+
return copy;
262+
},
263+
})
264+
);
235265
},
236266
});
237267
setContext(LISTBOX_CONTEXT_NAME, api);
@@ -256,6 +286,7 @@
256286
activeOptionIndex,
257287
disabled,
258288
orientation,
289+
mode,
259290
};
260291
});
261292

src/lib/components/listbox/ListboxOption.svelte

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212

1313
<script lang="ts">
1414
import { onDestroy, onMount, tick } from "svelte";
15-
import { ListboxStates, useListboxContext } from "./Listbox.svelte";
15+
import {
16+
ListboxStates,
17+
useListboxContext,
18+
ValueMode,
19+
} from "./Listbox.svelte";
1620
import { useId } from "$lib/hooks/use-id";
1721
import { Focus } from "$lib/utils/calculate-active-index";
1822
import Render from "$lib/utils/Render.svelte";
@@ -21,6 +25,7 @@
2125
import { get_current_component } from "svelte/internal";
2226
import type { HTMLActionArray } from "$lib/hooks/use-actions";
2327
import type { TPassThroughProps } from "$lib/types";
28+
import { match } from "$lib/utils/match";
2429
2530
/***** Props *****/
2631
type TAsProp = $$Generic<SupportedAs>;
@@ -39,13 +44,26 @@
3944
let id = `headlessui-listbox-option-${useId()}`;
4045
4146
let buttonRef = $api.buttonRef;
47+
let isFirstSelected = false;
4248
4349
$: active =
4450
$api.activeOptionIndex !== null
4551
? $api.options[$api.activeOptionIndex].id === id
4652
: false;
4753
48-
$: selected = $api.value === value;
54+
$: selected = match($api.mode, {
55+
[ValueMode.Single]: () => $api.value === value,
56+
[ValueMode.Multi]: () => ($api.value as unknown[]).includes(value),
57+
});
58+
59+
$: isFirstSelected = match($api.mode, {
60+
[ValueMode.Single]: () => selected,
61+
[ValueMode.Multi]: () =>
62+
$api.options.find((option: unknown) =>
63+
($api.value as unknown[]).includes(option)
64+
)?.id === id,
65+
});
66+
4967
$: dataRef = {
5068
disabled,
5169
value,
@@ -75,7 +93,14 @@
7593
await tick();
7694
if (newState !== oldState || newSelected !== oldSelected) {
7795
if (newState === ListboxStates.Open && newSelected) {
78-
$api.goToOption(Focus.Specific, id);
96+
match($api.mode, {
97+
[ValueMode.Multi]: () => {
98+
if (isFirstSelected) $api.goToOption(Focus.Specific, id);
99+
},
100+
[ValueMode.Single]: () => {
101+
$api.goToOption(Focus.Specific, id);
102+
},
103+
});
79104
}
80105
}
81106
if (newState !== oldState || newActive !== oldActive) {

src/routes/docs/listbox.svx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,44 @@ You can use these states to conditionally apply whatever active/focus styles you
9999
</Listbox>
100100
```
101101

102+
## Multiple Values
103+
104+
To allow selecting multiple values in your listbox, use the `multiple` prop and pass an array to `value` instead of a single option.
105+
106+
```svelte
107+
<script>
108+
import {
109+
Listbox,
110+
ListboxButton,
111+
ListboxLabel,
112+
ListboxOptions,
113+
ListboxOption,
114+
} from "@rgossiaux/svelte-headlessui";
115+
116+
const people = [
117+
{ id: 1, name: "Durward Reynolds" },
118+
{ id: 2, name: "Kenton Towne" },
119+
{ id: 3, name: "Therese Wunsch" },
120+
{ id: 4, name: "Benedict Kessler" },
121+
{ id: 5, name: "Katelyn Rohan" },
122+
];
123+
124+
let selectedPeople = [people[0], people[1]];
125+
</script>
126+
127+
<Listbox value={selectedPeople} on:change={(e) => (selectedPeople = e.detail)} multiple>
128+
<ListboxLabel>Assignee:</ListboxLabel>
129+
<ListboxButton>{selectedPeople.map((person) => person.name).join(', ')}</ListboxButton>
130+
<ListboxOptions>
131+
{#each people as person (person.id)}
132+
<ListboxOption value={person}>
133+
{person.name}
134+
</ListboxOption>
135+
{/each}
136+
</ListboxOptions>
137+
</Listbox>
138+
```
139+
102140
## Using a custom label
103141

104142
By default, the `Listbox` will use the `<ListboxButton>` contents as the label for screenreaders. If you'd like more control over what is announced to assistive technologies, use the `ListboxLabel` component:

0 commit comments

Comments
 (0)