combobox: improved picker width behaviour

add width behaviour options, fix match width
This commit is contained in:
Elijah Duffy
2026-03-10 16:05:47 -07:00
parent 3d240048c7
commit f8eb05cccf
3 changed files with 65 additions and 28 deletions

View File

@@ -41,7 +41,6 @@
import Label from './Label.svelte'; import Label from './Label.svelte';
import StyledRawInput from './StyledRawInput.svelte'; import StyledRawInput from './StyledRawInput.svelte';
import { InputValidatorEvent, validate, type ValidatorOptions } from '@svelte-toolkit/validate'; import { InputValidatorEvent, validate, type ValidatorOptions } from '@svelte-toolkit/validate';
import type { Action } from 'svelte/action';
import { onMount, tick, untrack, type Snippet } from 'svelte'; import { onMount, tick, untrack, type Snippet } from 'svelte';
import { Portal } from '@jsrob/svelte-portal'; import { Portal } from '@jsrob/svelte-portal';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';
@@ -52,6 +51,7 @@
import type { KeyOption } from 'match-sorter'; import type { KeyOption } from 'match-sorter';
import { Tween } from 'svelte/motion'; import { Tween } from 'svelte/motion';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import type { Attachment } from 'svelte/attachments';
interface Props { interface Props {
/** /**
@@ -104,11 +104,16 @@
/** Bindable open state of the combobox */ /** Bindable open state of the combobox */
open?: boolean; open?: boolean;
/** /**
* If enabled, matches the picker width to the input width. Otherwise, * Determines picker width based on content with four options:
* the picker width is determined by the average of its content, with * `match`: matches the input width
* a reasonable minimum (default: false). * `average`: calculates average width based on the length of option
* content, with a reasonable minimum
* `longest`: sets width based on the longest option, which can lead to
* excessively wide pickers but tries to prevent wrapping
* `max-match` (default): sets width based on the maximum of the input
* width and the longest option
*/ */
matchWidth?: boolean; pickerWidth?: 'match' | 'average' | 'longest' | 'max-match';
/** Uses a compact layout for the search input with less padding and smaller text */ /** Uses a compact layout for the search input with less padding and smaller text */
compact?: boolean; compact?: boolean;
/** /**
@@ -182,7 +187,7 @@
value = $bindable<ComboboxOption | undefined>(undefined), value = $bindable<ComboboxOption | undefined>(undefined),
open = $bindable(false), open = $bindable(false),
usePreview = 'auto', usePreview = 'auto',
matchWidth = false, pickerWidth = 'average',
options, options,
required = false, required = false,
invalidMessage = 'Please select an option', invalidMessage = 'Please select an option',
@@ -223,7 +228,6 @@
let pickerContainer = $state<HTMLDivElement | null>(null); let pickerContainer = $state<HTMLDivElement | null>(null);
// TODO: there's something weird with dropdown placement on narrow screens // TODO: there's something weird with dropdown placement on narrow screens
// TODO: match width to input isn't working properly for vconf selectors
/** options filtered by search value and searchKeys */ /** options filtered by search value and searchKeys */
const filteredItems = $derived.by(() => { const filteredItems = $derived.by(() => {
@@ -355,26 +359,58 @@
onchange?.(newValue); onchange?.(newValue);
}; };
/** sets minimum width of the picker as configured by props */ /**
const minWidth: Action<HTMLDivElement, { options: ComboboxOption[] }> = ( * Calculates average width based on provided options returning an estimated
container, * width in 'ch' units or a reasonable minimum when no options are provided.
buildOpts */
) => { const calculateAvgWidth = (opts: ComboboxOption[]) => {
const f = (opts: typeof buildOpts) => { if (opts.length === 0) return '10ch'; // reasonable minimum width when no options
if (matchWidth && searchInput) { const labelAvg = opts.reduce((acc, item) => acc + getLabel(item).length, 0) / opts.length;
container.style.width = searchInput.scrollWidth + 'px'; const infotextAvg =
return; opts.reduce((acc, item) => acc + (item.infotext ? item.infotext.length : 0), 0) / opts.length;
} const avgWidth = labelAvg * 2 + infotextAvg * 1.5 + (infotextAvg > 0 ? 10 : 0);
return `${Math.max(avgWidth, 10)}ch`;
const options = opts.options;
if (options.length === 0) return;
const avg = options.reduce((acc, item) => acc + getLabel(item).length, 0) / options.length;
container.style.width = `${avg * 2.5}ch`;
}; };
f(buildOpts);
return { /**
update: (updateOpts: typeof buildOpts) => { * Calculates longest width based on provided options, attempting to prevent
f(updateOpts); * wrapping by returning a width in 'ch' units based on the longest label and
* infotext, or a reasonable minimum when no options are provided.
*/
const calculateLongestWidth = (opts: ComboboxOption[]) => {
if (opts.length === 0) return '10ch'; // reasonable minimum width when no options
const longestLabel = Math.max(...opts.map((item) => getLabel(item).length));
const longestInfotext = Math.max(
...opts.map((item) => (item.infotext ? item.infotext.length : 0))
);
const longestWidth = longestLabel * 2 + longestInfotext * 1.5 + (longestInfotext > 0 ? 10 : 0);
return `${Math.max(longestWidth, 10)}ch`;
};
/** sets minimum width of the picker as configured by props */
const minWidth = (opts: {
options: ComboboxOption[];
matchElem: HTMLInputElement | null;
mode: Props['pickerWidth'];
}): Attachment<HTMLDivElement> => {
return (elem) => {
if ((opts.mode === 'match' || opts.options.length === 0) && opts.matchElem) {
// match width if explicitly enabled, or no options to average
elem.style.width = opts.matchElem.offsetWidth + 'px';
console.log('matched', elem.style.width, 'to', opts.matchElem.offsetWidth);
} else {
// otherwise, set width based on average content width
let val = '10ch';
if (opts.mode === 'average') {
val = calculateAvgWidth(opts.options);
} else if (opts.mode === 'longest' || opts.mode === 'max-match') {
val = calculateLongestWidth(opts.options);
if (opts.mode === 'max-match') {
elem.style.maxWidth = opts.matchElem ? opts.matchElem.offsetWidth + 'px' : 'none';
}
}
elem.style.width = val;
console.log('averaged', val);
} }
}; };
}; };
@@ -526,7 +562,6 @@
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={{ options: options }}
bind:this={pickerContainer} bind:this={pickerContainer}
transition:scale={{ duration: 200 }} transition:scale={{ duration: 200 }}
role="listbox" role="listbox"
@@ -547,6 +582,7 @@
onscroll({ event: e, top: atTop, bottom: atBottom, searchInput: searchInput?.value ?? '' }); onscroll({ event: e, top: atTop, bottom: atBottom, searchInput: searchInput?.value ?? '' });
}} }}
tabindex="0" tabindex="0"
{@attach minWidth({ options, mode: pickerWidth, matchElem: searchInput })}
> >
{#each filteredItems as opt (opt.value)} {#each filteredItems as opt (opt.value)}
{@render option(opt)} {@render option(opt)}

View File

@@ -120,7 +120,7 @@
{required} {required}
bind:value={timezone} bind:value={timezone}
{options} {options}
matchWidth pickerWidth="match"
placeholder="Select a timezone" placeholder="Select a timezone"
class={classValue} class={classValue}
/> />

View File

@@ -184,6 +184,7 @@
]} ]}
onchange={(e) => console.log('Selected:', e.value)} onchange={(e) => console.log('Selected:', e.value)}
onvalidate={(e) => console.log('Validation:', e.detail)} onvalidate={(e) => console.log('Validation:', e.detail)}
pickerWidth="max-match"
> >
{#snippet labelRender(opt: ComboboxOption)} {#snippet labelRender(opt: ComboboxOption)}
Processed {opt.label} Processed {opt.label}