Skip to content

Commit

Permalink
Add max props to limit the number of selected values for `selection…
Browse files Browse the repository at this point in the history
…Store`, `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
  • Loading branch information
techniq authored Feb 6, 2024
1 parent de92fdd commit e567797
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .changeset/neat-dragons-smell.md
Original file line number Diff line number Diff line change
@@ -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
40 changes: 35 additions & 5 deletions packages/svelte-ux/src/lib/components/MultiSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -77,6 +82,7 @@
$: selection = selectionStore({
initial: selectedOptions.map((x) => get(x, valueProp)),
max,
});
$: isSelectionDirty = dirtyStore(selection);
Expand Down Expand Up @@ -116,13 +122,16 @@
{/if}

<div class={cls('overflow-auto py-1 px-4', theme.root, classes.root, $$restProps.class)}>
<slot name="beforeOptions" selection={$selection} />

<!-- initially selected options -->
<InfiniteScroll items={filteredSelectedOptions} disabled={!infiniteScroll} let:visibleItems>
{#each visibleItems as option (get(option, valueProp))}
{@const label = get(option, labelProp)}
{@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
Expand All @@ -137,8 +146,17 @@
$selection.toggleSelected(value);
}}
<div animate:flip={{ duration }}>
<slot name="option" {option} {label} {value} {checked} {indeterminate} {onChange}>
<MultiSelectOption {checked} {indeterminate} on:change={onChange}>
<slot
name="option"
{option}
{label}
{value}
{checked}
{indeterminate}
{disabled}
{onChange}
>
<MultiSelectOption {checked} {indeterminate} {disabled} on:change={onChange}>
{label}
</MultiSelectOption>
</slot>
Expand All @@ -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
Expand All @@ -172,8 +191,17 @@
$selection.toggleSelected(value);
}}
<div animate:flip={{ duration }}>
<slot name="option" {option} {label} {value} {checked} {indeterminate} {onChange}>
<MultiSelectOption {checked} {indeterminate} on:change={onChange}>
<slot
name="option"
{option}
{label}
{value}
{checked}
{indeterminate}
{disabled}
{onChange}
>
<MultiSelectOption {checked} {indeterminate} {disabled} on:change={onChange}>
{label}
</MultiSelectOption>
</slot>
Expand All @@ -184,10 +212,12 @@
{/if}
{/each}
</InfiniteScroll>

<slot name="afterOptions" selection={$selection} />
</div>

<div class="grid grid-cols-[auto,1fr,auto] border-t border-gray-100 px-4 py-2">
<slot name="actions" {searchText}>
<slot name="actions" selection={$selection} {searchText}>
<div />
</slot>

Expand Down
13 changes: 10 additions & 3 deletions packages/svelte-ux/src/lib/components/MultiSelectField.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -196,6 +198,7 @@
{options}
{value}
{indeterminateSelected}
{max}
{placement}
{infiniteScroll}
{labelProp}
Expand All @@ -209,6 +212,9 @@
bind:menuOptionsEl
{...menuProps}
>
<slot name="beforeOptions" slot="beforeOptions" let:selection {selection} />
<slot name="afterOptions" slot="afterOptions" let:selection {selection} />

<!-- TODO: If only `<slot name="option" slot="option" />` just worked -->
<svelte:fragment
slot="option"
Expand All @@ -217,16 +223,17 @@
let:value
let:checked
let:indeterminate
let:disabled
let:onChange
>
<slot name="option" {option} {label} {value} {checked} {indeterminate} {onChange}>
<MultiSelectOption {checked} {indeterminate} on:change={onChange}>
<slot name="option" {option} {label} {value} {checked} {indeterminate} {disabled} {onChange}>
<MultiSelectOption {checked} {indeterminate} {disabled} on:change={onChange}>
{label}
</MultiSelectOption>
</slot>
</svelte:fragment>

<slot name="actions" slot="actions">
<slot name="actions" slot="actions" let:selection {selection}>
<div />
</slot>
</MultiSelectMenu>
Expand Down
15 changes: 12 additions & 3 deletions packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,6 +55,7 @@
{options}
{value}
{indeterminateSelected}
{max}
{open}
{duration}
{inlineSearch}
Expand All @@ -66,6 +69,9 @@
on:change={() => close()}
on:change
>
<slot name="beforeOptions" slot="beforeOptions" let:selection {selection} />
<slot name="afterOptions" slot="afterOptions" let:selection {selection} />

<!-- TODO: If only `<slot name="option" slot="option" />` just worked -->
<svelte:fragment
slot="option"
Expand All @@ -74,15 +80,18 @@
let:value
let:checked
let:indeterminate
let:disabled
let:onChange
>
<slot name="option" {option} {label} {value} {checked} {indeterminate} {onChange}>
<MultiSelectOption {checked} {indeterminate} on:change={onChange}>
<slot name="option" {option} {label} {value} {checked} {indeterminate} {disabled} {onChange}>
<MultiSelectOption {checked} {indeterminate} {disabled} on:change={onChange}>
{label}
</MultiSelectOption>
</slot>
</svelte:fragment>

<slot name="actions" slot="actions" />
<slot name="actions" slot="actions" let:selection {selection}>
<div />
</slot>
</MultiSelect>
</Menu>
4 changes: 3 additions & 1 deletion packages/svelte-ux/src/lib/components/Selection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
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);
</script>

<!-- TODO: `<slot {...$selection} />` does not play well with sveld -->
<slot
selected={$selection.selected}
isSelected={$selection.isSelected}
isDisabled={$selection.isDisabled}
toggleAll={$selection.toggleAll}
toggleSelected={$selection.toggleSelected}
isAllSelected={$selection.isAllSelected}
Expand Down
25 changes: 24 additions & 1 deletion packages/svelte-ux/src/lib/stores/selectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,34 @@ import { derived, writable } from 'svelte/store';
import uniqueStore from './uniqueStore';

export type SelectionProps<T> = {
/** 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<T>(props: SelectionProps<T> = {}) {
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)) {
Expand All @@ -27,7 +40,11 @@ export default function selectionStore<T>(props: SelectionProps<T> = {}) {
return new Set([value]);
} else {
// Add
return $selected.add(value);
if (max == null || $selected.size < max) {
return $selected.add(value);
} else {
return $selected;
}
}
});
}
Expand All @@ -40,6 +57,10 @@ export default function selectionStore<T>(props: SelectionProps<T> = {}) {
return $all.some((v) => $selected.has(v));
}

function isMaxSelected() {
return max != null ? $selected.size >= max : false;
}

function toggleAll() {
let values: T[];
if (isAllSelected()) {
Expand All @@ -66,9 +87,11 @@ export default function selectionStore<T>(props: SelectionProps<T> = {}) {
selected: single ? selectedArr[0] ?? null : selectedArr,
toggleSelected,
isSelected,
isDisabled,
toggleAll,
isAllSelected,
isAnySelected,
isMaxSelected,
clear,
reset,
all,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -38,6 +39,31 @@
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch />
</Preview>

<h2>max selected</h2>

<Preview>
{value.length} selected
<MultiSelect {options} {value} max={2} on:change={(e) => (value = e.detail.value)} />
</Preview>

<h2>max selected with warning</h2>

<Preview>
{value.length} selected
<MultiSelect {options} {value} max={2} on:change={(e) => (value = e.detail.value)}>
<svelte:fragment slot="beforeOptions" let:selection>
{#if selection.isMaxSelected()}
<div
class="bg-red-50 border-red-500 text-red-600 border text-sm font-semibold p-2 rounded mb-1"
transition:slide
>
Maximum selection reached
</div>
{/if}
</svelte:fragment>
</MultiSelect>
</Preview>

<h2>many options</h2>

<Preview>
Expand Down Expand Up @@ -89,6 +115,21 @@
</div>
</Preview>

<h2>actions slot with max warning</h2>

<Preview>
{value.length} selected
<div class="flex flex-col max-h-[360px] overflow-auto">
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch max={2}>
<div slot="actions" let:selection class="flex items-center">
{#if selection.isMaxSelected()}
<div class="text-sm text-red-500">Maximum selection reached</div>
{/if}
</div>
</MultiSelect>
</div>
</Preview>

<h2>option slot</h2>

<Preview>
Expand Down
Loading

0 comments on commit e567797

Please sign in to comment.