combobox: improve props, fix placement with scroll

This commit is contained in:
Elijah Duffy
2025-07-03 10:57:21 -07:00
parent f2994abad2
commit a307ffee92
6 changed files with 60 additions and 37 deletions

View File

@@ -5,6 +5,7 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { validate } from '@svelte-toolkit/validate'; import { validate } from '@svelte-toolkit/validate';
import { generateIdentifier } from './util.js';
interface Props { interface Props {
name?: string; name?: string;
@@ -24,9 +25,7 @@
onchange onchange
}: Props = $props(); }: Props = $props();
let id = $derived.by(() => { let id = $derived(generateIdentifier('checkbox', name));
return `checkbox-${name || Math.random().toString(36).substring(2, 15)}`;
});
let valid = $state(true); let valid = $state(true);
</script> </script>

View File

@@ -28,29 +28,15 @@
import { Portal } from '@jsrob/svelte-portal'; import { Portal } from '@jsrob/svelte-portal';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';
import { generateIdentifier } from './util.js';
let { interface Props {
name, name?: string;
value = $bindable<ComboboxItem | undefined>(undefined),
open = $bindable(false),
usePreview = 'auto',
matchWidth = false,
items,
required = false,
invalidMessage,
label,
placeholder,
notFoundMessage = 'No results found',
onvalidate,
onchange
}: {
name: string;
value?: ComboboxItem; value?: ComboboxItem;
highlighted?: ComboboxItem;
open?: boolean; open?: boolean;
usePreview?: boolean | 'auto'; usePreview?: boolean | 'auto';
matchWidth?: boolean; matchWidth?: boolean;
items: ComboboxItem[]; options: ComboboxItem[];
required?: boolean; required?: boolean;
invalidMessage?: string; invalidMessage?: string;
label?: string; label?: string;
@@ -58,7 +44,25 @@
notFoundMessage?: string; notFoundMessage?: string;
onvalidate?: (e: InputValidatorEvent) => void; onvalidate?: (e: InputValidatorEvent) => void;
onchange?: (e: ComboboxChangeEvent) => void; onchange?: (e: ComboboxChangeEvent) => void;
} = $props(); }
let {
name,
value = $bindable<ComboboxItem | undefined>(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 valid = $state(true);
let searchValue = $state(''); let searchValue = $state('');
@@ -71,8 +75,8 @@
const filteredItems = $derived( const filteredItems = $derived(
searchValue === '' searchValue === ''
? items ? options
: items.filter((item) => getLabel(item).toLowerCase().includes(searchValue.toLowerCase())) : options.filter((item) => getLabel(item).toLowerCase().includes(searchValue.toLowerCase()))
); );
let highlighted = $derived.by((): ComboboxItem | undefined => { let highlighted = $derived.by((): ComboboxItem | undefined => {
@@ -95,15 +99,15 @@
return filteredItems.findIndex((item) => item.value === highlighted?.value); return filteredItems.findIndex((item) => item.value === highlighted?.value);
}; };
const minWidth: Action<HTMLDivElement, { items: ComboboxItem[] }> = (container, buildOpts) => { const minWidth: Action<HTMLDivElement, { options: ComboboxItem[] }> = (container, buildOpts) => {
const f = (opts: typeof buildOpts) => { const f = (opts: typeof buildOpts) => {
if (matchWidth && searchInput) { if (matchWidth && searchInput) {
container.style.width = searchInput.scrollWidth + 'px'; container.style.width = searchInput.scrollWidth + 'px';
return; return;
} }
const items = opts.items; const options = opts.options;
const avg = items.reduce((acc, item) => acc + getLabel(item).length, 0) / items.length; const avg = options.reduce((acc, item) => acc + getLabel(item).length, 0) / options.length;
container.style.width = `${avg * 2.5}ch`; container.style.width = `${avg * 2.5}ch`;
console.log(`minWidth: ${avg * 2.5}ch`); console.log(`minWidth: ${avg * 2.5}ch`);
}; };
@@ -139,13 +143,15 @@
const outerMargin = 24; const outerMargin = 24;
if (availableSpaceBelow < availableSpaceAbove) { 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.top = 'auto';
overlay.style.maxHeight = `${availableSpaceAbove - outerMargin}px`; overlay.style.maxHeight = `${availableSpaceAbove - outerMargin}px`;
pickerPosition = 'top'; pickerPosition = 'top';
overlay.dataset.side = 'top'; overlay.dataset.side = 'top';
} else { } 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.bottom = 'auto';
overlay.style.maxHeight = `${availableSpaceBelow - outerMargin}px`; overlay.style.maxHeight = `${availableSpaceBelow - outerMargin}px`;
pickerPosition = 'bottom'; pickerPosition = 'bottom';
@@ -197,7 +203,7 @@
} }
// when the picker is opened, update its position // 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) // when the picker is closed, reset highlighted to the currently selected value (if any)
$effect(() => { $effect(() => {
if (open) { if (open) {
@@ -253,7 +259,7 @@
open && pickerPosition === 'top' && 'mb-[var(--outer-gap)]', open && pickerPosition === 'top' && 'mb-[var(--outer-gap)]',
open && pickerPosition === 'bottom' && 'mt-[var(--outer-gap)]' open && pickerPosition === 'bottom' && 'mt-[var(--outer-gap)]'
]} ]}
use:minWidth={{ items: items }} use:minWidth={{ options: options }}
bind:this={pickerContainer} bind:this={pickerContainer}
transition:scale={{ duration: 200 }} transition:scale={{ duration: 200 }}
role="listbox" role="listbox"
@@ -272,7 +278,7 @@
aria-label={getLabel(item)} aria-label={getLabel(item)}
aria-disabled={item.disabled} aria-disabled={item.disabled}
class={[ 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', 'rounded-sm text-sm capitalize outline-hidden select-none',
'hover:bg-accent-500/30 dark:hover:bg-accent-700/30', 'hover:bg-accent-500/30 dark:hover:bg-accent-700/30',
item.value === highlighted?.value && 'bg-accent-500/80 dark:bg-accent-700/80' item.value === highlighted?.value && 'bg-accent-500/80 dark:bg-accent-700/80'
@@ -321,12 +327,13 @@
<div> <div>
<!-- Combobox Label --> <!-- Combobox Label -->
{#if label} {#if label}
<Label for={name}>{label}</Label> <Label for={id}>{label}</Label>
{/if} {/if}
<!-- Hidden input stores currently selected value --> <!-- Hidden input stores currently selected value -->
<input <input
{name} {name}
{id}
value={value?.value ?? ''} value={value?.value ?? ''}
class="hidden" class="hidden"
use:validate={validateOpts} use:validate={validateOpts}
@@ -344,7 +351,7 @@
<!-- Error message if invalid --> <!-- Error message if invalid -->
{#if invalidMessage} {#if invalidMessage}
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}> <div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
<Label for={name} error>{invalidMessage}</Label> <Label for={id} error>{invalidMessage}</Label>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -117,7 +117,7 @@
> >
<ExpandableCombobox <ExpandableCombobox
name="{name}_country" name="{name}_country"
items={options} {options}
bind:value={selectedCountryItem} bind:value={selectedCountryItem}
bind:open={countriesOpen} bind:open={countriesOpen}
{required} {required}

View File

@@ -94,7 +94,7 @@
{invalidMessage} {invalidMessage}
{required} {required}
bind:value={timezone} bind:value={timezone}
items={options} {options}
matchWidth matchWidth
placeholder="Select a timezone" placeholder="Select a timezone"
/> />

17
src/lib/util.ts Normal file
View File

@@ -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}`;
};

View File

@@ -49,7 +49,7 @@
name="example-combobox" name="example-combobox"
label="Select an option" label="Select an option"
placeholder="Choose..." placeholder="Choose..."
items={[ options={[
{ value: 'option1', label: 'Option 1' }, { value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' }, { value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' } { value: 'option3', label: 'Option 3' }