@joewinke/jatui 0.39.0 → 0.41.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 (75) hide show
  1. package/CLAUDE.md +52 -5
  2. package/THIRD_PARTY_NOTICES.md +31 -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/AnimatedNumber.svelte +42 -1
  8. package/dist/components/AnimatedNumber.svelte.d.ts +7 -0
  9. package/dist/components/BottomSheet.svelte +7 -6
  10. package/dist/components/BottomSheet.svelte.d.ts +2 -0
  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/IconButton.svelte +200 -0
  16. package/dist/components/IconButton.svelte.d.ts +46 -0
  17. package/dist/components/LinkButton.svelte +117 -0
  18. package/dist/components/LinkButton.svelte.d.ts +32 -0
  19. package/dist/components/MediaLightbox.svelte +2 -7
  20. package/dist/components/Modal.svelte +2 -10
  21. package/dist/components/PinInput.svelte +94 -31
  22. package/dist/components/PinInput.svelte.d.ts +4 -0
  23. package/dist/components/SegmentedControl.svelte +263 -0
  24. package/dist/components/SegmentedControl.svelte.d.ts +48 -0
  25. package/dist/components/TextArea.svelte +67 -5
  26. package/dist/components/TextArea.svelte.d.ts +4 -6
  27. package/dist/components/ThemeSelector.svelte +29 -9
  28. package/dist/components/ThemeSelector.svelte.d.ts +6 -0
  29. package/dist/components/ThemeToggle.svelte +28 -3
  30. package/dist/components/ThemeToggle.svelte.d.ts +9 -0
  31. package/dist/components/basic/Breadcrumb.svelte +226 -11
  32. package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
  33. package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
  34. package/dist/components/basic/breadcrumbCollapse.js +12 -0
  35. package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
  36. package/dist/components/settings/SettingsRow.svelte +177 -0
  37. package/dist/components/settings/SettingsRow.svelte.d.ts +32 -0
  38. package/dist/components/settings/SettingsSection.svelte +139 -0
  39. package/dist/components/settings/SettingsSection.svelte.d.ts +21 -0
  40. package/dist/components/stats/ActivityHeatmap.svelte +412 -0
  41. package/dist/components/stats/ActivityHeatmap.svelte.d.ts +72 -0
  42. package/dist/components/stats/BarList.svelte +262 -0
  43. package/dist/components/stats/BarList.svelte.d.ts +57 -0
  44. package/dist/components/stats/ChartCard.svelte +230 -0
  45. package/dist/components/stats/ChartCard.svelte.d.ts +63 -0
  46. package/dist/components/stats/StatCard.svelte +387 -0
  47. package/dist/components/stats/StatCard.svelte.d.ts +37 -0
  48. package/dist/components/stats/StatGroup.svelte +67 -0
  49. package/dist/components/stats/StatGroup.svelte.d.ts +14 -0
  50. package/dist/components/stats/heatmap.d.ts +76 -0
  51. package/dist/components/stats/heatmap.js +151 -0
  52. package/dist/components/tree/TreeView.svelte +680 -0
  53. package/dist/components/tree/TreeView.svelte.d.ts +123 -0
  54. package/dist/components/tree/flattenTree.d.ts +57 -0
  55. package/dist/components/tree/flattenTree.js +91 -0
  56. package/dist/components/tree/treeKeys.d.ts +46 -0
  57. package/dist/components/tree/treeKeys.js +80 -0
  58. package/dist/data/grid/cells.d.ts +40 -0
  59. package/dist/data/grid/cells.js +89 -0
  60. package/dist/data/grid/clipboard.d.ts +46 -0
  61. package/dist/data/grid/clipboard.js +131 -0
  62. package/dist/data/grid/edits.d.ts +148 -0
  63. package/dist/data/grid/edits.js +222 -0
  64. package/dist/data/grid/index.d.ts +12 -0
  65. package/dist/data/grid/index.js +12 -0
  66. package/dist/index.d.ts +36 -1
  67. package/dist/index.js +31 -1
  68. package/dist/styles/tokens.css +80 -1
  69. package/dist/utils/pinInput.d.ts +33 -0
  70. package/dist/utils/pinInput.js +63 -0
  71. package/dist/utils/textareaRows.d.ts +23 -0
  72. package/dist/utils/textareaRows.js +13 -0
  73. package/dist/utils/themeWipe.d.ts +36 -17
  74. package/dist/utils/themeWipe.js +145 -35
  75. package/package.json +5 -1
