Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@
"types": "./dist/forms/search/Search.svelte.d.ts",
"svelte": "./dist/forms/search/Search.svelte"
},
"./MultiSelect.svelte": {
"types": "./dist/forms/select/MultiSelect.svelte.d.ts",
"svelte": "./dist/forms/select/MultiSelect.svelte"
},
"./Select.svelte": {
"types": "./dist/forms/select/Select.svelte.d.ts",
"svelte": "./dist/forms/select/Select.svelte"
Expand Down
129 changes: 129 additions & 0 deletions src/lib/forms/select/MultiSelect.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<script lang="ts" generics="V, T extends SelectOptionType<V>">
import { twMerge } from "tailwind-merge";
import { multiSelect as selectCls, type MultiSelectProps as Props, type SelectOptionType } from ".";
import { createEventDispatcher } from "svelte";
import Badge from "$lib/badge/Badge.svelte";
import CloseButton from "$lib/utils/CloseButton.svelte";

let { badge = defaultBadge, items, value = $bindable([]), size = 'md', dropdownClass, placeholder, disabled = false, oninput, onclick, class: className, ...restProps }: Props<V, T> = $props();
let show = $state(false);
let activeIndex: number | null = $state(null);
let activeItem = $derived(activeIndex !== null ? items[((activeIndex % items?.length) + items.length) % items.length] : null)
let selectedItems = $derived(items.filter(i => value.includes(i.value)));

const dispatcher = createEventDispatcher();

const multiSelectClass = $derived(twMerge('relative border border-gray-300 flex items-center rounded-lg gap-2 dark:border-gray-600 ring-primary-500 dark:focus-within:border-primary-500 dark:ring-primary-500 focus-visible:outline-none', selectCls({ size, disabled }), className));
let multiSelectDropdown = $derived(twMerge('absolute z-50 p-3 flex flex-col gap-1 max-h-64 bg-white border border-gray-300 dark:bg-gray-700 dark:border-gray-600 start-0 top-[calc(100%+1rem)] rounded-lg cursor-pointer overflow-y-scroll w-full', dropdownClass));
const itemsClass = 'py-2 px-3 rounded-lg text-gray-600 hover:text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:text-gray-300 dark:hover:bg-gray-600';
const itemsSelectedClass = 'bg-gray-100 text-black font-semibold hover:text-black dark:text-white dark:bg-gray-600 dark:hover:text-white';
const activeItemClass = 'bg-primary-100 text-primary-500 dark:bg-primary-500 dark:text-primary-100 hover:bg-primary-100 dark:hover:bg-primary-500 hover:text-primary-600 dark:hover:text-primary-100';
const disabledItemClass = 'text-gray-400 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-600 cursor-not-allowed';

function selectOption(select: T) {
if(disabled || select.disabled) return;
if(value.includes(select.value)) {
value = value.filter(v => v !== select.value);
} else {
value = [...value, select.value];
}
dispatcher('change');
}
function clearOption(select: T) {
if(disabled || select.disabled) return;
if(value.includes(select.value)) {
value = value.filter(v => v !== select.value);
dispatcher('change');
}
}
function clearAll() {
if(disabled) return;
if(value.length > 0) {
value = selectedItems.filter(i => i.disabled).map(i => i.value);
dispatcher('change');
}
}

// keyboard navigation
function onkeydown(e: KeyboardEvent) {
switch (e.key) {
case 'Escape': {
show = false;
break;
}
case 'Enter':
case ' ': {
if(disabled) break;
if(!show) {
show = true;
activeIndex = 0;
} else if(activeItem !== null) {
selectOption(activeItem);
}
break;
}
case 'ArrowDown': {
handleArrowKey(1);
break;
}
case 'ArrowUp': {
handleArrowKey(-1);
break;
}
}
e.stopPropagation();
e.preventDefault();
}
function handleArrowKey(offset: number) {
if(disabled) return;
if(!show || activeIndex === null) {
show = true;
activeIndex = 0;
} else {
activeIndex += offset;
}
}
</script>

<!-- hidden select for form submission -->
<select {value} hidden multiple {...restProps}>
{#each items as { value, name }}
<option {value}>{name}</option>
{/each}
</select>

{#snippet defaultBadge({ item, clear, disabled }: { item: T, clear: () => void, disabled: boolean })}
<Badge color='gray' large={size === 'lg'} dismissable params={{ duration: 100 }} onclick={clear}>
{item.name}
</Badge>
{/snippet}

<div onclick={() => show = !disabled && !show} onfocusout={() => show = false} {onkeydown} tabindex="0" role="listbox" class={multiSelectClass}>
{#if !selectedItems.length}
<span class='text-gray-400'>{placeholder}</span>
{/if}
<span class='flex gap-2 flex-wrap'>
{#each selectedItems as item (item.value)}
{@render badge({ item, clear: () => clearOption(item), disabled: item.disabled || disabled })}
{/each}
</span>
<div class='flex ms-auto gap-2 items-center'>
{#if selectedItems.length}
<CloseButton onclick={clearAll} class='p-0 focus:ring-gray-400 dark:text-white' />
{/if}
<div class="w-[1px] bg-gray-300 dark:bg-gray-600"></div>
<svg class="cursor-pointer h-3 w-3 ms-1 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={show ? 'm1 5 4-4 4 4' : 'm9 1-4 4-4-4'} />
</svg>
</div>

{#if show}
<div onclick={e => (e.stopPropagation(), onclick?.(e))} role="presentation" class={multiSelectDropdown}>
{#each items as item (item.value)}
<div onclick={() => selectOption(item)} role="presentation" class={twMerge(itemsClass, selectedItems.includes(item) && itemsSelectedClass, activeItem === item && activeItemClass, item.disabled && disabledItemClass)}>
{item.name}
</div>
{/each}
</div>
{/if}
</div>
8 changes: 4 additions & 4 deletions src/lib/forms/select/Select.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
<script lang="ts" generics="T">
import { type SelectProps as Props, select as selectCls } from '.';

let { children, items, value = $bindable(), underline, size = 'md', class: className, placeholder = 'Choose option ...', ...restProps }: Props = $props();
let { children, items, value = $bindable(), underline, size = 'md', class: className, placeholder = 'Choose option ...', ...restProps }: Props<T> = $props();

const selectStyle = $derived(selectCls({ underline, size, className }));
</script>
Expand All @@ -12,8 +12,8 @@
{/if}

{#if items}
{#each items as { value, name }}
<option {value}>{name}</option>
{#each items as { value, name, disabled }}
<option {value} {disabled}>{name}</option>
{/each}
{/if}

Expand Down
21 changes: 12 additions & 9 deletions src/lib/forms/select/index.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
import Select from './Select.svelte';
import type { Snippet } from 'svelte';
import type { HTMLSelectAttributes, HTMLAttributes } from 'svelte/elements';
import { select } from './theme';
import { select, multiSelect } from './theme';

type SelectOptionType<T> = {
name: string | number;
value: T;
disabled?: boolean;
};

interface SelectProps extends Omit<HTMLSelectAttributes, 'size'> {
interface SelectProps<T> extends Omit<HTMLSelectAttributes, 'size'> {
children?: Snippet;
items?: SelectOptionType<any>[];
value?: any;
items?: SelectOptionType<T>[];
value?: T;
underline?: boolean;
size?: 'sm' | 'md' | 'lg';
placeholder?: string;
}

interface MultiSelectProps<T> extends HTMLAttributes<HTMLDivElement> {
children?: Snippet;
items?: SelectOptionType<T>[];
value?: (string | number)[];
interface MultiSelectProps<V, T extends SelectOptionType<V>> extends Omit<HTMLAttributes<HTMLSelectElement>, "children" | "onclick"> {
badge?: Snippet<[{ item: T, clear: () => void, disabled: boolean }]>;
items: T[];
value?: V[];
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
dropdownClass?: string;
placeholder?: string;
change?: (event: Event) => void;
onclick?: (event: MouseEvent & { currentTarget: EventTarget & HTMLDivElement }) => void;
}

export { Select, select, type SelectProps, type SelectOptionType, type MultiSelectProps };
export { Select, select, multiSelect, type SelectProps, type SelectOptionType, type MultiSelectProps };
18 changes: 18 additions & 0 deletions src/lib/forms/select/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,21 @@ export const select = tv({
size: 'md'
}
});

export const multiSelect = tv({
variants: {
size: {
sm: 'px-2 py-1 min-h-[2.4rem]',
md: 'px-3 py-1 min-h-[2.7rem]',
lg: 'px-4 py-2 min-h-[3.2rem]'
},
disabled: {
true: 'opacity-50 cursor-not-allowed',
false: 'focus-within:ring-1 focus-within:border-primary-500 dark:focus-within:border-primary-500'
}
},
defaultVariants: {
size: 'md',
disabled: false
}
});
79 changes: 76 additions & 3 deletions src/routes/forms/select/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { Select, Label, Radio, Helper, Dropdown, DropdownUl, DropdownLi, uiHelpers, ButtonGroup, Button } from '$lib';
import { ChevronDownOutline } from 'flowbite-svelte-icons';
import { CaretDownSolid, CaretUpSolid, ChevronDownOutline } from 'flowbite-svelte-icons';
import HighlightCompo from '../../utils/HighlightCompo.svelte';
import CodeWrapper from '../../utils/CodeWrapper.svelte';
import H1 from '../../utils/H1.svelte';
Expand All @@ -10,6 +10,11 @@
import Germany from '../../utils/icons/Germany.svelte';
import Italy from '../../utils/icons/Italy.svelte';
import China from '../../utils/icons/China.svelte';
import { H3 } from '../../utils/heading';
import MultiSelect from '$lib/forms/select/MultiSelect.svelte';
import P from '$lib/typography/paragraph/P.svelte';
import Kbd from '$lib/kbd/Kbd.svelte';
import Badge from '$lib/badge/Badge.svelte';

const modules = import.meta.glob('./md/*.md', {
query: '?raw',
Expand Down Expand Up @@ -60,8 +65,8 @@
});

const sizes = ['sm', 'md', 'lg'];
let selectSize: Select['size'] = $state('md');
const sizeDisplay: Record<Select['size'], string> = {
let selectSize: Select<any>['size'] = $state('md');
const sizeDisplay: Record<Select<any>['size'], string> = {
sm: 'Small',
md: 'Medium',
lg: 'Large'
Expand Down Expand Up @@ -180,3 +185,71 @@
<HighlightCompo code={modules['./md/custom-options.md'] as string} />
{/snippet}
</CodeWrapper>

<H2>MultiSelect</H2>

<H3>Basic example</H3>
<CodeWrapper>
<MultiSelect items={states} placeholder="Placeholder text" />
{#snippet codeblock()}
<HighlightCompo code={modules['./md/default-multiselect.md'] as string} />
{/snippet}
</CodeWrapper>

<H3>Preselect values</H3>
<CodeWrapper>
<MultiSelect items={states} value={['CA', 'FL']} />
{#snippet codeblock()}
<HighlightCompo code={modules['./md/multiselect-preselect.md'] as string} />
{/snippet}
</CodeWrapper>

<H3>Disabled MultiSelect</H3>
<CodeWrapper>
<MultiSelect items={states} value={['CA', 'FL']} disabled />
{#snippet codeblock()}
<HighlightCompo code={modules['./md/disabled-multiselect.md'] as string} />
{/snippet}
</CodeWrapper>

<H3>Disabled options</H3>
<CodeWrapper>
<MultiSelect items={[
{ value: 'us', name: 'United States', disabled: true },
{ value: 'ca', name: 'Canada' },
{ value: 'fr', name: 'France', disabled: true },
{ value: 'jp', name: 'Japan' },
{ value: 'en', name: 'England' }
]} value={['fr', 'en']} />
{#snippet codeblock()}
<HighlightCompo code={modules['./md/disabled-options-multiselect.md'] as string} />
{/snippet}
</CodeWrapper>

<H3>Keyboard Usage</H3>
<P>
Some keyboard interaction was implemented. Use <Kbd class="inline-flex items-center px-2 py-1.5"><CaretUpSolid size="xs" /><span class="sr-only">Arrow key up</span></Kbd>/<Kbd class="inline-flex items-center px-2 py-1.5"><CaretDownSolid size="xs" /><span class="sr-only">Arrow key down</span></Kbd>
to highlight an item, then press <Kbd class="inline-flex items-center px-2 py-1.5">Enter</Kbd> or
<Kbd class="inline-flex items-center px-2 py-1.5">SpaceBar</Kbd> to toggle the selected item. Press
<Kbd class="inline-flex items-center px-2 py-1.5">Esc</Kbd> to close the selection pop-up.
</P>

<H3>Customization</H3>
<CodeWrapper>
<MultiSelect items={[
{ value: 'us', name: 'United States', color: 'indigo' },
{ value: 'ca', name: 'Canada', color: 'green' },
{ value: 'fr', name: 'France', color: 'blue' },
{ value: 'jp', name: 'Japan', color: 'red' },
{ value: 'en', name: 'England', color: 'yellow' }
] as const}>
{#snippet badge({ item, clear })}
<Badge color={item.color} large dismissable params={{ duration: 100 }} onclick={clear}>
{item.name}
</Badge>
{/snippet}
</MultiSelect>
{#snippet codeblock()}
<HighlightCompo code={modules['./md/custom-multiselect.md'] as string} />
{/snippet}
</CodeWrapper>
17 changes: 17 additions & 0 deletions src/routes/forms/select/md/custom-multiselect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script>
const items = [
{ value: 'us', name: 'United States', color: 'indigo' },
{ value: 'ca', name: 'Canada', color: 'green' },
{ value: 'fr', name: 'France', color: 'blue' },
{ value: 'jp', name: 'Japan', color: 'red' },
{ value: 'en', name: 'England', color: 'yellow' }
] as const;
</script>

<MultiSelect {items}>
{#snippet badge({ item, clear })}
<Badge color={item.color} large dismissable params={{ duration: 100 }} onclick={clear}>
{item.name}
</Badge>
{/snippet}
</MultiSelect>
14 changes: 14 additions & 0 deletions src/routes/forms/select/md/default-multiselect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script>
let placeholder = 'Placeholder text';
let selected = [];
let states = [
{ value: 'CA', name: 'California' },
{ value: 'TX', name: 'Texas' },
{ value: 'WH', name: 'Washinghton' },
{ value: 'FL', name: 'Florida' },
{ value: 'VG', name: 'Virginia' },
{ value: 'GE', name: 'Georgia' },
{ value: 'MI', name: 'Michigan' }
];
</script>
<MultiSelect items={states} {placeholder} bind:value={selected} />
12 changes: 12 additions & 0 deletions src/routes/forms/select/md/disabled-multiselect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script>
let states = [
{ value: 'CA', name: 'California' },
{ value: 'TX', name: 'Texas' },
{ value: 'WH', name: 'Washinghton' },
{ value: 'FL', name: 'Florida' },
{ value: 'VG', name: 'Virginia' },
{ value: 'GE', name: 'Georgia' },
{ value: 'MI', name: 'Michigan' }
];
</script>
<MultiSelect items={states} value={['CA', 'FL']} disabled />
10 changes: 10 additions & 0 deletions src/routes/forms/select/md/disabled-options-multiselect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script>
let countries = [
{ value: 'us', name: 'United States', disabled: true },
{ value: 'ca', name: 'Canada' },
{ value: 'fr', name: 'France', disabled: true },
{ value: 'jp', name: 'Japan' },
{ value: 'en', name: 'England' }
];
</script>
<MultiSelect items={countries} value={['fr', 'en']} />
Loading