54 Commits

Author SHA1 Message Date
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
Elijah Duffy
d3c4962495 0.3.5 2025-12-17 22:17:47 -08:00
Elijah Duffy
3cdda64686 dialog: allow disabling controls from rendering 2025-12-17 22:17:42 -08:00
Elijah Duffy
6630051d67 generic merge object function 2025-12-17 22:17:15 -08:00
Elijah Duffy
bf77a20ff9 0.3.4 2025-12-15 17:14:34 -08:00
Elijah Duffy
42c9bc0bcc banner: fix control config merge behaviour 2025-12-15 17:14:33 -08:00
Elijah Duffy
ed48b404d4 0.3.3 2025-12-15 17:05:39 -08:00
Elijah Duffy
54f7924c4a banner: add control swap 2025-12-15 17:05:20 -08:00
Elijah Duffy
d9cd2b406a banner: fix control placement with optionals disabled 2025-12-15 16:42:44 -08:00
Elijah Duffy
46bd6b935a 0.3.2 2025-12-15 15:40:33 -08:00
Elijah Duffy
9782a31846 add Banner component 2025-12-15 15:24:53 -08:00
Elijah Duffy
63b29e3f6a actually fix date input demo bad bind:value 2025-12-15 15:24:19 -08:00
Elijah Duffy
1c4fac7523 demo page: set title properly in svelte:head 2025-12-15 15:23:55 -08:00
Elijah Duffy
098bf75bd3 frameless button, link: add inverted colour state 2025-12-15 15:23:24 -08:00
Elijah Duffy
a321cbffe9 input group: allow wrapping by default 2025-12-15 15:23:12 -08:00
Elijah Duffy
1cc8cd6913 fix date input demo bad bind:value 2025-12-15 14:40:43 -08:00
Elijah Duffy
72b277d138 0.3.1 2025-12-12 13:59:35 -08:00
Elijah Duffy
3885ac09a1 link: refactor href rewrite
- uses prop instead of attempting to read env variable
- uses URL type instead of custom heuristics
2025-12-12 13:59:21 -08:00
Elijah Duffy
ae3abad769 use tinyduration for ISO8601 parsing and serializing 2025-12-11 20:40:27 -08:00
Elijah Duffy
837029b598 0.3.0 2025-12-11 17:30:36 -08:00
Elijah Duffy
3409adc614 date input: add onchange callback prop 2025-12-11 17:02:32 -08:00
Elijah Duffy
a538db4065 date input: use distinct 'null' value state if unset by user 2025-12-11 16:02:09 -08:00
Elijah Duffy
9d0f10f0fd duration & time input: input visual invalidation 2025-12-11 16:00:41 -08:00
Elijah Duffy
df1dd238e2 StyledRawInput: support external force invalid visual state 2025-12-11 16:00:26 -08:00
Elijah Duffy
0608343741 DurationInput: limit precision to TimeDuration type
Remove support for 'days', adds support for 'milliseconds'
2025-12-11 15:42:59 -08:00
Elijah Duffy
f843c91284 add DurationInput component 2025-12-11 15:20:42 -08:00
Elijah Duffy
a7fa9fd6d8 fix '0' > prefixZero > '00' (should still be '0') 2025-12-11 15:19:45 -08:00
Elijah Duffy
7fd2bbb879 dialog: don't default to scrollable demo to open 2025-12-11 11:43:16 -08:00
Elijah Duffy
f260038aac checkbox: support disabled state 2025-12-11 11:42:28 -08:00
Elijah Duffy
058e20fa2d checkbox: support MaybeGetter pattern 2025-12-10 19:04:44 -08:00
Elijah Duffy
020f5ea4ad 0.2.15 2025-12-09 16:24:16 -08:00
Elijah Duffy
08cde9c3ee dialog: handle overflow scrolling 2025-12-09 16:24:08 -08:00
28 changed files with 1862 additions and 507 deletions

View File

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

View File

@@ -4,7 +4,7 @@
"type": "git", "type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/sui.git" "url": "https://gitea.auvem.com/svelte-toolkit/sui.git"
}, },
"version": "0.2.14", "version": "1.1.0",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build && pnpm run prepack", "build": "vite build && pnpm run prepack",
@@ -40,12 +40,12 @@
}, },
"peerDependencies": { "peerDependencies": {
"@sveltejs/kit": "^2.20.2", "@sveltejs/kit": "^2.20.2",
"svelte": "^5.0.0",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
"@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/vite": "^4.1.11" "@tailwindcss/vite": "^4.1.11",
"svelte": "^5.0.0",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7"
}, },
"dependencies": { "dependencies": {
"@internationalized/date": "^3.8.2", "@internationalized/date": "^3.8.2",
@@ -56,11 +56,13 @@
"match-sorter": "^8.0.0", "match-sorter": "^8.0.0",
"melt": "^0.37.0", "melt": "^0.37.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"phosphor-svelte": "^3.0.1" "phosphor-svelte": "^3.0.1",
"tinyduration": "^3.4.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@floating-ui/dom": "^1.7.6",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/package": "^2.5.0", "@sveltejs/package": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",

63
pnpm-lock.yaml generated
View File

@@ -4,9 +4,6 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
'@svelte-toolkit/validate': link:../validate
importers: importers:
.: .:
@@ -18,8 +15,8 @@ importers:
specifier: ^0.2.1 specifier: ^0.2.1
version: 0.2.1(svelte@5.38.1) version: 0.2.1(svelte@5.38.1)
'@svelte-toolkit/validate': '@svelte-toolkit/validate':
specifier: link:../validate specifier: ^1.0.1
version: link:../validate version: 1.0.1(svelte@5.38.1)
'@sveltejs/kit': '@sveltejs/kit':
specifier: ^2.20.2 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)) 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 version: 8.1.0
melt: melt:
specifier: ^0.37.0 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: moment:
specifier: ^2.30.1 specifier: ^2.30.1
version: 2.30.1 version: 2.30.1
@@ -59,6 +56,9 @@ importers:
tailwindcss-animate: tailwindcss-animate:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.11) version: 1.0.7(tailwindcss@4.1.11)
tinyduration:
specifier: ^3.4.1
version: 3.4.1
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^1.2.5 specifier: ^1.2.5
@@ -66,6 +66,9 @@ importers:
'@eslint/js': '@eslint/js':
specifier: ^9.18.0 specifier: ^9.18.0
version: 9.33.0 version: 9.33.0
'@floating-ui/dom':
specifier: ^1.7.6
version: 1.7.6
'@sveltejs/adapter-auto': '@sveltejs/adapter-auto':
specifier: ^4.0.0 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))) 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)))
@@ -328,14 +331,14 @@ packages:
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.7.3': '@floating-ui/core@1.7.5':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.7.3': '@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==} resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/utils@0.2.10': '@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@humanfs/core@0.19.1': '@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
@@ -514,6 +517,11 @@ packages:
'@standard-schema/spec@1.0.0': '@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} 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': '@sveltejs/acorn-typescript@1.0.5':
resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==} resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==}
peerDependencies: peerDependencies:
@@ -664,6 +672,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 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': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1539,6 +1550,9 @@ packages:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'} engines: {node: '>=18'}
tinyduration@3.4.1:
resolution: {integrity: sha512-NemFoamVYn7TmtwZKZ3OiliM9fZkr6EWiTM+wKknco6POSy2gS689xx/pXip0JYp40HXpUw6k65CUYHWYUXdaA==}
tinyglobby@0.2.14: tinyglobby@0.2.14:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -1792,16 +1806,16 @@ snapshots:
'@eslint/core': 0.15.2 '@eslint/core': 0.15.2
levn: 0.4.1 levn: 0.4.1
'@floating-ui/core@1.7.3': '@floating-ui/core@1.7.5':
dependencies: 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: dependencies:
'@floating-ui/core': 1.7.3 '@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.10 '@floating-ui/utils': 0.2.11
'@floating-ui/utils@0.2.10': {} '@floating-ui/utils@0.2.11': {}
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
@@ -1931,6 +1945,11 @@ snapshots:
'@standard-schema/spec@1.0.0': {} '@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)': '@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)':
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
@@ -2080,6 +2099,8 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/google.maps@3.58.1': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@24.2.1': '@types/node@24.2.1':
@@ -2623,9 +2644,9 @@ snapshots:
'@babel/runtime': 7.28.2 '@babel/runtime': 7.28.2
remove-accents: 0.5.0 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: dependencies:
'@floating-ui/dom': 1.7.3 '@floating-ui/dom': 1.7.6
dequal: 2.0.3 dequal: 2.0.3
focus-trap: 7.6.5 focus-trap: 7.6.5
jest-axe: 9.0.0 jest-axe: 9.0.0
@@ -2917,6 +2938,8 @@ snapshots:
mkdirp: 3.0.1 mkdirp: 3.0.1
yallist: 5.0.0 yallist: 5.0.0
tinyduration@3.4.1: {}
tinyglobby@0.2.14: tinyglobby@0.2.14:
dependencies: dependencies:
fdir: 6.4.6(picomatch@4.0.3) fdir: 6.4.6(picomatch@4.0.3)

View File

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

228
src/lib/Banner.svelte Normal file
View File