@@ -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">;
@@ -0,0 +1,263 @@
1
+ <!--
2
+ SegmentedControl: single-select toggle group that SETS A VALUE
3
+ (jatui-6kitr). Use Tabs to switch panels; use this for "Week / Month /
4
+ Year", view modes and filter scopes.
5
+
6
+ Radio semantics (role="radiogroup" + role="radio"), roving tabindex, arrow
7
+ keys move AND select (as native radios do), Home/End. A sliding thumb marks
8
+ the selection on motion tokens; it jumps under reduced motion. Variants
9
+ after BoardUI's segmented control (MIT, © 2026 Mertcan Dundar Esmergul):
10
+ `solid` (track + raised thumb) and `plain` (tinted thumb, no track).
11
+ -->
12
+ <script lang="ts" module>
13
+ export interface SegmentedOption<V extends string | number = string> {
14
+ value: V;
15
+ label: string;
16
+ disabled?: boolean;
17
+ /** Accessible name when `label` is visually hidden by an `option` snippet. */
18
+ ariaLabel?: string;
19
+ }
20
+ </script>
21
+
22
+ <script lang="ts" generics="V extends string | number = string">
23
+ import type { Snippet } from 'svelte';
24
+
25
+ interface Props {
26
+ /** Options, or plain strings (value = label). */
27
+ options: readonly (SegmentedOption<V> | V)[];
28
+ value?: V;
29
+ size?: 'xs' | 'sm' | 'md';
30
+ variant?: 'solid' | 'plain';
31
+ /** Stretch to the container, segments share the width. */
32
+ fullWidth?: boolean;
33
+ disabled?: boolean;
34
+ ariaLabel?: string;
35
+ /** Custom segment content (icon + label, counts…). */
36
+ option?: Snippet<[SegmentedOption<V>, { selected: boolean }]>;
37
+ onChange?: (value: V) => void;
38
+ class?: string;
39
+ }
40
+
41
+ let {
42
+ options,
43
+ value = $bindable(),
44
+ size = 'sm',
45
+ variant = 'solid',
46
+ fullWidth = false,
47
+ disabled = false,
48
+ ariaLabel,
49
+ option,
50
+ onChange,
51
+ class: className = ''
52
+ }: Props = $props();
53
+
54
+ const items = $derived(
55
+ options.map((o): SegmentedOption<V> =>
56
+ typeof o === 'object' ? o : { value: o, label: String(o) }
57
+ )
58
+ );
59
+ const selectedIndex = $derived(items.findIndex((o) => o.value === value));
60
+ const tabIndexOf = $derived(selectedIndex >= 0 ? selectedIndex : items.findIndex((o) => !o.disabled));
61
+
62
+ let trackEl: HTMLDivElement | undefined = $state();
63
+ const buttons: HTMLButtonElement[] = $state([]);
64
+ let thumb = $state<{ x: number; w: number } | null>(null);
65
+ let ready = $state(false);
66
+
67
+ function measure() {
68
+ const b = buttons[selectedIndex];
69
+ if (!b || !trackEl) {
70
+ thumb = null;
71
+ return;
72
+ }
73
+ thumb = { x: b.offsetLeft, w: b.offsetWidth };
74
+ }
75
+
76
+ $effect(() => {
77
+ void selectedIndex;
78
+ void items.length;
79
+ measure();
80
+ // Enable the slide only after the first placement (no fly-in on mount).
81
+ const id = requestAnimationFrame(() => (ready = true));
82
+ return () => cancelAnimationFrame(id);
83
+ });
84
+
85
+ $effect(() => {
86
+ if (!trackEl || typeof ResizeObserver === 'undefined') return;
87
+ const ro = new ResizeObserver(() => measure());
88
+ ro.observe(trackEl);
89
+ return () => ro.disconnect();
90
+ });
91
+
92
+ function choose(i: number) {
93
+ const o = items[i];
94
+ if (!o || o.disabled || disabled) return;
95
+ if (o.value !== value) {
96
+ value = o.value;
97
+ onChange?.(o.value);
98
+ }
99
+ }
100
+
101
+ function onKeydown(e: KeyboardEvent, i: number) {
102
+ const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
103
+ let next = -1;
104
+ const n = items.length;
105
+ if (step) {
106
+ for (let k = 1; k <= n; k++) {
107
+ const j = (((i + step * k) % n) + n) % n;
108
+ if (!items[j].disabled) {
109
+ next = j;
110
+ break;
111
+ }
112
+ }
113
+ } else if (e.key === 'Home') next = items.findIndex((o) => !o.disabled);
114
+ else if (e.key === 'End') next = items.findLastIndex((o) => !o.disabled);
115
+ else return;
116
+ if (next < 0) return;
117
+ e.preventDefault();
118
+ choose(next);
119
+ buttons[next]?.focus();
120
+ }
121
+ </script>
122
+
123
+ <div
124
+ bind:this={trackEl}
125
+ class="jatui-segmented seg-{size} seg-{variant} {className}"
126
+ class:seg-full={fullWidth}
127
+ class:seg-ready={ready}
128
+ class:seg-disabled={disabled}
129
+ role="radiogroup"
130
+ aria-label={ariaLabel}
131
+ aria-disabled={disabled || undefined}
132
+ >
133
+ {#if thumb}
134
+ <span class="seg-thumb" aria-hidden="true" style:--x="{thumb.x}px" style:--w="{thumb.w}px"></span>
135
+ {/if}
136
+ {#each items as o, i (o.value)}
137
+ {@const sel = i === selectedIndex}
138
+ <button
139
+ bind:this={buttons[i]}
140
+ type="button"
141
+ role="radio"
142
+ class="seg-item"
143
+ class:is-selected={sel}
144
+ aria-checked={sel}
145
+ aria-label={o.ariaLabel}
146
+ disabled={o.disabled || disabled}
147
+ tabindex={i === tabIndexOf ? 0 : -1}
148
+ onclick={() => choose(i)}
149
+ onkeydown={(e) => onKeydown(e, i)}
150
+ >
151
+ {#if option}{@render option(o, { selected: sel })}{:else}{o.label}{/if}
152
+ </button>
153
+ {/each}
154
+ </div>
155
+
156
+ <style>
157
+ .jatui-segmented {
158
+ --seg-h: 2rem;
159
+ --seg-px: 0.75rem;
160
+ --seg-pad: 3px;
161
+ position: relative;
162
+ display: inline-flex;
163
+ align-items: stretch;
164
+ gap: 2px;
165
+ padding: var(--seg-pad);
166
+ border-radius: var(--jatui-radius-field, 0.25rem);
167
+ font-size: 0.875rem;
168
+ isolation: isolate;
169
+ }
170
+ .seg-solid {
171
+ background: color-mix(in oklch, var(--color-base-content) 7%, var(--color-base-100));
172
+ border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 8%, transparent);
173
+ }
174
+ .seg-xs {
175
+ --seg-h: 1.5rem;
176
+ --seg-px: 0.5rem;
177
+ --seg-pad: 2px;
178
+ font-size: 0.75rem;
179
+ }
180
+ .seg-sm {
181
+ --seg-h: 1.75rem;
182
+ --seg-px: 0.625rem;
183
+ font-size: 0.8125rem;
184
+ }
185
+ .seg-full {
186
+ display: flex;
187
+ width: 100%;
188
+ }
189
+ .seg-full .seg-item {
190
+ flex: 1 1 0;
191
+ }
192
+
193
+ .seg-item {
194
+ position: relative;
195
+ z-index: 1;
196
+ display: inline-flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+ gap: var(--jatui-space-sm, 4px);
200
+ min-height: var(--seg-h);
201
+ padding: 0 var(--seg-px);
202
+ border: 0;
203
+ border-radius: calc(var(--jatui-radius-field, 0.25rem) - 1px);
204
+ background: transparent;
205
+ color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
206
+ font: inherit;
207
+ font-weight: 500;
208
+ white-space: nowrap;
209
+ cursor: pointer;
210
+ transition: color var(--motion-fast, 150ms) var(--motion-standard, ease);
211
+ }
212
+ .seg-item:hover:not(:disabled):not(.is-selected) {
213
+ color: var(--color-base-content);
214
+ }
215
+ .seg-item.is-selected {
216
+ color: var(--color-base-content);
217
+ }
218
+ .seg-plain .seg-item.is-selected {
219
+ color: var(--color-primary);
220
+ }
221
+ .seg-item:focus-visible {
222
+ outline: 2px solid var(--color-primary);
223
+ outline-offset: 1px;
224
+ }
225
+ .seg-item:disabled {
226
+ opacity: 0.45;
227
+ cursor: not-allowed;
228
+ }
229
+
230
+ .seg-thumb {
231
+ position: absolute;
232
+ z-index: 0;
233
+ top: var(--seg-pad);
234
+ bottom: var(--seg-pad);
235
+ left: 0;
236
+ width: var(--w);
237
+ transform: translateX(var(--x));
238
+ border-radius: calc(var(--jatui-radius-field, 0.25rem) - 1px);
239
+ pointer-events: none;
240
+ }
241
+ .seg-solid .seg-thumb {
242
+ background: var(--color-base-100);
243
+ border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 10%, transparent);
244
+ }
245
+ .seg-plain .seg-thumb {
246
+ background: color-mix(in oklch, var(--color-primary) 14%, transparent);
247
+ }
248
+ .seg-ready .seg-thumb {
249
+ transition:
250
+ transform var(--motion-move, 250ms) var(--motion-glide, cubic-bezier(0.22, 1, 0.36, 1)),
251
+ width var(--motion-move, 250ms) var(--motion-glide, cubic-bezier(0.22, 1, 0.36, 1));
252
+ }
253
+ .seg-disabled {
254
+ opacity: 0.6;
255
+ }
256
+
257
+ @media (prefers-reduced-motion: reduce) {
258
+ .seg-ready .seg-thumb,
259
+ .seg-item {
260
+ transition: none;
261
+ }
262
+ }
263
+ </style>
@@ -0,0 +1,48 @@
1
+ export interface SegmentedOption<V extends string | number = string> {
2
+ value: V;
3
+ label: string;
4
+ disabled?: boolean;
5
+ /** Accessible name when `label` is visually hidden by an `option` snippet. */
6
+ ariaLabel?: string;
7
+ }
8
+ import type { Snippet } from 'svelte';
9
+ declare function $$render<V extends string | number = string>(): {
10
+ props: {
11
+ /** Options, or plain strings (value = label). */
12
+ options: readonly (SegmentedOption<V> | V)[];
13
+ value?: V;
14
+ size?: "xs" | "sm" | "md";
15
+ variant?: "solid" | "plain";
16
+ /** Stretch to the container, segments share the width. */
17
+ fullWidth?: boolean;
18
+ disabled?: boolean;
19
+ ariaLabel?: string;
20
+ /** Custom segment content (icon + label, counts…). */
21
+ option?: Snippet<[SegmentedOption<V>, {
22
+ selected: boolean;
23
+ }]>;
24
+ onChange?: (value: V) => void;
25
+ class?: string;
26
+ };
27
+ exports: {};
28
+ bindings: "value";
29
+ slots: {};
30
+ events: {};
31
+ };
32
+ declare class __sveltets_Render<V extends string | number = string> {
33
+ props(): ReturnType<typeof $$render<V>>['props'];
34
+ events(): ReturnType<typeof $$render<V>>['events'];
35
+ slots(): ReturnType<typeof $$render<V>>['slots'];
36
+ bindings(): "value";
37
+ exports(): {};
38
+ }
39
+ interface $$IsomorphicComponent {
40
+ new <V extends string | number = string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<V>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<V>['props']>, ReturnType<__sveltets_Render<V>['events']>, ReturnType<__sveltets_Render<V>['slots']>> & {
41
+ $$bindings?: ReturnType<__sveltets_Render<V>['bindings']>;
42
+ } & ReturnType<__sveltets_Render<V>['exports']>;
43
+ <V extends string | number = string>(internal: unknown, props: ReturnType<__sveltets_Render<V>['props']> & {}): ReturnType<__sveltets_Render<V>['exports']>;
44
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
45
+ }
46
+ declare const SegmentedControl: $$IsomorphicComponent;
47
+ type SegmentedControl<V extends string | number = string> = InstanceType<typeof SegmentedControl<V>>;
48
+ export default SegmentedControl;
@@ -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;