@nqmcreative/ui 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +220 -28
- package/cli/index.mjs +247 -70
- package/dist/core/calendar.d.ts +1 -1
- package/dist/core/calendar.js +1 -1
- package/dist/core/color.d.ts +24 -0
- package/dist/core/color.js +39 -0
- package/dist/core/date.d.ts +3 -3
- package/dist/core/date.js +1 -1
- package/dist/core/files.d.ts +2 -3
- package/dist/core/files.js +2 -3
- package/dist/core/index.d.ts +9 -4
- package/dist/core/index.js +6 -4
- package/dist/core/number.d.ts +22 -0
- package/dist/core/number.js +33 -0
- package/dist/core/password.d.ts +1 -1
- package/dist/core/password.js +1 -1
- package/dist/core/pin.d.ts +23 -0
- package/dist/core/pin.js +38 -0
- package/dist/core/tags.d.ts +37 -0
- package/dist/core/tags.js +51 -0
- package/dist/core/time.d.ts +30 -0
- package/dist/core/time.js +78 -0
- package/dist/styles/matte/Alert.svelte +1 -3
- package/dist/styles/matte/Breadcrumb.svelte +1 -4
- package/dist/styles/matte/Calendar.svelte +6 -9
- package/dist/styles/matte/ColorInput.svelte +107 -0
- package/dist/styles/matte/ColorInput.svelte.d.ts +16 -0
- package/dist/styles/matte/Combobox.svelte +3 -6
- package/dist/styles/matte/CommandPalette.svelte +3 -6
- package/dist/styles/matte/ConfirmDialog.svelte +2 -5
- package/dist/styles/matte/ContextMenu.svelte +1 -4
- package/dist/styles/matte/CurrencyInput.svelte +108 -0
- package/dist/styles/matte/CurrencyInput.svelte.d.ts +26 -0
- package/dist/styles/matte/DatePicker.svelte +9 -9
- package/dist/styles/matte/DatePicker.svelte.d.ts +3 -0
- package/dist/styles/matte/Drawer.svelte +1 -3
- package/dist/styles/matte/Dropdown.svelte +2 -5
- package/dist/styles/matte/Dropdown.svelte.d.ts +1 -1
- package/dist/styles/matte/Dropzone.svelte +9 -12
- package/dist/styles/matte/Dropzone.svelte.d.ts +1 -1
- package/dist/styles/matte/FileInput.svelte +191 -0
- package/dist/styles/matte/FileInput.svelte.d.ts +32 -0
- package/dist/styles/matte/Footer.svelte +1 -1
- package/dist/styles/matte/Footer.svelte.d.ts +1 -1
- package/dist/styles/matte/InputAddon.svelte +36 -0
- package/dist/styles/matte/InputAddon.svelte.d.ts +13 -0
- package/dist/styles/matte/Modal.svelte +1 -3
- package/dist/styles/matte/MultiSelect.svelte +5 -8
- package/dist/styles/matte/Navbar.svelte +3 -9
- package/dist/styles/matte/Pagination.svelte +3 -6
- package/dist/styles/matte/PinInput.svelte +134 -0
- package/dist/styles/matte/PinInput.svelte.d.ts +25 -0
- package/dist/styles/matte/SearchInput.svelte +84 -0
- package/dist/styles/matte/SearchInput.svelte.d.ts +24 -0
- package/dist/styles/matte/Sidebar.svelte +3 -6
- package/dist/styles/matte/Spinner.svelte +2 -5
- package/dist/styles/matte/Spinner.svelte.d.ts +1 -1
- package/dist/styles/matte/Table.svelte +5 -8
- package/dist/styles/matte/TagsInput.svelte +170 -0
- package/dist/styles/matte/TagsInput.svelte.d.ts +29 -0
- package/dist/styles/matte/ThemeToggle.svelte +5 -8
- package/dist/styles/matte/TimeInput.svelte +126 -0
- package/dist/styles/matte/TimeInput.svelte.d.ts +21 -0
- package/dist/styles/matte/Toaster.svelte +2 -5
- package/dist/styles/matte/index.d.ts +10 -3
- package/dist/styles/matte/index.js +9 -3
- package/dist/styles/paper/Alert.svelte +1 -3
- package/dist/styles/paper/Breadcrumb.svelte +1 -4
- package/dist/styles/paper/Calendar.svelte +6 -9
- package/dist/styles/paper/ColorInput.svelte +109 -0
- package/dist/styles/paper/ColorInput.svelte.d.ts +16 -0
- package/dist/styles/paper/Combobox.svelte +3 -6
- package/dist/styles/paper/CommandPalette.svelte +3 -6
- package/dist/styles/paper/ConfirmDialog.svelte +2 -5
- package/dist/styles/paper/ContextMenu.svelte +1 -4
- package/dist/styles/paper/CurrencyInput.svelte +108 -0
- package/dist/styles/paper/CurrencyInput.svelte.d.ts +26 -0
- package/dist/styles/paper/DatePicker.svelte +9 -9
- package/dist/styles/paper/DatePicker.svelte.d.ts +3 -0
- package/dist/styles/paper/Drawer.svelte +1 -3
- package/dist/styles/paper/Dropdown.svelte +2 -5
- package/dist/styles/paper/Dropdown.svelte.d.ts +1 -1
- package/dist/styles/paper/Dropzone.svelte +9 -12
- package/dist/styles/paper/Dropzone.svelte.d.ts +1 -1
- package/dist/styles/paper/FileInput.svelte +201 -0
- package/dist/styles/paper/FileInput.svelte.d.ts +32 -0
- package/dist/styles/paper/Footer.svelte +1 -1
- package/dist/styles/paper/Footer.svelte.d.ts +1 -1
- package/dist/styles/paper/InputAddon.svelte +36 -0
- package/dist/styles/paper/InputAddon.svelte.d.ts +13 -0
- package/dist/styles/paper/Modal.svelte +1 -3
- package/dist/styles/paper/MultiSelect.svelte +5 -8
- package/dist/styles/paper/Navbar.svelte +3 -9
- package/dist/styles/paper/Pagination.svelte +3 -6
- package/dist/styles/paper/PinInput.svelte +134 -0
- package/dist/styles/paper/PinInput.svelte.d.ts +25 -0
- package/dist/styles/paper/SearchInput.svelte +89 -0
- package/dist/styles/paper/SearchInput.svelte.d.ts +24 -0
- package/dist/styles/paper/Sidebar.svelte +3 -6
- package/dist/styles/paper/Spinner.svelte +2 -5
- package/dist/styles/paper/Spinner.svelte.d.ts +1 -1
- package/dist/styles/paper/Table.svelte +5 -8
- package/dist/styles/paper/TagsInput.svelte +175 -0
- package/dist/styles/paper/TagsInput.svelte.d.ts +29 -0
- package/dist/styles/paper/ThemeToggle.svelte +5 -8
- package/dist/styles/paper/TimeInput.svelte +152 -0
- package/dist/styles/paper/TimeInput.svelte.d.ts +21 -0
- package/dist/styles/paper/Toaster.svelte +3 -6
- package/dist/styles/paper/index.d.ts +10 -3
- package/dist/styles/paper/index.js +9 -3
- package/dist/styles/sprout/Alert.svelte +1 -3
- package/dist/styles/sprout/Breadcrumb.svelte +1 -4
- package/dist/styles/sprout/Calendar.svelte +6 -9
- package/dist/styles/sprout/ColorInput.svelte +109 -0
- package/dist/styles/sprout/ColorInput.svelte.d.ts +16 -0
- package/dist/styles/sprout/Combobox.svelte +3 -6
- package/dist/styles/sprout/CommandPalette.svelte +3 -6
- package/dist/styles/sprout/ConfirmDialog.svelte +2 -5
- package/dist/styles/sprout/ContextMenu.svelte +1 -4
- package/dist/styles/sprout/CurrencyInput.svelte +108 -0
- package/dist/styles/sprout/CurrencyInput.svelte.d.ts +26 -0
- package/dist/styles/sprout/DatePicker.svelte +9 -9
- package/dist/styles/sprout/DatePicker.svelte.d.ts +3 -0
- package/dist/styles/sprout/Drawer.svelte +1 -3
- package/dist/styles/sprout/Dropdown.svelte +2 -5
- package/dist/styles/sprout/Dropdown.svelte.d.ts +1 -1
- package/dist/styles/sprout/Dropzone.svelte +9 -12
- package/dist/styles/sprout/Dropzone.svelte.d.ts +1 -1
- package/dist/styles/sprout/FileInput.svelte +202 -0
- package/dist/styles/sprout/FileInput.svelte.d.ts +32 -0
- package/dist/styles/sprout/Footer.svelte +1 -1
- package/dist/styles/sprout/Footer.svelte.d.ts +1 -1
- package/dist/styles/sprout/InputAddon.svelte +37 -0
- package/dist/styles/sprout/InputAddon.svelte.d.ts +13 -0
- package/dist/styles/sprout/Modal.svelte +1 -3
- package/dist/styles/sprout/MultiSelect.svelte +5 -8
- package/dist/styles/sprout/Navbar.svelte +3 -9
- package/dist/styles/sprout/Pagination.svelte +3 -6
- package/dist/styles/sprout/PinInput.svelte +135 -0
- package/dist/styles/sprout/PinInput.svelte.d.ts +25 -0
- package/dist/styles/sprout/SearchInput.svelte +89 -0
- package/dist/styles/sprout/SearchInput.svelte.d.ts +24 -0
- package/dist/styles/sprout/Sidebar.svelte +3 -6
- package/dist/styles/sprout/Spinner.svelte +2 -5
- package/dist/styles/sprout/Spinner.svelte.d.ts +1 -1
- package/dist/styles/sprout/Table.svelte +5 -8
- package/dist/styles/sprout/TagsInput.svelte +176 -0
- package/dist/styles/sprout/TagsInput.svelte.d.ts +29 -0
- package/dist/styles/sprout/ThemeToggle.svelte +5 -8
- package/dist/styles/sprout/TimeInput.svelte +152 -0
- package/dist/styles/sprout/TimeInput.svelte.d.ts +21 -0
- package/dist/styles/sprout/Toaster.svelte +3 -6
- package/dist/styles/sprout/index.d.ts +10 -3
- package/dist/styles/sprout/index.js +9 -3
- package/package.json +98 -19
- package/registry.json +139 -38
- package/dist/core/locale.svelte.d.ts +0 -93
- package/dist/core/locale.svelte.js +0 -153
- package/dist/styles/matte/LocaleProvider.svelte +0 -24
- package/dist/styles/matte/LocaleProvider.svelte.d.ts +0 -10
- package/dist/styles/paper/LocaleProvider.svelte +0 -24
- package/dist/styles/paper/LocaleProvider.svelte.d.ts +0 -10
- package/dist/styles/sprout/LocaleProvider.svelte +0 -24
- package/dist/styles/sprout/LocaleProvider.svelte.d.ts +0 -10
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
import { anchored } from '../../core/actions/anchor.js';
|
|
3
3
|
import { clickOutside, portal } from '../../core/actions/dismissable.js';
|
|
4
4
|
import { dateHint } from '../../core/calendar.js';
|
|
5
|
-
import { formatISO, parseFormatted } from '../../core/date.js';
|
|
6
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
5
|
+
import { formatISO, parseFormatted, type DateFormat } from '../../core/date.js';
|
|
7
6
|
import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
|
|
8
7
|
import Calendar from './Calendar.svelte';
|
|
9
8
|
import { edge, soft } from './lift.js';
|
|
@@ -16,6 +15,8 @@
|
|
|
16
15
|
max?: string;
|
|
17
16
|
isDisabled?: (date: Date) => boolean;
|
|
18
17
|
weekStart?: 0 | 1;
|
|
18
|
+
/** Order of the date parts the field accepts and prints. */
|
|
19
|
+
format?: DateFormat;
|
|
19
20
|
tone?: Tone;
|
|
20
21
|
invalid?: boolean;
|
|
21
22
|
disabled?: boolean;
|
|
@@ -34,6 +35,7 @@
|
|
|
34
35
|
max,
|
|
35
36
|
isDisabled,
|
|
36
37
|
weekStart = 1,
|
|
38
|
+
format = 'dmy',
|
|
37
39
|
tone = 'brand',
|
|
38
40
|
invalid = false,
|
|
39
41
|
disabled = false,
|
|
@@ -45,8 +47,6 @@
|
|
|
45
47
|
class: className = ''
|
|
46
48
|
}: Props = $props();
|
|
47
49
|
|
|
48
|
-
const t = useLocale();
|
|
49
|
-
|
|
50
50
|
let open = $state(false);
|
|
51
51
|
let text = $state('');
|
|
52
52
|
let editing = $state(false);
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
let inputEl: HTMLInputElement | null = $state(null);
|
|
55
55
|
|
|
56
56
|
/** While typing the field shows the raw text; otherwise the formatted value. */
|
|
57
|
-
const display = $derived(editing ? text : formatISO(value,
|
|
58
|
-
const hint = $derived(placeholder ?? dateHint(
|
|
57
|
+
const display = $derived(editing ? text : formatISO(value, format));
|
|
58
|
+
const hint = $derived(placeholder ?? dateHint(format));
|
|
59
59
|
|
|
60
60
|
function commit() {
|
|
61
61
|
editing = false;
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
}
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
|
-
const parsed = parseFormatted(text,
|
|
69
|
+
const parsed = parseFormatted(text, format);
|
|
70
70
|
if (parsed && parsed !== value) {
|
|
71
71
|
value = parsed;
|
|
72
72
|
onchange?.(parsed);
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
onchange?.('');
|
|
134
134
|
inputEl?.focus();
|
|
135
135
|
}}
|
|
136
|
-
aria-label=
|
|
136
|
+
aria-label="Clear selection"
|
|
137
137
|
class="shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
|
|
138
138
|
>
|
|
139
139
|
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
type="button"
|
|
152
152
|
{disabled}
|
|
153
153
|
onclick={() => (open = !open)}
|
|
154
|
-
aria-label=
|
|
154
|
+
aria-label="Select a date"
|
|
155
155
|
aria-expanded={open}
|
|
156
156
|
class="shrink-0 rounded p-1.5 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
|
|
157
157
|
>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type DateFormat } from '../../core/date.js';
|
|
1
2
|
import { type Tone } from '../../core/tones.js';
|
|
2
3
|
interface Props {
|
|
3
4
|
/** Bindable `YYYY-MM-DD` — the same shape as `<input type="date">`. */
|
|
@@ -7,6 +8,8 @@ interface Props {
|
|
|
7
8
|
max?: string;
|
|
8
9
|
isDisabled?: (date: Date) => boolean;
|
|
9
10
|
weekStart?: 0 | 1;
|
|
11
|
+
/** Order of the date parts the field accepts and prints. */
|
|
12
|
+
format?: DateFormat;
|
|
10
13
|
tone?: Tone;
|
|
11
14
|
invalid?: boolean;
|
|
12
15
|
disabled?: boolean;
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { iconLg } from './icon.js';
|
|
4
4
|
import type { HTMLDialogAttributes } from 'svelte/elements';
|
|
5
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
6
5
|
import { isBackdropClick, syncDialog } from '../../core/dialog.svelte.js';
|
|
7
6
|
import { float } from './lift.js';
|
|
8
7
|
|
|
@@ -40,7 +39,6 @@
|
|
|
40
39
|
...rest
|
|
41
40
|
}: Props = $props();
|
|
42
41
|
|
|
43
|
-
const t = useLocale();
|
|
44
42
|
let dialog: HTMLDialogElement | null = $state(null);
|
|
45
43
|
|
|
46
44
|
const horizontal = $derived(side === 'left' || side === 'right');
|
|
@@ -110,7 +108,7 @@
|
|
|
110
108
|
<button
|
|
111
109
|
type="button"
|
|
112
110
|
onclick={close}
|
|
113
|
-
aria-label=
|
|
111
|
+
aria-label="Close"
|
|
114
112
|
class="-m-1 shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
|
115
113
|
>
|
|
116
114
|
<svg class="size-4" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import { anchored, type Placement } from '../../core/actions/anchor.js';
|
|
4
4
|
import { clickOutside, focusTrap, navigateList, portal } from '../../core/actions/dismissable.js';
|
|
5
5
|
import { describeTrigger, menuItems, popupTrigger } from '../../core/trigger.js';
|
|
6
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
7
6
|
import { soft } from './lift.js';
|
|
8
7
|
|
|
9
8
|
interface Props {
|
|
@@ -18,7 +17,7 @@
|
|
|
18
17
|
matchWidth?: boolean;
|
|
19
18
|
/** Close as soon as an item inside is activated. Default true. */
|
|
20
19
|
closeOnSelect?: boolean;
|
|
21
|
-
/** Accessible name for the menu. Defaults to
|
|
20
|
+
/** Accessible name for the menu. Defaults to `Menu`. */
|
|
22
21
|
label?: string;
|
|
23
22
|
class?: string;
|
|
24
23
|
/** Classes for the wrapper around the trigger. */
|
|
@@ -38,8 +37,6 @@
|
|
|
38
37
|
triggerClass = ''
|
|
39
38
|
}: Props = $props();
|
|
40
39
|
|
|
41
|
-
const t = useLocale();
|
|
42
|
-
|
|
43
40
|
let anchorEl: HTMLElement | null = $state(null);
|
|
44
41
|
let menuEl: HTMLElement | null = $state(null);
|
|
45
42
|
|
|
@@ -86,7 +83,7 @@
|
|
|
86
83
|
onkeydown={onMenuKeydown}
|
|
87
84
|
onclick={onMenuClick}
|
|
88
85
|
role="menu"
|
|
89
|
-
aria-label={label ??
|
|
86
|
+
aria-label={label ?? 'Menu'}
|
|
90
87
|
tabindex="-1"
|
|
91
88
|
class="z-50 flex max-h-[min(24rem,80vh)] min-w-48 flex-col overflow-y-auto rounded-2xl border border-hairline bg-bg py-1 font-sans {soft} {className}"
|
|
92
89
|
>
|
|
@@ -12,7 +12,7 @@ interface Props {
|
|
|
12
12
|
matchWidth?: boolean;
|
|
13
13
|
/** Close as soon as an item inside is activated. Default true. */
|
|
14
14
|
closeOnSelect?: boolean;
|
|
15
|
-
/** Accessible name for the menu. Defaults to
|
|
15
|
+
/** Accessible name for the menu. Defaults to `Menu`. */
|
|
16
16
|
label?: string;
|
|
17
17
|
class?: string;
|
|
18
18
|
/** Classes for the wrapper around the trigger. */
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { toneBorder, toneSoft, toneSurface, toneText, type Tone } from '../../core/tones.js';
|
|
4
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
5
4
|
import { formatSize as toReadableSize, sortFiles, type RejectedFile } from '../../core/files.js';
|
|
6
5
|
import { edge } from './lift.js';
|
|
7
6
|
|
|
@@ -17,7 +16,7 @@
|
|
|
17
16
|
maxFiles?: number;
|
|
18
17
|
disabled?: boolean;
|
|
19
18
|
tone?: Tone;
|
|
20
|
-
/** Headline inside the zone. Defaults to
|
|
19
|
+
/** Headline inside the zone. Defaults to `Drop files here`. */
|
|
21
20
|
label?: string;
|
|
22
21
|
hint?: string;
|
|
23
22
|
/** Hide the built-in file list to render your own. */
|
|
@@ -46,15 +45,13 @@
|
|
|
46
45
|
class: className = ''
|
|
47
46
|
}: Props = $props();
|
|
48
47
|
|
|
49
|
-
const t = useLocale();
|
|
50
|
-
|
|
51
48
|
let input: HTMLInputElement | null = $state(null);
|
|
52
49
|
let dragging = $state(false);
|
|
53
50
|
let rejected = $state<RejectedFile[]>([]);
|
|
54
51
|
/** Nested elements fire dragleave too, so count depth instead of toggling. */
|
|
55
52
|
let depth = 0;
|
|
56
53
|
|
|
57
|
-
const formatSize = (bytes: number) => toReadableSize(bytes
|
|
54
|
+
const formatSize = (bytes: number) => toReadableSize(bytes);
|
|
58
55
|
|
|
59
56
|
const defaultHint = $derived.by(() => {
|
|
60
57
|
if (hint) return hint;
|
|
@@ -97,9 +94,9 @@
|
|
|
97
94
|
}
|
|
98
95
|
|
|
99
96
|
const reasons = $derived({
|
|
100
|
-
type:
|
|
101
|
-
size:
|
|
102
|
-
count:
|
|
97
|
+
type: 'wrong file type',
|
|
98
|
+
size: 'too large',
|
|
99
|
+
count: 'over the file limit'
|
|
103
100
|
});
|
|
104
101
|
</script>
|
|
105
102
|
|
|
@@ -147,10 +144,10 @@
|
|
|
147
144
|
{/if}
|
|
148
145
|
</span>
|
|
149
146
|
<span class="flex flex-col gap-1">
|
|
150
|
-
<span class="text-sm font-semibold text-text">{label ??
|
|
147
|
+
<span class="text-sm font-semibold text-text">{label ?? 'Drop files here'}</span>
|
|
151
148
|
<span class="text-[13px] text-text-secondary">
|
|
152
|
-
|
|
153
|
-
<span class="underline underline-offset-2">
|
|
149
|
+
or
|
|
150
|
+
<span class="underline underline-offset-2">browse</span>
|
|
154
151
|
</span>
|
|
155
152
|
{#if defaultHint}
|
|
156
153
|
<span class="text-xs text-text-muted">{defaultHint}</span>
|
|
@@ -192,7 +189,7 @@
|
|
|
192
189
|
<button
|
|
193
190
|
type="button"
|
|
194
191
|
onclick={() => remove(i)}
|
|
195
|
-
aria-label="
|
|
192
|
+
aria-label="Remove {file.name}"
|
|
196
193
|
class="-m-1 shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
|
|
197
194
|
>
|
|
198
195
|
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
@@ -13,7 +13,7 @@ interface Props {
|
|
|
13
13
|
maxFiles?: number;
|
|
14
14
|
disabled?: boolean;
|
|
15
15
|
tone?: Tone;
|
|
16
|
-
/** Headline inside the zone. Defaults to
|
|
16
|
+
/** Headline inside the zone. Defaults to `Drop files here`. */
|
|
17
17
|
label?: string;
|
|
18
18
|
hint?: string;
|
|
19
19
|
/** Hide the built-in file list to render your own. */
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatSize as toReadableSize, sortFiles, type RejectedFile } from '../../core/files.js';
|
|
3
|
+
import { toneFocusWithinBorder, type Tone } from '../../core/tones.js';
|
|
4
|
+
import { edge } from './lift.js';
|
|
5
|
+
import type { InputSize } from './Input.svelte';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Bindable list of accepted files. */
|
|
9
|
+
files?: File[];
|
|
10
|
+
/** Same syntax as the input's `accept` — `'image/*,.pdf'`. */
|
|
11
|
+
accept?: string;
|
|
12
|
+
multiple?: boolean;
|
|
13
|
+
/** Per-file ceiling in bytes. `0` means no limit. */
|
|
14
|
+
maxSize?: number;
|
|
15
|
+
/** Cap on how many files may be held at once. `0` means no limit. */
|
|
16
|
+
maxFiles?: number;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
size?: InputSize;
|
|
19
|
+
tone?: Tone;
|
|
20
|
+
invalid?: boolean;
|
|
21
|
+
/** Text on the button. Defaults to `Choose file`. */
|
|
22
|
+
label?: string;
|
|
23
|
+
/** Shown while nothing is chosen. Defaults to `No file chosen`. */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
/** Show the list of held files below the row. */
|
|
26
|
+
showList?: boolean;
|
|
27
|
+
/** The `name` the hidden input posts under, for a plain form submit. */
|
|
28
|
+
name?: string;
|
|
29
|
+
onaccept?: (files: File[]) => void;
|
|
30
|
+
onreject?: (rejected: RejectedFile[]) => void;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
files = $bindable([]),
|
|
36
|
+
accept,
|
|
37
|
+
multiple = false,
|
|
38
|
+
maxSize = 0,
|
|
39
|
+
maxFiles = 0,
|
|
40
|
+
disabled = false,
|
|
41
|
+
size = 'md',
|
|
42
|
+
tone = 'brand',
|
|
43
|
+
invalid = false,
|
|
44
|
+
label,
|
|
45
|
+
placeholder,
|
|
46
|
+
showList = true,
|
|
47
|
+
name,
|
|
48
|
+
onaccept,
|
|
49
|
+
onreject,
|
|
50
|
+
class: className = ''
|
|
51
|
+
}: Props = $props();
|
|
52
|
+
|
|
53
|
+
let input: HTMLInputElement | null = $state(null);
|
|
54
|
+
let rejected = $state<RejectedFile[]>([]);
|
|
55
|
+
|
|
56
|
+
const formatSize = (bytes: number) => toReadableSize(bytes);
|
|
57
|
+
|
|
58
|
+
/** One file reads as its name and weight; several read as a count. */
|
|
59
|
+
const summary = $derived.by(() => {
|
|
60
|
+
if (files.length === 0) return placeholder ?? 'No file chosen';
|
|
61
|
+
if (files.length === 1) return `${files[0].name} · ${formatSize(files[0].size)}`;
|
|
62
|
+
return `${files.length} $selected`;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function add(incoming: FileList | File[] | null) {
|
|
66
|
+
if (!incoming || disabled) return;
|
|
67
|
+
|
|
68
|
+
const { accepted, rejected: bad } = sortFiles(incoming, {
|
|
69
|
+
accept,
|
|
70
|
+
maxSize,
|
|
71
|
+
maxFiles,
|
|
72
|
+
multiple,
|
|
73
|
+
held: files.length
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
rejected = bad;
|
|
77
|
+
if (bad.length) onreject?.(bad);
|
|
78
|
+
if (accepted.length === 0) return;
|
|
79
|
+
|
|
80
|
+
files = multiple ? [...files, ...accepted] : accepted.slice(0, 1);
|
|
81
|
+
onaccept?.(accepted);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clear() {
|
|
85
|
+
files = [];
|
|
86
|
+
rejected = [];
|
|
87
|
+
if (input) input.value = '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const reasons: Record<RejectedFile['reason'], string> = $derived({
|
|
91
|
+
type: 'wrong file type',
|
|
92
|
+
size: 'too large',
|
|
93
|
+
count: 'over the file limit'
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const sizes: Record<InputSize, string> = {
|
|
97
|
+
sm: 'h-8 text-[13px]',
|
|
98
|
+
md: 'h-10 text-sm',
|
|
99
|
+
lg: 'h-11 text-[15px]'
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const buttons: Record<InputSize, string> = {
|
|
103
|
+
sm: 'h-6 px-2 text-[11px]',
|
|
104
|
+
md: 'h-7 px-2.5 text-xs',
|
|
105
|
+
lg: 'h-8 px-3 text-xs'
|
|
106
|
+
};
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<div class="flex w-full flex-col gap-3 {className}">
|
|
110
|
+
<span
|
|
111
|
+
class="inline-flex w-full items-center gap-2 rounded-xl border bg-bg px-1 {edge} transition-colors duration-150 ease-brand-out
|
|
112
|
+
{sizes[size]}
|
|
113
|
+
{invalid
|
|
114
|
+
? 'border-danger focus-within:border-danger'
|
|
115
|
+
: `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
|
|
116
|
+
{disabled ? 'pointer-events-none opacity-50' : ''}"
|
|
117
|
+
>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
{disabled}
|
|
121
|
+
onclick={() => input?.click()}
|
|
122
|
+
class="shrink-0 rounded-lg border border-hairline-strong bg-bg-alt font-sans font-medium text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current
|
|
123
|
+
{buttons[size]}"
|
|
124
|
+
>
|
|
125
|
+
{label ?? 'Choose file'}
|
|
126
|
+
</button>
|
|
127
|
+
|
|
128
|
+
<span
|
|
129
|
+
class="min-w-0 flex-1 truncate font-sans {files.length ? 'text-text' : 'text-text-muted'}"
|
|
130
|
+
>
|
|
131
|
+
{summary}
|
|
132
|
+
</span>
|
|
133
|
+
|
|
134
|
+
{#if files.length}
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
onclick={clear}
|
|
138
|
+
aria-label="Remove"
|
|
139
|
+
class="mr-1 shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
|
|
140
|
+
>
|
|
141
|
+
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
142
|
+
<path
|
|
143
|
+
d="m2 2 10 10M12 2 2 12"
|
|
144
|
+
stroke="currentColor"
|
|
145
|
+
stroke-width="1.6"
|
|
146
|
+
stroke-linecap="round"
|
|
147
|
+
/>
|
|
148
|
+
</svg>
|
|
149
|
+
</button>
|
|
150
|
+
{/if}
|
|
151
|
+
</span>
|
|
152
|
+
|
|
153
|
+
<input
|
|
154
|
+
bind:this={input}
|
|
155
|
+
type="file"
|
|
156
|
+
{name}
|
|
157
|
+
{accept}
|
|
158
|
+
{multiple}
|
|
159
|
+
{disabled}
|
|
160
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
161
|
+
class="sr-only"
|
|
162
|
+
onchange={(event) => add(event.currentTarget.files)}
|
|
163
|
+
/>
|
|
164
|
+
|
|
165
|
+
{#if rejected.length}
|
|
166
|
+
<ul class="flex flex-col gap-1">
|
|
167
|
+
{#each rejected as item (item.file.name + item.reason)}
|
|
168
|
+
<li class="font-sans text-sm text-danger">
|
|
169
|
+
{item.file.name} — {reasons[item.reason]}
|
|
170
|
+
</li>
|
|
171
|
+
{/each}
|
|
172
|
+
</ul>
|
|
173
|
+
{/if}
|
|
174
|
+
|
|
175
|
+
{#if showList && multiple && files.length > 1}
|
|
176
|
+
<ul class="flex flex-col rounded-[20px] border border-hairline bg-bg {edge}">
|
|
177
|
+
{#each files as file, i (file.name + file.size + i)}
|
|
178
|
+
<li
|
|
179
|
+
class="flex items-center gap-3 border-b border-hairline px-4 py-2.5 font-sans last:border-b-0"
|
|
180
|
+
>
|
|
181
|
+
<span class="min-w-0 flex-1 truncate text-sm text-text">{file.name}</span>
|
|
182
|
+
<span class="shrink-0 text-xs text-text-muted tabular-nums">{formatSize(file.size)}</span>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onclick={() => (files = files.filter((_, at) => at !== i))}
|
|
186
|
+
aria-label="Remove {file.name}"
|
|
187
|
+
class="-m-1 shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-danger focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger"
|
|
188
|
+
>
|
|
189
|
+
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
190
|
+
<path
|
|
191
|
+
d="m2 2 10 10M12 2 2 12"
|
|
192
|
+
stroke="currentColor"
|
|
193
|
+
stroke-width="1.6"
|
|
194
|
+
stroke-linecap="round"
|
|
195
|
+
/>
|
|
196
|
+
</svg>
|
|
197
|
+
</button>
|
|
198
|
+
</li>
|
|
199
|
+
{/each}
|
|
200
|
+
</ul>
|
|
201
|
+
{/if}
|
|
202
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type RejectedFile } from '../../core/files.js';
|
|
2
|
+
import { type Tone } from '../../core/tones.js';
|
|
3
|
+
import type { InputSize } from './Input.svelte';
|
|
4
|
+
interface Props {
|
|
5
|
+
/** Bindable list of accepted files. */
|
|
6
|
+
files?: File[];
|
|
7
|
+
/** Same syntax as the input's `accept` — `'image/*,.pdf'`. */
|
|
8
|
+
accept?: string;
|
|
9
|
+
multiple?: boolean;
|
|
10
|
+
/** Per-file ceiling in bytes. `0` means no limit. */
|
|
11
|
+
maxSize?: number;
|
|
12
|
+
/** Cap on how many files may be held at once. `0` means no limit. */
|
|
13
|
+
maxFiles?: number;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
size?: InputSize;
|
|
16
|
+
tone?: Tone;
|
|
17
|
+
invalid?: boolean;
|
|
18
|
+
/** Text on the button. Defaults to `Choose file`. */
|
|
19
|
+
label?: string;
|
|
20
|
+
/** Shown while nothing is chosen. Defaults to `No file chosen`. */
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
/** Show the list of held files below the row. */
|
|
23
|
+
showList?: boolean;
|
|
24
|
+
/** The `name` the hidden input posts under, for a plain form submit. */
|
|
25
|
+
name?: string;
|
|
26
|
+
onaccept?: (files: File[]) => void;
|
|
27
|
+
onreject?: (rejected: RejectedFile[]) => void;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
declare const FileInput: import("svelte").Component<Props, {}, "files">;
|
|
31
|
+
type FileInput = ReturnType<typeof FileInput>;
|
|
32
|
+
export default FileInput;
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
columns?: FooterColumn[];
|
|
23
23
|
/** Logo, wordmark and a line of copy on the left. */
|
|
24
24
|
brand?: Snippet;
|
|
25
|
-
/** Right side of the bottom bar — social links, a
|
|
25
|
+
/** Right side of the bottom bar — social links, a language picker. */
|
|
26
26
|
bottom?: Snippet;
|
|
27
27
|
/** Left side of the bottom bar. Ignored when `bottom` covers both. */
|
|
28
28
|
copyright?: string;
|
|
@@ -15,7 +15,7 @@ interface Props {
|
|
|
15
15
|
columns?: FooterColumn[];
|
|
16
16
|
/** Logo, wordmark and a line of copy on the left. */
|
|
17
17
|
brand?: Snippet;
|
|
18
|
-
/** Right side of the bottom bar — social links, a
|
|
18
|
+
/** Right side of the bottom bar — social links, a language picker. */
|
|
19
19
|
bottom?: Snippet;
|
|
20
20
|
/** Left side of the bottom bar. Ignored when `bottom` covers both. */
|
|
21
21
|
copyright?: string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
|
+
import { edge } from './lift.js';
|
|
5
|
+
import type { InputSize } from './Input.svelte';
|
|
6
|
+
|
|
7
|
+
interface Props extends HTMLAttributes<HTMLSpanElement> {
|
|
8
|
+
/** Match the Input it sits beside — the heights have to agree. */
|
|
9
|
+
size?: InputSize;
|
|
10
|
+
/** Paint it like a field instead of a fixed label. */
|
|
11
|
+
variant?: 'muted' | 'plain';
|
|
12
|
+
children: Snippet;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
size = 'md',
|
|
17
|
+
variant = 'muted',
|
|
18
|
+
class: className = '',
|
|
19
|
+
children,
|
|
20
|
+
...rest
|
|
21
|
+
}: Props = $props();
|
|
22
|
+
|
|
23
|
+
const sizes: Record<InputSize, string> = {
|
|
24
|
+
sm: 'h-8 px-2.5 text-[13px]',
|
|
25
|
+
md: 'h-10 px-3 text-sm',
|
|
26
|
+
lg: 'h-11 px-3 text-[15px]'
|
|
27
|
+
};
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<span
|
|
31
|
+
class="inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-hairline-strong font-sans whitespace-nowrap text-text-secondary {edge}
|
|
32
|
+
{sizes[size]}
|
|
33
|
+
{variant === 'muted' ? 'bg-bg-alt' : 'bg-bg'} {className}"
|
|
34
|
+
{...rest}
|
|
35
|
+
>
|
|
36
|
+
{@render children()}
|
|
37
|
+
</span>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import type { InputSize } from './Input.svelte';
|
|
4
|
+
interface Props extends HTMLAttributes<HTMLSpanElement> {
|
|
5
|
+
/** Match the Input it sits beside — the heights have to agree. */
|
|
6
|
+
size?: InputSize;
|
|
7
|
+
/** Paint it like a field instead of a fixed label. */
|
|
8
|
+
variant?: 'muted' | 'plain';
|
|
9
|
+
children: Snippet;
|
|
10
|
+
}
|
|
11
|
+
declare const InputAddon: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type InputAddon = ReturnType<typeof InputAddon>;
|
|
13
|
+
export default InputAddon;
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import { iconLg } from './icon.js';
|
|
4
4
|
import type { HTMLDialogAttributes } from 'svelte/elements';
|
|
5
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
6
5
|
import { isBackdropClick, syncDialog } from '../../core/dialog.svelte.js';
|
|
7
6
|
import { float } from './lift.js';
|
|
8
7
|
|
|
@@ -41,7 +40,6 @@
|
|
|
41
40
|
...rest
|
|
42
41
|
}: Props = $props();
|
|
43
42
|
|
|
44
|
-
const t = useLocale();
|
|
45
43
|
let dialog: HTMLDialogElement | null = $state(null);
|
|
46
44
|
|
|
47
45
|
const closable = $derived(showClose ?? dismissible);
|
|
@@ -93,7 +91,7 @@
|
|
|
93
91
|
<button
|
|
94
92
|
type="button"
|
|
95
93
|
onclick={close}
|
|
96
|
-
aria-label=
|
|
94
|
+
aria-label="Close"
|
|
97
95
|
class="-m-1 shrink-0 rounded-xl p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
|
98
96
|
>
|
|
99
97
|
<svg class="size-4" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import { anchored } from '../../core/actions/anchor.js';
|
|
17
17
|
import { clickOutside, portal } from '../../core/actions/dismissable.js';
|
|
18
18
|
import { ListCursor, groupItems, matchQuery, revealIndex } from '../../core/list.svelte.js';
|
|
19
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
20
19
|
import { iconMd, iconSm } from './icon.js';
|
|
21
20
|
import { toneFocusWithinBorder, toneSoft, toneText, type Tone } from '../../core/tones.js';
|
|
22
21
|
import { edge, soft } from './lift.js';
|
|
@@ -59,8 +58,6 @@
|
|
|
59
58
|
class: className = ''
|
|
60
59
|
}: Props = $props();
|
|
61
60
|
|
|
62
|
-
const t = useLocale();
|
|
63
|
-
|
|
64
61
|
let open = $state(false);
|
|
65
62
|
let query = $state('');
|
|
66
63
|
let wrapper: HTMLElement | null = $state(null);
|
|
@@ -150,7 +147,7 @@
|
|
|
150
147
|
class="inline-flex items-center rounded-xl px-2 py-0.5 text-xs font-medium {toneSoft[tone]}"
|
|
151
148
|
>
|
|
152
149
|
{chosen.length}
|
|
153
|
-
|
|
150
|
+
selected
|
|
154
151
|
</span>
|
|
155
152
|
{:else}
|
|
156
153
|
{#each chosen as option (option.value)}
|
|
@@ -163,7 +160,7 @@
|
|
|
163
160
|
<button
|
|
164
161
|
type="button"
|
|
165
162
|
onclick={() => remove(option)}
|
|
166
|
-
aria-label="
|
|
163
|
+
aria-label="Remove {option.label}"
|
|
167
164
|
class="inline-flex size-4 items-center justify-center rounded transition-colors duration-150 hover:bg-bg/70 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-current"
|
|
168
165
|
>
|
|
169
166
|
<svg class="size-2.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
@@ -190,7 +187,7 @@
|
|
|
190
187
|
aria-expanded={open}
|
|
191
188
|
aria-controls="{id ?? 'multiselect'}-listbox"
|
|
192
189
|
aria-invalid={invalid ? 'true' : undefined}
|
|
193
|
-
placeholder={chosen.length ? '' : (placeholder ??
|
|
190
|
+
placeholder={chosen.length ? '' : (placeholder ?? 'Search…')}
|
|
194
191
|
oninput={() => {
|
|
195
192
|
open = true;
|
|
196
193
|
cursor.index = 0;
|
|
@@ -212,7 +209,7 @@
|
|
|
212
209
|
onchange?.([]);
|
|
213
210
|
inputEl?.focus();
|
|
214
211
|
}}
|
|
215
|
-
aria-label=
|
|
212
|
+
aria-label="Clear selection"
|
|
216
213
|
class="shrink-0 rounded p-1 text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
|
|
217
214
|
>
|
|
218
215
|
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
@@ -240,7 +237,7 @@
|
|
|
240
237
|
>
|
|
241
238
|
{#if flat.length === 0}
|
|
242
239
|
<p class="px-3 py-6 text-center text-sm text-text-muted">
|
|
243
|
-
{emptyText ??
|
|
240
|
+
{emptyText ?? 'No matches'}
|
|
244
241
|
</p>
|
|
245
242
|
{:else}
|
|
246
243
|
{#each groups as [group, items] (group)}
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
|
|
17
17
|
<script lang="ts">
|
|
18
18
|
import type { Snippet } from 'svelte';
|
|
19
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
20
19
|
import { iconMd } from './icon.js';
|
|
21
20
|
import { focusRing, toneRing, toneSoft, toneText, type Tone } from '../../core/tones.js';
|
|
22
21
|
import Drawer from './Drawer.svelte';
|
|
@@ -53,8 +52,6 @@
|
|
|
53
52
|
class: className = ''
|
|
54
53
|
}: Props = $props();
|
|
55
54
|
|
|
56
|
-
const t = useLocale();
|
|
57
|
-
|
|
58
55
|
// sprout gives the active link a filled pill rather than a colour change, so
|
|
59
56
|
// the current page is legible without relying on hue alone.
|
|
60
57
|
const link =
|
|
@@ -66,10 +63,7 @@
|
|
|
66
63
|
? 'border-b border-hairline'
|
|
67
64
|
: ''} {className}"
|
|
68
65
|
>
|
|
69
|
-
<nav
|
|
70
|
-
aria-label={t.current.navigation}
|
|
71
|
-
class="mx-auto flex h-16 w-full max-w-6xl items-center gap-6 px-6"
|
|
72
|
-
>
|
|
66
|
+
<nav aria-label="Navigation" class="mx-auto flex h-16 w-full max-w-6xl items-center gap-6 px-6">
|
|
73
67
|
{#if brand}
|
|
74
68
|
<div class="flex shrink-0 items-center">{@render brand()}</div>
|
|
75
69
|
{/if}
|
|
@@ -140,7 +134,7 @@
|
|
|
140
134
|
<button
|
|
141
135
|
type="button"
|
|
142
136
|
onclick={() => (menuOpen = true)}
|
|
143
|
-
aria-label=
|
|
137
|
+
aria-label="Open menu"
|
|
144
138
|
aria-expanded={menuOpen}
|
|
145
139
|
class="inline-flex size-9 items-center justify-center rounded-xl text-text-secondary transition-colors duration-150 hover:bg-bg-inset hover:text-text md:hidden {focusRing} {toneRing[
|
|
146
140
|
tone
|
|
@@ -159,7 +153,7 @@
|
|
|
159
153
|
</nav>
|
|
160
154
|
</header>
|
|
161
155
|
|
|
162
|
-
<Drawer bind:open={menuOpen} side="right" size="sm" title=
|
|
156
|
+
<Drawer bind:open={menuOpen} side="right" size="sm" title="Navigation">
|
|
163
157
|
<ul class="flex flex-col gap-0.5">
|
|
164
158
|
{#each items as item (item.label)}
|
|
165
159
|
<li>
|