@joewinke/jatui 0.38.6 → 0.40.0
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/CLAUDE.md +54 -5
- package/THIRD_PARTY_NOTICES.md +67 -0
- package/dist/actions/scrollEdge.d.ts +55 -0
- package/dist/actions/scrollEdge.js +144 -0
- package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +1 -1
- package/dist/artifact-blocks/blocks/diagram/schema.d.ts +1 -1
- package/dist/components/BottomSheet.svelte +7 -6
- package/dist/components/BottomSheet.svelte.d.ts +2 -0
- package/dist/components/BulkActionBar.svelte +32 -2
- package/dist/components/BulkActionBar.svelte.d.ts +5 -1
- package/dist/components/CloseButton.svelte +75 -0
- package/dist/components/CloseButton.svelte.d.ts +29 -0
- package/dist/components/Drawer.svelte +10 -11
- package/dist/components/Drawer.svelte.d.ts +2 -8
- package/dist/components/FloatingActionBar.svelte +102 -34
- package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
- package/dist/components/IconButton.svelte +200 -0
- package/dist/components/IconButton.svelte.d.ts +46 -0
- package/dist/components/LinkButton.svelte +117 -0
- package/dist/components/LinkButton.svelte.d.ts +32 -0
- package/dist/components/MediaExplorer.svelte +249 -0
- package/dist/components/MediaExplorer.svelte.d.ts +37 -0
- package/dist/components/MediaLightbox.svelte +2 -7
- package/dist/components/Modal.svelte +2 -10
- package/dist/components/NotificationCenter.svelte +481 -0
- package/dist/components/NotificationCenter.svelte.d.ts +29 -0
- package/dist/components/PinInput.svelte +94 -31
- package/dist/components/PinInput.svelte.d.ts +4 -0
- package/dist/components/SearchDropdown.svelte +54 -8
- package/dist/components/SearchDropdown.svelte.d.ts +12 -0
- package/dist/components/TextArea.svelte +67 -5
- package/dist/components/TextArea.svelte.d.ts +4 -6
- package/dist/components/ThemeSelector.svelte +29 -9
- package/dist/components/ThemeSelector.svelte.d.ts +6 -0
- package/dist/components/ThemeToggle.svelte +28 -3
- package/dist/components/ThemeToggle.svelte.d.ts +9 -0
- package/dist/components/agent/AgentLog.svelte +217 -0
- package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
- package/dist/components/agent/AgentLogRow.svelte +281 -0
- package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
- package/dist/components/agent/AgentTaskList.svelte +607 -0
- package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
- package/dist/components/agent/AgentThinking.svelte +280 -0
- package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
- package/dist/components/agent/ComposerGlow.svelte +265 -0
- package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
- package/dist/components/agent/ContextMeter.svelte +277 -0
- package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
- package/dist/components/agent/Questionnaire.svelte +767 -0
- package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
- package/dist/components/agent/ShimmerText.svelte +100 -0
- package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
- package/dist/components/agent/StreamedText.svelte +135 -0
- package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
- package/dist/components/agent/agentLog.d.ts +22 -0
- package/dist/components/agent/agentLog.js +26 -0
- package/dist/components/agent/agentTaskList.d.ts +90 -0
- package/dist/components/agent/agentTaskList.js +93 -0
- package/dist/components/agent/agentThinking.d.ts +46 -0
- package/dist/components/agent/agentThinking.js +78 -0
- package/dist/components/agent/contextMeter.d.ts +97 -0
- package/dist/components/agent/contextMeter.js +156 -0
- package/dist/components/agent/questionnaire.d.ts +104 -0
- package/dist/components/agent/questionnaire.js +225 -0
- package/dist/components/agent/revealCore.d.ts +74 -0
- package/dist/components/agent/revealCore.js +160 -0
- package/dist/components/agent/revealTicker.d.ts +34 -0
- package/dist/components/agent/revealTicker.js +73 -0
- package/dist/components/agent/streamedText.d.ts +28 -0
- package/dist/components/agent/streamedText.js +59 -0
- package/dist/components/basic/Breadcrumb.svelte +226 -11
- package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
- package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
- package/dist/components/basic/breadcrumbCollapse.js +12 -0
- package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
- package/dist/components/messaging/MessageInput.svelte +34 -4
- package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
- package/dist/components/messaging/MessageItem.svelte +2 -2
- package/dist/components/notificationCenter.d.ts +65 -0
- package/dist/components/notificationCenter.js +110 -0
- package/dist/components/progress/Steps.svelte +269 -5
- package/dist/components/progress/Steps.svelte.d.ts +16 -1
- package/dist/components/progress/stepsSummary.d.ts +14 -0
- package/dist/components/progress/stepsSummary.js +32 -0
- package/dist/index.d.ts +48 -1
- package/dist/index.js +39 -1
- package/dist/selection/clickSelection.d.ts +89 -0
- package/dist/selection/clickSelection.js +152 -0
- package/dist/selection/createClickSelection.d.ts +79 -0
- package/dist/selection/createClickSelection.js +134 -0
- package/dist/styles/tokens.css +80 -1
- package/dist/utils/pinInput.d.ts +33 -0
- package/dist/utils/pinInput.js +63 -0
- package/dist/utils/textareaRows.d.ts +23 -0
- package/dist/utils/textareaRows.js +13 -0
- package/dist/utils/themeWipe.d.ts +36 -17
- package/dist/utils/themeWipe.js +145 -35
- package/package.json +3 -2
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PinInput — one box per character for PINs and one-time codes.
|
|
4
|
+
*
|
|
5
|
+
* OTP autofill (BoardUI wave 1, jatui-owg2z): every cell carries
|
|
6
|
+
* `autocomplete="one-time-code"` and accepts multi-character input (no
|
|
7
|
+
* maxlength), so iOS/Android SMS autofill and Chrome WebOTP — which drop
|
|
8
|
+
* the WHOLE code into whichever cell is focused — fill every cell from the
|
|
9
|
+
* first. A paste distributes from the caret cell. Logic: utils/pinInput.ts.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
applyCellInput,
|
|
13
|
+
cellsFromValue,
|
|
14
|
+
distribute,
|
|
15
|
+
isPinComplete
|
|
16
|
+
} from '../utils/pinInput';
|
|
17
|
+
|
|
2
18
|
interface Props {
|
|
3
19
|
length?: number;
|
|
4
20
|
value?: string;
|
|
@@ -7,6 +23,10 @@
|
|
|
7
23
|
error?: string;
|
|
8
24
|
disabled?: boolean;
|
|
9
25
|
label?: string;
|
|
26
|
+
/** `name` on the first cell, so a plain form post carries the code. */
|
|
27
|
+
name?: string;
|
|
28
|
+
/** Override the autocomplete token. Default `one-time-code`; use `off` for a static PIN. */
|
|
29
|
+
autocomplete?: HTMLInputElement['autocomplete'];
|
|
10
30
|
oncomplete?: (value: string) => void;
|
|
11
31
|
}
|
|
12
32
|
|
|
@@ -18,14 +38,27 @@
|
|
|
18
38
|
error = '',
|
|
19
39
|
disabled = false,
|
|
20
40
|
label = '',
|
|
41
|
+
name,
|
|
42
|
+
autocomplete = 'one-time-code',
|
|
21
43
|
oncomplete
|
|
22
44
|
}: Props = $props();
|
|
23
45
|
|
|
24
|
-
|
|
46
|
+
// Cells mirror `value`; an external write (reset, prefill) re-seeds them.
|
|
47
|
+
let cells: string[] = $state(cellsFromValue(value, length, numeric));
|
|
25
48
|
let inputEls: HTMLInputElement[] = $state([]);
|
|
26
49
|
let poppingCells: boolean[] = $state(Array(length).fill(false));
|
|
27
50
|
let shaking = $state(false);
|
|
28
51
|
let hadError = false;
|
|
52
|
+
let lastCompleted = '';
|
|
53
|
+
|
|
54
|
+
$effect(() => {
|
|
55
|
+
const v = value ?? '';
|
|
56
|
+
const n = length;
|
|
57
|
+
if (v !== cells.join('') || cells.length !== n) {
|
|
58
|
+
cells = cellsFromValue(v, n, numeric);
|
|
59
|
+
poppingCells = Array(n).fill(false);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
29
62
|
|
|
30
63
|
$effect(() => {
|
|
31
64
|
const hasError = !!error;
|
|
@@ -48,50 +81,73 @@
|
|
|
48
81
|
setTimeout(() => { poppingCells[i] = false; }, 350);
|
|
49
82
|
}
|
|
50
83
|
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
inputEls[nextIdx]?.focus();
|
|
68
|
-
} else {
|
|
69
|
-
cells[i] = char.slice(-1);
|
|
70
|
-
if (cells[i]) {
|
|
71
|
-
popCell(i);
|
|
72
|
-
inputEls[i + 1]?.focus();
|
|
84
|
+
function commit(next: string[], focus: number) {
|
|
85
|
+
next.forEach((c, i) => {
|
|
86
|
+
if (c && c !== cells[i]) popCell(i);
|
|
87
|
+
});
|
|
88
|
+
cells = next;
|
|
89
|
+
// The DOM value may hold extra characters (no maxlength, so OS autofill
|
|
90
|
+
// can deliver a whole code) — write each cell back to its single char.
|
|
91
|
+
inputEls.forEach((el, i) => {
|
|
92
|
+
if (el && el.value !== next[i]) el.value = next[i];
|
|
93
|
+
});
|
|
94
|
+
value = next.join('');
|
|
95
|
+
inputEls[focus]?.focus();
|
|
96
|
+
if (isPinComplete(next, length)) {
|
|
97
|
+
if (value !== lastCompleted) {
|
|
98
|
+
lastCompleted = value;
|
|
99
|
+
oncomplete?.(value);
|
|
73
100
|
}
|
|
101
|
+
} else {
|
|
102
|
+
lastCompleted = '';
|
|
74
103
|
}
|
|
104
|
+
}
|
|
75
105
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
106
|
+
function onInput(i: number, e: Event) {
|
|
107
|
+
const el = e.target as HTMLInputElement;
|
|
108
|
+
const r = applyCellInput(cells, i, el.value, { length, numeric });
|
|
109
|
+
commit(r.cells, r.focus);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function onPaste(i: number, e: ClipboardEvent) {
|
|
113
|
+
const text = e.clipboardData?.getData('text') ?? '';
|
|
114
|
+
if (!text) return;
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
const r = distribute(cells, i, text, { length, numeric });
|
|
117
|
+
commit(r.cells, r.focus);
|
|
80
118
|
}
|
|
81
119
|
|
|
82
120
|
function onKeydown(i: number, e: KeyboardEvent) {
|
|
83
121
|
if (e.key === 'Backspace') {
|
|
122
|
+
e.preventDefault();
|
|
84
123
|
if (!cells[i]) {
|
|
85
|
-
|
|
124
|
+
if (i > 0) {
|
|
125
|
+
const next = [...cells];
|
|
126
|
+
next[i - 1] = '';
|
|
127
|
+
commit(next, i - 1);
|
|
128
|
+
}
|
|
86
129
|
} else {
|
|
87
|
-
|
|
88
|
-
|
|
130
|
+
const next = [...cells];
|
|
131
|
+
next[i] = '';
|
|
132
|
+
commit(next, i);
|
|
89
133
|
}
|
|
134
|
+
} else if (e.key === 'Delete') {
|
|
90
135
|
e.preventDefault();
|
|
136
|
+
const next = [...cells];
|
|
137
|
+
next[i] = '';
|
|
138
|
+
commit(next, i);
|
|
91
139
|
} else if (e.key === 'ArrowLeft') {
|
|
140
|
+
e.preventDefault();
|
|
92
141
|
inputEls[i - 1]?.focus();
|
|
93
142
|
} else if (e.key === 'ArrowRight') {
|
|
143
|
+
e.preventDefault();
|
|
94
144
|
inputEls[i + 1]?.focus();
|
|
145
|
+
} else if (e.key === 'Home') {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
inputEls[0]?.focus();
|
|
148
|
+
} else if (e.key === 'End') {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
inputEls[length - 1]?.focus();
|
|
95
151
|
}
|
|
96
152
|
}
|
|
97
153
|
|
|
@@ -111,6 +167,7 @@
|
|
|
111
167
|
|
|
112
168
|
<div
|
|
113
169
|
class="flex gap-2"
|
|
170
|
+
dir="ltr"
|
|
114
171
|
role="group"
|
|
115
172
|
aria-label={label || 'PIN input'}
|
|
116
173
|
aria-describedby={error ? `${groupId}-error` : undefined}
|
|
@@ -120,7 +177,12 @@
|
|
|
120
177
|
bind:this={inputEls[i]}
|
|
121
178
|
type={masked ? 'password' : 'text'}
|
|
122
179
|
inputmode={numeric ? 'numeric' : 'text'}
|
|
123
|
-
|
|
180
|
+
pattern={numeric ? '[0-9]*' : undefined}
|
|
181
|
+
autocomplete={autocomplete as any}
|
|
182
|
+
name={i === 0 ? name : undefined}
|
|
183
|
+
dir="ltr"
|
|
184
|
+
spellcheck="false"
|
|
185
|
+
autocapitalize="off"
|
|
124
186
|
value={cells[i]}
|
|
125
187
|
{disabled}
|
|
126
188
|
aria-label={`Digit ${i + 1} of ${length}`}
|
|
@@ -129,6 +191,7 @@
|
|
|
129
191
|
class:pin-cell--filled={!!cells[i]}
|
|
130
192
|
class:pin-cell--pop={poppingCells[i]}
|
|
131
193
|
oninput={(e) => onInput(i, e)}
|
|
194
|
+
onpaste={(e) => onPaste(i, e)}
|
|
132
195
|
onkeydown={(e) => onKeydown(i, e)}
|
|
133
196
|
onfocus={(e) => onFocus(e.target as HTMLInputElement)}
|
|
134
197
|
/>
|
|
@@ -6,6 +6,10 @@ interface Props {
|
|
|
6
6
|
error?: string;
|
|
7
7
|
disabled?: boolean;
|
|
8
8
|
label?: string;
|
|
9
|
+
/** `name` on the first cell, so a plain form post carries the code. */
|
|
10
|
+
name?: string;
|
|
11
|
+
/** Override the autocomplete token. Default `one-time-code`; use `off` for a static PIN. */
|
|
12
|
+
autocomplete?: HTMLInputElement['autocomplete'];
|
|
9
13
|
oncomplete?: (value: string) => void;
|
|
10
14
|
}
|
|
11
15
|
declare const PinInput: import("svelte").Component<Props, {}, "value">;
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
searchDebounceMs = 250,
|
|
41
41
|
onCreate,
|
|
42
42
|
createLabel = (q: string) => `Create "${q}"`,
|
|
43
|
+
option: optionSnippet,
|
|
44
|
+
searchLabel,
|
|
43
45
|
}: {
|
|
44
46
|
value: string;
|
|
45
47
|
groups: SearchDropdownGroup[];
|
|
@@ -92,8 +94,22 @@
|
|
|
92
94
|
onCreate?: (query: string) => void;
|
|
93
95
|
/** Label for the create row; receives the trimmed query. */
|
|
94
96
|
createLabel?: (query: string) => string;
|
|
97
|
+
/**
|
|
98
|
+
* Custom body for each option row (replaces the default avatar, label and
|
|
99
|
+
* check). The row itself (click, highlight, keyboard) stays the
|
|
100
|
+
* component's; the snippet only draws what is inside it — e.g. a colour
|
|
101
|
+
* dot, the label with the matched letters marked, and a status note.
|
|
102
|
+
*/
|
|
103
|
+
option?: Snippet<[SearchDropdownOption, { highlighted: boolean; selected: boolean }]>;
|
|
104
|
+
/** Accessible name for the search box and its list (defaults to the placeholder). */
|
|
105
|
+
searchLabel?: string;
|
|
95
106
|
} = $props();
|
|
96
107
|
|
|
108
|
+
// ARIA combobox wiring: the search box owns the listbox and points at the
|
|
109
|
+
// highlighted row, so arrows + Enter are announced without moving focus.
|
|
110
|
+
const listId = `sd-${Math.random().toString(36).slice(2, 9)}`;
|
|
111
|
+
const optionId = (i: number) => `${listId}-${i}`;
|
|
112
|
+
|
|
97
113
|
let open = $state(false);
|
|
98
114
|
let searchQuery = $state('');
|
|
99
115
|
let highlightedIndex = $state(-1);
|
|
@@ -166,10 +182,18 @@
|
|
|
166
182
|
highlightedIndex = idx >= 0 ? idx : 0;
|
|
167
183
|
});
|
|
168
184
|
|
|
185
|
+
// Embedded panels stay open after a pick (several picks in a row): hand
|
|
186
|
+
// focus back to the search box so the next one is typed straight away,
|
|
187
|
+
// even when the pick was a click that moved focus onto the row.
|
|
188
|
+
function refocusEmbedded() {
|
|
189
|
+
if (embedded) tick().then(() => searchInput?.focus());
|
|
190
|
+
}
|
|
191
|
+
|
|
169
192
|
function select(optionValue: string) {
|
|
170
193
|
open = false;
|
|
171
194
|
searchQuery = '';
|
|
172
195
|
onChange(optionValue);
|
|
196
|
+
refocusEmbedded();
|
|
173
197
|
}
|
|
174
198
|
|
|
175
199
|
function doCreate() {
|
|
@@ -178,6 +202,7 @@
|
|
|
178
202
|
open = false;
|
|
179
203
|
searchQuery = '';
|
|
180
204
|
onCreate(q);
|
|
205
|
+
refocusEmbedded();
|
|
181
206
|
}
|
|
182
207
|
|
|
183
208
|
function scrollHighlightedIntoView() {
|
|
@@ -202,6 +227,9 @@
|
|
|
202
227
|
|
|
203
228
|
function handleSearchKeydown(e: KeyboardEvent) {
|
|
204
229
|
if (e.key === 'Escape') {
|
|
230
|
+
// An embedded panel with an empty search has nothing of its own to
|
|
231
|
+
// close: let Escape reach the host (e.g. close the dialog it sits in).
|
|
232
|
+
if (embedded && !searchQuery) return;
|
|
205
233
|
e.stopPropagation();
|
|
206
234
|
open = false;
|
|
207
235
|
searchQuery = '';
|
|
@@ -319,6 +347,12 @@
|
|
|
319
347
|
{placeholder}
|
|
320
348
|
class="sd-search-input"
|
|
321
349
|
autocomplete="off"
|
|
350
|
+
role="combobox"
|
|
351
|
+
aria-label={searchLabel ?? placeholder}
|
|
352
|
+
aria-expanded={open || embedded}
|
|
353
|
+
aria-controls={listId}
|
|
354
|
+
aria-autocomplete="list"
|
|
355
|
+
aria-activedescendant={(open || embedded) && highlightedIndex >= 0 && highlightedIndex <= maxIndex ? optionId(highlightedIndex) : undefined}
|
|
322
356
|
/>
|
|
323
357
|
{#if searchQuery}
|
|
324
358
|
<button type="button" aria-label="Clear search" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear">
|
|
@@ -329,17 +363,21 @@
|
|
|
329
363
|
</div>
|
|
330
364
|
|
|
331
365
|
<!-- Options list -->
|
|
332
|
-
<ul class="sd-list" bind:this={optionListRef}>
|
|
366
|
+
<ul class="sd-list" bind:this={optionListRef} id={listId} role="listbox" aria-label={searchLabel ?? placeholder}>
|
|
333
367
|
{#if filteredGroups.length > 0}
|
|
334
368
|
{#each filteredGroups as group, _gi}
|
|
335
|
-
<li class="sd-group-label">
|
|
369
|
+
<li class="sd-group-label" role="presentation">
|
|
336
370
|
<span>{group.label}</span>
|
|
337
371
|
</li>
|
|
338
372
|
{#each group.options as option}
|
|
339
373
|
{@const flatIdx = flatOptions.indexOf(option)}
|
|
340
|
-
<li>
|
|
374
|
+
<li role="presentation">
|
|
341
375
|
<button
|
|
342
376
|
type="button"
|
|
377
|
+
id={optionId(flatIdx)}
|
|
378
|
+
role="option"
|
|
379
|
+
tabindex="-1"
|
|
380
|
+
aria-selected={flatIdx === highlightedIndex}
|
|
343
381
|
onclick={() => select(option.value)}
|
|
344
382
|
class="sd-option"
|
|
345
383
|
class:sd-option-selected={value === option.value}
|
|
@@ -351,7 +389,9 @@
|
|
|
351
389
|
: `border-left-color: ${colorFn(option.value)}; color: ${colorFn(option.value)};`
|
|
352
390
|
: ''}
|
|
353
391
|
>
|
|
354
|
-
{#if
|
|
392
|
+
{#if optionSnippet}
|
|
393
|
+
{@render optionSnippet(option, { highlighted: flatIdx === highlightedIndex, selected: value === option.value })}
|
|
394
|
+
{:else if option.image}
|
|
355
395
|
<img src={option.image} alt="" class="sd-avatar" />
|
|
356
396
|
{:else if option.icon}
|
|
357
397
|
<span class="sd-option-icon">{option.icon}</span>
|
|
@@ -360,12 +400,14 @@
|
|
|
360
400
|
{option.label.charAt(0).toUpperCase()}
|
|
361
401
|
</span>
|
|
362
402
|
{/if}
|
|
363
|
-
{#if
|
|
403
|
+
{#if optionSnippet}
|
|
404
|
+
<!-- the snippet drew the whole row body -->
|
|
405
|
+
{:else if variant === 'chip' && colorFn && colorFn(option.value)}
|
|
364
406
|
<span class="sd-option-chip">{option.label}</span>
|
|
365
407
|
{:else}
|
|
366
408
|
<span class="truncate">{option.label}</span>
|
|
367
409
|
{/if}
|
|
368
|
-
{#if value === option.value}
|
|
410
|
+
{#if !optionSnippet && value === option.value}
|
|
369
411
|
<svg class="sd-check" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
|
|
370
412
|
{/if}
|
|
371
413
|
</button>
|
|
@@ -373,13 +415,17 @@
|
|
|
373
415
|
{/each}
|
|
374
416
|
{/each}
|
|
375
417
|
{:else if !canCreate}
|
|
376
|
-
<li class="sd-empty">No matches for "{searchQuery}"</li>
|
|
418
|
+
<li class="sd-empty" role="presentation">No matches for "{searchQuery}"</li>
|
|
377
419
|
{/if}
|
|
378
420
|
{#if canCreate}
|
|
379
421
|
{@const createIdx = flatOptions.length}
|
|
380
|
-
<li>
|
|
422
|
+
<li role="presentation">
|
|
381
423
|
<button
|
|
382
424
|
type="button"
|
|
425
|
+
id={optionId(createIdx)}
|
|
426
|
+
role="option"
|
|
427
|
+
tabindex="-1"
|
|
428
|
+
aria-selected={highlightedIndex === createIdx}
|
|
383
429
|
onclick={doCreate}
|
|
384
430
|
class="sd-option sd-option-create"
|
|
385
431
|
class:sd-option-highlighted={highlightedIndex === createIdx}
|
|
@@ -61,6 +61,18 @@ type $$ComponentProps = {
|
|
|
61
61
|
onCreate?: (query: string) => void;
|
|
62
62
|
/** Label for the create row; receives the trimmed query. */
|
|
63
63
|
createLabel?: (query: string) => string;
|
|
64
|
+
/**
|
|
65
|
+
* Custom body for each option row (replaces the default avatar, label and
|
|
66
|
+
* check). The row itself (click, highlight, keyboard) stays the
|
|
67
|
+
* component's; the snippet only draws what is inside it — e.g. a colour
|
|
68
|
+
* dot, the label with the matched letters marked, and a status note.
|
|
69
|
+
*/
|
|
70
|
+
option?: Snippet<[SearchDropdownOption, {
|
|
71
|
+
highlighted: boolean;
|
|
72
|
+
selected: boolean;
|
|
73
|
+
}]>;
|
|
74
|
+
/** Accessible name for the search box and its list (defaults to the placeholder). */
|
|
75
|
+
searchLabel?: string;
|
|
64
76
|
};
|
|
65
77
|
declare const SearchDropdown: import("svelte").Component<$$ComponentProps, {
|
|
66
78
|
openDropdown: () => void;
|
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Multi-line text input with validation, auto-resize, and character count.
|
|
6
6
|
* Converted from Svelte 4 to Svelte 5 runes.
|
|
7
|
+
*
|
|
8
|
+
* Auto-grow (`autoResize`, or implied by `maxRows`) grows a whole line at a
|
|
9
|
+
* time from `minRows` (default `rows`) up to `maxRows`, then scrolls. Where
|
|
10
|
+
* the browser supports CSS `field-sizing: content` the growth is pure CSS
|
|
11
|
+
* (no per-keystroke measuring); elsewhere it falls back to the row-quantized
|
|
12
|
+
* JS path in utils/textareaRows.ts.
|
|
7
13
|
*/
|
|
14
|
+
import { textareaRowsHeight } from '../utils/textareaRows';
|
|
8
15
|
|
|
9
16
|
interface Props {
|
|
10
17
|
label?: string;
|
|
@@ -17,6 +24,10 @@
|
|
|
17
24
|
disabled?: boolean;
|
|
18
25
|
readonly?: boolean;
|
|
19
26
|
autoResize?: boolean;
|
|
27
|
+
/** Auto-grow floor in rows. Default `rows`. */
|
|
28
|
+
minRows?: number;
|
|
29
|
+
/** Auto-grow ceiling in rows; past it the field scrolls. Setting it turns auto-grow on. */
|
|
30
|
+
maxRows?: number;
|
|
20
31
|
error?: string;
|
|
21
32
|
helpText?: string;
|
|
22
33
|
showCount?: boolean;
|
|
@@ -39,6 +50,8 @@
|
|
|
39
50
|
disabled = false,
|
|
40
51
|
readonly = false,
|
|
41
52
|
autoResize = false,
|
|
53
|
+
minRows,
|
|
54
|
+
maxRows,
|
|
42
55
|
error = '',
|
|
43
56
|
helpText = '',
|
|
44
57
|
showCount = false,
|
|
@@ -73,24 +86,73 @@
|
|
|
73
86
|
const characterCount = $derived(value?.length || 0);
|
|
74
87
|
const isOverLimit = $derived(maxlength ? characterCount > maxlength : false);
|
|
75
88
|
|
|
89
|
+
const grows = $derived(autoResize || maxRows != null);
|
|
90
|
+
const floorRows = $derived(Math.max(1, minRows ?? rows));
|
|
91
|
+
// Decided after mount: SSR has no CSS.supports, and hydration does not
|
|
92
|
+
// repair a style attribute that differs from the server render.
|
|
93
|
+
let fieldSizing = $state(false);
|
|
94
|
+
$effect(() => {
|
|
95
|
+
fieldSizing = typeof CSS !== 'undefined' && CSS.supports?.('field-sizing', 'content') === true;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Vertical padding + border, measured once mounted (border-box sizing).
|
|
99
|
+
let chromePx = $state(18);
|
|
100
|
+
let paddingPx = $state(16);
|
|
101
|
+
let lineHeightPx = $state(24);
|
|
102
|
+
|
|
103
|
+
function measureChrome(el: HTMLTextAreaElement) {
|
|
104
|
+
const cs = getComputedStyle(el);
|
|
105
|
+
const n = (v: string) => parseFloat(v) || 0;
|
|
106
|
+
paddingPx = n(cs.paddingTop) + n(cs.paddingBottom);
|
|
107
|
+
chromePx = paddingPx + n(cs.borderTopWidth) + n(cs.borderBottomWidth);
|
|
108
|
+
const lh = n(cs.lineHeight);
|
|
109
|
+
lineHeightPx = lh > 0 ? lh : n(cs.fontSize) * 1.5 || 24;
|
|
110
|
+
}
|
|
111
|
+
|
|
76
112
|
function handleInput(event: Event) {
|
|
77
113
|
const target = event.target as HTMLTextAreaElement;
|
|
78
114
|
value = target.value;
|
|
79
|
-
if (
|
|
115
|
+
if (grows && !fieldSizing) autoResizeTextarea(target);
|
|
80
116
|
oninput?.(value);
|
|
81
117
|
}
|
|
82
118
|
|
|
83
119
|
function autoResizeTextarea(textarea: HTMLTextAreaElement) {
|
|
84
120
|
textarea.style.height = 'auto';
|
|
85
|
-
|
|
121
|
+
const r = textareaRowsHeight({
|
|
122
|
+
scrollHeight: textarea.scrollHeight,
|
|
123
|
+
lineHeight: lineHeightPx,
|
|
124
|
+
chrome: chromePx,
|
|
125
|
+
padding: paddingPx,
|
|
126
|
+
minRows: floorRows,
|
|
127
|
+
maxRows
|
|
128
|
+
});
|
|
129
|
+
textarea.style.height = r.height + 'px';
|
|
130
|
+
textarea.style.overflowY = r.overflow;
|
|
86
131
|
}
|
|
87
132
|
|
|
88
133
|
$effect(() => {
|
|
89
|
-
if (
|
|
134
|
+
if (grows && textareaElement) measureChrome(textareaElement);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
$effect(() => {
|
|
138
|
+
if (grows && !fieldSizing && textareaElement && value !== undefined) {
|
|
139
|
+
void floorRows;
|
|
140
|
+
void maxRows;
|
|
90
141
|
autoResizeTextarea(textareaElement);
|
|
91
142
|
}
|
|
92
143
|
});
|
|
93
144
|
|
|
145
|
+
const growStyle = $derived.by(() => {
|
|
146
|
+
if (!grows) return '';
|
|
147
|
+
const min = `min-height: ${Math.round(floorRows * lineHeightPx + chromePx)}px;`;
|
|
148
|
+
if (!fieldSizing) return min;
|
|
149
|
+
const max =
|
|
150
|
+
maxRows != null
|
|
151
|
+
? ` max-height: ${Math.round(Math.max(floorRows, maxRows) * lineHeightPx + chromePx)}px; overflow-y: auto;`
|
|
152
|
+
: '';
|
|
153
|
+
return `field-sizing: content; ${min}${max}`;
|
|
154
|
+
});
|
|
155
|
+
|
|
94
156
|
// Error-state shake (transitions.dev). Replays `t-input-shake` when `error`
|
|
95
157
|
// becomes set. See TextInput for the full rationale.
|
|
96
158
|
let hadError = false;
|
|
@@ -127,7 +189,7 @@
|
|
|
127
189
|
{id}
|
|
128
190
|
{name}
|
|
129
191
|
{placeholder}
|
|
130
|
-
{rows}
|
|
192
|
+
rows={grows ? floorRows : rows}
|
|
131
193
|
{maxlength}
|
|
132
194
|
{minlength}
|
|
133
195
|
{required}
|
|
@@ -138,7 +200,7 @@
|
|
|
138
200
|
class={textareaClass}
|
|
139
201
|
aria-describedby={error ? `${id}-error` : helpText ? `${id}-help` : undefined}
|
|
140
202
|
aria-invalid={error ? 'true' : 'false'}
|
|
141
|
-
style={
|
|
203
|
+
style={growStyle}
|
|
142
204
|
onanimationend={() => textareaElement?.classList.remove('t-input-shake')}
|
|
143
205
|
></textarea>
|
|
144
206
|
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TextArea Component
|
|
3
|
-
*
|
|
4
|
-
* Multi-line text input with validation, auto-resize, and character count.
|
|
5
|
-
* Converted from Svelte 4 to Svelte 5 runes.
|
|
6
|
-
*/
|
|
7
1
|
interface Props {
|
|
8
2
|
label?: string;
|
|
9
3
|
value?: string;
|
|
@@ -15,6 +9,10 @@ interface Props {
|
|
|
15
9
|
disabled?: boolean;
|
|
16
10
|
readonly?: boolean;
|
|
17
11
|
autoResize?: boolean;
|
|
12
|
+
/** Auto-grow floor in rows. Default `rows`. */
|
|
13
|
+
minRows?: number;
|
|
14
|
+
/** Auto-grow ceiling in rows; past it the field scrolls. Setting it turns auto-grow on. */
|
|
15
|
+
maxRows?: number;
|
|
18
16
|
error?: string;
|
|
19
17
|
helpText?: string;
|
|
20
18
|
showCount?: boolean;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* By default includes all 33 standard DaisyUI themes. Pass custom `themes` prop to override.
|
|
7
7
|
*/
|
|
8
8
|
import { onMount } from 'svelte';
|
|
9
|
+
import { themeWipe } from '../utils/themeWipe';
|
|
9
10
|
|
|
10
11
|
interface ThemeOption {
|
|
11
12
|
name: string;
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
onThemeChange,
|
|
56
57
|
compact = false,
|
|
57
58
|
inline = false,
|
|
59
|
+
reveal = false,
|
|
58
60
|
}: {
|
|
59
61
|
dropUp?: boolean;
|
|
60
62
|
themes?: ThemeOption[];
|
|
@@ -65,6 +67,12 @@
|
|
|
65
67
|
compact?: boolean;
|
|
66
68
|
/** Render a collapsible `<details>` theme list instead of a dropdown — for embedding inside another menu. */
|
|
67
69
|
inline?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Reveal the new theme with a circular View Transition wipe from the
|
|
72
|
+
* clicked item (`themeWipe`). `true`/'hard' = crisp edge, 'soft' =
|
|
73
|
+
* feathered. Level-scaled; instant under reduced motion. Default off.
|
|
74
|
+
*/
|
|
75
|
+
reveal?: boolean | 'hard' | 'soft';
|
|
68
76
|
} = $props();
|
|
69
77
|
|
|
70
78
|
// svelte-ignore state_referenced_locally
|
|
@@ -87,9 +95,26 @@
|
|
|
87
95
|
}
|
|
88
96
|
});
|
|
89
97
|
|
|
90
|
-
function
|
|
98
|
+
function applyTheme(themeName: string) {
|
|
99
|
+
currentTheme = themeName;
|
|
100
|
+
document.documentElement.setAttribute('data-theme', themeName);
|
|
101
|
+
localStorage.setItem(storageKey, themeName);
|
|
102
|
+
onThemeChange?.(themeName);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handleThemeChange(themeName: string, event?: MouseEvent) {
|
|
91
106
|
if (themeName === currentTheme) return;
|
|
92
107
|
|
|
108
|
+
if (reveal) {
|
|
109
|
+
// Close the menu first so the snapshot doesn't freeze it open. The
|
|
110
|
+
// origin (pointer, or the item's centre on keyboard) is read now.
|
|
111
|
+
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
|
112
|
+
void themeWipe(event ?? null, () => applyTheme(themeName), {
|
|
113
|
+
edge: reveal === 'soft' ? 'soft' : 'hard'
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
// Close dropdown first so the trigger is visible during animation
|
|
94
119
|
if (document.activeElement instanceof HTMLElement) {
|
|
95
120
|
document.activeElement.blur();
|
|
@@ -97,12 +122,7 @@
|
|
|
97
122
|
|
|
98
123
|
isAnimating = true;
|
|
99
124
|
|
|
100
|
-
setTimeout(() =>
|
|
101
|
-
currentTheme = themeName;
|
|
102
|
-
document.documentElement.setAttribute('data-theme', themeName);
|
|
103
|
-
localStorage.setItem(storageKey, themeName);
|
|
104
|
-
onThemeChange?.(themeName);
|
|
105
|
-
}, 150);
|
|
125
|
+
setTimeout(() => applyTheme(themeName), 150);
|
|
106
126
|
|
|
107
127
|
setTimeout(() => {
|
|
108
128
|
isAnimating = false;
|
|
@@ -133,7 +153,7 @@
|
|
|
133
153
|
{#each themes as theme}
|
|
134
154
|
<button
|
|
135
155
|
class="flex items-center gap-2 w-full px-2 py-1 rounded transition-colors hover:bg-base-300 {currentTheme === theme.name ? 'bg-primary/20' : ''}"
|
|
136
|
-
onclick={() => handleThemeChange(theme.name)}
|
|
156
|
+
onclick={(e) => handleThemeChange(theme.name, e)}
|
|
137
157
|
>
|
|
138
158
|
<div
|
|
139
159
|
data-theme={theme.name}
|
|
@@ -193,7 +213,7 @@
|
|
|
193
213
|
<li>
|
|
194
214
|
<button
|
|
195
215
|
class="gap-3 px-2"
|
|
196
|
-
onclick={() => handleThemeChange(theme.name)}
|
|
216
|
+
onclick={(e) => handleThemeChange(theme.name, e)}
|
|
197
217
|
class:active={currentTheme === theme.name}
|
|
198
218
|
>
|
|
199
219
|
<div
|
|
@@ -12,6 +12,12 @@ type $$ComponentProps = {
|
|
|
12
12
|
compact?: boolean;
|
|
13
13
|
/** Render a collapsible `<details>` theme list instead of a dropdown — for embedding inside another menu. */
|
|
14
14
|
inline?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Reveal the new theme with a circular View Transition wipe from the
|
|
17
|
+
* clicked item (`themeWipe`). `true`/'hard' = crisp edge, 'soft' =
|
|
18
|
+
* feathered. Level-scaled; instant under reduced motion. Default off.
|
|
19
|
+
*/
|
|
20
|
+
reveal?: boolean | 'hard' | 'soft';
|
|
15
21
|
};
|
|
16
22
|
declare const ThemeSelector: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
23
|
type ThemeSelector = ReturnType<typeof ThemeSelector>;
|
|
@@ -19,12 +19,16 @@
|
|
|
19
19
|
* motion.css global floor and a component-local guard, since not every
|
|
20
20
|
* consumer loads motion.css). Colours are token-only — no literals.
|
|
21
21
|
*/
|
|
22
|
+
import { flushSync } from 'svelte';
|
|
23
|
+
import { themeWipe } from '../utils/themeWipe';
|
|
24
|
+
|
|
22
25
|
let {
|
|
23
26
|
theme = $bindable('light'),
|
|
24
27
|
lightTheme = 'light',
|
|
25
28
|
darkTheme = 'dark',
|
|
26
29
|
onchange,
|
|
27
|
-
size = 'md'
|
|
30
|
+
size = 'md',
|
|
31
|
+
reveal = false
|
|
28
32
|
}: {
|
|
29
33
|
/** Current theme name (bindable). Anything that is not `darkTheme` reads as light. */
|
|
30
34
|
theme?: string;
|
|
@@ -36,6 +40,15 @@
|
|
|
36
40
|
onchange?: (theme: string) => void;
|
|
37
41
|
/** Hit-target size — sm 40px, md 44px, lg 48px. Never below the 40px touch floor. */
|
|
38
42
|
size?: 'sm' | 'md' | 'lg';
|
|
43
|
+
/**
|
|
44
|
+
* Reveal the new theme with a circular View Transition wipe from the
|
|
45
|
+
* button (`themeWipe`): `true`/'hard' = crisp edge, 'soft' = feathered.
|
|
46
|
+
* The flip (bound `theme` + `onchange`) runs inside the transition, so
|
|
47
|
+
* the consumer's theme write must be synchronous in `onchange` or in an
|
|
48
|
+
* effect (it is flushed). Keyboard activation grows from the button's
|
|
49
|
+
* centre; reduced motion swaps instantly. Default off.
|
|
50
|
+
*/
|
|
51
|
+
reveal?: boolean | 'hard' | 'soft';
|
|
39
52
|
} = $props();
|
|
40
53
|
|
|
41
54
|
let isDark = $derived(theme === darkTheme);
|
|
@@ -50,11 +63,23 @@
|
|
|
50
63
|
|
|
51
64
|
const label = $derived(isDark ? 'Switch to light mode' : 'Switch to dark mode');
|
|
52
65
|
|
|
53
|
-
function
|
|
54
|
-
const next = isDark ? lightTheme : darkTheme;
|
|
66
|
+
function flip(next: string) {
|
|
55
67
|
theme = next;
|
|
56
68
|
onchange?.(next);
|
|
57
69
|
}
|
|
70
|
+
|
|
71
|
+
function toggle(event: MouseEvent) {
|
|
72
|
+
const next = isDark ? lightTheme : darkTheme;
|
|
73
|
+
if (!reveal) {
|
|
74
|
+
flip(next);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// The origin is read synchronously: pointer position, or this button's
|
|
78
|
+
// centre for a keyboard activation.
|
|
79
|
+
void themeWipe(event, () => flushSync(() => flip(next)), {
|
|
80
|
+
edge: reveal === 'soft' ? 'soft' : 'hard'
|
|
81
|
+
});
|
|
82
|
+
}
|
|
58
83
|
</script>
|
|
59
84
|
|
|
60
85
|
<button
|