@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
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
3
|
import { focusRing, toneRing, toneSolid, type Tone } from '../../core/tones.js';
|
|
4
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
5
4
|
import { pageRange } from '../../core/pagination.js';
|
|
6
5
|
import { liftSm } from './lift.js';
|
|
7
6
|
|
|
@@ -26,8 +25,6 @@
|
|
|
26
25
|
...rest
|
|
27
26
|
}: Props = $props();
|
|
28
27
|
|
|
29
|
-
const t = useLocale();
|
|
30
|
-
|
|
31
28
|
const pages = $derived(pageRange(page, total, siblings));
|
|
32
29
|
|
|
33
30
|
function go(next: number) {
|
|
@@ -40,12 +37,12 @@
|
|
|
40
37
|
const cell = `inline-flex h-9 min-w-9 items-center justify-center rounded-full px-2 font-sans text-sm font-medium tabular-nums transition-colors duration-150 ease-brand-out disabled:opacity-40 disabled:pointer-events-none ${focusRing}`;
|
|
41
38
|
</script>
|
|
42
39
|
|
|
43
|
-
<nav aria-label=
|
|
40
|
+
<nav aria-label="Pagination" class="flex items-center gap-1 {className}" {...rest}>
|
|
44
41
|
<button
|
|
45
42
|
type="button"
|
|
46
43
|
class="{cell} {toneRing[tone]} text-text-secondary hover:bg-bg-inset"
|
|
47
44
|
disabled={page <= 1}
|
|
48
|
-
aria-label=
|
|
45
|
+
aria-label="Previous page"
|
|
49
46
|
onclick={() => go(page - 1)}
|
|
50
47
|
>
|
|
51
48
|
<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
@@ -74,7 +71,7 @@
|
|
|
74
71
|
type="button"
|
|
75
72
|
class="{cell} {toneRing[tone]} text-text-secondary hover:bg-bg-inset"
|
|
76
73
|
disabled={page >= total}
|
|
77
|
-
aria-label=
|
|
74
|
+
aria-label="Next page"
|
|
78
75
|
onclick={() => go(page + 1)}
|
|
79
76
|
>
|
|
80
77
|
<svg class="size-4" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { caretIndex, clearFrom, fillFrom, sanitisePin, type PinMode } from '../../core/pin.js';
|
|
3
|
+
import { toneFocusBorder, type Tone } from '../../core/tones.js';
|
|
4
|
+
import { edge } from './lift.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** Bindable code. Shorter than `length` means still incomplete. */
|
|
8
|
+
value?: string;
|
|
9
|
+
/** How many boxes. */
|
|
10
|
+
length?: number;
|
|
11
|
+
/** What a box accepts. */
|
|
12
|
+
mode?: PinMode;
|
|
13
|
+
/** Render the characters as dots. */
|
|
14
|
+
mask?: boolean;
|
|
15
|
+
/** Draw a gap after this many boxes — `3` gives `123 456`. */
|
|
16
|
+
groupAfter?: number;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
invalid?: boolean;
|
|
19
|
+
tone?: Tone;
|
|
20
|
+
/** `name` for a plain form submit — one hidden field holding the code. */
|
|
21
|
+
name?: string;
|
|
22
|
+
/** Fires once the last box is filled. */
|
|
23
|
+
oncomplete?: (value: string) => void;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
value = $bindable(''),
|
|
29
|
+
length = 6,
|
|
30
|
+
mode = 'numeric',
|
|
31
|
+
mask = false,
|
|
32
|
+
groupAfter = 0,
|
|
33
|
+
disabled = false,
|
|
34
|
+
invalid = false,
|
|
35
|
+
tone = 'brand',
|
|
36
|
+
name,
|
|
37
|
+
oncomplete,
|
|
38
|
+
class: className = ''
|
|
39
|
+
}: Props = $props();
|
|
40
|
+
|
|
41
|
+
let boxes: HTMLInputElement[] = $state([]);
|
|
42
|
+
|
|
43
|
+
const chars = $derived(Array.from({ length }, (_, i) => value[i] ?? ''));
|
|
44
|
+
|
|
45
|
+
function focusAt(index: number) {
|
|
46
|
+
boxes[Math.max(0, Math.min(length - 1, index))]?.focus();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The DOM is written back on every keystroke, not only when the value
|
|
51
|
+
* changed: a refused character leaves the box holding text the state never
|
|
52
|
+
* accepted, and nothing else would clear it.
|
|
53
|
+
*/
|
|
54
|
+
function sync(box: HTMLInputElement, index: number) {
|
|
55
|
+
box.value = value[index] ?? '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function commit(next: string, from: number) {
|
|
59
|
+
const was = value;
|
|
60
|
+
value = next;
|
|
61
|
+
if (value.length === length && was.length < length) oncomplete?.(value);
|
|
62
|
+
// Land on the first empty box, or the one after whatever was just typed.
|
|
63
|
+
focusAt(Math.min(Math.max(from, caretIndex(value, length)), length - 1));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function onInput(event: Event & { currentTarget: HTMLInputElement }, index: number) {
|
|
67
|
+
const typed = sanitisePin(event.currentTarget.value, mode);
|
|
68
|
+
if (!typed) {
|
|
69
|
+
sync(event.currentTarget, index);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const next = fillFrom(value, index, typed, length, mode);
|
|
73
|
+
commit(next, index + typed.length);
|
|
74
|
+
for (const [at, box] of boxes.entries()) sync(box, at);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function onKeydown(event: KeyboardEvent, index: number) {
|
|
78
|
+
if (event.key === 'Backspace') {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
// An empty box deletes the one before it, the way a caret would.
|
|
81
|
+
const target = value[index] ? index : index - 1;
|
|
82
|
+
if (target < 0) return;
|
|
83
|
+
value = clearFrom(value, target);
|
|
84
|
+
for (const [at, box] of boxes.entries()) sync(box, at);
|
|
85
|
+
focusAt(target);
|
|
86
|
+
} else if (event.key === 'ArrowLeft') {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
focusAt(index - 1);
|
|
89
|
+
} else if (event.key === 'ArrowRight') {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
focusAt(index + 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onPaste(event: ClipboardEvent, index: number) {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
const text = event.clipboardData?.getData('text') ?? '';
|
|
98
|
+
const clean = sanitisePin(text, mode);
|
|
99
|
+
if (!clean) return;
|
|
100
|
+
commit(fillFrom(value, index, clean, length, mode), index + clean.length);
|
|
101
|
+
for (const [at, box] of boxes.entries()) sync(box, at);
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<div class="flex items-center gap-2 {className}">
|
|
106
|
+
{#each chars as char, index (index)}
|
|
107
|
+
{#if groupAfter && index > 0 && index % groupAfter === 0}
|
|
108
|
+
<span class="h-px w-2 bg-hairline-strong" aria-hidden="true"></span>
|
|
109
|
+
{/if}
|
|
110
|
+
<input
|
|
111
|
+
bind:this={boxes[index]}
|
|
112
|
+
value={char}
|
|
113
|
+
{disabled}
|
|
114
|
+
type={mask ? 'password' : 'text'}
|
|
115
|
+
inputmode={mode === 'numeric' ? 'numeric' : 'text'}
|
|
116
|
+
autocomplete={index === 0 ? 'one-time-code' : 'off'}
|
|
117
|
+
autocapitalize="characters"
|
|
118
|
+
spellcheck="false"
|
|
119
|
+
maxlength={length}
|
|
120
|
+
aria-label="Digit {index + 1}"
|
|
121
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
122
|
+
oninput={(event) => onInput(event, index)}
|
|
123
|
+
onkeydown={(event) => onKeydown(event, index)}
|
|
124
|
+
onpaste={(event) => onPaste(event, index)}
|
|
125
|
+
onfocus={(event) => event.currentTarget.select()}
|
|
126
|
+
class="size-11 rounded-xl border bg-bg text-center font-sans text-base font-medium text-text tabular-nums {edge} transition-colors duration-150 ease-brand-out focus:outline-none
|
|
127
|
+
{invalid ? 'border-danger' : `border-hairline-strong ${toneFocusBorder[tone]}`}
|
|
128
|
+
{disabled ? 'pointer-events-none opacity-50' : ''}"
|
|
129
|
+
/>
|
|
130
|
+
{/each}
|
|
131
|
+
|
|
132
|
+
{#if name}
|
|
133
|
+
<input type="hidden" {name} {value} />
|
|
134
|
+
{/if}
|
|
135
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type PinMode } from '../../core/pin.js';
|
|
2
|
+
import { type Tone } from '../../core/tones.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Bindable code. Shorter than `length` means still incomplete. */
|
|
5
|
+
value?: string;
|
|
6
|
+
/** How many boxes. */
|
|
7
|
+
length?: number;
|
|
8
|
+
/** What a box accepts. */
|
|
9
|
+
mode?: PinMode;
|
|
10
|
+
/** Render the characters as dots. */
|
|
11
|
+
mask?: boolean;
|
|
12
|
+
/** Draw a gap after this many boxes — `3` gives `123 456`. */
|
|
13
|
+
groupAfter?: number;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
invalid?: boolean;
|
|
16
|
+
tone?: Tone;
|
|
17
|
+
/** `name` for a plain form submit — one hidden field holding the code. */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** Fires once the last box is filled. */
|
|
20
|
+
oncomplete?: (value: string) => void;
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
declare const PinInput: import("svelte").Component<Props, {}, "value">;
|
|
24
|
+
type PinInput = ReturnType<typeof PinInput>;
|
|
25
|
+
export default PinInput;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
4
|
+
import type { Tone } from '../../core/tones.js';
|
|
5
|
+
import Input, { type InputSize } from './Input.svelte';
|
|
6
|
+
|
|
7
|
+
interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'type'> {
|
|
8
|
+
value?: string;
|
|
9
|
+
size?: InputSize;
|
|
10
|
+
tone?: Tone;
|
|
11
|
+
invalid?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Milliseconds to wait after the last keystroke before `onsearch` fires.
|
|
14
|
+
* `0` fires on every one.
|
|
15
|
+
*/
|
|
16
|
+
debounce?: number;
|
|
17
|
+
/** The debounced value. The bound `value` still updates immediately. */
|
|
18
|
+
onsearch?: (value: string) => void;
|
|
19
|
+
onclear?: () => void;
|
|
20
|
+
/** Replaces the magnifier. */
|
|
21
|
+
icon?: Snippet;
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let {
|
|
26
|
+
value = $bindable(''),
|
|
27
|
+
size = 'md',
|
|
28
|
+
tone = 'brand',
|
|
29
|
+
invalid = false,
|
|
30
|
+
disabled = false,
|
|
31
|
+
debounce = 0,
|
|
32
|
+
onsearch,
|
|
33
|
+
onclear,
|
|
34
|
+
icon,
|
|
35
|
+
class: className = '',
|
|
36
|
+
...rest
|
|
37
|
+
}: Props = $props();
|
|
38
|
+
|
|
39
|
+
// The timer is cleared by the effect's own teardown, so a keystroke that
|
|
40
|
+
// lands mid-wait restarts the clock rather than queueing a second call.
|
|
41
|
+
$effect(() => {
|
|
42
|
+
const next = value;
|
|
43
|
+
if (!onsearch) return;
|
|
44
|
+
if (debounce <= 0) {
|
|
45
|
+
onsearch(next);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const timer = setTimeout(() => onsearch(next), debounce);
|
|
49
|
+
return () => clearTimeout(timer);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function clear() {
|
|
53
|
+
value = '';
|
|
54
|
+
onclear?.();
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<Input bind:value type="search" {size} {tone} {invalid} {disabled} class={className} {...rest}>
|
|
59
|
+
{#snippet prefix()}
|
|
60
|
+
{#if icon}
|
|
61
|
+
{@render icon()}
|
|
62
|
+
{:else}
|
|
63
|
+
<svg class="size-4" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
64
|
+
<circle cx="9" cy="9" r="5.5" stroke="currentColor" stroke-width="1.6" />
|
|
65
|
+
<path d="m13 13 3.5 3.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
|
66
|
+
</svg>
|
|
67
|
+
{/if}
|
|
68
|
+
{/snippet}
|
|
69
|
+
|
|
70
|
+
{#snippet suffix()}
|
|
71
|
+
{#if value}
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onclick={clear}
|
|
75
|
+
aria-label="Clear search"
|
|
76
|
+
class="-mr-1 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"
|
|
77
|
+
>
|
|
78
|
+
<svg class="size-3.5" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
79
|
+
<path
|
|
80
|
+
d="m2 2 10 10M12 2 2 12"
|
|
81
|
+
stroke="currentColor"
|
|
82
|
+
stroke-width="1.6"
|
|
83
|
+
stroke-linecap="round"
|
|
84
|
+
/>
|
|
85
|
+
</svg>
|
|
86
|
+
</button>
|
|
87
|
+
{/if}
|
|
88
|
+
{/snippet}
|
|
89
|
+
</Input>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
import type { Tone } from '../../core/tones.js';
|
|
4
|
+
import { type InputSize } from './Input.svelte';
|
|
5
|
+
interface Props extends Omit<HTMLInputAttributes, 'size' | 'prefix' | 'type'> {
|
|
6
|
+
value?: string;
|
|
7
|
+
size?: InputSize;
|
|
8
|
+
tone?: Tone;
|
|
9
|
+
invalid?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Milliseconds to wait after the last keystroke before `onsearch` fires.
|
|
12
|
+
* `0` fires on every one.
|
|
13
|
+
*/
|
|
14
|
+
debounce?: number;
|
|
15
|
+
/** The debounced value. The bound `value` still updates immediately. */
|
|
16
|
+
onsearch?: (value: string) => void;
|
|
17
|
+
onclear?: () => void;
|
|
18
|
+
/** Replaces the magnifier. */
|
|
19
|
+
icon?: Snippet;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
declare const SearchInput: import("svelte").Component<Props, {}, "value">;
|
|
23
|
+
type SearchInput = ReturnType<typeof SearchInput>;
|
|
24
|
+
export default SearchInput;
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
</script>
|
|
36
36
|
|
|
37
37
|
<script lang="ts">
|
|
38
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
39
38
|
import { iconMd } from './icon.js';
|
|
40
39
|
import { focusRing, toneRing, toneSoft, type Tone } from '../../core/tones.js';
|
|
41
40
|
|
|
@@ -69,8 +68,6 @@
|
|
|
69
68
|
class: className = ''
|
|
70
69
|
}: Props = $props();
|
|
71
70
|
|
|
72
|
-
const t = useLocale();
|
|
73
|
-
|
|
74
71
|
const keyOf = (item: SidebarItem) => item.id ?? item.href ?? item.label;
|
|
75
72
|
|
|
76
73
|
/** Groups start open when asked, or when they contain the active item. */
|
|
@@ -141,7 +138,7 @@
|
|
|
141
138
|
</div>
|
|
142
139
|
{/if}
|
|
143
140
|
|
|
144
|
-
<nav aria-label=
|
|
141
|
+
<nav aria-label="Navigation" class="flex-1 overflow-y-auto p-2">
|
|
145
142
|
{#each sections as section, i (section.label ?? i)}
|
|
146
143
|
<div class="flex flex-col gap-0.5 {i > 0 ? 'mt-4' : ''}">
|
|
147
144
|
{#if section.label && !collapsed}
|
|
@@ -230,7 +227,7 @@
|
|
|
230
227
|
<button
|
|
231
228
|
type="button"
|
|
232
229
|
onclick={() => (collapsed = !collapsed)}
|
|
233
|
-
aria-label={collapsed ?
|
|
230
|
+
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
234
231
|
class="inline-flex h-8 items-center justify-center gap-2 rounded-xl font-sans text-xs text-text-muted transition-colors duration-150 hover:bg-bg-inset hover:text-text {focusRing} {toneRing[
|
|
235
232
|
tone
|
|
236
233
|
]}"
|
|
@@ -248,7 +245,7 @@
|
|
|
248
245
|
stroke-linecap="round"
|
|
249
246
|
/>
|
|
250
247
|
</svg>
|
|
251
|
-
{#if !collapsed}<span>
|
|
248
|
+
{#if !collapsed}<span>Collapse sidebar</span>{/if}
|
|
252
249
|
</button>
|
|
253
250
|
{/if}
|
|
254
251
|
</div>
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
3
|
import { toneText, type Tone } from '../../core/tones.js';
|
|
4
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
5
4
|
|
|
6
5
|
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg';
|
|
7
6
|
|
|
@@ -9,12 +8,10 @@
|
|
|
9
8
|
size?: SpinnerSize;
|
|
10
9
|
/** Omit to inherit the parent's `currentColor` (e.g. inside a Button). */
|
|
11
10
|
tone?: Tone;
|
|
12
|
-
/** Defaults to
|
|
11
|
+
/** Defaults to `Loading`; pass `''` to hide it. */
|
|
13
12
|
label?: string;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
const t = useLocale();
|
|
17
|
-
|
|
18
15
|
let { size = 'md', tone, label, class: className = '', ...rest }: Props = $props();
|
|
19
16
|
|
|
20
17
|
const sizes: Record<SpinnerSize, string> = {
|
|
@@ -27,7 +24,7 @@
|
|
|
27
24
|
|
|
28
25
|
<span
|
|
29
26
|
role="status"
|
|
30
|
-
aria-label={label ??
|
|
27
|
+
aria-label={label ?? 'Loading'}
|
|
31
28
|
class="inline-block shrink-0 animate-spin rounded-full border-current/25 border-t-current
|
|
32
29
|
{sizes[size]} {tone ? toneText[tone] : ''} {className}"
|
|
33
30
|
{...rest}
|
|
@@ -5,7 +5,7 @@ interface Props extends HTMLAttributes<HTMLSpanElement> {
|
|
|
5
5
|
size?: SpinnerSize;
|
|
6
6
|
/** Omit to inherit the parent's `currentColor` (e.g. inside a Button). */
|
|
7
7
|
tone?: Tone;
|
|
8
|
-
/** Defaults to
|
|
8
|
+
/** Defaults to `Loading`; pass `''` to hide it. */
|
|
9
9
|
label?: string;
|
|
10
10
|
}
|
|
11
11
|
declare const Spinner: import("svelte").Component<Props, {}, "">;
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import Checkbox from './Checkbox.svelte';
|
|
17
17
|
import type { Tone } from '../../core/tones.js';
|
|
18
18
|
import { nextSort, sortRows, toggleKey, type TableSort } from '../../core/table.js';
|
|
19
|
-
import { useLocale } from '../../core/locale.svelte.js';
|
|
20
19
|
import { edge } from './lift.js';
|
|
21
20
|
|
|
22
21
|
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onselect'> {
|
|
@@ -83,8 +82,6 @@
|
|
|
83
82
|
...rest
|
|
84
83
|
}: Props = $props();
|
|
85
84
|
|
|
86
|
-
const t = useLocale();
|
|
87
|
-
|
|
88
85
|
const aligns = {
|
|
89
86
|
left: 'text-left',
|
|
90
87
|
center: 'text-center',
|
|
@@ -140,14 +137,14 @@
|
|
|
140
137
|
>
|
|
141
138
|
<span class="font-sans text-[13px] font-semibold text-text">
|
|
142
139
|
{selected.length}
|
|
143
|
-
|
|
140
|
+
selected
|
|
144
141
|
</span>
|
|
145
142
|
<button
|
|
146
143
|
type="button"
|
|
147
144
|
onclick={clearSelection}
|
|
148
145
|
class="rounded-full font-sans text-[13px] font-medium text-text-muted transition-colors duration-150 hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
|
|
149
146
|
>
|
|
150
|
-
|
|
147
|
+
Clear selection
|
|
151
148
|
</button>
|
|
152
149
|
<div class="ml-auto flex flex-wrap items-center gap-2">
|
|
153
150
|
{@render bulkActions?.({ selected, clear: clearSelection })}
|
|
@@ -177,7 +174,7 @@
|
|
|
177
174
|
checked={allSelected}
|
|
178
175
|
indeterminate={someSelected}
|
|
179
176
|
{tone}
|
|
180
|
-
aria-label=
|
|
177
|
+
aria-label="Select all rows"
|
|
181
178
|
onchange={toggleAll}
|
|
182
179
|
/>
|
|
183
180
|
</th>
|
|
@@ -224,7 +221,7 @@
|
|
|
224
221
|
colspan={columns.length + (selectable ? 1 : 0)}
|
|
225
222
|
class="px-4 py-10 text-center text-sm text-text-muted"
|
|
226
223
|
>
|
|
227
|
-
{#if empty}{@render empty()}{:else}{
|
|
224
|
+
{#if empty}{@render empty()}{:else}No data{/if}
|
|
228
225
|
</td>
|
|
229
226
|
</tr>
|
|
230
227
|
{:else}
|
|
@@ -241,7 +238,7 @@
|
|
|
241
238
|
<Checkbox
|
|
242
239
|
checked={isSelected}
|
|
243
240
|
{tone}
|
|
244
|
-
aria-label=
|
|
241
|
+
aria-label="Select row"
|
|
245
242
|
onchange={() => toggleRow(row)}
|
|
246
243
|
/>
|
|
247
244
|
</td>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { addTag, splitTags, type TagRejection } from '../../core/tags.js';
|
|
3
|
+
import { toneFocusWithinBorder, toneSoft, type Tone } from '../../core/tones.js';
|
|
4
|
+
import { edge } from './lift.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** Bindable list of tags, in the order they were added. */
|
|
8
|
+
tags?: string[];
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
/** Cap on how many may be held. `0` means no limit. */
|
|
11
|
+
max?: number;
|
|
12
|
+
/** Shortest a tag may be, after trimming. */
|
|
13
|
+
minLength?: number;
|
|
14
|
+
/** Keys that commit the tag under the caret, besides Enter. */
|
|
15
|
+
separators?: string[];
|
|
16
|
+
/** Refuse a tag that differs from an existing one only in case. */
|
|
17
|
+
caseInsensitive?: boolean;
|
|
18
|
+
/** Commit whatever is half-typed when the field loses focus. */
|
|
19
|
+
commitOnBlur?: boolean;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
invalid?: boolean;
|
|
22
|
+
tone?: Tone;
|
|
23
|
+
/** `name` for a plain form submit — one hidden field, comma-joined. */
|
|
24
|
+
name?: string;
|
|
25
|
+
onadd?: (tag: string) => void;
|
|
26
|
+
onremove?: (tag: string) => void;
|
|
27
|
+
onreject?: (reason: TagRejection, raw: string) => void;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
tags = $bindable([]),
|
|
33
|
+
placeholder,
|
|
34
|
+
max = 0,
|
|
35
|
+
minLength = 1,
|
|
36
|
+
separators = [','],
|
|
37
|
+
caseInsensitive = true,
|
|
38
|
+
commitOnBlur = true,
|
|
39
|
+
disabled = false,
|
|
40
|
+
invalid = false,
|
|
41
|
+
tone = 'brand',
|
|
42
|
+
name,
|
|
43
|
+
onadd,
|
|
44
|
+
onremove,
|
|
45
|
+
onreject,
|
|
46
|
+
class: className = ''
|
|
47
|
+
}: Props = $props();
|
|
48
|
+
|
|
49
|
+
let draft = $state('');
|
|
50
|
+
let field: HTMLInputElement | null = $state(null);
|
|
51
|
+
/** The last refusal, cleared as soon as the next keystroke lands. */
|
|
52
|
+
let refused = $state<TagRejection | null>(null);
|
|
53
|
+
|
|
54
|
+
const full = $derived(max > 0 && tags.length >= max);
|
|
55
|
+
const rules = $derived({ max, minLength, caseInsensitive });
|
|
56
|
+
|
|
57
|
+
function commit(raw: string) {
|
|
58
|
+
const { tags: next, rejected } = addTag(tags, raw, rules);
|
|
59
|
+
if (rejected) {
|
|
60
|
+
// An empty commit is the Enter key on an empty field, not a mistake.
|
|
61
|
+
if (rejected !== 'empty') {
|
|
62
|
+
refused = rejected;
|
|
63
|
+
onreject?.(rejected, raw);
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
tags = next;
|
|
68
|
+
onadd?.(next[next.length - 1]);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function remove(index: number) {
|
|
73
|
+
const [gone] = tags.slice(index, index + 1);
|
|
74
|
+
tags = tags.filter((_, at) => at !== index);
|
|
75
|
+
if (gone !== undefined) onremove?.(gone);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onKeydown(event: KeyboardEvent) {
|
|
79
|
+
refused = null;
|
|
80
|
+
|
|
81
|
+
if (event.key === 'Enter' || separators.includes(event.key)) {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
if (commit(draft)) draft = '';
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Empty field: Backspace peels the last chip off, the way chips behave
|
|
88
|
+
// everywhere else in this library.
|
|
89
|
+
if (event.key === 'Backspace' && draft === '' && tags.length) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
remove(tags.length - 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onPaste(event: ClipboardEvent) {
|
|
96
|
+
const text = event.clipboardData?.getData('text') ?? '';
|
|
97
|
+
const parts = splitTags(text, separators);
|
|
98
|
+
if (parts.length <= 1) return;
|
|
99
|
+
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
for (const part of parts) commit(part);
|
|
102
|
+
draft = '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function onBlur() {
|
|
106
|
+
if (commitOnBlur && draft.trim() && commit(draft)) draft = '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const messages: Record<TagRejection, string> = $derived({
|
|
110
|
+
empty: '',
|
|
111
|
+
short: `${minLength}+`,
|
|
112
|
+
duplicate: 'Already added',
|
|
113
|
+
full: `$selected ${tags.length}/${max}`
|
|
114
|
+
});
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<div class="flex w-full flex-col gap-1.5 {className}">
|
|
118
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
119
|
+
<div
|
|
120
|
+
onclick={() => field?.focus()}
|
|
121
|
+
class="flex w-full flex-wrap items-center gap-1.5 rounded-xl border bg-bg py-1.5 pr-1.5 pl-2 {edge} transition-colors duration-150 ease-brand-out
|
|
122
|
+
{invalid
|
|
123
|
+
? 'border-danger focus-within:border-danger'
|
|
124
|
+
: `border-hairline-strong ${toneFocusWithinBorder[tone]}`}
|
|
125
|
+
{disabled ? 'pointer-events-none opacity-50' : ''}"
|
|
126
|
+
>
|
|
127
|
+
{#each tags as tag, index (tag)}
|
|
128
|
+
<span
|
|
129
|
+
class="inline-flex items-center gap-1 rounded-xl py-0.5 pr-0.5 pl-2 text-xs font-medium {toneSoft[
|
|
130
|
+
tone
|
|
131
|
+
]}"
|
|
132
|
+
>
|
|
133
|
+
{tag}
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onclick={() => remove(index)}
|
|
137
|
+
aria-label="Remove {tag}"
|
|
138
|
+
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"
|
|
139
|
+
>
|
|
140
|
+
<svg class="size-2.5" viewBox="0 0 10 10" fill="none" aria-hidden="true">
|
|
141
|
+
<path
|
|
142
|
+
d="m1.5 1.5 7 7M8.5 1.5l-7 7"
|
|
143
|
+
stroke="currentColor"
|
|
144
|
+
stroke-width="1.6"
|
|
145
|
+
stroke-linecap="round"
|
|
146
|
+
/>
|
|
147
|
+
</svg>
|
|
148
|
+
</button>
|
|
149
|
+
</span>
|
|
150
|
+
{/each}
|
|
151
|
+
|
|
152
|
+
<input
|
|
153
|
+
bind:this={field}
|
|
154
|
+
bind:value={draft}
|
|
155
|
+
{disabled}
|
|
156
|
+
readonly={full}
|
|
157
|
+
placeholder={tags.length === 0 ? (placeholder ?? 'Add a tag') : ''}
|
|
158
|
+
aria-label={placeholder ?? 'Add a tag'}
|
|
159
|
+
aria-invalid={invalid ? 'true' : undefined}
|
|
160
|
+
onkeydown={onKeydown}
|
|
161
|
+
onpaste={onPaste}
|
|
162
|
+
onblur={onBlur}
|
|
163
|
+
class="h-7 min-w-24 flex-1 bg-transparent font-sans text-sm text-text placeholder:text-text-muted focus:outline-none"
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
{#if name}
|
|
167
|
+
<input type="hidden" {name} value={tags.join(',')} />
|
|
168
|
+
{/if}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{#if refused && messages[refused]}
|
|
172
|
+
<span class="text-xs font-medium text-danger">{messages[refused]}</span>
|
|
173
|
+
{:else if max}
|
|
174
|
+
<span class="text-xs text-text-muted tabular-nums">{tags.length}/{max}</span>
|
|
175
|
+
{/if}
|
|
176
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type TagRejection } from '../../core/tags.js';
|
|
2
|
+
import { type Tone } from '../../core/tones.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Bindable list of tags, in the order they were added. */
|
|
5
|
+
tags?: string[];
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
/** Cap on how many may be held. `0` means no limit. */
|
|
8
|
+
max?: number;
|
|
9
|
+
/** Shortest a tag may be, after trimming. */
|
|
10
|
+
minLength?: number;
|
|
11
|
+
/** Keys that commit the tag under the caret, besides Enter. */
|
|
12
|
+
separators?: string[];
|
|
13
|
+
/** Refuse a tag that differs from an existing one only in case. */
|
|
14
|
+
caseInsensitive?: boolean;
|
|
15
|
+
/** Commit whatever is half-typed when the field loses focus. */
|
|
16
|
+
commitOnBlur?: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
invalid?: boolean;
|
|
19
|
+
tone?: Tone;
|
|
20
|
+
/** `name` for a plain form submit — one hidden field, comma-joined. */
|
|
21
|
+
name?: string;
|
|
22
|
+
onadd?: (tag: string) => void;
|
|
23
|
+
onremove?: (tag: string) => void;
|
|
24
|
+
onreject?: (reason: TagRejection, raw: string) => void;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
declare const TagsInput: import("svelte").Component<Props, {}, "tags">;
|
|
28
|
+
type TagsInput = ReturnType<typeof TagsInput>;
|
|
29
|
+
export default TagsInput;
|