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 StyledRawInput from './StyledRawInput.svelte';
import { InputValidatorEvent, validate, type ValidatorOptions } from '@svelte-toolkit/validate';
import type { Action } from 'svelte/action';
import { onMount, tick, untrack, type Snippet } from 'svelte';
import { Portal } from '@jsrob/svelte-portal';
import { scale } from 'svelte/transition';
@@ -52,6 +51,7 @@
import type { KeyOption } from 'match-sorter';
import { Tween } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import type { Attachment } from 'svelte/attachments';
interface Props {
/**
@@ -104,11 +104,16 @@
/** Bindable open state of the combobox */
open?: boolean;
/**
* If enabled, matches the picker width to the input width. Otherwise,
* the picker width is determined by the average of its content, with
* a reasonable minimum (default: false).
* Determines picker width based on content with four options:
* `match`: matches the input width
* `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 */
compact?: boolean;
/**
@@ -182,7 +187,7 @@
value = $bindable<ComboboxOption | undefined>(undefined),
open = $bindable(false),
usePreview = 'auto',
matchWidth = false,
pickerWidth = 'average',
options,
required = false,
invalidMessage = 'Please select an option',
@@ -223,7 +228,6 @@
let pickerContainer = $state<HTMLDivElement | null>(null);
// 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 */
const filteredItems = $derived.by(() => {
@@ -355,26 +359,58 @@
onchange?.(newValue);
};
/** sets minimum width of the picker as configured by props */
const minWidth: Action<HTMLDivElement, { options: ComboboxOption[] }> = (
container,
buildOpts
) => {
const f = (opts: typeof buildOpts) => {
if (matchWidth && searchInput) {
container.style.width = searchInput.scrollWidth + 'px';
return;
}
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`;
/**
* Calculates average width based on provided options returning an estimated
* width in 'ch' units or a reasonable minimum when no options are provided.
*/
const calculateAvgWidth = (opts: ComboboxOption[]) => {
if (opts.length === 0) return '10ch'; // reasonable minimum width when no options
const labelAvg = opts.reduce((acc, item) => acc + getLabel(item).length, 0) / opts.length;
const infotextAvg =
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`;
};
f(buildOpts);
return {
update: (updateOpts: typeof buildOpts) => {
f(updateOpts);
/**
* Calculates longest width based on provided options, attempting to prevent
* 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 === 'bottom' && 'mt-[var(--outer-gap)]'
]}
use:minWidth={{ options: options }}
bind:this={pickerContainer}
transition:scale={{ duration: 200 }}
role="listbox"
@@ -547,6 +582,7 @@
onscroll({ event: e, top: atTop, bottom: atBottom, searchInput: searchInput?.value ?? '' });
}}
tabindex="0"
{@attach minWidth({ options, mode: pickerWidth, matchElem: searchInput })}
>
{#each filteredItems as opt (opt.value)}
{@render option(opt)}

View File

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

View File

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