@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.
Files changed (98) hide show
  1. package/CLAUDE.md +54 -5
  2. package/THIRD_PARTY_NOTICES.md +67 -0
  3. package/dist/actions/scrollEdge.d.ts +55 -0
  4. package/dist/actions/scrollEdge.js +144 -0
  5. package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +1 -1
  6. package/dist/artifact-blocks/blocks/diagram/schema.d.ts +1 -1
  7. package/dist/components/BottomSheet.svelte +7 -6
  8. package/dist/components/BottomSheet.svelte.d.ts +2 -0
  9. package/dist/components/BulkActionBar.svelte +32 -2
  10. package/dist/components/BulkActionBar.svelte.d.ts +5 -1
  11. package/dist/components/CloseButton.svelte +75 -0
  12. package/dist/components/CloseButton.svelte.d.ts +29 -0
  13. package/dist/components/Drawer.svelte +10 -11
  14. package/dist/components/Drawer.svelte.d.ts +2 -8
  15. package/dist/components/FloatingActionBar.svelte +102 -34
  16. package/dist/components/FloatingActionBar.svelte.d.ts +9 -0
  17. package/dist/components/IconButton.svelte +200 -0
  18. package/dist/components/IconButton.svelte.d.ts +46 -0
  19. package/dist/components/LinkButton.svelte +117 -0
  20. package/dist/components/LinkButton.svelte.d.ts +32 -0
  21. package/dist/components/MediaExplorer.svelte +249 -0
  22. package/dist/components/MediaExplorer.svelte.d.ts +37 -0
  23. package/dist/components/MediaLightbox.svelte +2 -7
  24. package/dist/components/Modal.svelte +2 -10
  25. package/dist/components/NotificationCenter.svelte +481 -0
  26. package/dist/components/NotificationCenter.svelte.d.ts +29 -0
  27. package/dist/components/PinInput.svelte +94 -31
  28. package/dist/components/PinInput.svelte.d.ts +4 -0
  29. package/dist/components/SearchDropdown.svelte +54 -8
  30. package/dist/components/SearchDropdown.svelte.d.ts +12 -0
  31. package/dist/components/TextArea.svelte +67 -5
  32. package/dist/components/TextArea.svelte.d.ts +4 -6
  33. package/dist/components/ThemeSelector.svelte +29 -9
  34. package/dist/components/ThemeSelector.svelte.d.ts +6 -0
  35. package/dist/components/ThemeToggle.svelte +28 -3
  36. package/dist/components/ThemeToggle.svelte.d.ts +9 -0
  37. package/dist/components/agent/AgentLog.svelte +217 -0
  38. package/dist/components/agent/AgentLog.svelte.d.ts +72 -0
  39. package/dist/components/agent/AgentLogRow.svelte +281 -0
  40. package/dist/components/agent/AgentLogRow.svelte.d.ts +21 -0
  41. package/dist/components/agent/AgentTaskList.svelte +607 -0
  42. package/dist/components/agent/AgentTaskList.svelte.d.ts +40 -0
  43. package/dist/components/agent/AgentThinking.svelte +280 -0
  44. package/dist/components/agent/AgentThinking.svelte.d.ts +22 -0
  45. package/dist/components/agent/ComposerGlow.svelte +265 -0
  46. package/dist/components/agent/ComposerGlow.svelte.d.ts +18 -0
  47. package/dist/components/agent/ContextMeter.svelte +277 -0
  48. package/dist/components/agent/ContextMeter.svelte.d.ts +21 -0
  49. package/dist/components/agent/Questionnaire.svelte +767 -0
  50. package/dist/components/agent/Questionnaire.svelte.d.ts +29 -0
  51. package/dist/components/agent/ShimmerText.svelte +100 -0
  52. package/dist/components/agent/ShimmerText.svelte.d.ts +19 -0
  53. package/dist/components/agent/StreamedText.svelte +135 -0
  54. package/dist/components/agent/StreamedText.svelte.d.ts +13 -0
  55. package/dist/components/agent/agentLog.d.ts +22 -0
  56. package/dist/components/agent/agentLog.js +26 -0
  57. package/dist/components/agent/agentTaskList.d.ts +90 -0
  58. package/dist/components/agent/agentTaskList.js +93 -0
  59. package/dist/components/agent/agentThinking.d.ts +46 -0
  60. package/dist/components/agent/agentThinking.js +78 -0
  61. package/dist/components/agent/contextMeter.d.ts +97 -0
  62. package/dist/components/agent/contextMeter.js +156 -0
  63. package/dist/components/agent/questionnaire.d.ts +104 -0
  64. package/dist/components/agent/questionnaire.js +225 -0
  65. package/dist/components/agent/revealCore.d.ts +74 -0
  66. package/dist/components/agent/revealCore.js +160 -0
  67. package/dist/components/agent/revealTicker.d.ts +34 -0
  68. package/dist/components/agent/revealTicker.js +73 -0
  69. package/dist/components/agent/streamedText.d.ts +28 -0
  70. package/dist/components/agent/streamedText.js +59 -0
  71. package/dist/components/basic/Breadcrumb.svelte +226 -11
  72. package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
  73. package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
  74. package/dist/components/basic/breadcrumbCollapse.js +12 -0
  75. package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
  76. package/dist/components/messaging/MessageInput.svelte +34 -4
  77. package/dist/components/messaging/MessageInput.svelte.d.ts +7 -0
  78. package/dist/components/messaging/MessageItem.svelte +2 -2
  79. package/dist/components/notificationCenter.d.ts +65 -0
  80. package/dist/components/notificationCenter.js +110 -0
  81. package/dist/components/progress/Steps.svelte +269 -5
  82. package/dist/components/progress/Steps.svelte.d.ts +16 -1
  83. package/dist/components/progress/stepsSummary.d.ts +14 -0
  84. package/dist/components/progress/stepsSummary.js +32 -0
  85. package/dist/index.d.ts +48 -1
  86. package/dist/index.js +39 -1
  87. package/dist/selection/clickSelection.d.ts +89 -0
  88. package/dist/selection/clickSelection.js +152 -0
  89. package/dist/selection/createClickSelection.d.ts +79 -0
  90. package/dist/selection/createClickSelection.js +134 -0
  91. package/dist/styles/tokens.css +80 -1
  92. package/dist/utils/pinInput.d.ts +33 -0
  93. package/dist/utils/pinInput.js +63 -0
  94. package/dist/utils/textareaRows.d.ts +23 -0
  95. package/dist/utils/textareaRows.js +13 -0
  96. package/dist/utils/themeWipe.d.ts +36 -17
  97. package/dist/utils/themeWipe.js +145 -35
  98. 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
