From f8eb05cccf73e13083aca4180d303cd01be81a21 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 10 Mar 2026 16:05:47 -0700 Subject: [PATCH] combobox: improved picker width behaviour add width behaviour options, fix match width --- src/lib/Combobox.svelte | 90 +++++++++++++++++++++++++----------- src/lib/TimezoneInput.svelte | 2 +- src/routes/+page.svelte | 1 + 3 files changed, 65 insertions(+), 28 deletions(-) diff --git a/src/lib/Combobox.svelte b/src/lib/Combobox.svelte index 0dcd0f6..1683d5b 100644 --- a/src/lib/Combobox.svelte +++ b/src/lib/Combobox.svelte @@ -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(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(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 = ( - container, - buildOpts - ) => { - const f = (opts: typeof buildOpts) => { - if (matchWidth && searchInput) { - container.style.width = searchInput.scrollWidth + 'px'; - return; - } + /** + * 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`; + }; - 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) => { - 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 => { + 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)} diff --git a/src/lib/TimezoneInput.svelte b/src/lib/TimezoneInput.svelte index ffc3ea5..4eaa465 100644 --- a/src/lib/TimezoneInput.svelte +++ b/src/lib/TimezoneInput.svelte @@ -120,7 +120,7 @@ {required} bind:value={timezone} {options} - matchWidth + pickerWidth="match" placeholder="Select a timezone" class={classValue} /> diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 00a3f58..74db7ea 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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}