From e567797036114da982517186298ebf5fb5550c42 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 6 Feb 2024 09:46:13 -0500 Subject: [PATCH] Add `max` props to limit the number of selected values for `selectionStore`, `Selection`, `MultiSelect`, `MultiSelectField`, and `MultiSelectMenu` (#231) * Add `max` props to limit the number of selected values for `selectionStore`, `Selection`, `MultiSelect`, `MultiSelectField`, and `MultiSelectMenu` * Add ability to show warning message when max selection reached. * Update changelog --- .changeset/neat-dragons-smell.md | 5 +++ .../src/lib/components/MultiSelect.svelte | 40 +++++++++++++++--- .../lib/components/MultiSelectField.svelte | 13 ++++-- .../src/lib/components/MultiSelectMenu.svelte | 15 +++++-- .../src/lib/components/Selection.svelte | 4 +- .../src/lib/stores/selectionStore.ts | 25 ++++++++++- .../docs/components/MultiSelect/+page.svelte | 41 +++++++++++++++++++ .../components/MultiSelectField/+page.svelte | 36 ++++++++++++++++ .../docs/stores/selectionStore/+page.md | 18 ++++++++ 9 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 .changeset/neat-dragons-smell.md diff --git a/.changeset/neat-dragons-smell.md b/.changeset/neat-dragons-smell.md new file mode 100644 index 000000000..967886cff --- /dev/null +++ b/.changeset/neat-dragons-smell.md @@ -0,0 +1,5 @@ +--- +'svelte-ux': patch +--- + +Add `max` prop to limit the number of selected values for `selectionStore`, `Selection`, `MultiSelect`, `MultiSelectField`, and `MultiSelectMenu`. Add `beforeOptions` and `afterOptions` slots, and pass `selection` to `actions` slot diff --git a/packages/svelte-ux/src/lib/components/MultiSelect.svelte b/packages/svelte-ux/src/lib/components/MultiSelect.svelte index c550bc92e..35bbf2545 100644 --- a/packages/svelte-ux/src/lib/components/MultiSelect.svelte +++ b/packages/svelte-ux/src/lib/components/MultiSelect.svelte @@ -25,8 +25,13 @@ export let duration = 200; export let inlineSearch = false; export let placeholder = 'Search items'; + + /** Wrap options in `InfiniteScroll` to amortize rendering of a large number of options */ export let infiniteScroll = false; + /** Maximum number of options that can be selected */ + export let max: number | undefined = undefined; + export let labelProp = 'name'; export let valueProp = 'value'; @@ -77,6 +82,7 @@ $: selection = selectionStore({ initial: selectedOptions.map((x) => get(x, valueProp)), + max, }); $: isSelectionDirty = dirtyStore(selection); @@ -116,6 +122,8 @@ {/if}
+ + {#each visibleItems as option (get(option, valueProp))} @@ -123,6 +131,7 @@ {@const value = get(option, valueProp)} {@const checked = $selection.isSelected(value)} {@const indeterminate = $indeterminateStore.has(value)} + {@const disabled = $selection.isDisabled(value)} {@const onChange = () => { // TODO: Try to figure out how to keep underling Checkbox controlled so state goes `indeterminate` => `checked` => `unchecked` // If partial/indeterminate, transition to fully selected, then deselect/select as usual @@ -137,8 +146,17 @@ $selection.toggleSelected(value); }}
- - + + {label} @@ -158,6 +176,7 @@ {@const value = get(option, valueProp)} {@const checked = $selection.isSelected(value)} {@const indeterminate = $indeterminateStore.has(value)} + {@const disabled = $selection.isDisabled(value)} {@const onChange = () => { // TODO: Try to figure out how to keep underling Checkbox controlled so state goes `indeterminate` => `checked` => `unchecked` // If partial/indeterminate, transition to fully selected, then deselect/select as usual @@ -172,8 +191,17 @@ $selection.toggleSelected(value); }}
- - + + {label} @@ -184,10 +212,12 @@ {/if} {/each} + +
- +
diff --git a/packages/svelte-ux/src/lib/components/MultiSelectField.svelte b/packages/svelte-ux/src/lib/components/MultiSelectField.svelte index b5b2cca60..f3be3c2b5 100644 --- a/packages/svelte-ux/src/lib/components/MultiSelectField.svelte +++ b/packages/svelte-ux/src/lib/components/MultiSelectField.svelte @@ -22,6 +22,8 @@ export let options: Option[]; export let value: any[] = []; export let indeterminateSelected: any[] = []; + /** Maximum number of options that can be selected */ + export let max: number | undefined = undefined; export let placement: Placement = 'bottom-start'; export let infiniteScroll = false; export let labelProp = 'name'; // TODO: Default to 'label' @@ -196,6 +198,7 @@ {options} {value} {indeterminateSelected} + {max} {placement} {infiniteScroll} {labelProp} @@ -209,6 +212,9 @@ bind:menuOptionsEl {...menuProps} > + + + - - + + {label} - +
diff --git a/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte b/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte index 84cf69971..67cf86ee0 100644 --- a/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte +++ b/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte @@ -22,6 +22,8 @@ export let placeholder: string | undefined = undefined; export let infiniteScroll = false; export let searchText = ''; + /** Maximum number of options that can be selected */ + export let max: number | undefined = undefined; export let labelProp = 'name'; export let valueProp = 'value'; @@ -53,6 +55,7 @@ {options} {value} {indeterminateSelected} + {max} {open} {duration} {inlineSearch} @@ -66,6 +69,9 @@ on:change={() => close()} on:change > + + + - - + + {label} - + +
+ diff --git a/packages/svelte-ux/src/lib/components/Selection.svelte b/packages/svelte-ux/src/lib/components/Selection.svelte index 8451c25a0..91035b137 100644 --- a/packages/svelte-ux/src/lib/components/Selection.svelte +++ b/packages/svelte-ux/src/lib/components/Selection.svelte @@ -6,8 +6,9 @@ export let initial: any[] = []; export let all: any[] = []; export let single = false; + export let max = false; - const selection = selectionStore({ initial, all, single }); + const selection = selectionStore({ initial, all, single, max }); $: $selection.all.set(all); @@ -15,6 +16,7 @@ = { + /** Initial values */ initial?: T[]; + + /** All values to select when `toggleAll()` is called */ all?: T[]; + + /** Only allow 1 selected value */ single?: boolean; + + /** Maximum number of values that can be selected */ + max?: number; }; export default function selectionStore(props: SelectionProps = {}) { const selected = uniqueStore(props.initial ?? []); const all = writable(props.all ?? []); const single = props.single ?? false; + const max = props.max; return derived([selected, all], ([$selected, $all]) => { function isSelected(value: T) { return $selected.has(value); } + function isDisabled(value: T) { + return !isSelected(value) && isMaxSelected(); + } + function toggleSelected(value: T) { selected.update(($selected) => { if ($selected.has(value)) { @@ -27,7 +40,11 @@ export default function selectionStore(props: SelectionProps = {}) { return new Set([value]); } else { // Add - return $selected.add(value); + if (max == null || $selected.size < max) { + return $selected.add(value); + } else { + return $selected; + } } }); } @@ -40,6 +57,10 @@ export default function selectionStore(props: SelectionProps = {}) { return $all.some((v) => $selected.has(v)); } + function isMaxSelected() { + return max != null ? $selected.size >= max : false; + } + function toggleAll() { let values: T[]; if (isAllSelected()) { @@ -66,9 +87,11 @@ export default function selectionStore(props: SelectionProps = {}) { selected: single ? selectedArr[0] ?? null : selectedArr, toggleSelected, isSelected, + isDisabled, toggleAll, isAllSelected, isAnySelected, + isMaxSelected, clear, reset, all, diff --git a/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte b/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte index 78864a399..7c3b6a562 100644 --- a/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte +++ b/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte @@ -6,6 +6,7 @@ import Preview from '$lib/components/Preview.svelte'; import MultiSelect from '$lib/components/MultiSelect.svelte'; import MultiSelectOption from '$lib/components/MultiSelectOption.svelte'; + import { slide } from 'svelte/transition'; const options = [ { name: 'One', value: 1 }, @@ -38,6 +39,31 @@ (value = e.detail.value)} inlineSearch /> +

max selected

+ + + {value.length} selected + (value = e.detail.value)} /> + + +

max selected with warning

+ + + {value.length} selected + (value = e.detail.value)}> + + {#if selection.isMaxSelected()} +
+ Maximum selection reached +
+ {/if} +
+
+
+

many options

@@ -89,6 +115,21 @@
+

actions slot with max warning

+ + + {value.length} selected +
+ (value = e.detail.value)} inlineSearch max={2}> +
+ {#if selection.isMaxSelected()} +
Maximum selection reached
+ {/if} +
+
+
+
+

option slot

diff --git a/packages/svelte-ux/src/routes/docs/components/MultiSelectField/+page.svelte b/packages/svelte-ux/src/routes/docs/components/MultiSelectField/+page.svelte index 706806385..cd4b914ff 100644 --- a/packages/svelte-ux/src/routes/docs/components/MultiSelectField/+page.svelte +++ b/packages/svelte-ux/src/routes/docs/components/MultiSelectField/+page.svelte @@ -7,6 +7,7 @@ import MultiSelectField from '$lib/components/MultiSelectField.svelte'; import MultiSelectOption from '$lib/components/MultiSelectOption.svelte'; import ToggleButton from '$lib/components/ToggleButton.svelte'; + import { slide } from 'svelte/transition'; const options = [ { name: 'One', value: 1 }, @@ -43,6 +44,29 @@ (value = e.detail.value)} disabled /> +

max selected

+ + + (value = e.detail.value)} /> + + +

max selected with warning

+ + + (value = e.detail.value)}> + + {#if selection.isMaxSelected()} +
+ Maximum selection reached +
+ {/if} +
+
+
+

many options

@@ -76,6 +100,18 @@ +

actions slot with max warning

+ + + (value = e.detail.value)} max={2}> +
+ {#if selection.isMaxSelected()} +
Maximum selection reached
+ {/if} +
+
+
+

within Drawer

diff --git a/packages/svelte-ux/src/routes/docs/stores/selectionStore/+page.md b/packages/svelte-ux/src/routes/docs/stores/selectionStore/+page.md index 3cf6518d3..56e1e347c 100644 --- a/packages/svelte-ux/src/routes/docs/stores/selectionStore/+page.md +++ b/packages/svelte-ux/src/routes/docs/stores/selectionStore/+page.md @@ -15,10 +15,28 @@ const selection2 = selectionStore({ initial: [3,4,5]}) const selection3 = selectionStore({ all: items.map(item => item.id)}); const selection4 = selectionStore({ single: true }); + const selection5 = selectionStore({ max: 3 });

Usage

+

Max

+ +```js +const selection = selectionStore({ max: 3 }); +``` + + + {#each items as item} +
+ $selection5.toggleSelected(item.id)} disabled={$selection5.isDisabled(item.id)}> + {item.id} + +
+ {/each} + selected: {JSON.stringify($selection5.selected)} +
+

Basic

```js