- let cells: string[] = $state(Array(length).fill(''));
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 onInput(i: number, e: Event) {
52
- const el = e.target as HTMLInputElement;
53
- let char = el.value;
54
-
55
- if (numeric) char = char.replace(/\D/g, '');
56
-
57
- if (char.length > 1) {
58
- // paste — split across cells
59
- const chars = char.slice(0, length - i).split('');
60
- chars.forEach((c, offset) => {
61
- if (i + offset < length) {
62
- cells[i + offset] = c;
63
- if (c) popCell(i + offset);
64
- }
65
- });
66
- const nextIdx = Math.min(i + chars.length, length - 1);
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
- value = cells.join('');
77
- if (value.length === length && !value.includes('')) {
78
- oncomplete?.(value);
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
- inputEls[i - 1]?.focus();
124
+ if (i > 0) {
125
+ const next = [...cells];
126
+ next[i - 1] = '';
127
+ commit(next, i - 1);
128
+ }
86
129
  } else {
87
- cells[i] = '';
88
- value = cells.join('');
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
- maxlength={1}
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 option.image}
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 variant === 'chip' && colorFn && colorFn(option.value)}
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 (autoResize) autoResizeTextarea(target);
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
- textarea.style.height = textarea.scrollHeight + 'px';
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 (autoResize && textareaElement && value !== undefined) {
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={autoResize ? `min-height: calc(1.5em * ${rows} + 1rem);` : ''}
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 handleThemeChange(themeName: string) {
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 toggle() {
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