diff --git a/src/lib/Checkbox.svelte b/src/lib/Checkbox.svelte index a69bb3b..89a13b3 100644 --- a/src/lib/Checkbox.svelte +++ b/src/lib/Checkbox.svelte @@ -5,6 +5,7 @@ diff --git a/src/lib/Combobox.svelte b/src/lib/Combobox.svelte index 987f7eb..63607fb 100644 --- a/src/lib/Combobox.svelte +++ b/src/lib/Combobox.svelte @@ -28,29 +28,15 @@ import { Portal } from '@jsrob/svelte-portal'; import { browser } from '$app/environment'; import { scale } from 'svelte/transition'; + import { generateIdentifier } from './util.js'; - let { - name, - value = $bindable(undefined), - open = $bindable(false), - usePreview = 'auto', - matchWidth = false, - items, - required = false, - invalidMessage, - label, - placeholder, - notFoundMessage = 'No results found', - onvalidate, - onchange - }: { - name: string; + interface Props { + name?: string; value?: ComboboxItem; - highlighted?: ComboboxItem; open?: boolean; usePreview?: boolean | 'auto'; matchWidth?: boolean; - items: ComboboxItem[]; + options: ComboboxItem[]; required?: boolean; invalidMessage?: string; label?: string; @@ -58,7 +44,25 @@ notFoundMessage?: string; onvalidate?: (e: InputValidatorEvent) => void; onchange?: (e: ComboboxChangeEvent) => void; - } = $props(); + } + + let { + name, + value = $bindable(undefined), + open = $bindable(false), + usePreview = 'auto', + matchWidth = false, + options, + required = false, + invalidMessage, + label, + placeholder, + notFoundMessage = 'No results found', + onvalidate, + onchange + }: Props = $props(); + + let id = $derived(generateIdentifier('combobox', name)); let valid = $state(true); let searchValue = $state(''); @@ -71,8 +75,8 @@ const filteredItems = $derived( searchValue === '' - ? items - : items.filter((item) => getLabel(item).toLowerCase().includes(searchValue.toLowerCase())) + ? options + : options.filter((item) => getLabel(item).toLowerCase().includes(searchValue.toLowerCase())) ); let highlighted = $derived.by((): ComboboxItem | undefined => { @@ -95,15 +99,15 @@ return filteredItems.findIndex((item) => item.value === highlighted?.value); }; - const minWidth: Action = (container, buildOpts) => { + const minWidth: Action = (container, buildOpts) => { const f = (opts: typeof buildOpts) => { if (matchWidth && searchInput) { container.style.width = searchInput.scrollWidth + 'px'; return; } - const items = opts.items; - const avg = items.reduce((acc, item) => acc + getLabel(item).length, 0) / items.length; + const options = opts.options; + const avg = options.reduce((acc, item) => acc + getLabel(item).length, 0) / options.length; container.style.width = `${avg * 2.5}ch`; console.log(`minWidth: ${avg * 2.5}ch`); }; @@ -139,13 +143,15 @@ const outerMargin = 24; if (availableSpaceBelow < availableSpaceAbove) { - overlay.style.bottom = `${window.innerHeight - targetRect.top}px`; + // overlay should be above the target + overlay.style.bottom = `${window.innerHeight - targetRect.top - window.scrollY}px`; overlay.style.top = 'auto'; overlay.style.maxHeight = `${availableSpaceAbove - outerMargin}px`; pickerPosition = 'top'; overlay.dataset.side = 'top'; } else { - overlay.style.top = `${targetRect.bottom}px`; + // overlay should be below the target + overlay.style.top = `${targetRect.bottom + window.scrollY}px`; overlay.style.bottom = 'auto'; overlay.style.maxHeight = `${availableSpaceBelow - outerMargin}px`; pickerPosition = 'bottom'; @@ -197,7 +203,7 @@ } // when the picker is opened, update its position - // when the picker is closed, clear any search value (next time it opens, all items will be shown) + // when the picker is closed, clear any search value (next time it opens, all options will be shown) // when the picker is closed, reset highlighted to the currently selected value (if any) $effect(() => { if (open) { @@ -253,7 +259,7 @@ open && pickerPosition === 'top' && 'mb-[var(--outer-gap)]', open && pickerPosition === 'bottom' && 'mt-[var(--outer-gap)]' ]} - use:minWidth={{ items: items }} + use:minWidth={{ options: options }} bind:this={pickerContainer} transition:scale={{ duration: 200 }} role="listbox" @@ -272,7 +278,7 @@ aria-label={getLabel(item)} aria-disabled={item.disabled} class={[ - 'picker-item mb-0.5 flex min-h-10 flex-wrap items-center py-2.5 pr-1.5 pl-5', + 'picker-item options-center mb-0.5 flex min-h-10 flex-wrap py-2.5 pr-1.5 pl-5', 'rounded-sm text-sm capitalize outline-hidden select-none', 'hover:bg-accent-500/30 dark:hover:bg-accent-700/30', item.value === highlighted?.value && 'bg-accent-500/80 dark:bg-accent-700/80' @@ -321,12 +327,13 @@ {#if label} - {label} + {label} {/if} {#if invalidMessage} - {invalidMessage} + {invalidMessage} {/if} diff --git a/src/lib/PhoneInput.svelte b/src/lib/PhoneInput.svelte index 1de8688..e356029 100644 --- a/src/lib/PhoneInput.svelte +++ b/src/lib/PhoneInput.svelte @@ -117,7 +117,7 @@ > diff --git a/src/lib/util.ts b/src/lib/util.ts new file mode 100644 index 0000000..4417ff1 --- /dev/null +++ b/src/lib/util.ts @@ -0,0 +1,17 @@ +/** + * Generates a unique identifier string unless an identifier is provided. + * If a prefix is provided, it will be prepended to the identifier. + * The identifier is a combination of a random part and a timestamp. + * + * @param {string} [prefix] - Optional prefix to prepend to the identifier. + * @param {string} [identifier] - Optional identifier to use instead of generating a new one. + * @returns {string} - A unique identifier string. + */ +export const generateIdentifier = (prefix?: string, identifier?: string): string => { + if (identifier) { + return `${prefix ? prefix + '-' : ''}${identifier}`; + } + const randomPart = Math.random().toString(36).substring(2, 10); + const timestampPart = Date.now().toString(36); + return `${prefix ? prefix + '-' : ''}${randomPart}-${timestampPart}`; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3077b22..ee2eacb 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -49,7 +49,7 @@ name="example-combobox" label="Select an option" placeholder="Choose..." - items={[ + options={[ { value: 'option1', label: 'Option 1' }, { value: 'option2', label: 'Option 2' }, { value: 'option3', label: 'Option 3' }