@@ -0,0 +1,228 @@
<script lang="ts" module>
export interface BannerAPI {
/** opens the banner */
open: () => void;
/** closes the banner */
close: () => void;
/** returns whether the banner is open */
isOpen: () => boolean;
/** disables banner controls */
disable: () => void;
/** enables banner controls */
enable: () => void;
/** returns whether the banner controls are disabled */
isDisabled: () => boolean;
/** freezes banner state (cannot be dismissed) */
freeze: () => void;
/** unfreezes banner state */
unfreeze: () => void;
/** returns whether the banner is frozen */
isFrozen: () => boolean;
}
type BannerControlButton = {
label?: string;
class?: ClassValue;
/** if framed is false, FramelessButton is used instead of Button */
framed?: boolean;
action?: (banner: BannerAPI) => void;
};
export type BannerControls = {
accept?: BannerControlButton | null;
decline?: BannerControlButton | null;
moreInfo?:
| (Omit<BannerControlButton, 'framed'> & {
type?: 'link' | 'framed' | 'frameless';
href?: string;
})
| null;
dismiss?: Omit<BannerControlButton, 'framed'> | null;
/** if true, accept and decline buttons are swapped with more info */
swap?: boolean | null;
};
const defaultBannerControls: BannerControls = {
accept: {
label: 'Accept',
framed: true,
action: (banner) => banner.close()
},
decline: {
label: 'Decline',
framed: false,
action: (banner) => banner.close()
},
dismiss: {
action: (banner) => banner.close()
},
swap: false
};
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import Link from './Link.svelte';
import Button from './Button.svelte';
import FramelessButton from './FramelessButton.svelte';
import { X } from 'phosphor-svelte';
import { mergeOverrideObject } from './util';
/**
* Banner provides a simple component to display a banner message with
* additional tooling for easy control integration. Controls tools are
* geared toward cookie consent, announcements, and similar use cases.
*/
interface Props {
title?: string;
open?: boolean;
disabled?: boolean;
frozen?: boolean;
position?: 'top' | 'bottom';
controls?: BannerControls | null;
/** if true, frameless buttons and links are inverted (default: true) */
invertFrameless?: boolean;
class?: ClassValue | null;
onaccept?: (banner: BannerAPI) => void;
ondecline?: (banner: BannerAPI) => void;
children?: Snippet;
}
let {
title,
open = $bindable(false),
disabled = $bindable(false),
frozen = $bindable(false),
position,
controls: rawControls = defaultBannerControls,
invertFrameless = true,
class: classValue,
onaccept,
ondecline,
children
}: Props = $props();
const controls = $derived(mergeOverrideObject(defaultBannerControls, rawControls));
const api: BannerAPI = {
open: () => (open = true),
close: () => (open = false),
isOpen: () => open,
disable: () => (disabled = true),
enable: () => (disabled = false),
isDisabled: () => disabled,
freeze: () => (frozen = true),
unfreeze: () => (frozen = false),
isFrozen: () => frozen
};
const handleAccept = () => {
controls.accept?.action?.(api);
onaccept?.(api);
};
const handleDecline = () => {
controls.decline?.action?.(api);
ondecline?.(api);
};
</script>
{#if open}
<div
class={[
'fixed left-0 z-50 w-screen px-8 py-6',
position === 'top' ? 'top-0' : 'bottom-0',
'bg-sui-secondary-800 text-sui-background',
classValue
]}
>
{#if title || controls?.dismiss}
<div class="mb-2 flex items-center justify-between gap-4">
{#if title}
<h3 class="mb-2 text-lg font-semibold">{title}</h3>
{/if}
{#if controls?.dismiss}
<FramelessButton
inverted={invertFrameless}
onclick={() => controls.dismiss?.action?.(api)}
class="ml-auto"
>
{#if controls.dismiss?.label}
{controls.dismiss.label}
{/if}
<X class="mt-0.5" size="1.2rem" weight="bold" />
</FramelessButton>
{/if}
</div>
{/if}
{@render children?.()}
{#if controls !== null}
<div
class={['mt-4 flex flex-wrap justify-between gap-4', controls.swap && 'flex-row-reverse']}
>
<!-- More info button/link -->
{#if controls.moreInfo}
{#if controls.moreInfo.type === 'link' && controls.moreInfo.href}
<Link
href={controls.moreInfo.href}
class={controls.moreInfo.class}
inverted={invertFrameless}
>
{controls.moreInfo.label || 'More Info'}
</Link>
{:else if controls.moreInfo.type === 'framed'}
<Button
class={controls.moreInfo.class}
onclick={() => controls.moreInfo?.action?.(api)}
>
{controls.moreInfo.label || 'More Info'}
</Button>
{:else}
<FramelessButton
class={controls.moreInfo.class}
onclick={() => controls.moreInfo?.action?.(api)}
inverted={invertFrameless}
>
{controls.moreInfo.label || 'More Info'}
</FramelessButton>
{/if}
{/if}
<div
class={['flex justify-end gap-4', controls.swap ? 'mr-auto flex-row-reverse' : 'ml-auto']}
>
<!-- Decline button -->
{@render buttonControl(controls.decline, handleDecline, false)}
<!-- Accept button -->
{@render buttonControl(controls.accept, handleAccept, true)}
</div>
</div>
{/if}
</div>
{/if}
{#snippet buttonControl(
button: BannerControlButton | undefined | null,
handleClick: (banner: BannerAPI) => void,
framedDefault: boolean
)}
{#if button}
{#if button.framed || framedDefault}
<Button class={button.class} onclick={() => handleClick(api)}>
{button.label || 'Button'}
</Button>
{:else}
<FramelessButton
class={button.class}
onclick={() => handleClick(api)}
inverted={invertFrameless}
>
{button.label || 'Button'}
</FramelessButton>
{/if}
{/if}
{/snippet}

View File

@@ -5,14 +5,15 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { validate } from '@svelte-toolkit/validate'; import { validate } from '@svelte-toolkit/validate';
import { generateIdentifier } from './util'; import { generateIdentifier, resolveGetter, type MaybeGetter } from './util';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { Check, Minus } from 'phosphor-svelte'; import { Check, Minus } from 'phosphor-svelte';
interface Props { interface Props {
name?: string; name?: string;
required?: boolean; required?: boolean;
value?: CheckboxState; disabled?: boolean;
value?: MaybeGetter<CheckboxState>;
color?: 'default' | 'contrast'; color?: 'default' | 'contrast';
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
children?: Snippet; children?: Snippet;
@@ -22,6 +23,7 @@
let { let {
name, name,
required = false, required = false,
disabled = false,
value = $bindable(), value = $bindable(),
color = 'contrast', color = 'contrast',
class: classValue, class: classValue,
@@ -32,17 +34,19 @@
let id = $derived(generateIdentifier('checkbox', name)); let id = $derived(generateIdentifier('checkbox', name));
let valid = $state(true); let valid = $state(true);
let Icon = $derived(value === 'indeterminate' ? Minus : value ? Check : undefined); let Icon = $derived(
resolveGetter(value) === 'indeterminate' ? Minus : resolveGetter(value) ? Check : undefined
);
</script> </script>
<div class={['flex items-center', classValue]}> <div class={['flex items-center', classValue]}>
<input <input
type="hidden" type="hidden"
{name} {name}
value={value?.toString() ?? 'false'} value={resolveGetter(value)?.toString() ?? 'false'}
use:validate={{ use:validate={{
valfunc: () => { valfunc: () => {
if (required && value !== true) { if (required && resolveGetter(value) !== true) {
return false; return false;
} }
return true; return true;
@@ -56,15 +60,18 @@
<button <button
type="button" type="button"
{id} {id}
{disabled}
class={[ class={[
'text-sui-text flex size-7 shrink-0 appearance-none items-center', 'text-sui-text flex size-7 shrink-0 appearance-none items-center',
'justify-center rounded-lg shadow transition-all hover:opacity-75', 'justify-center rounded-lg shadow transition-all',
color === 'default' && 'bg-white', color === 'default' && 'bg-white',
color === 'contrast' && 'border-text/40 border bg-white', color === 'contrast' && 'border-text/40 border bg-white',
!valid && 'border border-red-500' !valid && 'border border-red-500',
disabled ? 'cursor-not-allowed opacity-50' : 'hover:opacity-75'
]} ]}
onclick={() => { onclick={() => {
if (value === false || value === undefined || value === 'indeterminate') { const resolved = resolveGetter(value);
if (resolved === false || resolved === undefined || resolved === 'indeterminate') {
value = true; value = true;
} else { } else {
value = false; value = false;

View File

@@ -1,16 +1,38 @@
<script lang="ts" module> <script lang="ts" module>
export type ComboboxOption = { export type ComboboxOption = {
/** Value of the option */
value: string; 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; 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; 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; disabled?: boolean;
icon?: Snippet<[item: ComboboxOption]>;
render?: Snippet<[item: ComboboxOption]>;
}; };
const getLabel = (item: ComboboxOption | undefined): string => item?.label ?? item?.value ?? ''; /** returns option label, falling back to value or 'Undefined Option' if no option provided */
const getPreview = (item: ComboboxOption | undefined): string => item?.preview ?? getLabel(item); 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>
<script lang="ts"> <script lang="ts">
@@ -19,51 +41,146 @@
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';
import { generateIdentifier, type IconDef } from './util'; 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 { matchSorter } from 'match-sorter';
import Spinner from './Spinner.svelte'; 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 { 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 * Name of the input, used for form submission. The currently selected
* internally if lazy is enabled. loading is, however, not bindable, so don't expect * option's value will be submitted under this name.
* any internal changes to be propagated outward. */
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; loading?: boolean;
/** applies the loading state on first interaction */ /** Special always-disabled option displayed when loading */
lazy?: boolean; loadingOption?: ComboboxOption;
/** uses a compact layout for the search input with less padding and smaller text */ /**
compact?: boolean; * Applies the loading state when the picker is opened, allowing for
/** allows the user to select an option without selecting a value (events are still triggered) */ * asynchronous loading of options.
stateless?: boolean; * `always`: applies loading state each time the picker is opened
notFoundMessage?: string; * `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; class?: ClassValue | null | undefined;
/** Optional action applied to the main input */
use?: () => void; use?: () => void;
/** Callback when the input value changes, triggering validation */
onvalidate?: (e: InputValidatorEvent) => void; 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; onsearch?: (query: string) => boolean | void;
onscroll?: (detail: { event: UIEvent; top: boolean; bottom: boolean }) => void; /** Callback when the picker is scrolled */
/** this callback runs only once on the first interaction if lazy is enabled */ onscroll?: (detail: {
onlazy?: () => void; event: UIEvent;
onopen?: () => void; top: boolean;
onclose?: () => void; 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 { let {
@@ -71,18 +188,20 @@
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',
label, label,
placeholder, placeholder,
icon, icon,
loading = false, loading = $bindable(false),
lazy = false, lazy = false,
compact = false, compact = false,
stateless = 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, class: classValue,
use, use,
onvalidate, onvalidate,
@@ -90,32 +209,69 @@
onsearch, onsearch,
onscroll, onscroll,
onlazy, onlazy,
onopen, onopenchange,
onclose labelRender,
infotextRender,
iconRender
}: Props = $props(); }: 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 valid = $state(true);
let searchValue = $state(''); let searchValue = $state('');
let pickerPosition = $state<'top' | 'bottom'>('bottom'); let pickerPosition = $state<'top' | 'bottom'>('bottom');
let searching = $state(false); let searching = $state(false);
let iconWidth = $state<number | undefined>(undefined);
let searchInput = $state<HTMLInputElement | null>(null); let searchInput = $state<HTMLInputElement | null>(null);
let searchContainer = $state<HTMLDivElement | null>(null); let searchContainer = $state<HTMLDivElement | null>(null);
let pickerContainer = $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 filteredItems = $derived.by(() => {
const arr = matchSorter(options, searchValue, { keys: [(item) => getLabel(item)] }); let keys: KeyOption<ComboboxOption>[] = [];
// if (loading) { if (searchKeySet.has('label')) keys.push((item) => getLabel(item));
// arr.push({ value: 'loading', label: 'Loading more...', disabled: true }); 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; 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 => { let highlighted = $derived.by((): ComboboxOption | undefined => {
if (!searching) return undefined; // otherwise, the first item is highlighted on first open if (!searching) return undefined; // otherwise, the first item is highlighted on first open
if (filteredItems.length === 0) return undefined; if (filteredItems.length === 0) return undefined;
@@ -123,20 +279,50 @@
if (!filteredItems[0]?.disabled) return filteredItems[0]; 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) || (open && highlighted && highlighted.icon) ||
(value && value.icon && searchValue === '') || (value && value.icon && searchValue === '') ||
loading || loading ||
icon !== undefined icon !== undefined
); );
/** controls whether the highlighted option should be used in the selection preview */ /** whether the highlighted option should be used in the selection preview */
let useHighlighted = $derived.by(() => { const useHighlighted = $derived.by(() => {
return open && highlighted; 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 */ /** returns the index of the highlighted item in filteredItems */
const getHighlightedIndex = () => { const getHighlightedIndex = () => {
@@ -172,104 +358,66 @@
}; };
let lazyApplied = false; 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 */ /** updates the value of the combobox and triggers any callbacks, including closing the picker */
const updateValue = (newValue: ComboboxOption) => { const updateValue = (newValue: ComboboxOption) => {
if (!stateless) value = newValue; if (!stateless) value = newValue;
closePicker(); popover.setOpen(false);
onchange?.(newValue); onchange?.(newValue);
}; };
/** action to set the minimum width of the combobox based on the number of options */ /**
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;
const avg = options.reduce((acc, item) => acc + getLabel(item).length, 0) / options.length;
container.style.width = `${avg * 2.5}ch`;
}; };
f(buildOpts); /**
* Calculates longest width based on provided options, attempting to prevent
return { * wrapping by returning a width in 'ch' units based on the longest label and
update: (updateOpts: typeof buildOpts) => { * infotext, or a reasonable minimum when no options are provided.
f(updateOpts); */
} 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 */ /** sets minimum width of the picker as configured by props */
const updatePickerRect = async () => { const minWidth = (opts: {
if (!searchContainer || !pickerContainer) { options: ComboboxOption[];
await tick(); matchElem: HTMLInputElement | null;
if (!searchContainer || !pickerContainer) { mode: Props['pickerWidth'];
return; }): Attachment<HTMLDivElement> => {
} return (elem) => {
} if ((opts.mode === 'match' || opts.options.length === 0) && opts.matchElem) {
// match width if explicitly enabled, or no options to average
const overlay = pickerContainer; elem.style.width = opts.matchElem.offsetWidth + 'px';
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';
} else { } else {
// overlay should be below the target // otherwise, set width based on average content width
overlay.style.top = `${targetRect.bottom + window.scrollY}px`; let val = '10ch';
overlay.style.bottom = 'auto'; if (opts.mode === 'average') {
overlay.style.maxHeight = `${availableSpaceBelow - outerMargin}px`; val = calculateAvgWidth(opts.options);
pickerPosition = 'bottom'; } else if (opts.mode === 'longest' || opts.mode === 'max-match') {
overlay.dataset.side = 'bottom'; val = calculateLongestWidth(opts.options);
if (opts.mode === 'max-match') {
elem.style.maxWidth = opts.matchElem ? opts.matchElem.offsetWidth + 'px' : 'none';
} }
}
// set overlay left position elem.style.width = val;
overlay.style.left = `${targetRect.left}px`; }
};
}; };
/** scrolls the picker to the highlighted item */ /** scrolls the picker to the highlighted item */
@@ -292,7 +440,7 @@
} }
}; };
const conditionalUse = $derived(use ? use : () => {}); /*** EXPORTED API ***/
/** focuses the combobox search input */ /** focuses the combobox search input */
export const focus = () => { export const focus = () => {
@@ -309,18 +457,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(() => { $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); searchInput.value = getLabel(highlighted);
return; } else if (!usePreview || (usePreview === 'auto' && open)) {
}
if (untrack(() => searching)) return;
if (!usePreview || (usePreview === 'auto' && open)) {
searchInput.value = getLabel(value); searchInput.value = getLabel(value);
} else { } else {
searchInput.value = getPreview(value); searchInput.value = getPreview(value);
@@ -334,37 +485,26 @@
} }
}); });
onMount(() => { // close picker if clicked outside
// set initial picker position on load const handleWindowClick: MouseEventHandler<Window> = (e) => {
setTimeout(() => { if (!open || !searchContainer || !pickerContainer) return;
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;
}
if ( if (
searchContainer.contains(e.target as Node) || e.target instanceof Node &&
pickerContainer.contains(e.target as Node) || !searchContainer.contains(e.target) &&
!open !pickerContainer.contains(e.target)
) { ) {
return; popover.setOpen(false);
} }
};
closePicker();
});
});
</script> </script>
<svelte:window onclick={handleWindowClick} />
<!-- Combobox picker --> <!-- Combobox picker -->
<Portal target="body"> <Portal target="body">
{#if open} {#if open}
<!-- Picker container -->
<div <div
class={[ class={[
'picker absolute top-0 left-0 z-50 overflow-y-auto px-2 py-3', 'picker absolute top-0 left-0 z-50 overflow-y-auto px-2 py-3',
@@ -374,13 +514,12 @@
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"
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closePicker(); popover.setOpen(false);
searchInput?.focus(); searchInput?.focus();
} }
}} }}
@@ -392,72 +531,27 @@
const margin = 10; // 10px margin for top & bottom const margin = 10; // 10px margin for top & bottom
const atTop = target.scrollTop < margin; const atTop = target.scrollTop < margin;
const atBottom = target.scrollTop + target.clientHeight > target.scrollHeight - 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" tabindex="0"
{@attach minWidth({ options, mode: pickerWidth, matchElem: searchInput })}
{...popover.floating()}
> >
{#each filteredItems as item, i (item.value)} {#each filteredItems as opt (opt.value)}
<div {@render option(opt)}
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)}
{:else} {:else}
{getLabel(item)} <!-- Display loading state or not found if no options available -->
{/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">
{#if loading} {#if loading}
Loading... {@render option(loadingOption, true)}
{:else} {:else}
{notFoundMessage} {@render option(notFoundOption, true)}
{/if} {/if}
</span>
{/each} {/each}
</div> </div>
{/if} {/if}
</Portal> </Portal>
<!-- Combobox main input container -->
<div class={classValue}> <div class={classValue}>
<!-- Combobox Label --> <!-- Combobox Label -->
{#if label} {#if label}
@@ -490,34 +584,98 @@
{/if} {/if}
</div> </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 --> <!-- Search input box -->
{#snippet searchInputBox(caret: boolean = true)} {#snippet searchInputBox(caret: boolean = true)}
<div class="relative"> <div class="relative" {...popover.reference()}>
<!-- Persistant OR selected option icon, if visible -->
{#if iconVisible} {#if iconVisible}
<div <div
class={[ 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 transition:scale
bind:clientWidth={iconWidth} bind:clientWidth={iconWidth}
> >
{#if loading} {#if loading}
<Spinner class="stroke-sui-accent! -mt-0.5" size="1em" /> <Spinner class="stroke-sui-accent! -mt-0.5" size="1em" />
{:else if useHighlighted && highlighted?.icon} {:else if useHighlighted && highlighted}
{@render highlighted.icon(highlighted)} {@render optionIcon(highlighted)}
{:else if value?.icon} {:else if value}
{@render value.icon(value)} {@render optionIcon(value)}
{:else if icon} {:else if icon}
{#if typeof icon === 'function'}
{@render icon()}
{:else}
<icon.component {...icon.props} /> <icon.component {...icon.props} />
{/if}
{:else} {:else}
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Combobox input box -->
<StyledRawInput <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!']} class={[caret && 'pr-9', !valid && 'border-red-500!']}
{compact} {compact}
type="text" type="text"
@@ -526,13 +684,7 @@
autocomplete="off" autocomplete="off"
bind:ref={searchInput} bind:ref={searchInput}
onclick={() => { onclick={() => {
if (!open) { popover.setOpen(true);
setTimeout(() => {
searchInput?.select();
}, 100);
}
openPicker();
}} }}
onkeydown={(e) => { onkeydown={(e) => {
if (!searchInput) return; if (!searchInput) return;
@@ -542,17 +694,17 @@
updateValue(highlighted); updateValue(highlighted);
} }
if (e.key === 'Enter') { if (e.key === 'Enter') {
closePicker(); popover.setOpen(false);
e.preventDefault(); e.preventDefault();
} }
return; return;
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
closePicker(); popover.setOpen(false);
return; return;
} }
// open the picker // open the picker
openPicker(); popover.setOpen(true);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
searching = false; searching = false;
@@ -578,7 +730,8 @@
}} }}
/> />
{#if (value && value.infotext) || (highlighted && useHighlighted && highlighted.infotext)} <!-- Right-aligned infotext (overlay) -->
{#if (value && value.infotext) || (highlighted && useHighlighted && highlighted.infotext) || infotextRender}
<div <div
class={[ class={[
'pointer-events-none absolute top-1/2 -translate-y-1/2 transform text-sm select-none', 'pointer-events-none absolute top-1/2 -translate-y-1/2 transform text-sm select-none',
@@ -586,7 +739,11 @@
caret ? 'end-10' : 'end-[1.125rem]' 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> </div>
{/if} {/if}
@@ -594,14 +751,24 @@
<CaretUpDown <CaretUpDown
class="absolute end-2.5 top-1/2 size-6 -translate-y-1/2" class="absolute end-2.5 top-1/2 size-6 -translate-y-1/2"
onclick={() => { onclick={() => {
open = !open; popover.setOpen(!open);
if (open) searchInput?.focus();
}} }}
/> />
{/if} {/if}
</div> </div>
{/snippet} {/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"> <style lang="postcss">
@reference "./styles/reference.css"; @reference "./styles/reference.css";

View File

@@ -16,7 +16,7 @@
interface Props { interface Props {
name?: string; name?: string;
value?: CalendarDate; value?: CalendarDate | null;
min?: CalendarDate; min?: CalendarDate;
// max?: CalendarDate; // TODO: Implement validation. // max?: CalendarDate; // TODO: Implement validation.
label?: string; label?: string;
@@ -24,11 +24,12 @@
invalidMessage?: string; invalidMessage?: string;
class?: ClassValue | undefined | null; class?: ClassValue | undefined | null;
format?: FormatString[]; format?: FormatString[];
onchange?: (date: CalendarDate | null) => void;
} }
let { let {
name, name,
value = $bindable<CalendarDate | undefined>(), value = $bindable<CalendarDate | null>(null),
/** min specifies lower bounds for the date input (WARNING: NOT IMPLEMENTED) */ /** min specifies lower bounds for the date input (WARNING: NOT IMPLEMENTED) */
min = new CalendarDate(1900, 0, 1), min = new CalendarDate(1900, 0, 1),
/** max specifies upper bounds for the date input (WARNING: NOT IMPLEMENTED) */ /** max specifies upper bounds for the date input (WARNING: NOT IMPLEMENTED) */
@@ -37,7 +38,8 @@
required = false, required = false,
invalidMessage = 'Valid date is required', invalidMessage = 'Valid date is required',
class: classValue, class: classValue,
format = ['year', 'month', 'day'] format = ['year', 'month', 'day'],
onchange
}: Props = $props(); }: Props = $props();
const id = $derived(generateIdentifier('dateinput', name)); const id = $derived(generateIdentifier('dateinput', name));
@@ -108,7 +110,8 @@
if (value) { if (value) {
setPrevious(); setPrevious();
previousYearValue = undefined; previousYearValue = undefined;
value = undefined; value = null;
onchange?.(value);
} }
return; return;
} }
@@ -121,7 +124,8 @@
if (value) { if (value) {
setPrevious(); setPrevious();
previousMonthValue = undefined; previousMonthValue = undefined;
value = undefined; value = null;
onchange?.(value);
} }
return; return;
} }
@@ -134,7 +138,8 @@
if (value) { if (value) {
setPrevious(); setPrevious();
previousDayValue = undefined; previousDayValue = undefined;
value = undefined; value = null;
onchange?.(value);
} }
return; return;
} }
@@ -146,6 +151,7 @@
day ?? (value ? value.day : min.day) day ?? (value ? value.day : min.day)
); );
value = newDate; value = newDate;
onchange?.(value);
}; };
type caretPos = { start: number; end: number } | null; type caretPos = { start: number; end: number } | null;

View File

@@ -41,30 +41,49 @@
title: (title: string) => void; title: (title: string) => void;
} }
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;
};
/** /**
* Configures the default dialog controls. * Configures the default dialog controls.
*/ */
export type DialogControlOpts = { export type DialogControls = {
cancel?: { /** Options for the bottom cancel button */
label?: string; cancel?: DialogControlButton | null;
class?: ClassValue; /** Options for the bottom submit button */
action?: (dialog: DialogAPI) => void; ok?: DialogControlButton | null;
/** Inverts the order of the buttons */
flip?: boolean;
}; };
ok?: {
label?: string; /**
class?: ClassValue; * Stores internal state of the dialog, everything necessary to render
action?: (dialog: DialogAPI) => void; * internal snippets.
}; */
close?: { type DialogState = {
class?: ClassValue; frozen: boolean;
action?: (dialog: DialogAPI) => void; loading: boolean;
disabled: boolean;
api: DialogAPI;
}; };
const defaultDialogControls: DialogControls = {
cancel: { label: 'Cancel' },
ok: { label: 'OK' }
}; };
export { dialogCancelButton, dialogOkButton, dialogCloseButton };
</script> </script>
<script lang="ts"> <script lang="ts">
import { Portal } from '@jsrob/svelte-portal'; import { Portal } from '@jsrob/svelte-portal';
import { type Snippet } from 'svelte'; import { untrack, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { flyAndScale } from './transition'; import { flyAndScale } from './transition';
@@ -72,19 +91,36 @@
import { X } from 'phosphor-svelte'; import { X } from 'phosphor-svelte';
import { ErrorMessage, type RawError } from './error'; import { ErrorMessage, type RawError } from './error';
import ErrorBox from './ErrorBox.svelte'; import ErrorBox from './ErrorBox.svelte';
import { mergeOverrideObject } from './util';
interface Props { interface Props {
/** Bindable open state of the dialog */
open?: boolean; open?: boolean;
title: string; /** Title of the dialog */
description?: string; title: string | Snippet;
/** Description of the dialog, optionally rendered below the title */
description?: string | Snippet;
/** Size of the dialog (default: 'sm') */
size?: 'sm' | 'md' | 'lg' | 'max'; size?: 'sm' | 'md' | 'lg' | 'max';
/** Additional classes for the dialog */
class?: ClassValue; class?: ClassValue;
/** Content of the dialog */
children?: Snippet; children?: Snippet;
controls?: Snippet | DialogControlOpts; /** Bottom controls for the dialog */
controls?: Snippet | 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 */
onopen?: (dialog: DialogAPI) => void; onopen?: (dialog: DialogAPI) => void;
/** Callback when the dialog is closed */
onclose?: (dialog: DialogAPI) => void; onclose?: (dialog: DialogAPI) => void;
/** If default controls are used, controls loading state of submit button */
loading?: boolean; loading?: boolean;
/** If default controls are used, freezes all interactions */
frozen?: boolean; frozen?: boolean;
/** If default controls are used, disables submit button */
disabled?: boolean; disabled?: boolean;
} }
@@ -95,7 +131,9 @@
size = 'sm', size = 'sm',
class: classValue, class: classValue,
children, children,
controls, controls: rawControls = defaultDialogControls,
controlsAlign = 'end',
close = {},
onopen, onopen,
onclose, onclose,
loading = $bindable(false), loading = $bindable(false),
@@ -103,6 +141,12 @@
disabled = $bindable(false) disabled = $bindable(false)
}: Props = $props(); }: Props = $props();
let controls = $derived(
typeof rawControls === 'function'
? rawControls
: mergeOverrideObject(defaultDialogControls, rawControls)
);
let dialogContainer = $state<HTMLDivElement | null>(null); let dialogContainer = $state<HTMLDivElement | null>(null);
let error = $state<ErrorMessage | null>(null); let error = $state<ErrorMessage | null>(null);
@@ -110,14 +154,15 @@
$effect(() => { $effect(() => {
if (open) { if (open) {
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
onopen?.(dialogAPI); untrack(() => onopen?.(dialogAPI));
} else { } else {
document.body.style.overflow = ''; document.body.style.overflow = '';
onclose?.(dialogAPI); untrack(() => onclose?.(dialogAPI));
} }
}); });
const dialogAPI: DialogAPI = { /** DialogAPI instance to control this dialog */
export const dialogAPI: DialogAPI = {
error: (message) => (error = ErrorMessage.from(message)), error: (message) => (error = ErrorMessage.from(message)),
close: () => (open = false), close: () => (open = false),
open: () => (open = true), open: () => (open = true),
@@ -134,6 +179,16 @@
canContinue: () => !loading && !disabled && !frozen, canContinue: () => !loading && !disabled && !frozen,
title: (newTitle) => (title = newTitle) title: (newTitle) => (title = newTitle)
}; };
/** Returns the current state of the dialog */
export const getState = (): DialogState => {
return {
frozen,
loading,
disabled,
api: dialogAPI
};
};
</script> </script>
<Portal target="body"> <Portal target="body">
@@ -145,7 +200,8 @@
{#snippet dialog()} {#snippet dialog()}
<div <div
class={[ class={[
'fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm', 'fixed inset-0 z-50 flex items-center-safe justify-center bg-black/50 backdrop-blur-sm',
'overflow-auto p-8',
classValue classValue
]} ]}
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
@@ -165,7 +221,7 @@
<div <div
bind:this={dialogContainer} bind:this={dialogContainer}
class={[ class={[
'relative max-h-[85vh] w-[90vw] rounded-xl bg-white p-6 shadow-lg', 'relative w-[90vw] rounded-xl bg-white p-6 shadow-lg',
size === 'sm' && 'max-w-[450px]', size === 'sm' && 'max-w-[450px]',
size === 'md' && 'max-w-[650px]', size === 'md' && 'max-w-[650px]',
size === 'lg' && 'max-w-[850px]', size === 'lg' && 'max-w-[850px]',
@@ -177,7 +233,21 @@
start: 0.96 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">
{@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} {#if error}
<ErrorBox {error} /> <ErrorBox {error} />
@@ -185,55 +255,100 @@
{#if description} {#if description}
<p class="mb-3 leading-normal text-zinc-600"> <p class="mb-3 leading-normal text-zinc-600">
{description} {@render stringOrSnippet(description)}
</p> </p>
{/if} {/if}
{#if children}{@render children()}{:else}Dialog is empty{/if} {#if children}{@render children()}{:else}Dialog is empty{/if}
<div class="mt-6 flex justify-end gap-4"> <!-- Dialog Controls -->
{#if controls && typeof controls === 'function'}{@render controls()}{:else} <div
<Button class={[
class={controls?.cancel?.class} 'mt-6 flex gap-4',
onclick={() => { controlsAlign === 'start' && 'justify-start',
if (controls?.cancel?.action) { controlsAlign === 'center' && 'justify-center',
controls.cancel.action(dialogAPI); controlsAlign === 'end' && 'justify-end'
} else if (!frozen) { ]}
open = false;
}
}}
disabled={frozen}
> >
{controls?.cancel?.label || 'Cancel'} {#if controls && typeof controls === 'function'}{@render controls()}{:else if controls?.flip}
</Button> {#if controls.ok !== null}
<Button {@render dialogOkButton(getState(), controls.ok)}
class={controls?.ok?.class} {/if}
onclick={() => { {#if controls.cancel !== null}
if (controls?.ok?.action) { {@render dialogCancelButton(getState(), controls.cancel)}
controls.ok.action(dialogAPI); {/if}
} else if (!frozen && !loading && !disabled) { {:else}
open = false; {#if controls.cancel !== null}
} {@render dialogCancelButton(getState(), controls.cancel)}
}} {/if}
disabled={frozen || loading || disabled} {#if controls.ok !== null}
{loading} {@render dialogOkButton(getState(), controls.ok)}
> {/if}
{controls?.ok?.label || 'OK'}
</Button>
{/if} {/if}
</div> </div>
<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 (!frozen) open = false;
}}
disabled={frozen}
>
<X size="1.5em" weight="bold" />
</button>
</div> </div>
</div> </div>
{/snippet} {/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)}
{#if typeof val === 'string'}
{val}
{:else}
{@render val()}
{/if}
{/snippet}

View File

@@ -0,0 +1,295 @@
<script lang="ts" module>
/**
* formatDuration returns a human readable string for a TimeDuration.
*
* @param duration The duration to format, can be null or undefined.
* @param fallback Optional fallback string to return if duration is null or undefined.
* If not provided, an empty string will be returned.
* If provided, it will be returned when duration is null or undefined.
* @returns The duration in human readable format or the fallback string if undefined.
*/
export const formatDuration = (
duration: TimeDuration | null | undefined,
fallback?: string
): string => {
if (!duration) return fallback ?? '';
const parts: string[] = [];
if (duration.hours) parts.push(`${duration.hours}h`);
if (duration.minutes) parts.push(`${duration.minutes}m`);
if (duration.seconds) parts.push(`${duration.seconds}s`);
return parts.join(' ');
};
/**
* durationToISO8601 converts a TimeDuration to an ISO 8601 duration string.
* @param duration The duration to convert.
* @returns The ISO 8601 duration string.
*/
export const durationToISO8601 = (duration: TimeDuration): string => {
return serialize(duration);
};
/**
* iso8601ToDuration converts an ISO 8601 duration string to a TimeDuration.
* @param str The ISO 8601 duration string.
* @returns The TimeDuration object.
*/
export const iso8601ToDuration = (str: string): TimeDuration => {
return parse(str);
};
</script>
<script lang="ts">
import type { TimeDuration } from '@internationalized/date';
import type { ClassValue } from 'svelte/elements';
import { FocusManager } from './focus';
import { generateIdentifier, targetMust } from './util';
import { decrementValue, incrementValue } from './numeric-utils';
import Label from './Label.svelte';
import { liveValidator, validate } from '@svelte-toolkit/validate';
import StyledRawInput from './StyledRawInput.svelte';
import { parse, serialize } from 'tinyduration';
interface Props {
/**
* Name of the input for form integration, form will receive ISO 8601
* formatted string.
*/
name?: string;
/** Label for the input */
label?: string;
/** Precision for duration input */
precision?: { min: componentKey; max: componentKey };
/** Bindable TimeDuration value */
value?: TimeDuration | null;
/** Bindable formatted duration string, always matches current value and cannot be set */
formattedValue?: string;
/** Whether the input is required */
required?: boolean;
/** Message to show when the input is invalid */
invalidMessage?: string | null;
/** Whether to use compact styling */
compact?: boolean;
/** Additional classes are applied to the root container (div element) */
class?: ClassValue | null;
/** Triggered whenever the duration value is changed by the user */
onchange?: (details: { duration: TimeDuration | null; formattedDuration: string }) => void;
}
let {
name,
label,
precision = { max: 'minutes', min: 'hours' },
value = $bindable(null),
formattedValue = $bindable(''),
required = false,
invalidMessage = null,
compact,
class: classValue,
onchange
}: Props = $props();
const COMPONENT_KEYS = ['hours', 'minutes', 'seconds', 'milliseconds'] as const;
type componentKey = (typeof COMPONENT_KEYS)[number];
/** selected components are controlled by min and max precision */
let selectedComponents: componentKey[] = $derived.by(() => {
const minIndex = COMPONENT_KEYS.indexOf(precision.min);
const maxIndex = COMPONENT_KEYS.indexOf(precision.max);
if (minIndex === -1 || maxIndex === -1 || minIndex > maxIndex) {
throw new Error('Invalid precision settings for DurationInput');
}
return COMPONENT_KEYS.slice(minIndex, maxIndex + 1);
});
const focusList = new FocusManager();
const numericPattern = /^\d*$/;
const keydownValidatorOpts = { constrain: true };
const id = $derived(generateIdentifier('duration-input', name));
const values: Record<componentKey, string> = $derived.by(() => {
return COMPONENT_KEYS.reduce(
(acc, key) => {
if (!value || value[key] === 0) {
acc[key] = '';
} else {
acc[key] = value[key]?.toString() || '0';
}
return acc;
},
{} as Record<componentKey, string>
);
});
let valid: boolean = $state(true);
let hiddenInput: HTMLInputElement;
/** updateValue updates `value` with the current values from the input fields */
const updateValue = (override?: Partial<Record<componentKey, string>>) => {
const newValues: Record<componentKey, number> = {
hours: parseInt(override?.hours ?? values.hours) || 0,
minutes: parseInt(override?.minutes ?? values.minutes) || 0,
seconds: parseInt(override?.seconds ?? values.seconds) || 0,
milliseconds: parseInt(override?.milliseconds ?? values.milliseconds) || 0
};
let zero = true;
for (const key of selectedComponents) {
if (newValues[key] !== 0) {
zero = false;
break;
}
}
if (zero) {
value = null;
} else if (!value) {
// Create a new value object if we don't have one yet
value = newValues;
} else {
// Otherwise apply all new values to existing value object
for (const [k, v] of Object.entries(newValues)) {
value[k as componentKey] = v;
}
}
updateHiddenInput();
// update formatted value
formattedValue = formatDuration(value);
};
/**
* updateHiddenInput pushes the current value to the hidden input in ISO
* 8601 duration format and triggers a keyup event to allow validation to
* detect the change.
*/
const updateHiddenInput = () => {
hiddenInput.value = value ? durationToISO8601(value) : '';
hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
onchange?.({ duration: value, formattedDuration: formattedValue });
};
/**
* buildEventHandlers generates a fairly generic onkeydown and onblur
* handler pair for a given component key. Start is always 0, max is
* unset for all keys. No special handling per key.
*/
const buildEventHandlers = (key: componentKey) => {
return {
onkeydown: (e: KeyboardEvent) => {
const target = targetMust<HTMLInputElement>(e);
if (e.key === 'ArrowUp') {
incrementValue(target, { start: 0 });
} else if (e.key === 'ArrowDown') {
decrementValue(target, { start: 0 });
} else {
return;
}
updateValue({ [key]: target.value });
e.preventDefault();
},
onblur: (e: FocusEvent) => {
const target = targetMust<HTMLInputElement>(e);
updateValue({ [key]: target.value });
}
};
};
/** component definitions */
const components: Record<
componentKey,
{
label?: string;
pattern: RegExp;
onkeydown?: (e: KeyboardEvent) => void;
oninput?: (e: Event) => void;
onblur?: (e: FocusEvent) => void;
divider?: string;
placeholder?: string;
}
> = {
hours: {
pattern: numericPattern,
divider: ':',
placeholder: '0',
...buildEventHandlers('hours')
},
minutes: {
pattern: numericPattern,
divider: ':',
placeholder: '0',
...buildEventHandlers('minutes')
},
seconds: {
pattern: numericPattern,
divider: '.',
placeholder: '0',
...buildEventHandlers('seconds')
},
milliseconds: {
label: 'MS',
pattern: numericPattern,
placeholder: '000',
...buildEventHandlers('milliseconds')
}
};
</script>
<div class={classValue}>
{#if label}
<Label for={id}>{label}</Label>
{/if}
<!-- Hidden input stores the selected duration in ISO 8601 format -->
<input
type="hidden"
{id}
{name}
use:validate={{ required, autovalOnInvalid: true }}
onvalidate={(e) => (valid = e.detail.valid)}
bind:this={hiddenInput}
/>
<div class="flex items-start">
{#each selectedComponents as componentKey[] as key, index}
{@const opts = components[key]}
{@const partID = generateIdentifier('duration-input-part', key)}
<div class="flex flex-col items-center">
<StyledRawInput
id={partID}
class={[
'text-center text-xl focus:placeholder:text-transparent',
compact ? 'h-9 w-16! placeholder:text-base' : 'h-16 w-24!'
]}
value={values[key]}
inputmode="numeric"
pattern="[0-9]*"
placeholder={opts.placeholder}
validate={{ pattern: opts.pattern }}
use={(n) => liveValidator(n, keydownValidatorOpts)}
forceInvalid={!valid}
onkeydown={opts.onkeydown}
oninput={opts.oninput}
onblur={opts.onblur}
{@attach focusList.input({ selectAll: true })}
/>
<Label for={partID} class={['capitalize', compact && '-mt-0.5']}>{opts.label ?? key}</Label>
</div>
{#if opts.divider && index < selectedComponents.length - 1}
<span class={[compact ? 'mx-1 text-2xl' : 'mx-2 mt-3 text-3xl']}>
{opts.divider}
</span>
{/if}
{/each}
</div>
{#if !valid && invalidMessage}
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
<Label for={id} error={true}>
{invalidMessage}
</Label>
</div>
{/if}
</div>

View File

@@ -1,18 +1,28 @@
<script lang="ts"> <script lang="ts">
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import type { ErrorMessage } from './error'; import { ErrorMessage, type RawError } from './error';
interface Props { 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; 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> </script>
{#if error} {#if errorMessage && errorMessage.hasError()}
<!-- eslint-disable svelte/no-at-html-tags -->
<div class={['bg-sui-accent text-sui-background my-4 rounded-xs px-6 py-4', classValue]}> <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> </div>
{/if} {/if}

View File

@@ -7,6 +7,7 @@
icon?: IconDef; icon?: IconDef;
iconPosition?: 'left' | 'right'; iconPosition?: 'left' | 'right';
disabled?: boolean; disabled?: boolean;
inverted?: boolean;
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>; onclick?: MouseEventHandler<HTMLButtonElement>;
@@ -16,6 +17,7 @@
icon, icon,
iconPosition = 'right', iconPosition = 'right',
disabled = false, disabled = false,
inverted = false,
class: classValue, class: classValue,
children, children,
onclick onclick
@@ -31,8 +33,11 @@
<button <button
type="button" type="button"
class={[ class={[
'text-sui-accent hover:text-sui-primary inline-flex cursor-pointer items-center gap-1.5 transition-colors', 'inline-flex cursor-pointer items-center gap-1.5 transition-colors',
disabled && 'pointer-events-none cursor-not-allowed opacity-50', disabled && 'pointer-events-none cursor-not-allowed opacity-50',
inverted
? 'text-sui-background hover:text-sui-background/80 font-medium'
: 'text-sui-accent hover:text-sui-primary',
classValue classValue
]} ]}
{onclick} {onclick}

View File

@@ -9,6 +9,6 @@
let { class: classList, children }: Props = $props(); let { class: classList, children }: Props = $props();
</script> </script>
<div class={['mt-4 flex min-w-80 items-center justify-start gap-2', classList]}> <div class={['mt-4 flex min-w-80 flex-wrap items-center justify-start gap-2', classList]}>
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -1,32 +1,46 @@
<script lang="ts" module> <script lang="ts" module>
import { env } from '$env/dynamic/public'; /**
* Rewrites the href based on a given basepath.
* If the href is absolute, it is returned as is.
* If the href is relative, the basepath is prepended.
* @param href The original href.
* @returns The rewritten href.
*/
export const rewriteHref = (href: string, basepath?: string | null): string => {
// If no base path is set, return the href as is
if (!basepath) return href;
const { PUBLIC_BASEPATH } = env; // Use URL API to determine if href is relative or absolute
try {
const trim = (str: string, char: string, trimStart?: boolean, trimEnd?: boolean) => { // this will only succeed if href is absolute
let start = 0, const independentUrl = new URL(href);
end = str.length; return independentUrl.toString();
} catch {
if (trimStart || trimStart === undefined) { // now we can assume that href is relative or entirely invalid
while (start < end && str[start] === char) start++; // test with a generic baseURI to see if it's valid relative
try {
const relativeUrl = new URL(href, 'http://example.com');
// if we reach here, it's a valid relative URL
const prefix = trimEdges(basepath, '/');
return `/${prefix}/${trimEdges(relativeUrl.pathname, '/', true, false)}`;
} catch {
throw new Error(`Attempted to rewrite invalid href: ${href}`);
} }
if (trimEnd || trimEnd === undefined) {
while (end > start && str[end - 1] === char) end--;
} }
return str.substring(start, end);
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { ClassValue, MouseEventHandler } from 'svelte/elements'; import type { ClassValue, MouseEventHandler } from 'svelte/elements';
import { trimEdges } from './util';
interface Props { interface Props {
href: string; href: string;
basepath?: string | null;
disabled?: boolean; disabled?: boolean;
tab?: 'current' | 'new'; tab?: 'current' | 'new';
inverted?: boolean;
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLAnchorElement>; onclick?: MouseEventHandler<HTMLAnchorElement>;
@@ -34,26 +48,28 @@
let { let {
href, href,
basepath,
disabled = false, disabled = false,
tab = 'current', tab = 'current',
inverted = false,
class: classValue, class: classValue,
children, children,
onclick onclick
}: Props = $props(); }: Props = $props();
if (PUBLIC_BASEPATH && !href.startsWith('http://') && !href.startsWith('https://')) { const computedHref = $derived(rewriteHref(href, basepath));
let prefix = trim(PUBLIC_BASEPATH, '/');
href = `/${prefix}/${trim(href, '/', true, false)}`;
}
</script> </script>
<a <a
class={[ class={[
'text-sui-accent hover:text-sui-primary inline-flex items-center gap-1.5 transition-colors', 'inline-flex items-center gap-1.5 transition-colors',
disabled && 'pointer-events-none cursor-not-allowed opacity-50', disabled && 'pointer-events-none cursor-not-allowed opacity-50',
inverted
? 'text-sui-background hover:text-sui-background/80 font-medium'
: 'text-sui-accent hover:text-sui-primary',
classValue classValue
]} ]}
{href} href={computedHref}
target={tab === 'new' ? '_blank' : undefined} target={tab === 'new' ? '_blank' : undefined}
rel={tab === 'new' ? 'noopener noreferrer' : undefined} rel={tab === 'new' ? 'noopener noreferrer' : undefined}
{onclick} {onclick}

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 { tweened } from 'svelte/motion';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
import { validateForm } from '@svelte-toolkit/validate'; import { validateForm } from '@svelte-toolkit/validate';
import { ArrowLeft, Check, CheckFat } from 'phosphor-svelte'; import { ArrowLeft, CheckFat } from 'phosphor-svelte';
import type { IconDef } from './util'; import type { IconDef } from './util';
interface Props { interface Props {
@@ -49,6 +49,13 @@
failure: StateMachinePage; failure: StateMachinePage;
index?: number; index?: number;
action?: string; 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 { let {
@@ -57,7 +64,8 @@
success, success,
failure, failure,
index = $bindable(0), index = $bindable(0),
action action,
onsubmit
}: Props = $props(); }: Props = $props();
// add success and failure pages to the end of the pages array // add success and failure pages to the end of the pages array
@@ -250,6 +258,22 @@
// update button state // update button state
buttonLoading = true; 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(); const form_data = new FormData();
for (const [key, value] of Object.entries(collatedFormData)) { for (const [key, value] of Object.entries(collatedFormData)) {
form_data.append(key, value); form_data.append(key, value);

View File

@@ -16,6 +16,8 @@
compact?: boolean; compact?: boolean;
use?: (node: HTMLInputElement) => void; use?: (node: HTMLInputElement) => void;
ref?: HTMLInputElement | null; ref?: HTMLInputElement | null;
/** Forces the input to be visually marked as invalid */
forceInvalid?: boolean;
onvalidate?: (e: InputValidatorEvent) => void; onvalidate?: (e: InputValidatorEvent) => void;
}; };
@@ -30,6 +32,7 @@
compact = false, compact = false,
use, use,
ref = $bindable<HTMLInputElement | null>(null), ref = $bindable<HTMLInputElement | null>(null),
forceInvalid = false,
onvalidate, onvalidate,
class: classValue, class: classValue,
...others ...others
@@ -71,7 +74,7 @@
'dark:text-sui-background dark:placeholder:text-sui-background/60 dark:sm:bg-slate-800', 'dark:text-sui-background dark:placeholder:text-sui-background/60 dark:sm:bg-slate-800',
'ring-sui-primary ring-offset-1 placeholder-shown:text-ellipsis focus:ring-2', 'ring-sui-primary ring-offset-1 placeholder-shown:text-ellipsis focus:ring-2',
!compact ? 'px-[1.125rem] py-3.5' : 'px-[0.75rem] py-2', !compact ? 'px-[1.125rem] py-3.5' : 'px-[0.75rem] py-2',
!valid && 'border-red-500!', (!valid || forceInvalid) && 'border-red-500!',
disabled && disabled &&
'border-sui-accent/20 text-sui-text/60 dark:text-sui-background/60 cursor-not-allowed', 'border-sui-accent/20 text-sui-text/60 dark:text-sui-background/60 cursor-not-allowed',
classValue classValue

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> <script lang="ts" module>
export type TabPage = { export type TabPage = {
title: string; title: string;
@@ -14,12 +19,16 @@
interface Props { interface Props {
pages: TabPage[]; pages: TabPage[];
/** Currently active tab index (default: 0) */
activeIndex?: number; activeIndex?: number;
/** Callback fired when the active tab changes */
onchange?: (event: { index: number; tab: TabPage }) => void; onchange?: (event: { index: number; tab: TabPage }) => void;
/** Applies layout padding to the tab header & content areas (default: false) */
padded?: boolean;
class?: ClassValue | null; 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 primaryContainerEl: HTMLDivElement;
let tabContainerEl: HTMLDivElement; let tabContainerEl: HTMLDivElement;
@@ -88,7 +97,10 @@
<div bind:this={primaryContainerEl} class={[classValue]}> <div bind:this={primaryContainerEl} class={[classValue]}>
<div <div
bind:this={tabContainerEl} 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)} {#each pages as page, i (page.title)}
{@const active = activeIndex === i} {@const active = activeIndex === i}
@@ -122,7 +134,7 @@
{#key activeIndex} {#key activeIndex}
<div <div
class={[]} class={[padded && 'px-layout']}
in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }} in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }}
out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }} out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }}
onoutrostart={lockHeight} onoutrostart={lockHeight}

View File

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

View File

@@ -23,17 +23,31 @@
import { generateIdentifier, prefixZero, targetMust } from './util'; import { generateIdentifier, prefixZero, targetMust } from './util';
import { FocusManager } from './focus'; import { FocusManager } from './focus';
import { Time } from '@internationalized/date'; import { Time } from '@internationalized/date';
import { incrementValue, decrementValue } from './numeric-utils';
interface Props { interface Props {
/**
* Name of the input for form integration, form will receive ISO 8601
* formatted string.
*/
name?: string; name?: string;
/** Label for the input */
label?: string; label?: string;
/** Bindable Time value */
value?: Time | null; value?: Time | null;
/** Bindable formatted time string, always matches current value and cannot be set */
formattedValue?: string; formattedValue?: string;
/** Whether the input is required */
required?: boolean; required?: boolean;
/** Message to show when the input is invalid */
invalidMessage?: string; invalidMessage?: string;
/** Controls visibility for a confirmation text below the input */
showConfirm?: boolean; showConfirm?: boolean;
/** Whether to use compact styling */
compact?: boolean; compact?: boolean;
/** Class is applied to the root container (div element) */
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
/** Triggered whenever the time value is changed by the user */
onchange?: (details: { time: Time | null; formattedTime: string }) => void; onchange?: (details: { time: Time | null; formattedTime: string }) => void;
} }
@@ -95,55 +109,6 @@
} }
}; };
/**
* incrementValue increments the value of the input by 1
* @param input The input element to increment
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was incremented, false if it looped back to 0
*/
const incrementValue = (input: HTMLInputElement, max: number, start: number): boolean => {
if (input.value.length === 0) {
input.value = start.toString();
return true;
}
const value = parseInt(input.value);
if (value === max) {
input.value = start.toString();
return false;
} else if (value > max) {
input.value = (value - max).toString();
return false;
} else {
input.value = (value + 1).toString();
return true;
}
};
/**
* decrementValue decrements the value of the input by 1
* @param input The input element to decrement
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was decremented, false if it looped back to max
*/
const decrementValue = (input: HTMLInputElement, max: number, start: number): boolean => {
if (input.value.length === 0) {
input.value = max.toString();
return true;
}
const value = parseInt(input.value);
if (value <= start) {
input.value = max.toString();
return false;
} else {
input.value = (value - 1).toString();
return true;
}
};
/** /**
* updateValue updates `value` with the current time in 24-hour format. * updateValue updates `value` with the current time in 24-hour format.
* If any component is invalid or blank, it sets `value` to an empty string. * If any component is invalid or blank, it sets `value` to an empty string.
@@ -216,10 +181,10 @@
} }
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
incrementValue(target, 12, 1); incrementValue(target, { max: 12, start: 1 });
if (target.value === '12') toggleAMPM(); if (target.value === '12') toggleAMPM();
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
decrementValue(target, 12, 1); decrementValue(target, { max: 12, start: 1 });
if (target.value === '11') toggleAMPM(); if (target.value === '11') toggleAMPM();
} else { } else {
return; return;
@@ -248,9 +213,9 @@
const target = targetMust<HTMLInputElement>(e); const target = targetMust<HTMLInputElement>(e);
if (e.key === 'ArrowUp') { if (e.key === 'ArrowUp') {
incrementValue(target, 59, 0); incrementValue(target, { max: 59, start: 0 });
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
decrementValue(target, 59, 0); decrementValue(target, { max: 59, start: 0 });
} else { } else {
return; return;
} }
@@ -301,6 +266,7 @@
min={0} min={0}
validate={{ pattern: opts.pattern }} validate={{ pattern: opts.pattern }}
use={(n) => liveValidator(n, keydownValidatorOpts)} use={(n) => liveValidator(n, keydownValidatorOpts)}
forceInvalid={!valid}
onkeydown={opts.onkeydown} onkeydown={opts.onkeydown}
oninput={opts.oninput} oninput={opts.oninput}
onblur={opts.onblur} onblur={opts.onblur}

View File

@@ -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}
/> />

View File

@@ -10,20 +10,41 @@ export interface GraphError {
export type RawError = Error | string | GraphError[]; export type RawError = Error | string | GraphError[];
export class ErrorMessage { export class ErrorMessage {
private _message: string; private _lines: string[] = [];
/** converts a RawError to a string and stores it for later access */ /**
constructor(raw: RawError) { * Converts a RawError to an array of lines and stores it for later access,
this._message = ErrorMessage.rawErrorToString(raw); * 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 */ /** returns the stored lines */
get message(): string { get lines(): string[] {
return this._message; return this._lines;
} }
/** returns the error as a string */ /** returns the error lines as a string, separated by newlines */
toString(): string { 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 */ /** optionally returns a new ErrorMessage only if the RawError is not empty */
@@ -32,28 +53,27 @@ export class ErrorMessage {
return new ErrorMessage(raw); return new ErrorMessage(raw);
} }
/** converts a RawError to a string */ /** converts a RawError to an array of lines */
static rawErrorToString(raw: RawError | null | undefined): string { static rawErrorToLines(raw: RawError | null | undefined): string[] {
if (!raw) return 'No error'; if (!raw) return ['No error'];
let errorString: string; let errorLines: string[];
if (typeof raw === 'string') { if (typeof raw === 'string') {
errorString = raw; errorLines = [raw];
} else if (raw instanceof Error) { } else if (raw instanceof Error) {
errorString = raw.message; errorLines = [raw.message];
} else if (Array.isArray(raw)) { } else if (Array.isArray(raw)) {
errorString = raw errorLines = raw.map((e) => {
.flatMap((e) => {
const messageString = e.message || 'Unknown error'; const messageString = e.message || 'Unknown error';
if (e.path && e.path.length > 0) { if (e.path && e.path.length > 0) {
return `"${messageString}" at ${e.path.join('.')}`; return `"${messageString}" at ${e.path.join('.')}`;
} }
}) return messageString;
.join('<br />'); });
} else { } else {
throw `Bad error value ${raw}`; 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

@@ -1,21 +1,37 @@
// Reexport your entry components here // Reexport your entry components here
export { default as ActionSelect, type ActionSelectOption } from './ActionSelect.svelte'; export { default as ActionSelect, type ActionSelectOption } from './ActionSelect.svelte';
export { default as Banner, type BannerControls, type BannerAPI } from './Banner.svelte';
export { default as Button } from './Button.svelte'; export { default as Button } from './Button.svelte';
export { default as CenterBox } from './CenterBox.svelte'; export { default as CenterBox } from './CenterBox.svelte';
export { default as Checkbox, type CheckboxState } from './Checkbox.svelte'; export { default as Checkbox, type CheckboxState } from './Checkbox.svelte';
export { default as Combobox, type ComboboxOption } from './Combobox.svelte'; export { default as Combobox, type ComboboxOption } from './Combobox.svelte';
export { default as DateInput } from './DateInput.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,
durationToISO8601,
iso8601ToDuration
} from './DurationInput.svelte';
export { default as ErrorBox } from './ErrorBox.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 FramelessButton } from './FramelessButton.svelte';
export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte'; export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte';
export { default as InjectUmami } from './InjectUmami.svelte'; export { default as InjectUmami } from './InjectUmami.svelte';
export { default as InputGroup } from './InputGroup.svelte'; export { default as InputGroup } from './InputGroup.svelte';
export { default as Label } from './Label.svelte'; export { default as Label } from './Label.svelte';
export { default as Link } from './Link.svelte'; export { default as Link, rewriteHref } from './Link.svelte';
export { default as PhoneInput } from './PhoneInput.svelte'; export { default as PhoneInput } from './PhoneInput.svelte';
export { default as PinInput } from './PinInput.svelte'; export { default as PinInput } from './PinInput.svelte';
export { default as RadioGroup } from './RadioGroup.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 Spinner } from './Spinner.svelte';
export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte'; export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte';
export { default as StyledRawInput } from './StyledRawInput.svelte'; export { default as StyledRawInput } from './StyledRawInput.svelte';
@@ -41,7 +57,8 @@ export {
getValue, getValue,
targetMust, targetMust,
capitalizeFirstLetter, capitalizeFirstLetter,
prefixZero prefixZero,
trimEdges
} from './util'; } from './util';
export { export {
type ToolbarToggleState, type ToolbarToggleState,

67
src/lib/numeric-utils.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* numeric-utils.ts
* Utility functions for numeric input manipulation.
*/
/**
* incrementValue increments the value of the input by 1
* @param input The input element to increment
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was incremented, false if it looped back to 0
*/
export const incrementValue = (
input: HTMLInputElement,
opts: { max?: number; start: number }
): boolean => {
if (input.value.length === 0) {
input.value = opts.start.toString();
return true;
}
const value = parseInt(input.value);
if (value === opts.max) {
input.value = opts.start.toString();
return false;
} else if (opts.max && value > opts.max) {
input.value = (value - opts.max).toString();
return false;
} else {
input.value = (value + 1).toString();
return true;
}
};
/**
* decrementValue decrements the value of the input by 1
* @param input The input element to decrement
* @param max The maximum value of the input
* @param start The starting value of the input
* @returns true if the value was decremented, false if it looped back to max
*/
export const decrementValue = (
input: HTMLInputElement,
opts: { max?: number; start: number }
): boolean => {
const setToMax = (): boolean => {
if (opts.max) {
input.value = opts.max.toString();
return true;
} else {
input.value = '0';
return false;
}
};
if (input.value.length === 0) {
return setToMax();
}
const value = parseInt(input.value);
if (value <= opts.start) {
return !setToMax();
} else {
input.value = (value - 1).toString();
return true;
}
};

View File

@@ -17,6 +17,15 @@
monospace 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 */ /* Primary Colors */
--color-sui-primary-50: var(--ui-primary-50, #f0f8fe); --color-sui-primary-50: var(--ui-primary-50, #f0f8fe);
--color-sui-primary-100: var(--ui-primary-100, #ddeefc); --color-sui-primary-100: var(--ui-primary-100, #ddeefc);

View File

@@ -110,13 +110,88 @@ export const capitalizeFirstLetter = (str: string): string => {
}; };
/** /**
* prefixZero adds a leading zero to the string if it is less than 10 * prefixZero adds a leading zero to the string if it is less than 10 and not 0
* @param str The string to prefix * @param str The string to prefix
* @returns The string with a leading zero if it was only 1 digit long * @returns The string with a leading zero if it was only 1 digit long
*/ */
export const prefixZero = (str: string): string => { export const prefixZero = (str: string): string => {
if (str.length === 1) { if (str.length === 1 && str !== '0') {
return '0' + str; return '0' + str;
} }
return str; return str;
}; };
/**
* Trims the specified character from the start and/or end of the string.
* @param str The string to trim.
* @param char The character to trim.
* @param trimStart Whether to trim from the start of the string. Default: true.
* @param trimEnd Whether to trim from the end of the string. Default: true.
* @returns The trimmed string.
*/
export const trimEdges = (str: string, char: string, trimStart?: boolean, trimEnd?: boolean) => {
let start = 0,
end = str.length;
if (trimStart || trimStart === undefined) {
while (start < end && str[start] === char) start++;
}
if (trimEnd || trimEnd === undefined) {
while (end > start && str[end - 1] === char) end--;
}
return str.substring(start, end);
};
// helper: only treat plain objects as mergeable
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' &&
v !== null &&
!Array.isArray(v) &&
Object.getPrototypeOf(v) === Object.prototype;
/** Merge two plain object maps. No `any` used. */
function mergePlainObjects(
baseObj: Record<string, unknown>,
overrideObj: Record<string, unknown>
): Record<string, unknown> {
const res: Record<string, unknown> = { ...baseObj };
for (const k of Object.keys(overrideObj)) {
const v = overrideObj[k];
if (v === undefined) continue; // undefined preserves base
const b = res[k];
if (isPlainObject(v) && isPlainObject(b)) {
res[k] = mergePlainObjects(b as Record<string, unknown>, v as Record<string, unknown>);
} else {
// primitives, null, arrays, non-plain objects replace base
res[k] = v;
}
}
return res;
}
/**
* Merge `base` with `override`.
* - `null` in `override` replaces (kept as valid override)
* - `undefined` in `override` is ignored (keeps base)
* - Only plain objects are deep-merged
* - If `override` is null/undefined we return a shallow copy of `base`
*/
export const mergeOverrideObject = <T extends Record<string, unknown>>(
base: T,
override?: Partial<T> | null
): T => {
if (override == null) return { ...base } as T;
// Use plain maps internally to avoid explicit any
const baseMap = { ...base } as Record<string, unknown>;
const overrideMap = override as Record<string, unknown>;
const merged = mergePlainObjects(baseMap, overrideMap);
return merged as T;
};

View File

@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { CalendarDate, today, getLocalTimeZone } from '@internationalized/date'; import {
CalendarDate,
today,
getLocalTimeZone,
type TimeDuration
} from '@internationalized/date';
import Button from '$lib/Button.svelte'; import Button from '$lib/Button.svelte';
import ActionSelect from '$lib/ActionSelect.svelte'; import ActionSelect from '$lib/ActionSelect.svelte';
import Checkbox, { type CheckboxState } from '$lib/Checkbox.svelte'; import Checkbox, { type CheckboxState } from '$lib/Checkbox.svelte';
@@ -33,6 +38,8 @@
import { onMount, type Component } from 'svelte'; import { onMount, type Component } from 'svelte';
import ErrorBox from '$lib/ErrorBox.svelte'; import ErrorBox from '$lib/ErrorBox.svelte';
import TextareaInput from '$lib/TextareaInput.svelte'; import TextareaInput from '$lib/TextareaInput.svelte';
import DurationInput, { formatDuration } from '$lib/DurationInput.svelte';
import Banner from '$lib/Banner.svelte';
// Lazy-load heavy components // Lazy-load heavy components
let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte')); let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte'));
@@ -50,15 +57,17 @@
{ value: 'option3', label: 'Option 3', disabled: true } { value: 'option3', label: 'Option 3', disabled: true }
]; ];
let lazyOptions: ComboboxOption[] = $state([]); let lazyOptions: ComboboxOption[] = $state([]);
let dateInputValue = $state<CalendarDate | undefined>(undefined); let dateInputValue = $state<CalendarDate | null>(null);
let checkboxValue = $state<CheckboxState>('indeterminate'); let checkboxValue = $state<CheckboxState>('indeterminate');
let dialogOpen = $state(false); let dialogOpen = $state(false);
let scrollableDialogOpen = $state(false);
let toggleOptions: Option[] = $state([ let toggleOptions: Option[] = $state([
'item one', 'item one',
'item two', 'item two',
{ value: 'complex', label: 'Complex item' } { value: 'complex', label: 'Complex item' }
]); ]);
let timeValue = $state<Time | null>(null); let timeValue = $state<Time | null>(null);
let durationValue = $state<TimeDuration | null>(null);
const toolbar = new Toolbar(); const toolbar = new Toolbar();
const fontGroup = toolbar.group(); const fontGroup = toolbar.group();
@@ -66,7 +75,25 @@
const boldStore = boldToggle.store; const boldStore = boldToggle.store;
</script> </script>
<title>sui</title> <svelte:head>
<title>sui</title>
</svelte:head>
<!-- Cookie Consent Banner Demo -->
<Banner
title="Manage Cookies"
controls={{
moreInfo: { label: 'More Info', type: 'link', href: '#!' },
dismiss: null,
swap: true
}}
onaccept={() => console.log('Cookies accepted!')}
ondecline={() => console.log('Cookies declined!')}
open
>
We use cookies and similar technologies to enhance your experience, analyze site traffic, and
measure our ads. You can manage your preferences anytime.
</Banner>
<h1 class="mb-4 text-3xl font-bold">sui — Opinionated Svelte 5 UI toolkit</h1> <h1 class="mb-4 text-3xl font-bold">sui — Opinionated Svelte 5 UI toolkit</h1>
@@ -92,6 +119,7 @@
<Link href="https://svelte.dev">Visit Svelte</Link> <Link href="https://svelte.dev">Visit Svelte</Link>
<Button onclick={() => (dialogOpen = true)}>Open Dialog</Button> <Button onclick={() => (dialogOpen = true)}>Open Dialog</Button>
<Button onclick={() => (scrollableDialogOpen = true)}>Open Scrollable Dialog</Button>
</div> </div>
</div> </div>
@@ -144,10 +172,27 @@
name="example-combobox" name="example-combobox"
label="Select an option" label="Select an option"
placeholder="Choose..." 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)} onchange={(e) => console.log('Selected:', e.value)}
onvalidate={(e) => console.log('Validation:', e.detail)} 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 <Combobox
loading loading
@@ -173,15 +218,17 @@
label="Lazy combobox" label="Lazy combobox"
placeholder="Choose..." placeholder="Choose..."
options={lazyOptions} options={lazyOptions}
lazy lazy={'always'}
onlazy={() => { onlazy={async () => {
setTimeout(() => { await new Promise((resolve) => setTimeout(resolve, 2500));
lazyOptions = [ lazyOptions = [
{ value: 'option1', label: 'Option 1' }, { value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' }, { value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' } { value: 'option3', label: 'Option 3' }
]; ];
}, 2500); }}
onopenchange={(open) => {
if (!open) lazyOptions = [];
}} }}
/> />
<Combobox <Combobox
@@ -284,11 +331,18 @@
</div> </div>
<div class="component"> <div class="component">
<p class="title">Time Input</p> <p class="title">Time & Duration Input</p>
<InputGroup class="gap-8"> <InputGroup class="gap-8">
<TimeInput label="Regular time input" name="example-time-input" /> <TimeInput label="Regular time input" name="example-time-input" />
<TimeInput label="Compact time" compact bind:value={timeValue} /> <TimeInput label="Compact time" compact bind:value={timeValue} />
<DurationInput
label="Duration input"
name="example-duration-input"
precision={{ min: 'hours', max: 'seconds' }}
bind:value={durationValue}
/>
<DurationInput label="Compact duration" compact />
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<p>Selected time is {formatTime(timeValue, 'undefined')} ({timeValue?.toString()})</p> <p>Selected time is {formatTime(timeValue, 'undefined')} ({timeValue?.toString()})</p>
@@ -299,6 +353,7 @@
> >
Set 3:00 PM Set 3:00 PM
</Button> </Button>
<p>Precise duration is {formatDuration(durationValue)}</p>
</InputGroup> </InputGroup>
</div> </div>
@@ -393,6 +448,7 @@
<p class="title">Tabs</p> <p class="title">Tabs</p>
<Tabs <Tabs
padded={true}
pages={[ pages={[
{ {
title: 'Dashboard', title: 'Dashboard',
@@ -459,6 +515,24 @@
{/snippet} {/snippet}
</div> </div>
<!-- Link with href rewriting -->
<div class="component">
<p class="title">Link (with href rewriting)</p>
<p class="mb-3">
href rewriting allows you to prepend a basepath to relative links, making it easier to manage
URLs in your application. It is recommended to wrap this element with your own, e.g. AppLink,
that automatically provides the basepath from your app's configuration.
</p>
<div class="flex flex-col gap-3">
<Link href="/about" basepath="/sui-demo">Go to About Page (with basepath)</Link>
<Link href="https://svelte.dev" basepath="/sui-demo">External Svelte Site</Link>
<Link href="contact">Contact Us (relative link, no basepath)</Link>
</div>
</div>
<!-- Regular Dialog Demo -->
<Dialog <Dialog
bind:open={dialogOpen} bind:open={dialogOpen}
title="Dialog Title" title="Dialog Title"
@@ -469,7 +543,8 @@
dialog.close(); dialog.close();
alert('Dialog submitted!'); alert('Dialog submitted!');
} }
} },
cancel: null
}} }}
onopen={(dialog) => { onopen={(dialog) => {
dialog.error('Example error message!'); dialog.error('Example error message!');
@@ -482,6 +557,40 @@
<p>This is a dialog content area.</p> <p>This is a dialog content area.</p>
</Dialog> </Dialog>
<!-- Scrollable Dialog Demo -->
<Dialog bind:open={scrollableDialogOpen} title="Scrollable Dialog" size="sm">
<div class="space-y-4">
<p>
Ullamco nulla sunt laboris esse commodo irure id pariatur est irure eiusmod. Cupidatat Lorem
ad deserunt non culpa aliqua qui qui ut reprehenderit minim consequat amet. Qui elit ipsum
dolor enim laboris. Exercitation sint esse dolore enim irure veniam esse incididunt fugiat.
</p>
<p>
In elit tempor quis enim id fugiat cillum consectetur minim sint ex. Minim reprehenderit culpa
sunt in reprehenderit. Amet in minim in nulla officia fugiat laborum velit dolor laborum
deserunt aliqua nostrud.
</p>
<p>
Ad dolor ad nisi est fugiat anim aute amet. Fugiat excepteur proident incididunt anim sunt.
Proident quis dolor ea voluptate esse commodo voluptate quis culpa cupidatat excepteur.
</p>
<p>
Cillum ut laboris laboris ea ex ex. Aliquip magna irure eiusmod qui eiusmod. Mollit id et
incididunt sint mollit anim cillum reprehenderit exercitation labore incididunt culpa. Officia
et ad occaecat quis ipsum. Culpa quis cupidatat reprehenderit reprehenderit incididunt
excepteur quis minim. Laboris cupidatat laborum est ipsum esse sint aliqua cillum laborum est
cillum dolore cupidatat pariatur. Dolor ipsum cillum enim esse consectetur dolor sunt magna.
</p>
<p>
Eu cillum reprehenderit Lorem duis sunt. Mollit laborum tempor magna dolor ad ipsum do fugiat
nisi quis culpa tempor veniam officia. Voluptate irure labore aliqua elit officia nulla dolor.
Lorem duis ea ea commodo deserunt minim enim. Excepteur non magna cupidatat ea eiusmod dolore
elit dolor veniam cupidatat. Amet voluptate culpa ut ex consequat culpa cillum. Exercitation
ex voluptate incididunt laboris qui sint id quis in aliqua excepteur incididunt.
</p>
</div>
</Dialog>
<style lang="postcss"> <style lang="postcss">
@reference '$lib/styles/tailwind.css'; @reference '$lib/styles/tailwind.css';