@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.
- package/CLAUDE.md +52 -5
- package/THIRD_PARTY_NOTICES.md +31 -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/AnimatedNumber.svelte +42 -1
- package/dist/components/AnimatedNumber.svelte.d.ts +7 -0
- package/dist/components/BottomSheet.svelte +7 -6
- package/dist/components/BottomSheet.svelte.d.ts +2 -0
- 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/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/MediaLightbox.svelte +2 -7
- package/dist/components/Modal.svelte +2 -10
- package/dist/components/PinInput.svelte +94 -31
- package/dist/components/PinInput.svelte.d.ts +4 -0
- package/dist/components/SegmentedControl.svelte +263 -0
- package/dist/components/SegmentedControl.svelte.d.ts +48 -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/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/settings/SettingsRow.svelte +177 -0
- package/dist/components/settings/SettingsRow.svelte.d.ts +32 -0
- package/dist/components/settings/SettingsSection.svelte +139 -0
- package/dist/components/settings/SettingsSection.svelte.d.ts +21 -0
- package/dist/components/stats/ActivityHeatmap.svelte +412 -0
- package/dist/components/stats/ActivityHeatmap.svelte.d.ts +72 -0
- package/dist/components/stats/BarList.svelte +262 -0
- package/dist/components/stats/BarList.svelte.d.ts +57 -0
- package/dist/components/stats/ChartCard.svelte +230 -0
- package/dist/components/stats/ChartCard.svelte.d.ts +63 -0
- package/dist/components/stats/StatCard.svelte +387 -0
- package/dist/components/stats/StatCard.svelte.d.ts +37 -0
- package/dist/components/stats/StatGroup.svelte +67 -0
- package/dist/components/stats/StatGroup.svelte.d.ts +14 -0
- package/dist/components/stats/heatmap.d.ts +76 -0
- package/dist/components/stats/heatmap.js +151 -0
- package/dist/components/tree/TreeView.svelte +680 -0
- package/dist/components/tree/TreeView.svelte.d.ts +123 -0
- package/dist/components/tree/flattenTree.d.ts +57 -0
- package/dist/components/tree/flattenTree.js +91 -0
- package/dist/components/tree/treeKeys.d.ts +46 -0
- package/dist/components/tree/treeKeys.js +80 -0
- package/dist/data/grid/cells.d.ts +40 -0
- package/dist/data/grid/cells.js +89 -0
- package/dist/data/grid/clipboard.d.ts +46 -0
- package/dist/data/grid/clipboard.js +131 -0
- package/dist/data/grid/edits.d.ts +148 -0
- package/dist/data/grid/edits.js +222 -0
- package/dist/data/grid/index.d.ts +12 -0
- package/dist/data/grid/index.js +12 -0
- package/dist/index.d.ts +36 -1
- package/dist/index.js +31 -1
- 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 +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
|
-
|
|
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">;
|
|
@@ -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 (
|
|
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;
|