diff --git a/src/lib/holocene/combobox/combobox-option.svelte b/src/lib/holocene/combobox/combobox-option.svelte index 621f532ca..e1f39850e 100644 --- a/src/lib/holocene/combobox/combobox-option.svelte +++ b/src/lib/holocene/combobox/combobox-option.svelte @@ -1,12 +1,20 @@ - - + + {#if multiselect} + dispatch('click')} checked={selected} /> + {/if} + + {label} diff --git a/src/lib/holocene/combobox/combobox.stories.svelte b/src/lib/holocene/combobox/combobox.stories.svelte index 149607dac..bf115b5ed 100644 --- a/src/lib/holocene/combobox/combobox.stories.svelte +++ b/src/lib/holocene/combobox/combobox.stories.svelte @@ -122,3 +122,20 @@ expect(noResults).toBeInTheDocument(); }} /> + + diff --git a/src/lib/holocene/combobox/combobox.svelte b/src/lib/holocene/combobox/combobox.svelte index 6e22e4daf..4763762fe 100644 --- a/src/lib/holocene/combobox/combobox.svelte +++ b/src/lib/holocene/combobox/combobox.svelte @@ -10,9 +10,12 @@ import MenuContainer from '$lib/holocene/menu/menu-container.svelte'; import Menu from '$lib/holocene/menu/menu.svelte'; + import Badge from '../badge.svelte'; import Button from '../button.svelte'; + import Chip from '../chip.svelte'; import type { IconName } from '../icon'; import Icon from '../icon/icon.svelte'; + import MenuDivider from '../menu/menu-divider.svelte'; type T = $$Generic; @@ -30,7 +33,6 @@ id: string; label: string; toggleLabel: string; - value: string; noResultsText: string; disabled?: boolean; labelHidden?: boolean; @@ -45,28 +47,48 @@ valid?: boolean; } - type UncontrolledStringOptionProps = { + type MultiSelectProps = { + multiselect: true; + value: string[]; + displayChips?: boolean; + chipLimit?: number; + removeChipLabel?: string; + selectAllLabel?: string; + selectNoneLabel?: string; + numberOfItemsSelectedLabel?: (count: number) => string; + }; + + type SingleSelectProps = { + multiselect?: false; + value: string; + chipLimit?: never; + }; + + type StringOptionProps = { options: string[]; optionValueKey?: never; optionLabelKey?: never; displayValue?: never; }; - type UncontrolledCustomOptionProps = { + type CustomOptionProps = { options: T[]; optionValueKey: keyof T; optionLabelKey?: keyof T; }; type $$Props = - | (BaseProps & UncontrolledStringOptionProps) - | (BaseProps & UncontrolledCustomOptionProps); + | (BaseProps & StringOptionProps & SingleSelectProps) + | (BaseProps & StringOptionProps & MultiSelectProps) + | (BaseProps & CustomOptionProps & SingleSelectProps) + | (BaseProps & CustomOptionProps & MultiSelectProps); let className = ''; export { className as class }; export let id: string; export let label: string; - export let value: string = undefined; + export let multiselect = false; + export let value: string | string[] = multiselect ? [] : undefined; export let toggleLabel: string; export let noResultsText: string; export let disabled = false; @@ -82,8 +104,15 @@ export let maxSize = 120; export let error = ''; export let valid = true; - - let displayValue: string; + export let displayChips = true; + export let chipLimit = 5; + export let selectAllLabel = 'Select All'; + export let deselectAllLabel = 'Deselect All'; + export let removeChipLabel = 'Remove Option'; + export let numberOfItemsSelectedLabel = (count: number) => + `${count} option${count > 1 ? 's' : ''} selected`; + + let displayValue: string = ''; let selectedOption: string | T; let menuElement: HTMLUListElement; let inputElement: HTMLInputElement; @@ -102,7 +131,7 @@ } } - $: { + $: if (!multiselect) { selectedOption = options.find((option) => { if (isStringOption(option)) { return option === value; @@ -147,7 +176,9 @@ list = options; }; - const narrowOption = (option: unknown): T => option as T; + const isArrayValue = (value: string | string[]): value is string[] => { + return Array.isArray(value); + }; const isStringOption = (option: string | T): option is string => { return typeof option === 'string'; @@ -170,7 +201,13 @@ }; const getDisplayValue = (option: string | T | undefined): string => { - if (!option) return value ?? ''; + if (!option) { + if (isArrayValue(value)) { + return ''; + } + + return value ?? ''; + } if (isStringOption(option)) { return option; @@ -183,18 +220,59 @@ const setValue = (option: string | T): void => { if (isStringOption(option)) { - value = option; + if (isArrayValue(value)) { + if (value.includes(option)) { + value = value.filter((o) => o !== option); + } else { + value = [...value, option]; + } + } else { + value = option; + } } if (isObjectOption(option) && canRenderCustomOption(option)) { - value = String(option[optionValueKey]); + const opt = String(option[optionValueKey]); + if (isArrayValue(value)) { + if (value.includes(opt)) { + value = value.filter((o) => o !== opt); + } else { + value = [...value, opt]; + } + } else { + value = opt; + } } }; const handleSelectOption = (option: string | T) => { setValue(option); dispatch('change', { value: option }); - resetValueAndOptions(); + if (!multiselect) { + resetValueAndOptions(); + } + }; + + const removeOption = (option: string) => { + if (isArrayValue(value)) { + value = value.filter((o) => o !== option); + } + }; + + const selectAll = () => { + if (!multiselect || !isArrayValue(value)) return; + + value = list.map((option) => { + if (isObjectOption(option) && canRenderCustomOption(option)) { + return String(option[optionValueKey]); + } else if (isStringOption(option)) { + return option; + } + }); + }; + + const deselectAll = () => { + value = []; }; const focusFirstOption = () => { @@ -252,41 +330,86 @@ const handleInputClick = () => { if (!$open) openList(); }; + + const isSelected = (option: string | T): boolean => { + if (isObjectOption(option)) { + const o = String(option[optionValueKey]); + return isArrayValue(value) ? value.includes(o) : value === o; + } else if (isStringOption(option)) { + return isArrayValue(value) ? value.includes(option) : value === option; + } + + return false; + }; + {#if leadingIcon} {/if} - + + {#if multiselect && isArrayValue(value) && value.length > 0} + {#if displayChips} + {#each value.slice(0, chipLimit) as v} + removeOption(v)} + removeButtonLabel={removeChipLabel}>{v} + {/each} + {#if value.length > chipLimit} + +{value.slice(chipLimit).length} + {/if} + {:else} + {numberOfItemsSelectedLabel(value.length)} + {/if} + {/if} + + + {#if multiselect && isArrayValue(value) && value.length > 0} + + + + {/if} {#if $$slots.action} - + {/if} - - {#each list as option} - {#if isStringOption(option)} + + {#if multiselect && isArrayValue(value)} + + + + {/if} + {#key value} + {#each list as option} handleSelectOption(option)} - selected={value === option} - > - {option} - - {:else if isObjectOption(option)} - {#if canRenderCustomOption(option)} - handleSelectOption(option)} - selected={value === option[optionValueKey]} - > - {option[optionLabelKey]} - - {:else} - - {/if} - {/if} - {:else} - {noResultsText} - {/each} + selected={isSelected(option)} + label={getDisplayValue(option)} + /> + {:else} + + {/each} + {/key} {#if error && !valid} @@ -339,7 +470,7 @@
+{value.slice(chipLimit).length}