29 Commits

Author SHA1 Message Date
Elijah Duffy
bb27d0c9d7 dialog: pass state to snippet overrides 2026-03-15 12:58:28 -07:00
Elijah Duffy
ade904d0c9 1.1.2 2026-03-13 16:27:42 -07:00
Elijah Duffy
50c052a3c3 combobox: don't use portal for picker
fixes z-stacking issues when used within dialogs
2026-03-13 16:27:35 -07:00
Elijah Duffy
bcfd3ea740 dialog: add z-index to dialog api 2026-03-13 16:27:13 -07:00
Elijah Duffy
1844fa0096 1.1.1 2026-03-12 23:15:52 -07:00
Elijah Duffy
7c9a6398d4 dialog: support nesting/stacking & improve ergonomics
- stacked/nested dialogs work properly
- dialog api expanded to include `hasError` and `focus`
- `onopenchange` prop added, `onopen` and `onclose` deprecated
- improve accessibility with additional aria props
2026-03-12 23:15:08 -07:00
Elijah Duffy
69ed04b499 1.1.0 2026-03-10 17:29:11 -07:00
Elijah Duffy
48e400939e combobox: use popover attachment to manage positioning 2026-03-10 17:28:45 -07:00
Elijah Duffy
9fbc6f6301 add floating-ui based popover attachment 2026-03-10 17:25:32 -07:00
Elijah Duffy
f8eb05cccf combobox: improved picker width behaviour
add width behaviour options, fix match width
2026-03-10 16:10:40 -07:00
Elijah Duffy
3d240048c7 combobox: improve icon padding behaviour
Tween the value and make it more consistent with the underlying
StyledRawInput type.
2026-03-10 14:55:44 -07:00
Elijah Duffy
7576f32e86 vscode: fix tailwind 2026-03-10 14:54:04 -07:00
Elijah Duffy
e83b980c6c combobox: fix special state spacing 2026-03-10 12:54:39 -07:00
Elijah Duffy
c4f973f1c2 combobox: refactor w/ snippet rendering overrides & better lazy loading 2026-03-10 12:50:40 -07:00
Elijah Duffy
f06867ad75 combobox: rough option snippets implementation 2026-03-09 16:37:02 -07:00
Elijah Duffy
28c027c48b 1.0.4 2026-03-06 16:18:02 -08:00
Elijah Duffy
80fc26eb3b dialog: fix duplicate controls caused by flip issue 2026-03-06 16:17:52 -08:00
Elijah Duffy
aa39aaf84f 1.0.3 2026-03-03 16:42:45 -08:00
Elijah Duffy
2d694a7277 dialog: title & control customization with snippets, fixed callbacks 2026-03-03 16:42:38 -08:00
Elijah Duffy
ae7d4912f9 1.0.2 2026-02-13 16:28:35 -08:00
Elijah Duffy
1c66bc0fcf improve error handling with 'builder'-type structure 2026-02-13 16:28:25 -08:00
Elijah Duffy
3b659c1e2d 1.0.1 2026-02-04 14:54:21 -08:00
Elijah Duffy
90ff836061 text input: change default asterisk behaviour
asterisk now shown by default if field is required.
2026-02-04 14:53:14 -08:00
Elijah Duffy
5e5f133763 statemachine: add onsubmit callback 2026-02-04 11:19:03 -08:00
Elijah Duffy
7317d69d9b 1.0.0 2026-01-26 18:02:07 -08:00
Elijah Duffy
e6d99cdfd2 add ScrollBox convenience helper 2026-01-26 18:01:23 -08:00
Elijah Duffy
2ae35cf847 tabs: supported padded mode with spacing-layout 2026-01-26 18:01:08 -08:00
Elijah Duffy
96daed474b add --spacing-layout helper 2026-01-26 18:00:57 -08:00
Elijah Duffy
eabb0f2dda unlink validate, enable dependency build scripts 2026-01-26 18:00:32 -08:00
17 changed files with 1092 additions and 455 deletions

View File

@@ -2,5 +2,6 @@
"files.associations": {
"*.css": "tailwindcss"
},
"makefile.configureOnOpen": false
"makefile.configureOnOpen": false,
"tailwindCSS.experimental.configFile": "src/lib/styles/tailwind.css"
}

View File

