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 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;
|
* Calculates longest width based on provided options, attempting to prevent
|
||||||
const avg = options.reduce((acc, item) => acc + getLabel(item).length, 0) / options.length;
|
* wrapping by returning a width in 'ch' units based on the longest label and
|
||||||
container.style.width = `${avg * 2.5}ch`;
|
* infotext, or a reasonable minimum when no options are provided.
|
||||||
};
|
*/
|
||||||
f(buildOpts);
|
const calculateLongestWidth = (opts: ComboboxOption[]) => {
|
||||||
return {
|
if (opts.length === 0) return '10ch'; // reasonable minimum width when no options
|
||||||
update: (updateOpts: typeof buildOpts) => {
|
const longestLabel = Math.max(...opts.map((item) => getLabel(item).length));
|
||||||
f(updateOpts);
|
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)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user