combobox: improved picker width behaviour
add width behaviour options, fix match width
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
{required}
|
||||
bind:value={timezone}
|
||||
{options}
|
||||
matchWidth
|
||||
pickerWidth="match"
|
||||
placeholder="Select a timezone"
|
||||
class={classValue}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user