@@ -4,7 +4,7 @@
"type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/sui.git"
},
"version": "0.3.5",
"version": "1.1.2",
"scripts": {
"dev": "vite dev",
"build": "vite build && pnpm run prepack",
@@ -62,6 +62,7 @@
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@floating-ui/dom": "^1.7.6",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/package": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",

55
pnpm-lock.yaml generated
View File

@@ -4,9 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@svelte-toolkit/validate': link:../validate
importers:
.:
@@ -18,8 +15,8 @@ importers:
specifier: ^0.2.1
version: 0.2.1(svelte@5.38.1)
'@svelte-toolkit/validate':
specifier: link:../validate
version: link:../validate
specifier: ^1.0.1
version: 1.0.1(svelte@5.38.1)
'@sveltejs/kit':
specifier: ^2.20.2
version: 2.28.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.1)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1))
@@ -43,7 +40,7 @@ importers:
version: 8.1.0
melt:
specifier: ^0.37.0
version: 0.37.0(@floating-ui/dom@1.7.3)(svelte@5.38.1)
version: 0.37.0(@floating-ui/dom@1.7.6)(svelte@5.38.1)
moment:
specifier: ^2.30.1
version: 2.30.1
@@ -69,6 +66,9 @@ importers:
'@eslint/js':
specifier: ^9.18.0
version: 9.33.0
'@floating-ui/dom':
specifier: ^1.7.6
version: 1.7.6
'@sveltejs/adapter-auto':
specifier: ^4.0.0
version: 4.0.0(@sveltejs/kit@2.28.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.1)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)))
@@ -331,14 +331,14 @@ packages:
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.7.3':
resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==}
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
@@ -517,6 +517,11 @@ packages:
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@svelte-toolkit/validate@1.0.1':
resolution: {integrity: sha512-KqyWc9m0nQwG7gay+hbHqwBFcUsb8q5+v7/wEwul7YedSEOwofK3XTLm5m9TSMm3djfhNSBJ+XER8E9YE8TDXA==, tarball: https://gitea.auvem.com/api/packages/svelte-toolkit/npm/%40svelte-toolkit%2Fvalidate/-/1.0.1/validate-1.0.1.tgz}
peerDependencies:
svelte: ^5.0.0
'@sveltejs/acorn-typescript@1.0.5':
resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==}
peerDependencies:
@@ -667,6 +672,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/google.maps@3.58.1':
resolution: {integrity: sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1798,16 +1806,16 @@ snapshots:
'@eslint/core': 0.15.2
levn: 0.4.1
'@floating-ui/core@1.7.3':
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/utils': 0.2.11
'@floating-ui/dom@1.7.3':
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
'@floating-ui/utils@0.2.10': {}
'@floating-ui/utils@0.2.11': {}
'@humanfs/core@0.19.1': {}
@@ -1937,6 +1945,11 @@ snapshots:
'@standard-schema/spec@1.0.0': {}
'@svelte-toolkit/validate@1.0.1(svelte@5.38.1)':
dependencies:
'@types/google.maps': 3.58.1
svelte: 5.38.1
'@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)':
dependencies:
acorn: 8.15.0
@@ -2086,6 +2099,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/google.maps@3.58.1': {}
'@types/json-schema@7.0.15': {}
'@types/node@24.2.1':
@@ -2629,9 +2644,9 @@ snapshots:
'@babel/runtime': 7.28.2
remove-accents: 0.5.0
melt@0.37.0(@floating-ui/dom@1.7.3)(svelte@5.38.1):
melt@0.37.0(@floating-ui/dom@1.7.6)(svelte@5.38.1):
dependencies:
'@floating-ui/dom': 1.7.3
'@floating-ui/dom': 1.7.6
dequal: 2.0.3
focus-trap: 7.6.5
jest-axe: 9.0.0

View File

@@ -1,2 +1,3 @@
overrides:
'@svelte-toolkit/validate': link:../validate
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- esbuild

View File

@@ -1,16 +1,38 @@
<script lang="ts" module>
export type ComboboxOption = {
/** Value of the option */
value: string;
/**
* Label of the option displayed in the picker and, if the option is
* selected, in the main input field. Preview overrides label if provided
* and the combobox is open (see usePreview prop for exceptions).
*/
label?: string;
infotext?: string;
/**
* Preview text for the option, displayed in the main input field when
* the option is selected. See usePreview prop for controlling when
* preview takes precedence over label.
*/
preview?: string;
/**
* Additional information text for the option, displayed beside the
* label in the picker and, if the option is selected, in the main
* input field.
*/
infotext?: string;
/** An optional icon for the option, displayed in the picker and,
* if the option is selected, in the main input field.
*/
icon?: IconDef;
/** Whether the option is disabled */
disabled?: boolean;
icon?: Snippet<[item: ComboboxOption]>;
render?: Snippet<[item: ComboboxOption]>;
};
const getLabel = (item: ComboboxOption | undefined): string => item?.label ?? item?.value ?? '';
const getPreview = (item: ComboboxOption | undefined): string => item?.preview ?? getLabel(item);
/** returns option label, falling back to value or 'Undefined Option' if no option provided */
const getLabel = (opt: ComboboxOption | undefined): string =>
opt ? (opt.label ?? opt.value) : 'Undefined Option';
/** returns option preview, falling back to getLabel if missing */
const getPreview = (opt: ComboboxOption | undefined): string => opt?.preview ?? getLabel(opt);
</script>
<script lang="ts">
@@ -19,51 +41,145 @@
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 { untrack, type Snippet } from 'svelte';
import { scale } from 'svelte/transition';
import { generateIdentifier, type IconDef } from './util';
import type { ClassValue } from 'svelte/elements';
import type { ClassValue, MouseEventHandler } from 'svelte/elements';
import { matchSorter } from 'match-sorter';
import Spinner from './Spinner.svelte';
import type { KeyOption } from 'match-sorter';
import { Tween } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import type { Attachment } from 'svelte/attachments';
import { Popover } from './floating.svelte';
interface Props {
name?: string;
value?: ComboboxOption;
open?: boolean;
usePreview?: boolean | 'auto';
matchWidth?: boolean;
options: ComboboxOption[];
required?: boolean;
invalidMessage?: string | null;
label?: string;
placeholder?: string;
/** displayed by default if no item is selected that has its own icon */
icon?: IconDef;
/**
* enables loading spinner and fallback text if no items are found. may be changed
* internally if lazy is enabled. loading is, however, not bindable, so don't expect
* any internal changes to be propagated outward.
* Name of the input, used for form submission. The currently selected
* option's value will be submitted under this name.
*/
name?: string;
/** Optional label for the combobox */
label?: string;
/** Optional placeholder for the main input field */
placeholder?: string;
/** Whether an option must be selected to continue (default: false) */
required?: boolean;
/**
* Message displayed below the main input field when required but no
* option is selected (default: 'Please select an option'). If set to
* null, no message is displayed.
*/
invalidMessage?: string | null;
/**
* Allows the user to select an option without updating the value
* (events are still triggered).
*/
stateless?: boolean;
/** Bindable value of the combobox, the currently selected option */
value?: ComboboxOption;
/** Array of ComboboxOptions for the picker */
options: ComboboxOption[];
/**
* Overrides label render behaviour for all options. Receives the option
* as an argument. If not provided, the option's `label` field is used.
* Option `label` field must still be present for search, accessibility,
* and visibility in the main input field.
*/
labelRender?: Snippet<[opt: ComboboxOption]>;
/**
* Overrides infotext render behaviour for all options. Receives the option
* as an argument. If not provided, the option's `infotext` field is used.
* Option `infotext` field must still be present for search and accessibility.
*/
infotextRender?: Snippet<[opt: ComboboxOption]>;
/**
* Overrides icon render behaviour for all options and the main input. Receives
* the option as an argument. If not provided, the option's `icon` field is used,
* based on the phosphor `IconDef` structure.
*/
iconRender?: Snippet<[opt: ComboboxOption]>;
/** Bindable open state of the combobox */
open?: boolean;
/**
* 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
*/
pickerWidth?: 'match' | 'average' | 'longest' | 'max-match';
/** Uses a compact layout for the search input with less padding and smaller text */
compact?: boolean;
/**
* Optional icon displayed as long as the selected option does not have
* its own icon.
*/
icon?: IconDef | Snippet;
/**
* Bindable loading state controls visibility of spinner and fallback
* text if no options are found (see notFoundMessage). Only changed
* internally if lazy loading is used.
*/
loading?: boolean;
/** applies the loading state on first interaction */
lazy?: boolean;
/** uses a compact layout for the search input with less padding and smaller text */
compact?: boolean;
/** allows the user to select an option without selecting a value (events are still triggered) */
stateless?: boolean;
notFoundMessage?: string;
/** Special always-disabled option displayed when loading */
loadingOption?: ComboboxOption;
/**
* Applies the loading state when the picker is opened, allowing for
* asynchronous loading of options.
* `always`: applies loading state each time the picker is opened
* `true`: applies loading state only the first time the picker is opened
* `false` (default): loading state must be controlled externally
*/
lazy?: 'always' | boolean;
/**
* Controls when the option preview should take precedence over the
* label in the main input field.
* `auto` (default): uses preview only when the picker is closed
* `true`: always uses preview
* `false`: never uses preview
*/
usePreview?: boolean | 'auto';
/** Special always-disabled option displayed when no options match the search */
notFoundOption?: ComboboxOption;
/**
* Configures which option fields are included in the search
* (default: 'label' and 'value'). An empty array triggers fallback
* to searching by value only.
*/
searchKeys?: ('value' | 'label' | 'preview' | 'infotext')[];
/** Additional classes applied to the persistent div container */
class?: ClassValue | null | undefined;
/** Optional action applied to the main input */
use?: () => void;
/** Callback when the input value changes, triggering validation */
onvalidate?: (e: InputValidatorEvent) => void;
onchange?: (item: ComboboxOption) => void;
/** Callback when the selected option changes */
onchange?: (opt: ComboboxOption) => void;
/** Callback when a search is performed */
onsearch?: (query: string) => boolean | void;
onscroll?: (detail: { event: UIEvent; top: boolean; bottom: boolean }) => void;
/** this callback runs only once on the first interaction if lazy is enabled */
onlazy?: () => void;
onopen?: () => void;
onclose?: () => void;
/** Callback when the picker is scrolled */
onscroll?: (detail: {
event: UIEvent;
top: boolean;
bottom: boolean;
searchInput: string;
}) => void;
/**
* Callback when options should be lazily loaded, see lazy prop.
* Expects a promise to be returned, allowing for loading state
* to be automatically managed.
*/
onlazy?: () => Promise<void>;
/** Callback when the picker is opened or closed */
onopenchange?: (open: boolean) => void;
}
let {
@@ -71,18 +187,20 @@
value = $bindable<ComboboxOption | undefined>(undefined),
open = $bindable(false),
usePreview = 'auto',
matchWidth = false,
pickerWidth = 'average',
options,
required = false,
invalidMessage = 'Please select an option',
label,
placeholder,
icon,
loading = false,
loading = $bindable(false),
lazy = false,
compact = false,
stateless = false,
notFoundMessage = 'No results found',
loadingOption = { value: 'special-loading', label: 'Loading...' },
notFoundOption = { value: 'special-not-found', label: 'No options found' },
searchKeys = ['label', 'value'],
class: classValue,
use,
onvalidate,
@@ -90,32 +208,69 @@
onsearch,
onscroll,
onlazy,
onopen,
onclose
onopenchange,
labelRender,
infotextRender,
iconRender
}: Props = $props();
let id = $derived(generateIdentifier('combobox', name));
const id = $derived(generateIdentifier('combobox', name));
const searchKeySet = $derived(new Set(searchKeys));
const conditionalUse = $derived(use ? use : () => {});
const popover = new Popover({
interaction: 'manual',
placement: 'bottom-start',
offset: 0,
ontoggle: async (isOpen) => {
open = isOpen;
if (isOpen) {
scrollToHighlighted();
// focus & select search input after 100ms
setTimeout(() => {
searchInput?.focus();
searchInput?.select();
}, 100);
// handle lazy loading behaviour
if (lazy === 'always' || (lazy === true && !lazyApplied)) {
lazyApplied = true;
loading = true;
await onlazy?.();
loading = false;
}
} else {
searchValue = ''; // clear search value for next time picker opens
searching = false; // reset searching state
highlighted = value; // reset highlighted item to current value
}
onopenchange?.(isOpen);
}
});
let valid = $state(true);
let searchValue = $state('');
let pickerPosition = $state<'top' | 'bottom'>('bottom');
let searching = $state(false);
let iconWidth = $state<number | undefined>(undefined);
let searchInput = $state<HTMLInputElement | null>(null);
let searchContainer = $state<HTMLDivElement | null>(null);
let pickerContainer = $state<HTMLDivElement | null>(null);
/** stores options filtered according to search value */
/** options filtered by search value and searchKeys */
const filteredItems = $derived.by(() => {
const arr = matchSorter(options, searchValue, { keys: [(item) => getLabel(item)] });
// if (loading) {
// arr.push({ value: 'loading', label: 'Loading more...', disabled: true });
// }
let keys: KeyOption<ComboboxOption>[] = [];
if (searchKeySet.has('label')) keys.push((item) => getLabel(item));
if (searchKeySet.has('preview')) keys.push((item) => getPreview(item));
if (searchKeySet.has('infotext')) keys.push('infotext');
if (searchKeySet.has('value') || keys.length === 0) keys.push('value');
const arr = matchSorter(options, searchValue, { keys });
return arr;
});
/** stores currently highlighted option (according to keyboard navigation or default item) */
/** currently highlighted option, updated by keyboard navigation or defaults to first item */
let highlighted = $derived.by((): ComboboxOption | undefined => {
if (!searching) return undefined; // otherwise, the first item is highlighted on first open
if (filteredItems.length === 0) return undefined;
@@ -123,20 +278,50 @@
if (!filteredItems[0]?.disabled) return filteredItems[0];
});
/** controls whether an icon should be displayed */
let iconVisible = $derived(
/**
* Whether an icon should be displayed with the main input.
* The icon is determined by the following precedence:
* highlighted option (only when the picker is open) > selected value > icon prop
*/
const iconVisible = $derived(
(open && highlighted && highlighted.icon) ||
(value && value.icon && searchValue === '') ||
loading ||
icon !== undefined
);
/** controls whether the highlighted option should be used in the selection preview */
let useHighlighted = $derived.by(() => {
/** whether the highlighted option should be used in the selection preview */
const useHighlighted = $derived.by(() => {
return open && highlighted;
});
const validateOpts: ValidatorOptions = { required };
/** validation options build from props */
const validateOpts: ValidatorOptions = $derived({ required });
/** calculates padding for the input based on icon visibility and size */
const calculatePadding = () => {
const gap = 5; // gap between icon and text
// base padding when no icon is visible, see StyledRawInput padding
const basePadding = compact ? 12 : 18;
// if icon is visible, padding is icon width + gap + base padding,
// otherwise it's just base padding
return iconVisible ? iconWidth + gap + basePadding : basePadding;
};
let iconWidth = $state<number>(0);
/** tweens main input padding */
const inputPadding = new Tween(calculatePadding(), {
duration: 150,
easing: cubicOut
});
$effect(() => {
if (iconWidth >= 0) {
untrack(() => {
inputPadding.target = calculatePadding();
});
}
});
/*** HELPER FUNCTIONS ***/
/** returns the index of the highlighted item in filteredItems */
const getHighlightedIndex = () => {
@@ -172,104 +357,66 @@
};
let lazyApplied = false;
/** opens combobox picker and propages any necessary events */
const openPicker = () => {
open = true;
// if lazy and not applied, enable loading state once and run callback
if (lazy && !lazyApplied) {
lazyApplied = true;
loading = true;
onlazy?.();
}
updatePickerRect(); // update picker position
scrollToHighlighted(); // scroll to highlighted item
onopen?.(); // trigger onopen event if defined
};
/** closes combobox picker and propages any necessary events */
const closePicker = () => {
open = false;
searchValue = ''; // clear search value for next time picker opens
searching = false; // reset searching state
highlighted = value; // reset highlighted item to current value
onclose?.(); // trigger onclose event if defined
};
/** updates the value of the combobox and triggers any callbacks, including closing the picker */
const updateValue = (newValue: ComboboxOption) => {
if (!stateless) value = newValue;
closePicker();
popover.setOpen(false);
onchange?.(newValue);
};
/** action to set the minimum width of the combobox based on the number of options */
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`;
};
/** updates the position of the picker */
const updatePickerRect = async () => {
if (!searchContainer || !pickerContainer) {
await tick();
if (!searchContainer || !pickerContainer) {
return;
}
}
const overlay = pickerContainer;
const target = searchContainer;
const targetRect = target.getBoundingClientRect();
if (!open) {
return;
}
// choose whether the overlay should be above or below the target
const availableSpaceBelow = window.innerHeight - targetRect.bottom;
const availableSpaceAbove = targetRect.top;
const outerMargin = 24;
if (availableSpaceBelow < availableSpaceAbove) {
// overlay should be above the target
overlay.style.bottom = `${window.innerHeight - targetRect.top - window.scrollY}px`;
overlay.style.top = 'auto';
overlay.style.maxHeight = `${availableSpaceAbove - outerMargin}px`;
pickerPosition = 'top';
overlay.dataset.side = 'top';
/** 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';
} else {
// overlay should be below the target
overlay.style.top = `${targetRect.bottom + window.scrollY}px`;
overlay.style.bottom = 'auto';
overlay.style.maxHeight = `${availableSpaceBelow - outerMargin}px`;
pickerPosition = 'bottom';
overlay.dataset.side = 'bottom';
// 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';
}
// set overlay left position
overlay.style.left = `${targetRect.left}px`;
}
elem.style.width = val;
}
};
};
/** scrolls the picker to the highlighted item */
@@ -292,7 +439,7 @@
}
};
const conditionalUse = $derived(use ? use : () => {});
/*** EXPORTED API ***/
/** focuses the combobox search input */
export const focus = () => {
@@ -309,18 +456,21 @@
}
};
// when the value (or, in some circumstances, highlighted item) changes, update the search input
/*** EFFECTS & WINDOW CALLBACKS ***/
// when the value (or, in some circumstances, highlighted item) changes,
// update the search input
//
// expected triggers include: value, highlighted, useHighlighted, usePreview,
// searchInput, searching, and open
$effect(() => {
if (!searchInput) return;
if (!searchInput || searching) return;
if (useHighlighted && !searching) {
if ((!value && !highlighted) || (useHighlighted && !highlighted)) {
searchInput.value = '';
} else if (useHighlighted) {
searchInput.value = getLabel(highlighted);
return;
}
if (untrack(() => searching)) return;
if (!usePreview || (usePreview === 'auto' && open)) {
} else if (!usePreview || (usePreview === 'auto' && open)) {
searchInput.value = getLabel(value);
} else {
searchInput.value = getPreview(value);
@@ -334,37 +484,25 @@
}
});
onMount(() => {
// set initial picker position on load
setTimeout(() => {
updatePickerRect();
}, 500);
// update picker position on window resize
window.addEventListener('resize', updatePickerRect);
// add window click listener to close picker
window.addEventListener('click', (e) => {
if (!searchContainer || !pickerContainer) {
return;
}
// close picker if clicked outside
const handleWindowClick: MouseEventHandler<Window> = (e) => {
if (!open || !searchContainer || !pickerContainer) return;
if (
searchContainer.contains(e.target as Node) ||
pickerContainer.contains(e.target as Node) ||
!open
e.target instanceof Node &&
!searchContainer.contains(e.target) &&
!pickerContainer.contains(e.target)
) {
return;
popover.setOpen(false);
}
closePicker();
});
});
};
</script>
<svelte:window onclick={handleWindowClick} />
<!-- Combobox picker -->
<Portal target="body">
{#if open}
<!-- Picker container -->
<div
class={[
'picker absolute top-0 left-0 z-50 overflow-y-auto px-2 py-3',
@@ -374,13 +512,12 @@
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"
onkeydown={(e) => {
if (e.key === 'Escape') {
closePicker();
popover.setOpen(false);
searchInput?.focus();
}
}}
@@ -392,72 +529,26 @@
const margin = 10; // 10px margin for top & bottom
const atTop = target.scrollTop < margin;
const atBottom = target.scrollTop + target.clientHeight > target.scrollHeight - margin;
onscroll({ event: e, top: atTop, bottom: atBottom });
onscroll({ event: e, top: atTop, bottom: atBottom, searchInput: searchInput?.value ?? '' });
}}
tabindex="0"
{@attach minWidth({ options, mode: pickerWidth, matchElem: searchInput })}
{...popover.floating()}
>
{#each filteredItems as item, i (item.value)}
<div
data-id={item.value}
aria-selected={value?.value === item.value}
aria-label={getLabel(item)}
aria-disabled={item.disabled}
class={[
!compact
? 'mb-0.5 min-h-10 py-2.5 pr-1.5 pl-5'
: 'mb-0.25 min-h-8 py-1.5 pr-1.5 pl-2.5',
'flex flex-wrap items-center',
'rounded-sm text-sm capitalize outline-hidden select-none',
'hover:bg-sui-accent-500/30 dark:hover:bg-sui-accent-700/30',
item.value === highlighted?.value && 'bg-sui-accent-500/80 dark:bg-sui-accent-700/80',
item.disabled && 'cursor-not-allowed opacity-50'
]}
role="option"
onclick={() => {
if (item.disabled) return;
updateValue(item);
searchInput?.focus();
}}
onkeydown={() => {}}
tabindex="-1"
>
{#if item.icon}
{@render item.icon(item)}
{/if}
<div class={['mr-8', item.icon && 'ml-2']}>
{#if item.render}
{@render item.render(item)}
{#each filteredItems as opt (opt.value)}
{@render option(opt)}
{:else}
{getLabel(item)}
{/if}
</div>
{#if item?.infotext}
<div class="text-sui-text/80 dark:text-sui-background/80 ml-auto text-sm">
{item.infotext}
</div>
{/if}
{#if value?.value === item.value}
<div class={[item?.infotext ? 'ml-2' : 'ml-auto']}>
<Check />
</div>
{/if}
</div>
{:else}
<span class="block px-5 py-2 text-sm">
<!-- Display loading state or not found if no options available -->
{#if loading}
Loading...
{@render option(loadingOption, true)}
{:else}
{notFoundMessage}
{@render option(notFoundOption, true)}
{/if}
</span>
{/each}
</div>
{/if}
</Portal>
<!-- Combobox main input container -->
<div class={classValue}>
<!-- Combobox Label -->
{#if label}
@@ -490,34 +581,98 @@
{/if}
</div>
{#snippet optionIcon(opt: ComboboxOption)}
{#if iconRender}
{@render iconRender(opt)}
{:else if opt.icon}
<opt.icon.component {...opt.icon.props} />
{/if}
{/snippet}
<!-- Combobox option -->
{#snippet option(opt: ComboboxOption, forceDisabled?: boolean)}
{@const optDisabled = opt.disabled || forceDisabled}
<!-- Option container -->
<div
data-id={opt.value}
aria-selected={value?.value === opt.value}
aria-label={getLabel(opt)}
aria-disabled={optDisabled}
class={[
!compact ? 'mb-0.5 min-h-10 py-2.5 pr-1.5 pl-5' : 'mb-0.25 min-h-8 py-1.5 pr-1.5 pl-2.5',
'flex flex-wrap items-center',
'rounded-sm text-sm capitalize outline-hidden select-none',
'hover:bg-sui-accent-500/30 dark:hover:bg-sui-accent-700/30',
opt.value === highlighted?.value && 'bg-sui-accent-500/80 dark:bg-sui-accent-700/80',
optDisabled && 'cursor-not-allowed opacity-50'
]}
role="option"
onclick={() => {
if (optDisabled) return;
updateValue(opt);
searchInput?.focus();
}}
onkeydown={() => {}}
tabindex="-1"
>
<!-- Option icon -->
{@render optionIcon(opt)}
<!-- Option label -->
<div class={['mr-8', opt.icon && 'ml-2']}>
{@render snippetOrString(opt, labelRender || getLabel(opt))}
</div>
<!-- Option infotext (always right-aligned) -->
{#if opt.infotext || infotextRender}
<div class="text-sui-text/80 dark:text-sui-background/80 ml-auto text-sm">
{@render snippetOrString(opt, infotextRender || opt.infotext)}
</div>
{/if}
<!-- Option checkmark, visible if selected -->
{#if value?.value === opt.value}
<div class={[opt?.infotext ? 'ml-2' : 'ml-auto']}>
<Check />
</div>
{/if}
</div>
{/snippet}
<!-- Search input box -->
{#snippet searchInputBox(caret: boolean = true)}
<div class="relative">
<div class="relative" {...popover.reference()}>
<!-- Persistant OR selected option icon, if visible -->
{#if iconVisible}
<div
class={[
(iconWidth === undefined || iconWidth === 0) && 'opacity-0',
'pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 transform select-none'
'pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 transform select-none',
iconWidth === 0 && 'opacity-0'
]}
transition:scale
bind:clientWidth={iconWidth}
>
{#if loading}
<Spinner class="stroke-sui-accent! -mt-0.5" size="1em" />
{:else if useHighlighted && highlighted?.icon}
{@render highlighted.icon(highlighted)}
{:else if value?.icon}
{@render value.icon(value)}
{:else if useHighlighted && highlighted}
{@render optionIcon(highlighted)}
{:else if value}
{@render optionIcon(value)}
{:else if icon}
{#if typeof icon === 'function'}
{@render icon()}
{:else}
<icon.component {...icon.props} />
{/if}
{:else}
{/if}
</div>
{/if}
<!-- Combobox input box -->
<StyledRawInput
style={iconWidth && iconVisible ? `padding-left: ${iconWidth + 14 + 10}px` : undefined}
style={`padding-left: ${inputPadding.current}px`}
class={[caret && 'pr-9', !valid && 'border-red-500!']}
{compact}
type="text"
@@ -526,13 +681,7 @@
autocomplete="off"
bind:ref={searchInput}
onclick={() => {
if (!open) {
setTimeout(() => {
searchInput?.select();
}, 100);
}
openPicker();
popover.setOpen(true);
}}
onkeydown={(e) => {
if (!searchInput) return;
@@ -542,17 +691,17 @@
updateValue(highlighted);
}
if (e.key === 'Enter') {
closePicker();
popover.setOpen(false);
e.preventDefault();
}
return;
} else if (e.key === 'Escape') {
closePicker();
popover.setOpen(false);
return;
}
// open the picker
openPicker();
popover.setOpen(true);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
searching = false;
@@ -578,7 +727,8 @@
}}
/>
{#if (value && value.infotext) || (highlighted && useHighlighted && highlighted.infotext)}
<!-- Right-aligned infotext (overlay) -->
{#if (value && value.infotext) || (highlighted && useHighlighted && highlighted.infotext) || infotextRender}
<div
class={[
'pointer-events-none absolute top-1/2 -translate-y-1/2 transform text-sm select-none',
@@ -586,7 +736,11 @@
caret ? 'end-10' : 'end-[1.125rem]'
]}
>
{useHighlighted && highlighted?.infotext ? highlighted.infotext : value?.infotext}
{#if useHighlighted && highlighted}
{@render snippetOrString(highlighted, infotextRender || highlighted.infotext)}
{:else if value}
{@render snippetOrString(value, infotextRender || value.infotext)}
{/if}
</div>
{/if}
@@ -594,14 +748,24 @@
<CaretUpDown
class="absolute end-2.5 top-1/2 size-6 -translate-y-1/2"
onclick={() => {
open = !open;
if (open) searchInput?.focus();
popover.setOpen(!open);
}}
/>
{/if}
</div>
{/snippet}
{#snippet snippetOrString(
opt: ComboboxOption,
value: string | Snippet<[item: ComboboxOption]> | undefined
)}
{#if typeof value === 'function'}
{@render value(opt)}
{:else}
{value}
{/if}
{/snippet}
<style lang="postcss">
@reference "./styles/reference.css";

View File

@@ -5,6 +5,8 @@
export interface DialogAPI {
/** shows an error message at the top of the dialog */
error: (message: RawError | null) => void;
/** Returns if the dialog is displaying an error */
hasError: () => boolean;
/** closes the dialog */
close: () => void;
/** opens the dialog */
@@ -39,11 +41,24 @@
* changes made via this API will NOT propagate to consuming components.
*/
title: (title: string) => void;
/** Focuses the dialog */
focus: () => void;
/** Returns the stack index for this dialog */
stackIndex: () => number;
/**
* Returns the z-index of this dialog, where each incremental stack
* index increases the z-index by 100 starting from 1000. This can be
* used to layer custom elements on top of and within the dialog.
*/
zIndex: () => number;
}
type DialogControlButton = {
/** Label for the button */
label?: string;
/** Additional classes to apply to the button */
class?: ClassValue;
/** Callback when the button is pressed */
action?: (dialog: DialogAPI) => void;
};
@@ -51,25 +66,39 @@
* Configures the default dialog controls.
*/
export type DialogControls = {
/** Options for the bottom cancel button */
cancel?: DialogControlButton | null;
/** Options for the bottom submit button */
ok?: DialogControlButton | null;
close?: Omit<DialogControlButton, 'label'> | null;
/** Inverts the order of the buttons */
flip?: boolean;
};
/**
* Stores internal state of the dialog, everything necessary to render
* internal snippets.
*/
type DialogState = {
frozen: boolean;
loading: boolean;
disabled: boolean;
api: DialogAPI;
};
const defaultDialogControls: DialogControls = {
cancel: {
label: 'Cancel'
},
ok: {
label: 'OK'
},
close: {}
cancel: { label: 'Cancel' },
ok: { label: 'OK' }
};
export { dialogCancelButton, dialogOkButton, dialogCloseButton };
/** stack of currently open dialogs by identifier */
let dialogStack: DialogAPI[] = $state([]);
</script>
<script lang="ts">
import { Portal } from '@jsrob/svelte-portal';
import { type Snippet } from 'svelte';
import { untrack, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition';
import { flyAndScale } from './transition';
@@ -77,20 +106,44 @@
import { X } from 'phosphor-svelte';
import { ErrorMessage, type RawError } from './error';
import ErrorBox from './ErrorBox.svelte';
import { mergeOverrideObject } from './util';
import { generateIdentifier, mergeOverrideObject } from './util';
interface Props {
/** Bindable open state of the dialog */
open?: boolean;
title: string;
description?: string;
/** Title of the dialog */
title: string | Snippet<[state: DialogState]>;
/** Description of the dialog, optionally rendered below the title */
description?: string | Snippet<[state: DialogState]>;
/** Size of the dialog (default: 'sm') */
size?: 'sm' | 'md' | 'lg' | 'max';
/** Additional classes for the dialog */
class?: ClassValue;
/** Content of the dialog */
children?: Snippet;
controls?: Snippet | DialogControls;
/** Bottom controls for the dialog */
controls?: Snippet<[state: DialogState]> | DialogControls;
/** Sets bottom alignment of controls (default: end) */
controlsAlign?: 'start' | 'center' | 'end';
/** Top-right close control */
close?: Snippet | Omit<DialogControlButton, 'label'> | null;
/**
* Callback when the dialog is opened
* @deprecated use onopenchange instead and check the open parameter
*/
onopen?: (dialog: DialogAPI) => void;
/**
* Callback when the dialog is closed
* @deprecated use onopenchange instead and check the open parameter
*/
onclose?: (dialog: DialogAPI) => void;
/** Callback when the dialog open state changes */
onopenchange?: ({ open, dialog }: { open: boolean; dialog: DialogAPI }) => void;
/** If default controls are used, controls loading state of submit button */
loading?: boolean;
/** If default controls are used, freezes all interactions */
frozen?: boolean;
/** If default controls are used, disables submit button */
disabled?: boolean;
}
@@ -102,35 +155,73 @@
class: classValue,
children,
controls: rawControls = defaultDialogControls,
controlsAlign = 'end',
close = {},
onopen,
onclose,
onopenchange,
loading = $bindable(false),
frozen = $bindable(false),
disabled = $bindable(false)
}: Props = $props();
let controls = $derived(
const controls = $derived(
typeof rawControls === 'function'
? rawControls
: mergeOverrideObject(defaultDialogControls, rawControls)
);
const identifier = generateIdentifier('dialog');
let lastOpen = $state(open);
let dialogPage = $state<HTMLDivElement | null>(null);
let dialogContainer = $state<HTMLDivElement | null>(null);
let error = $state<ErrorMessage | null>(null);
let stackIndex = $state(-1);
const zIndex = $derived(1000 + stackIndex * 100);
// disable window scroll when dialog is open
$effect(() => {
if (open) {
document.body.style.overflow = 'hidden';
/** handles open change */
const handleOpenChange = (localOpen: boolean) => {
if (localOpen) {
document.body.style.overflow = 'hidden'; // prevent scrolling
// focus the dialog BEFORE callbacks for accessibility & flexibility
dialogPage?.focus();
// run callbacks
onopen?.(dialogAPI);
onopenchange?.({ open: true, dialog: dialogAPI });
//update stack
dialogStack.push(dialogAPI); // add to stack of open dialogs
stackIndex = dialogStack.length - 1; // track index in stack for this dialog
} else {
document.body.style.overflow = '';
// update stack
dialogStack.pop(); // remove from stack of open dialogs
stackIndex = -1; // reset stack index for this dialog
// update focus & handle scroll locking for accessibility
if (dialogStack.length > 0) {
dialogStack[dialogStack.length - 1].focus();
} else {
document.body.style.overflow = ''; // re-enable scrolling since no dialogs are open
}
// run callbacks
onclose?.(dialogAPI);
onopenchange?.({ open: false, dialog: dialogAPI });
}
};
// deduplicate open changes
$effect(() => {
if (open !== untrack(() => lastOpen)) {
lastOpen = open;
untrack(() => {
handleOpenChange(open);
});
}
});
const dialogAPI: DialogAPI = {
/** DialogAPI instance to control this dialog */
export const dialogAPI: DialogAPI = {
error: (message) => (error = ErrorMessage.from(message)),
hasError: () => error !== null,
close: () => (open = false),
open: () => (open = true),
isOpen: () => open,
@@ -144,7 +235,20 @@
unfreeze: () => (frozen = false),
isFrozen: () => frozen,
canContinue: () => !loading && !disabled && !frozen,
title: (newTitle) => (title = newTitle)
title: (newTitle) => (title = newTitle),
focus: () => dialogPage?.focus(),
stackIndex: () => stackIndex,
zIndex: () => zIndex
};
/** Returns the current state of the dialog */
export const getState = (): DialogState => {
return {
frozen,
loading,
disabled,
api: dialogAPI
};
};
</script>
@@ -156,11 +260,14 @@
{#snippet dialog()}
<div
bind:this={dialogPage}
class={[
'fixed inset-0 z-50 flex items-center-safe justify-center bg-black/50 backdrop-blur-sm',
'fixed inset-0 flex items-center-safe justify-center bg-black/50 backdrop-blur-sm',
'overflow-auto p-8',
classValue
]}
style={// increase z-index and decrease opacity for each nested dialog
`z-index: ${zIndex}`}
transition:fade={{ duration: 150 }}
onclick={(e) => {
const target = e.target as HTMLElement;
@@ -169,10 +276,15 @@
}}
onkeydown={(e) => {
if (e.key === 'Escape' && !frozen) {
if (stackIndex === dialogStack.length - 1) {
// only close if this dialog is the topmost dialog
open = false;
}
}
}}
role="dialog"
aria-labelledby="{identifier}-title"
aria-describedby="{identifier}-description"
tabindex="-1"
>
<div
@@ -190,76 +302,127 @@
start: 0.96
}}
>
<h2 class="pointer-events-none mb-2 text-lg font-medium text-black select-none">{title}</h2>
<div class="flex items-center justify-between">
<!-- Dialog title -->
<h2
class="pointer-events-none mb-2 text-lg font-medium text-black select-none"
id="{identifier}-title"
>
{@render stringOrSnippet(title)}
</h2>
<!-- Close Button -->
{#if close !== null}
{#if typeof close === 'function'}
{@render close()}
{:else}
{@render dialogCloseButton(getState(), close)}
{/if}
{/if}
</div>
{#if error}
<ErrorBox {error} />
{/if}
{#if description}
<p class="mb-3 leading-normal text-zinc-600">
{description}
<p class="mb-3 leading-normal text-zinc-600" id="{identifier}-description">
{@render stringOrSnippet(description)}
</p>
{/if}
{#if children}{@render children()}{:else}Dialog is empty{/if}
<!-- Dialog Controls -->
<div class="mt-6 flex justify-end gap-4">
{#if controls && typeof controls === 'function'}{@render controls()}{:else}
{#if controls.cancel !== null}
<Button
class={controls?.cancel?.class}
onclick={() => {
if (controls?.cancel?.action) {
controls.cancel.action(dialogAPI);
} else if (!frozen) {
open = false;
}
}}
disabled={frozen}
<div
class={[
'mt-6 flex gap-4',
controlsAlign === 'start' && 'justify-start',
controlsAlign === 'center' && 'justify-center',
controlsAlign === 'end' && 'justify-end'
]}
>
{controls?.cancel?.label || 'Cancel'}
</Button>
{#if controls && typeof controls === 'function'}{@render controls(
getState()
)}{:else if controls?.flip}
{#if controls.ok !== null}
{@render dialogOkButton(getState(), controls.ok)}
{/if}
{#if controls.cancel !== null}
{@render dialogCancelButton(getState(), controls.cancel)}
{/if}
{:else}
{#if controls.cancel !== null}
{@render dialogCancelButton(getState(), controls.cancel)}
{/if}
{#if controls.ok !== null}
<Button
class={controls?.ok?.class}
onclick={() => {
if (controls?.ok?.action) {
controls.ok.action(dialogAPI);
} else if (!frozen && !loading && !disabled) {
open = false;
}
}}
disabled={frozen || loading || disabled}
{loading}
>
{controls?.ok?.label || 'OK'}
</Button>
{@render dialogOkButton(getState(), controls.ok)}
{/if}
{/if}
</div>
<!-- Close Button -->
{#if typeof controls === 'function' || controls?.close !== null}
<button
type="button"
aria-label="close"
class="absolute top-4 right-4 inline-flex cursor-pointer items-center
justify-center disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
if (typeof controls !== 'function' && controls?.close?.action) {
controls?.close?.action?.(dialogAPI);
} else if (!frozen) {
open = false;
}
}}
disabled={frozen}
>
<X size="1.5em" weight="bold" />
</button>
{/if}
</div>
</div>
{/snippet}
{#snippet dialogCancelButton(state: DialogState, opts?: DialogControls['cancel'])}
<Button
class={opts?.class}
onclick={() => {
if (opts?.action) {
opts.action(state.api);
} else if (!state.frozen) {
state.api.close();
}
}}
disabled={state.frozen}
>
{opts?.label || 'Cancel'}
</Button>
{/snippet}
{#snippet dialogOkButton(state: DialogState, opts?: DialogControls['ok'])}
<Button
class={opts?.class}
onclick={() => {
if (opts?.action) {
opts.action(state.api);
} else if (!state.frozen && !state.loading && !state.disabled) {
state.api.close();
}
}}
disabled={state.frozen || state.loading || state.disabled}
loading={state.loading}
>
{opts?.label || 'OK'}
</Button>
{/snippet}
{#snippet dialogCloseButton(state: DialogState, opts?: Omit<DialogControlButton, 'label'> | null)}
<button
type="button"
aria-label="close"
class={[
'inline-flex cursor-pointer items-center justify-center',
'disabled:cursor-not-allowed disabled:opacity-50',
'rounded-full p-2 transition-colors hover:bg-zinc-200/50'
]}
onclick={() => {
if (opts?.action) {
opts.action(state.api);
} else if (!state.frozen) {
state.api.close();
}
}}
disabled={state.frozen}
>
<X size="1.25em" weight="bold" />
</button>
{/snippet}
{#snippet stringOrSnippet(val: string | Snippet<[state: DialogState]>)}
{#if typeof val === 'string'}
{val}
{:else}
{@render val(getState())}
{/if}
{/snippet}

View File

@@ -1,18 +1,28 @@
<script lang="ts">
import type { ClassValue } from 'svelte/elements';
import type { ErrorMessage } from './error';
import { ErrorMessage, type RawError } from './error';
interface Props {
error: ErrorMessage | null;
/** Error in the form of an ErrorMessage */
error?: ErrorMessage | null;
/** Raw error that can be converted to an ErrorMessage */
rawError?: RawError | null;
/** Additional CSS classes for the error box */
class?: ClassValue | null;
}
let { error, class: classValue }: Props = $props();
let { error, rawError, class: classValue }: Props = $props();
let errorMessage = $derived.by(() => {
if (error) return error;
if (rawError) return new ErrorMessage(rawError);
});
</script>
{#if error}
<!-- eslint-disable svelte/no-at-html-tags -->
{#if errorMessage && errorMessage.hasError()}
<div class={['bg-sui-accent text-sui-background my-4 rounded-xs px-6 py-4', classValue]}>
{@html error.message}
{#each errorMessage.lines as line}
<p>{line}</p>
{/each}
</div>
{/if}

28
src/lib/ScrollBox.svelte Normal file
View File

@@ -0,0 +1,28 @@
<!-- @component
ScrollBox provides a small convenience wrapper for creating a scrollable container.
This component expects to be used as a direct child of a flex container that typically
has a constrained height. It applies the necessary CSS styles to ensure that its content
can scroll properly and that the container expands to the maximum available space within
the parent flexbox. Typically, a parent might have these Tailwind classes applied:
`flex h-full`
More complex layouts that should not expand to fill all available space should use
a custom container with `overflow-auto` and an intentionally constrained height.
-->
<script lang="ts">
import type { ClassValue } from 'svelte/elements';
interface Props {
/** Applies ui-layout-px padding to the scrollable content area (default: false) */
padded?: boolean;
class?: ClassValue;
children?: import('svelte').Snippet;
}
let { padded = false, class: classValue, children }: Props = $props();
</script>
<div class={['min-h-0 min-w-0 flex-1 overflow-auto', padded && 'p-layout', classValue]}>
{@render children?.()}
</div>

View File

@@ -40,7 +40,7 @@
import { tweened } from 'svelte/motion';
import { fade, fly } from 'svelte/transition';
import { validateForm } from '@svelte-toolkit/validate';
import { ArrowLeft, Check, CheckFat } from 'phosphor-svelte';
import { ArrowLeft, CheckFat } from 'phosphor-svelte';
import type { IconDef } from './util';
interface Props {
@@ -49,6 +49,13 @@
failure: StateMachinePage;
index?: number;
action?: string;
/**
* Called when the form is submitted at the end of the state machine.
* Receives the collated form data as a key-value object.
* If you wish to prevent the submission, return false.
* If you wish to indicate a failure, you can also throw an error.
*/
onsubmit?: (data: Record<string, string>) => Promise<boolean> | boolean;
}
let {
@@ -57,7 +64,8 @@
success,
failure,
index = $bindable(0),
action
action,
onsubmit
}: Props = $props();
// add success and failure pages to the end of the pages array
@@ -250,6 +258,22 @@
// update button state
buttonLoading = true;
if (onsubmit) {
try {
const res = await onsubmit(collatedFormData);
if (res === false) {
buttonLoading = false;
index = pages.length - 2;
return;
}
} catch (e) {
console.log('onsubmit handler failed, submission prevented', e);
buttonLoading = false;
index = pages.length - 1;
return;
}
}
const form_data = new FormData();
for (const [key, value] of Object.entries(collatedFormData)) {
form_data.append(key, value);

View File

@@ -1,3 +1,8 @@
<!--
Documents the purpose, API, and usage details of the selected component.
Include key props, expected behavior, and any important notes for consumers.
-->
<script lang="ts" module>
export type TabPage = {
title: string;
@@ -14,12 +19,16 @@
interface Props {
pages: TabPage[];
/** Currently active tab index (default: 0) */
activeIndex?: number;
/** Callback fired when the active tab changes */
onchange?: (event: { index: number; tab: TabPage }) => void;
/** Applies layout padding to the tab header & content areas (default: false) */
padded?: boolean;
class?: ClassValue | null;
}
let { pages, activeIndex = 0, onchange, class: classValue }: Props = $props();
let { pages, activeIndex = 0, onchange, padded = false, class: classValue }: Props = $props();
let primaryContainerEl: HTMLDivElement;
let tabContainerEl: HTMLDivElement;
@@ -88,7 +97,10 @@
<div bind:this={primaryContainerEl} class={[classValue]}>
<div
bind:this={tabContainerEl}
class={['border-sui-text/15 relative mb-4 flex items-center gap-5 border-b-2']}
class={[
'border-sui-text/15 relative mb-4 flex items-center gap-5 border-b-2',
padded && 'px-layout'
]}
>
{#each pages as page, i (page.title)}
{@const active = activeIndex === i}
@@ -122,7 +134,7 @@
{#key activeIndex}
<div
class={[]}
class={[padded && 'px-layout']}
in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }}
out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }}
onoutrostart={lockHeight}

View File

@@ -11,7 +11,7 @@
value?: string;
invalidMessage?: string | null;
ref?: HTMLInputElement | null;
asterisk?: boolean;
asterisk?: boolean | null;
class?: ClassValue | null | undefined;
}
@@ -21,12 +21,16 @@
value = $bindable(''),
invalidMessage = 'Field is required',
ref = $bindable<HTMLInputElement | null>(null),
asterisk = false,
asterisk = null,
class: classValue,
forceInvalid = false,
...others
}: Props = $props();
let valid: boolean = $state(true);
let displayAsterisk = $derived(
asterisk === true || (asterisk !== false && others.validate && others.validate.required)
);
export const focus = () => {
if (ref) ref.focus();
@@ -37,7 +41,7 @@
{#if label}
<Label for={id}>
{label}
{#if asterisk}
{#if displayAsterisk}
<span class="text-red-500">*</span>
{/if}
</Label>
@@ -50,11 +54,12 @@
onvalidate={(e) => {
valid = e.detail.valid;
}}
{forceInvalid}
{...others}
/>
{#if others.validate && invalidMessage !== null}
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
<div class={['opacity-0 transition-opacity', (!valid || forceInvalid) && 'opacity-100']}>
<Label for={id} error>
{invalidMessage}
</Label>

View File

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

View File

@@ -10,20 +10,41 @@ export interface GraphError {
export type RawError = Error | string | GraphError[];
export class ErrorMessage {
private _message: string;
private _lines: string[] = [];
/** converts a RawError to a string and stores it for later access */
constructor(raw: RawError) {
this._message = ErrorMessage.rawErrorToString(raw);
/**
* Converts a RawError to an array of lines and stores it for later access,
* or initializes without any errors if the input is null or undefined.
* @param raw The raw error to convert and store, or null/undefined for no error.
* @throws If the raw error is of an unsupported type.
*/
constructor(raw: RawError | null | undefined) {
if (raw) {
this._lines = ErrorMessage.rawErrorToLines(raw);
}
}
/** returns the stored message */
get message(): string {
return this._message;
/** returns the stored lines */
get lines(): string[] {
return this._lines;
}
/** returns the error as a string */
/** returns the error lines as a string, separated by newlines */
toString(): string {
return this._message;
return this._lines.join('\n');
}
/** returns the error lines as an HTML string, separated by <br /> */
toHTML(): string {
return this._lines.join('<br />');
}
/** returns true if there are any error lines */
hasError(): boolean {
return this._lines.length > 0;
}
/** adds a new line to the error message */
addLine(line: string): void {
this._lines.push(line);
}
/** optionally returns a new ErrorMessage only if the RawError is not empty */
@@ -32,28 +53,27 @@ export class ErrorMessage {
return new ErrorMessage(raw);
}
/** converts a RawError to a string */
static rawErrorToString(raw: RawError | null | undefined): string {
if (!raw) return 'No error';
/** converts a RawError to an array of lines */
static rawErrorToLines(raw: RawError | null | undefined): string[] {
if (!raw) return ['No error'];
let errorString: string;
let errorLines: string[];
if (typeof raw === 'string') {
errorString = raw;
errorLines = [raw];
} else if (raw instanceof Error) {
errorString = raw.message;
errorLines = [raw.message];
} else if (Array.isArray(raw)) {
errorString = raw
.flatMap((e) => {
errorLines = raw.map((e) => {
const messageString = e.message || 'Unknown error';
if (e.path && e.path.length > 0) {
return `"${messageString}" at ${e.path.join('.')}`;
}
})
.join('<br />');
return messageString;
});
} else {
throw `Bad error value ${raw}`;
}
return errorString;
return errorLines;
}
}

144
src/lib/floating.svelte.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* implements a popover using floating-ui for positioning and auto-update
* see https://www.skeleton.dev/docs/svelte/guides/cookbook/floating-ui-attachments
* for more details, examples, and original source.
*/
import { computePosition, autoUpdate, flip, offset, type Placement } from '@floating-ui/dom';
import { createAttachmentKey } from 'svelte/attachments';
/**
* Options for configuring the Popover behavior and appearance.
*/
export interface PopoverOptions {
/** Interaction type for the popover */
interaction?: 'click' | 'hover' | 'manual';
/** Placement of the popover */
placement?: Placement;
/** Offset distance between the reference and floating elements (default: 8) */
offset?: number;
/** Callback when the popover is opened or closed */
ontoggle?: (open: boolean) => void;
}
/**
* Popover class that manages the state and behavior of a popover element.
* It uses floating-ui for positioning and auto-update functionality.
*/
export class Popover {
private options: PopoverOptions = {
interaction: 'click',
placement: 'bottom-start',
offset: 8
};
private referenceElement: HTMLElement | undefined = $state();
private floatingElement: HTMLElement | undefined = $state();
private open = $state(false);
/**
* Creates a new Popover instance with optional configuration options.
* @param options - Optional configuration for the popover behavior and appearance.
*/
constructor(options?: PopoverOptions) {
if (options) this.options = { ...this.options, ...options };
$effect(() => {
if (!this.referenceElement || !this.floatingElement) return;
return autoUpdate(this.referenceElement, this.floatingElement, this.#updatePosition);
});
}
/**
* Generates attributes for the reference element that triggers the popover.
* Includes event handlers based on the specified interaction type (click or hover).
* @returns An object containing necessary attributes and event handlers for
* the reference element.
*/
reference() {
const attrs = {
[createAttachmentKey()]: (node: HTMLElement) => {
this.referenceElement = node;
return () => {
this.referenceElement = undefined;
};
},
onclick: () => {},
onmouseover: () => {},
onmouseout: () => {}
};
// If click interaction
if (this.options.interaction === 'click') {
attrs['onclick'] = () => {
console.log('reference clicked, toggling popover');
this.setOpen(!this.open);
};
}
// If hover interaction
if (this.options.interaction === 'hover') {
attrs['onclick'] = () => {
this.setOpen(!this.open);
};
attrs['onmouseover'] = () => {
this.setOpen(true);
};
attrs['onmouseout'] = () => {
this.setOpen(false);
};
}
return attrs;
}
/** Returns whether the popover is open */
isOpen() {
return this.open;
}
/** Sets whether the popover is open, and triggers callbacks */
setOpen(open: boolean) {
if (this.open !== open) {
this.open = open;
this.options.ontoggle?.(open);
}
}
/**
* Generates attributes for the floating element (popover content) that is positioned
* relative to the reference element. It includes an attachment key to link the
* floating element to the popover instance.
* @returns An object containing necessary attributes for the floating element.
*/
floating() {
return {
[createAttachmentKey()]: (node: HTMLElement) => {
this.floatingElement = node;
node.style.position = 'absolute';
node.style.top = '0';
node.style.left = '0';
return () => {
this.floatingElement = undefined;
node.style.position = '';
node.style.top = '';
node.style.left = '';
};
}
};
}
/**
* Updates the position of the floating element based on the reference element using
* the computePosition function from floating-ui. It applies the calculated
* position to the floating element's style.
*/
#updatePosition = async () => {
if (!this.referenceElement || !this.floatingElement) {
return;
}
const position = await computePosition(this.referenceElement, this.floatingElement, {
placement: this.options.placement,
middleware: [flip(), offset(this.options.offset)]
});
const { x, y } = position;
Object.assign(this.floatingElement.style, {
left: `${x}px`,
top: `${y}px`
});
};
}

View File

@@ -6,7 +6,14 @@ export { default as CenterBox } from './CenterBox.svelte';
export { default as Checkbox, type CheckboxState } from './Checkbox.svelte';
export { default as Combobox, type ComboboxOption } from './Combobox.svelte';
export { default as DateInput } from './DateInput.svelte';
export { default as Dialog, type DialogAPI, type DialogControlOpts } from './Dialog.svelte';
export {
default as Dialog,
type DialogAPI,
type DialogControls,
dialogCancelButton,
dialogCloseButton,
dialogOkButton
} from './Dialog.svelte';
export {
default as DurationInput,
formatDuration,
@@ -14,6 +21,7 @@ export {
iso8601ToDuration
} from './DurationInput.svelte';
export { default as ErrorBox } from './ErrorBox.svelte';
export { type PopoverOptions, Popover } from './floating.svelte';
export { default as FramelessButton } from './FramelessButton.svelte';
export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte';
export { default as InjectUmami } from './InjectUmami.svelte';
@@ -23,6 +31,7 @@ export { default as Link, rewriteHref } from './Link.svelte';
export { default as PhoneInput } from './PhoneInput.svelte';
export { default as PinInput } from './PinInput.svelte';
export { default as RadioGroup } from './RadioGroup.svelte';
export { default as ScrollBox } from './ScrollBox.svelte';
export { default as Spinner } from './Spinner.svelte';
export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte';
export { default as StyledRawInput } from './StyledRawInput.svelte';

View File

@@ -17,6 +17,15 @@
monospace
);
/* Layout Controls */
--spacing-layout: calc(var(--spacing) * var(--ui-layout-gap, 4));
--spacing-layout-2x: calc(var(--spacing-layout) * 2);
/** TODO: Refine colors so we can pick more intent-based colors, perhaps on a per-component level???
Perhaps it's best to just wrap those individual components and apply classes there instead of
bloating the base styles with too many color variables?
*/
/* Primary Colors */
--color-sui-primary-50: var(--ui-primary-50, #f0f8fe);
--color-sui-primary-100: var(--ui-primary-100, #ddeefc);

View File

@@ -35,8 +35,7 @@
import { createLazyComponent, type ComboboxOption, type Option } from '$lib';
import Tabs from '$lib/Tabs.svelte';
import { Time } from '@internationalized/date';
import { onMount, type Component } from 'svelte';
import ErrorBox from '$lib/ErrorBox.svelte';
import { onMount } from 'svelte';
import TextareaInput from '$lib/TextareaInput.svelte';
import DurationInput, { formatDuration } from '$lib/DurationInput.svelte';
import Banner from '$lib/Banner.svelte';
@@ -60,6 +59,7 @@
let dateInputValue = $state<CalendarDate | null>(null);
let checkboxValue = $state<CheckboxState>('indeterminate');
let dialogOpen = $state(false);
let nestedDialogOpen = $state(false);
let scrollableDialogOpen = $state(false);
let toggleOptions: Option[] = $state([
'item one',
@@ -172,10 +172,27 @@
name="example-combobox"
label="Select an option"
placeholder="Choose..."
options={comboboxOptions}
options={[
{
value: 'option1',
label: 'Option 1',
preview: 'Prvw',
infotext: 'Info'
},
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3', disabled: true }
]}
onchange={(e) => console.log('Selected:', e.value)}
onvalidate={(e) => console.log('Validation:', e.detail)}
/>
pickerWidth="longest"
>
{#snippet labelRender(opt: ComboboxOption)}
Processed {opt.label}
{/snippet}
{#snippet infotextRender(opt: ComboboxOption)}
Processed {opt.infotext}
{/snippet}
</Combobox>
<Combobox
loading
@@ -201,15 +218,17 @@
label="Lazy combobox"
placeholder="Choose..."
options={lazyOptions}
lazy
onlazy={() => {
setTimeout(() => {
lazy="always"
onlazy={async () => {
await new Promise((resolve) => setTimeout(resolve, 2500));
lazyOptions = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' }
];
}, 2500);
}}
onopenchange={(open) => {
if (!open) lazyOptions = [];
}}
/>
<Combobox
@@ -429,6 +448,7 @@
<p class="title">Tabs</p>
<Tabs
padded={true}
pages={[
{
title: 'Dashboard',
@@ -517,15 +537,6 @@
bind:open={dialogOpen}
title="Dialog Title"
size="sm"
controls={{
ok: {
action: (dialog) => {
dialog.close();
alert('Dialog submitted!');
}
},
cancel: null
}}
onopen={(dialog) => {
dialog.error('Example error message!');
dialog.loading();
@@ -534,7 +545,27 @@
}, 2000);
}}
>
{#snippet controls(state)}
<Button onclick={() => state.api.close()}>Cancel</Button>
<Button
onclick={() => {
state.api.close();
alert('Dialog submitted!');
}}
loading={state.loading}
>
Submit
</Button>
{/snippet}
<p>This is a dialog content area.</p>
<Button onclick={() => (nestedDialogOpen = true)}>Open Nested Dialog</Button>
</Dialog>
<!-- Nested Dialog Demo -->
<Dialog bind:open={nestedDialogOpen} title="Nested Dialog" size="sm">
<p>This is a nested dialog content area.</p>
</Dialog>
<!-- Scrollable Dialog Demo -->