@dorsk/tsumikit 0.13.1 → 0.15.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/README.md +25 -0
- package/dist/components/atoms/Button.svelte +32 -5
- package/dist/components/atoms/Button.svelte.d.ts +1 -1
- package/dist/components/layouts/ResizablePanel.svelte +33 -22
- package/dist/components/layouts/ResizablePanel.svelte.d.ts +1 -1
- package/dist/components/layouts/resizable-panel-frame.d.ts +16 -0
- package/dist/components/layouts/resizable-panel-frame.js +63 -0
- package/dist/components/molecules/Popover.svelte +149 -2
- package/dist/components/molecules/Popover.svelte.d.ts +12 -0
- package/dist/components/molecules/SegmentedControl.svelte +12 -17
- package/dist/components/molecules/segmented-control-keyboard.d.ts +13 -0
- package/dist/components/molecules/segmented-control-keyboard.js +33 -0
- package/dist/styles/variables.css +10 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -108,6 +108,31 @@ proportionally with no code. That's the recommended path for magnification.
|
|
|
108
108
|
text tokens only) for reading-dense apps that want larger body text while
|
|
109
109
|
keeping chrome compact. It isn't wired into AppShell or any default.
|
|
110
110
|
|
|
111
|
+
### Toolbar control heights
|
|
112
|
+
|
|
113
|
+
`size="sm"` is the shared compact-toolbar contract for `Button`, `Popover`
|
|
114
|
+
triggers, and `SegmentedControl`. Each renders an exact
|
|
115
|
+
`--control-height-compact` outer box, so mixed controls share a vertical center:
|
|
116
|
+
|
|
117
|
+
```svelte
|
|
118
|
+
<SegmentedControl size="sm" {options} bind:value />
|
|
119
|
+
<Popover size="sm" variant="default" label="More actions">
|
|
120
|
+
{#snippet trigger()}More{/snippet}
|
|
121
|
+
<!-- panel content -->
|
|
122
|
+
</Popover>
|
|
123
|
+
<Button size="sm">Apply</Button>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`Popover` owns that single semantic trigger button; pass `variant`, `tone`,
|
|
127
|
+
`size`, `control`, `block`, and `disabled` directly instead of rendering a
|
|
128
|
+
`Button` inside its `trigger` snippet. Omitting these props preserves the
|
|
129
|
+
original ghost icon-button default. Use `control` on `Button` or `Popover` when
|
|
130
|
+
the roomier shared `--control-height` composer contract is required.
|
|
131
|
+
|
|
132
|
+
Button and Popover share the same semantic tones. For a confirmed positive
|
|
133
|
+
action, `tone="success"` gives neutral controls a success tint; combine it with
|
|
134
|
+
`variant="primary"` for a filled success action without consumer CSS.
|
|
135
|
+
|
|
111
136
|
## Built on the platform
|
|
112
137
|
|
|
113
138
|
Interactive components lean on modern web features rather than reimplementing
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
7
7
|
// Semantic state tint layered on top of the variant: tints text/border and
|
|
8
8
|
// adds a subtle fill on hover. For stateful controls — an "on"/active toggle
|
|
9
|
-
// (`accent`), or cost/severity states
|
|
10
|
-
tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
|
|
9
|
+
// (`accent`), positive actions (`success`), or cost/severity states.
|
|
10
|
+
tone?: 'none' | 'accent' | 'success' | 'info' | 'warn' | 'danger';
|
|
11
11
|
size?: 'sm' | 'md' | 'lg';
|
|
12
12
|
control?: boolean;
|
|
13
13
|
block?: boolean;
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
class:btn-icon={icon}
|
|
67
67
|
class:btn-chip={chip}
|
|
68
68
|
class:btn-tone-accent={tone === 'accent'}
|
|
69
|
+
class:btn-tone-success={tone === 'success'}
|
|
69
70
|
class:btn-tone-info={tone === 'info'}
|
|
70
71
|
class:btn-tone-warn={tone === 'warn'}
|
|
71
72
|
class:btn-tone-danger={tone === 'danger'}
|
|
@@ -89,7 +90,7 @@
|
|
|
89
90
|
justify-content: center;
|
|
90
91
|
gap: var(--sp-2);
|
|
91
92
|
padding: var(--sp-2) var(--sp-4);
|
|
92
|
-
min-height:
|
|
93
|
+
min-height: var(--control-height-default);
|
|
93
94
|
border: 1px solid var(--border-strong);
|
|
94
95
|
border-radius: var(--r-md);
|
|
95
96
|
background: var(--surface);
|
|
@@ -137,12 +138,13 @@
|
|
|
137
138
|
border-color: transparent;
|
|
138
139
|
}
|
|
139
140
|
.btn-sm {
|
|
140
|
-
|
|
141
|
+
height: var(--control-height-compact);
|
|
142
|
+
min-height: var(--control-height-compact);
|
|
141
143
|
padding: var(--sp-1) var(--sp-3);
|
|
142
144
|
font-size: var(--fs-xs);
|
|
143
145
|
}
|
|
144
146
|
.btn-lg {
|
|
145
|
-
min-height:
|
|
147
|
+
min-height: var(--control-height-large);
|
|
146
148
|
padding: var(--sp-3) var(--sp-5);
|
|
147
149
|
font-size: var(--fs-base);
|
|
148
150
|
}
|
|
@@ -155,6 +157,9 @@
|
|
|
155
157
|
.btn-tone-accent {
|
|
156
158
|
--btn-tone: var(--accent);
|
|
157
159
|
}
|
|
160
|
+
.btn-tone-success {
|
|
161
|
+
--btn-tone: var(--ok);
|
|
162
|
+
}
|
|
158
163
|
.btn-tone-info {
|
|
159
164
|
--btn-tone: var(--info);
|
|
160
165
|
}
|
|
@@ -165,6 +170,7 @@
|
|
|
165
170
|
--btn-tone: var(--danger);
|
|
166
171
|
}
|
|
167
172
|
.btn-tone-accent,
|
|
173
|
+
.btn-tone-success,
|
|
168
174
|
.btn-tone-info,
|
|
169
175
|
.btn-tone-warn,
|
|
170
176
|
.btn-tone-danger {
|
|
@@ -172,6 +178,7 @@
|
|
|
172
178
|
border-color: color-mix(in srgb, var(--btn-tone) 50%, var(--border));
|
|
173
179
|
}
|
|
174
180
|
.btn-tone-accent:hover:not(:disabled),
|
|
181
|
+
.btn-tone-success:hover:not(:disabled),
|
|
175
182
|
.btn-tone-info:hover:not(:disabled),
|
|
176
183
|
.btn-tone-warn:hover:not(:disabled),
|
|
177
184
|
.btn-tone-danger:hover:not(:disabled) {
|
|
@@ -233,6 +240,26 @@
|
|
|
233
240
|
border-color: var(--accent);
|
|
234
241
|
filter: brightness(1.08);
|
|
235
242
|
}
|
|
243
|
+
.btn-control.btn-tone-success {
|
|
244
|
+
color: var(--ok);
|
|
245
|
+
border-color: color-mix(in srgb, var(--ok) 50%, var(--border));
|
|
246
|
+
}
|
|
247
|
+
.btn-control.btn-tone-success:hover:not(:disabled) {
|
|
248
|
+
background: color-mix(in srgb, var(--ok) 14%, transparent);
|
|
249
|
+
border-color: var(--ok);
|
|
250
|
+
}
|
|
251
|
+
/* A positive primary action uses success as its fill, not merely as the
|
|
252
|
+
neutral tint. This follows the control override so both heights match. */
|
|
253
|
+
.btn-primary.btn-tone-success {
|
|
254
|
+
background: var(--ok);
|
|
255
|
+
border-color: var(--ok);
|
|
256
|
+
color: var(--text-on-success);
|
|
257
|
+
}
|
|
258
|
+
.btn-primary.btn-tone-success:hover:not(:disabled) {
|
|
259
|
+
background: var(--ok);
|
|
260
|
+
border-color: var(--ok);
|
|
261
|
+
filter: brightness(1.08);
|
|
262
|
+
}
|
|
236
263
|
|
|
237
264
|
/* Icon-only buttons (IconButton). Square box = consistent 2.25rem tap target. */
|
|
238
265
|
.btn-icon {
|
|
@@ -2,7 +2,7 @@ import type { Snippet } from 'svelte';
|
|
|
2
2
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
3
3
|
type ButtonProps = HTMLButtonAttributes & {
|
|
4
4
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
5
|
-
tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
|
|
5
|
+
tone?: 'none' | 'accent' | 'success' | 'info' | 'warn' | 'danger';
|
|
6
6
|
size?: 'sm' | 'md' | 'lg';
|
|
7
7
|
control?: boolean;
|
|
8
8
|
block?: boolean;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type
|
|
2
|
+
import { onDestroy, type Snippet } from 'svelte';
|
|
3
3
|
import { browser } from '../../env';
|
|
4
4
|
import Icon from '../atoms/Icon.svelte';
|
|
5
|
+
import { createFrameBatcher } from './resizable-panel-frame.js';
|
|
5
6
|
import { parseStoredCollapsed, parseStoredWidth } from './resizable-panel-persistence';
|
|
6
7
|
|
|
7
8
|
let {
|
|
@@ -89,6 +90,14 @@
|
|
|
89
90
|
if (persist) persistWidth();
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
const pointerWidths = createFrameBatcher<number>(
|
|
94
|
+
(callback) => requestAnimationFrame(callback),
|
|
95
|
+
(handle) => cancelAnimationFrame(handle),
|
|
96
|
+
(nextWidth) => setWidth(nextWidth, false)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
onDestroy(() => pointerWidths.discard());
|
|
100
|
+
|
|
92
101
|
function toggle() {
|
|
93
102
|
collapsed = !collapsed;
|
|
94
103
|
if (browser && widthKey && persistCollapsed) {
|
|
@@ -109,18 +118,22 @@
|
|
|
109
118
|
|
|
110
119
|
function resize(event: PointerEvent) {
|
|
111
120
|
if (!resizing) return;
|
|
112
|
-
|
|
121
|
+
pointerWidths.schedule(widthFromPointer(event.clientX));
|
|
113
122
|
}
|
|
114
123
|
|
|
115
124
|
function finishResize(event: PointerEvent) {
|
|
116
125
|
if (!resizing) return;
|
|
126
|
+
const finalWidth = Math.round(
|
|
127
|
+
Math.max(boundedMin, Math.min(widthFromPointer(event.clientX), boundedMax))
|
|
128
|
+
);
|
|
129
|
+
pointerWidths.flush(finalWidth);
|
|
117
130
|
resizing = false;
|
|
118
131
|
try {
|
|
119
132
|
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
120
133
|
} catch {
|
|
121
134
|
// Pointer capture may already have been released by the browser.
|
|
122
135
|
}
|
|
123
|
-
|
|
136
|
+
if (browser && widthKey) localStorage.setItem(widthKey, String(finalWidth));
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
function resizeWithKeyboard(event: KeyboardEvent) {
|
|
@@ -191,7 +204,6 @@
|
|
|
191
204
|
onclick={toggle}
|
|
192
205
|
>
|
|
193
206
|
<Icon name={toggleIcon} size={20} />
|
|
194
|
-
{#if !collapsed}<span>{toggleLabel}</span>{/if}
|
|
195
207
|
</button>
|
|
196
208
|
</aside>
|
|
197
209
|
|
|
@@ -200,7 +212,6 @@
|
|
|
200
212
|
|
|
201
213
|
<style>
|
|
202
214
|
.panel-layout {
|
|
203
|
-
--panel-collapsed-width: var(--control-height);
|
|
204
215
|
position: relative;
|
|
205
216
|
display: grid;
|
|
206
217
|
grid-template-columns: var(--panel-current-width) minmax(0, 1fr);
|
|
@@ -216,7 +227,7 @@
|
|
|
216
227
|
--panel-current-width: var(--panel-width);
|
|
217
228
|
}
|
|
218
229
|
.panel-layout.collapsed {
|
|
219
|
-
--panel-current-width:
|
|
230
|
+
--panel-current-width: 0px;
|
|
220
231
|
}
|
|
221
232
|
.panel {
|
|
222
233
|
position: relative;
|
|
@@ -250,23 +261,28 @@
|
|
|
250
261
|
overflow: auto;
|
|
251
262
|
}
|
|
252
263
|
.collapse-control {
|
|
264
|
+
position: absolute;
|
|
265
|
+
right: calc(-1 * var(--control-height));
|
|
266
|
+
bottom: var(--sp-3);
|
|
267
|
+
z-index: 3;
|
|
253
268
|
display: flex;
|
|
254
269
|
align-items: center;
|
|
255
270
|
justify-content: center;
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
margin-top: auto;
|
|
260
|
-
padding: var(--sp-2) var(--sp-3);
|
|
271
|
+
width: var(--control-height);
|
|
272
|
+
height: var(--control-height);
|
|
273
|
+
padding: 0;
|
|
261
274
|
color: var(--text-muted);
|
|
262
275
|
font: inherit;
|
|
263
|
-
font-size: var(--fs-sm);
|
|
264
|
-
font-weight: var(--fw-medium);
|
|
265
276
|
background: var(--bg-elevated-2);
|
|
266
|
-
border:
|
|
267
|
-
border-
|
|
277
|
+
border: 1px solid var(--border);
|
|
278
|
+
border-radius: var(--r-sm);
|
|
279
|
+
box-shadow: var(--shadow-sm);
|
|
268
280
|
cursor: pointer;
|
|
269
281
|
}
|
|
282
|
+
.right .collapse-control {
|
|
283
|
+
right: auto;
|
|
284
|
+
left: calc(-1 * var(--control-height));
|
|
285
|
+
}
|
|
270
286
|
.collapse-control:hover {
|
|
271
287
|
color: var(--text);
|
|
272
288
|
background: var(--surface);
|
|
@@ -280,7 +296,7 @@
|
|
|
280
296
|
position: absolute;
|
|
281
297
|
top: 0;
|
|
282
298
|
right: -6px;
|
|
283
|
-
bottom:
|
|
299
|
+
bottom: 0;
|
|
284
300
|
z-index: 2;
|
|
285
301
|
width: 12px;
|
|
286
302
|
padding: 0;
|
|
@@ -317,7 +333,7 @@
|
|
|
317
333
|
}
|
|
318
334
|
|
|
319
335
|
/* In a narrow host, the expanded panel overlays the main area instead of
|
|
320
|
-
squeezing it.
|
|
336
|
+
squeezing it. */
|
|
321
337
|
@container resizable-panel (max-width: 40rem) {
|
|
322
338
|
.panel-layout {
|
|
323
339
|
display: block;
|
|
@@ -339,11 +355,6 @@
|
|
|
339
355
|
}
|
|
340
356
|
.main {
|
|
341
357
|
height: 100%;
|
|
342
|
-
padding-left: var(--panel-collapsed-width);
|
|
343
|
-
}
|
|
344
|
-
.right .main {
|
|
345
|
-
padding-right: var(--panel-collapsed-width);
|
|
346
|
-
padding-left: 0;
|
|
347
358
|
}
|
|
348
359
|
}
|
|
349
360
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesce high-frequency values into one update per animation frame while
|
|
3
|
+
* still allowing pointer release to synchronously apply the final value.
|
|
4
|
+
*
|
|
5
|
+
* @template T
|
|
6
|
+
* @param {(callback: FrameRequestCallback) => number} requestFrame
|
|
7
|
+
* @param {(handle: number) => void} cancelFrame
|
|
8
|
+
* @param {(value: T) => void} apply
|
|
9
|
+
*/
|
|
10
|
+
export function createFrameBatcher<T>(requestFrame: (callback: FrameRequestCallback) => number, cancelFrame: (handle: number) => void, apply: (value: T) => void): {
|
|
11
|
+
/** @param {T} value */
|
|
12
|
+
schedule(value: T): void;
|
|
13
|
+
/** @param {T} value */
|
|
14
|
+
flush(value: T): void;
|
|
15
|
+
discard(): void;
|
|
16
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesce high-frequency values into one update per animation frame while
|
|
3
|
+
* still allowing pointer release to synchronously apply the final value.
|
|
4
|
+
*
|
|
5
|
+
* @template T
|
|
6
|
+
* @param {(callback: FrameRequestCallback) => number} requestFrame
|
|
7
|
+
* @param {(handle: number) => void} cancelFrame
|
|
8
|
+
* @param {(value: T) => void} apply
|
|
9
|
+
*/
|
|
10
|
+
export function createFrameBatcher(requestFrame, cancelFrame, apply) {
|
|
11
|
+
/** @type {number | undefined} */
|
|
12
|
+
let frame;
|
|
13
|
+
/** @type {T | undefined} */
|
|
14
|
+
let pending;
|
|
15
|
+
let scheduled = false;
|
|
16
|
+
let generation = 0;
|
|
17
|
+
|
|
18
|
+
/** Apply the latest queued value, invalidating any stale frame callback. */
|
|
19
|
+
function applyPending() {
|
|
20
|
+
if (pending === undefined) return;
|
|
21
|
+
const value = pending;
|
|
22
|
+
pending = undefined;
|
|
23
|
+
apply(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
/** @param {T} value */
|
|
28
|
+
schedule(value) {
|
|
29
|
+
pending = value;
|
|
30
|
+
if (scheduled) return;
|
|
31
|
+
|
|
32
|
+
scheduled = true;
|
|
33
|
+
const token = ++generation;
|
|
34
|
+
frame = requestFrame(() => {
|
|
35
|
+
if (!scheduled || token !== generation) return;
|
|
36
|
+
scheduled = false;
|
|
37
|
+
frame = undefined;
|
|
38
|
+
applyPending();
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/** @param {T} value */
|
|
43
|
+
flush(value) {
|
|
44
|
+
pending = value;
|
|
45
|
+
if (scheduled) {
|
|
46
|
+
scheduled = false;
|
|
47
|
+
generation += 1;
|
|
48
|
+
if (frame !== undefined) cancelFrame(frame);
|
|
49
|
+
frame = undefined;
|
|
50
|
+
}
|
|
51
|
+
applyPending();
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
discard() {
|
|
55
|
+
pending = undefined;
|
|
56
|
+
if (!scheduled) return;
|
|
57
|
+
scheduled = false;
|
|
58
|
+
generation += 1;
|
|
59
|
+
if (frame !== undefined) cancelFrame(frame);
|
|
60
|
+
frame = undefined;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
import { place } from '../../floating';
|
|
13
13
|
|
|
14
14
|
type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
15
|
+
type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
|
|
16
|
+
type TriggerTone = 'none' | 'accent' | 'success' | 'info' | 'warn' | 'danger';
|
|
17
|
+
type TriggerSize = 'sm' | 'md' | 'lg';
|
|
15
18
|
|
|
16
19
|
let {
|
|
17
20
|
placement = 'bottom-start',
|
|
@@ -21,6 +24,12 @@
|
|
|
21
24
|
children,
|
|
22
25
|
triggerClass = '',
|
|
23
26
|
bare = false,
|
|
27
|
+
variant,
|
|
28
|
+
tone = 'none',
|
|
29
|
+
size,
|
|
30
|
+
control = false,
|
|
31
|
+
block = false,
|
|
32
|
+
disabled = false,
|
|
24
33
|
onopen,
|
|
25
34
|
onclose
|
|
26
35
|
}: {
|
|
@@ -38,10 +47,23 @@
|
|
|
38
47
|
/** Drop the default ghost-icon chrome so the trigger is an unstyled button
|
|
39
48
|
* you fully own (pair with `triggerClass`). */
|
|
40
49
|
bare?: boolean;
|
|
50
|
+
/** Button-compatible trigger chrome. Omitting every chrome prop preserves
|
|
51
|
+
* the original ghost icon-button trigger. */
|
|
52
|
+
variant?: TriggerVariant;
|
|
53
|
+
tone?: TriggerTone;
|
|
54
|
+
size?: TriggerSize;
|
|
55
|
+
/** Use the shared `--control-height` toolbar/composer contract. */
|
|
56
|
+
control?: boolean;
|
|
57
|
+
block?: boolean;
|
|
58
|
+
disabled?: boolean;
|
|
41
59
|
onopen?: () => void;
|
|
42
60
|
onclose?: () => void;
|
|
43
61
|
} = $props();
|
|
44
62
|
|
|
63
|
+
const canonicalChrome = $derived(
|
|
64
|
+
variant !== undefined || tone !== 'none' || size !== undefined || control || block
|
|
65
|
+
);
|
|
66
|
+
|
|
45
67
|
const id = `pop-${Math.random().toString(36).slice(2, 8)}`;
|
|
46
68
|
let triggerEl = $state<HTMLButtonElement | null>(null);
|
|
47
69
|
let panelEl = $state<HTMLDivElement | null>(null);
|
|
@@ -70,8 +92,22 @@
|
|
|
70
92
|
type="button"
|
|
71
93
|
class="pop-trigger {triggerClass}"
|
|
72
94
|
class:bare
|
|
95
|
+
class:canonical={canonicalChrome}
|
|
96
|
+
class:trigger-primary={variant === 'primary'}
|
|
97
|
+
class:trigger-ghost={variant === 'ghost'}
|
|
98
|
+
class:trigger-danger={variant === 'danger'}
|
|
99
|
+
class:trigger-sm={size === 'sm'}
|
|
100
|
+
class:trigger-lg={size === 'lg'}
|
|
101
|
+
class:trigger-control={control}
|
|
102
|
+
class:trigger-block={block}
|
|
103
|
+
class:trigger-tone-accent={tone === 'accent'}
|
|
104
|
+
class:trigger-tone-success={tone === 'success'}
|
|
105
|
+
class:trigger-tone-info={tone === 'info'}
|
|
106
|
+
class:trigger-tone-warn={tone === 'warn'}
|
|
107
|
+
class:trigger-tone-danger={tone === 'danger'}
|
|
73
108
|
popovertarget={id}
|
|
74
109
|
aria-label={label}
|
|
110
|
+
{disabled}
|
|
75
111
|
>
|
|
76
112
|
{@render trigger()}
|
|
77
113
|
</button>
|
|
@@ -107,18 +143,129 @@
|
|
|
107
143
|
background 0.12s var(--ease),
|
|
108
144
|
border-color 0.12s var(--ease);
|
|
109
145
|
}
|
|
110
|
-
.pop-trigger:hover {
|
|
146
|
+
.pop-trigger:hover:not(:disabled) {
|
|
111
147
|
background: var(--bg-elevated-2);
|
|
112
148
|
}
|
|
149
|
+
/* Supplying canonical chrome props opts into the same dimensions, variants
|
|
150
|
+
and state tones as Button while keeping this one native trigger element. */
|
|
151
|
+
.pop-trigger.canonical {
|
|
152
|
+
min-width: 0;
|
|
153
|
+
min-height: var(--control-height-default);
|
|
154
|
+
padding: var(--sp-2) var(--sp-4);
|
|
155
|
+
border-color: var(--border-strong);
|
|
156
|
+
background: var(--surface);
|
|
157
|
+
font-weight: var(--fw-medium);
|
|
158
|
+
font-size: var(--fs-sm);
|
|
159
|
+
line-height: 1;
|
|
160
|
+
user-select: none;
|
|
161
|
+
white-space: nowrap;
|
|
162
|
+
}
|
|
163
|
+
.pop-trigger.canonical:hover:not(:disabled) {
|
|
164
|
+
border-color: var(--accent);
|
|
165
|
+
background: var(--surface);
|
|
166
|
+
}
|
|
167
|
+
.pop-trigger.trigger-primary {
|
|
168
|
+
background: var(--accent);
|
|
169
|
+
border-color: var(--accent);
|
|
170
|
+
color: var(--text-on-accent);
|
|
171
|
+
font-weight: var(--fw-semibold);
|
|
172
|
+
}
|
|
173
|
+
.pop-trigger.trigger-primary:hover:not(:disabled) {
|
|
174
|
+
background: var(--accent);
|
|
175
|
+
border-color: var(--accent);
|
|
176
|
+
filter: brightness(1.08);
|
|
177
|
+
}
|
|
178
|
+
.pop-trigger.trigger-ghost {
|
|
179
|
+
background: transparent;
|
|
180
|
+
border-color: transparent;
|
|
181
|
+
}
|
|
182
|
+
.pop-trigger.trigger-ghost:hover:not(:disabled) {
|
|
183
|
+
background: var(--bg-elevated-2);
|
|
184
|
+
border-color: transparent;
|
|
185
|
+
}
|
|
186
|
+
.pop-trigger.trigger-danger {
|
|
187
|
+
color: var(--danger);
|
|
188
|
+
border-color: color-mix(in srgb, var(--danger) 50%, var(--border));
|
|
189
|
+
}
|
|
190
|
+
.pop-trigger.trigger-danger:hover:not(:disabled) {
|
|
191
|
+
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
|
192
|
+
border-color: var(--danger);
|
|
193
|
+
}
|
|
194
|
+
.pop-trigger.trigger-sm {
|
|
195
|
+
height: var(--control-height-compact);
|
|
196
|
+
min-height: var(--control-height-compact);
|
|
197
|
+
padding: var(--sp-1) var(--sp-3);
|
|
198
|
+
font-size: var(--fs-xs);
|
|
199
|
+
}
|
|
200
|
+
.pop-trigger.trigger-lg {
|
|
201
|
+
min-height: var(--control-height-large);
|
|
202
|
+
padding: var(--sp-3) var(--sp-5);
|
|
203
|
+
font-size: var(--fs-base);
|
|
204
|
+
}
|
|
205
|
+
.pop-trigger.trigger-control {
|
|
206
|
+
height: var(--control-height);
|
|
207
|
+
min-height: var(--control-height);
|
|
208
|
+
padding: 0 var(--sp-3);
|
|
209
|
+
}
|
|
210
|
+
.pop-trigger.trigger-block {
|
|
211
|
+
width: 100%;
|
|
212
|
+
}
|
|
213
|
+
.pop-trigger.trigger-tone-accent {
|
|
214
|
+
--pop-trigger-tone: var(--accent);
|
|
215
|
+
}
|
|
216
|
+
.pop-trigger.trigger-tone-success {
|
|
217
|
+
--pop-trigger-tone: var(--ok);
|
|
218
|
+
}
|
|
219
|
+
.pop-trigger.trigger-tone-info {
|
|
220
|
+
--pop-trigger-tone: var(--info);
|
|
221
|
+
}
|
|
222
|
+
.pop-trigger.trigger-tone-warn {
|
|
223
|
+
--pop-trigger-tone: var(--warn);
|
|
224
|
+
}
|
|
225
|
+
.pop-trigger.trigger-tone-danger {
|
|
226
|
+
--pop-trigger-tone: var(--danger);
|
|
227
|
+
}
|
|
228
|
+
.pop-trigger.trigger-tone-accent,
|
|
229
|
+
.pop-trigger.trigger-tone-success,
|
|
230
|
+
.pop-trigger.trigger-tone-info,
|
|
231
|
+
.pop-trigger.trigger-tone-warn,
|
|
232
|
+
.pop-trigger.trigger-tone-danger {
|
|
233
|
+
color: var(--pop-trigger-tone);
|
|
234
|
+
border-color: color-mix(in srgb, var(--pop-trigger-tone) 50%, var(--border));
|
|
235
|
+
}
|
|
236
|
+
.pop-trigger.trigger-tone-accent:hover:not(:disabled),
|
|
237
|
+
.pop-trigger.trigger-tone-success:hover:not(:disabled),
|
|
238
|
+
.pop-trigger.trigger-tone-info:hover:not(:disabled),
|
|
239
|
+
.pop-trigger.trigger-tone-warn:hover:not(:disabled),
|
|
240
|
+
.pop-trigger.trigger-tone-danger:hover:not(:disabled) {
|
|
241
|
+
background: color-mix(in srgb, var(--pop-trigger-tone) 14%, transparent);
|
|
242
|
+
border-color: var(--pop-trigger-tone);
|
|
243
|
+
}
|
|
244
|
+
.pop-trigger.trigger-primary.trigger-tone-success {
|
|
245
|
+
background: var(--ok);
|
|
246
|
+
border-color: var(--ok);
|
|
247
|
+
color: var(--text-on-success);
|
|
248
|
+
}
|
|
249
|
+
.pop-trigger.trigger-primary.trigger-tone-success:hover:not(:disabled) {
|
|
250
|
+
background: var(--ok);
|
|
251
|
+
border-color: var(--ok);
|
|
252
|
+
filter: brightness(1.08);
|
|
253
|
+
}
|
|
254
|
+
.pop-trigger:disabled {
|
|
255
|
+
opacity: 0.45;
|
|
256
|
+
cursor: not-allowed;
|
|
257
|
+
}
|
|
113
258
|
/* `bare`: strip the chrome down to a plain button the consumer styles. */
|
|
114
259
|
.pop-trigger.bare {
|
|
260
|
+
height: auto;
|
|
115
261
|
min-height: 0;
|
|
116
262
|
min-width: 0;
|
|
263
|
+
width: auto;
|
|
117
264
|
padding: 0;
|
|
118
265
|
border: 0;
|
|
119
266
|
background: none;
|
|
120
267
|
}
|
|
121
|
-
.pop-trigger.bare:hover {
|
|
268
|
+
.pop-trigger.bare:hover:not(:disabled) {
|
|
122
269
|
background: none;
|
|
123
270
|
}
|
|
124
271
|
.pop-panel {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
3
|
+
type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
|
|
4
|
+
type TriggerTone = 'none' | 'accent' | 'success' | 'info' | 'warn' | 'danger';
|
|
5
|
+
type TriggerSize = 'sm' | 'md' | 'lg';
|
|
3
6
|
type $$ComponentProps = {
|
|
4
7
|
placement?: Placement;
|
|
5
8
|
gap?: number;
|
|
@@ -15,6 +18,15 @@ type $$ComponentProps = {
|
|
|
15
18
|
/** Drop the default ghost-icon chrome so the trigger is an unstyled button
|
|
16
19
|
* you fully own (pair with `triggerClass`). */
|
|
17
20
|
bare?: boolean;
|
|
21
|
+
/** Button-compatible trigger chrome. Omitting every chrome prop preserves
|
|
22
|
+
* the original ghost icon-button trigger. */
|
|
23
|
+
variant?: TriggerVariant;
|
|
24
|
+
tone?: TriggerTone;
|
|
25
|
+
size?: TriggerSize;
|
|
26
|
+
/** Use the shared `--control-height` toolbar/composer contract. */
|
|
27
|
+
control?: boolean;
|
|
28
|
+
block?: boolean;
|
|
29
|
+
disabled?: boolean;
|
|
18
30
|
onopen?: () => void;
|
|
19
31
|
onclose?: () => void;
|
|
20
32
|
};
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// Space/Enter (or click) activate. `value` is bindable.
|
|
23
23
|
import type { Snippet } from 'svelte';
|
|
24
24
|
import Icon from '../atoms/Icon.svelte';
|
|
25
|
+
import { nextEnabledSegment } from './segmented-control-keyboard.js';
|
|
25
26
|
|
|
26
27
|
let {
|
|
27
28
|
options,
|
|
@@ -68,25 +69,10 @@
|
|
|
68
69
|
);
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
|
-
// Step to the next non-disabled option in a direction, wrapping around.
|
|
72
|
-
function step(from: number, dir: 1 | -1): number {
|
|
73
|
-
const n = options.length;
|
|
74
|
-
for (let k = 1; k <= n; k++) {
|
|
75
|
-
const j = (((from + dir * k) % n) + n) % n;
|
|
76
|
-
if (!options[j].disabled) return j;
|
|
77
|
-
}
|
|
78
|
-
return from;
|
|
79
|
-
}
|
|
80
72
|
function onkeydown(e: KeyboardEvent) {
|
|
81
73
|
const i = options.findIndex((o) => o.value === value);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = step(i, 1);
|
|
85
|
-
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = step(i, -1);
|
|
86
|
-
else if (e.key === 'Home') next = options.findIndex((o) => !o.disabled);
|
|
87
|
-
else if (e.key === 'End')
|
|
88
|
-
next = options.length - 1 - [...options].reverse().findIndex((o) => !o.disabled);
|
|
89
|
-
else return;
|
|
74
|
+
const next = nextEnabledSegment(options, i, e.key);
|
|
75
|
+
if (next === undefined) return;
|
|
90
76
|
e.preventDefault();
|
|
91
77
|
select(options[next].value, true);
|
|
92
78
|
}
|
|
@@ -165,6 +151,15 @@
|
|
|
165
151
|
padding: 0.2rem var(--sp-2);
|
|
166
152
|
font-size: var(--fs-xs);
|
|
167
153
|
}
|
|
154
|
+
/* Compact toolbar contract: the outer interactive box exactly matches
|
|
155
|
+
Button size="sm" and Popover size="sm". */
|
|
156
|
+
.seg-sm {
|
|
157
|
+
height: var(--control-height-compact);
|
|
158
|
+
min-height: var(--control-height-compact);
|
|
159
|
+
}
|
|
160
|
+
.seg-sm .seg-item {
|
|
161
|
+
height: 100%;
|
|
162
|
+
}
|
|
168
163
|
.seg-icon .seg-item {
|
|
169
164
|
gap: 0;
|
|
170
165
|
padding: 0.35rem;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a SegmentedControl navigation key to the next selectable option.
|
|
3
|
+
* Arrow navigation wraps and skips disabled options; Home/End jump to the
|
|
4
|
+
* first/last selectable option. Unhandled keys do not change selection.
|
|
5
|
+
*
|
|
6
|
+
* @param {{ disabled?: boolean }[]} options
|
|
7
|
+
* @param {number} current
|
|
8
|
+
* @param {string} key
|
|
9
|
+
* @returns {number | undefined}
|
|
10
|
+
*/
|
|
11
|
+
export function nextEnabledSegment(options: {
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}[], current: number, key: string): number | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a SegmentedControl navigation key to the next selectable option.
|
|
3
|
+
* Arrow navigation wraps and skips disabled options; Home/End jump to the
|
|
4
|
+
* first/last selectable option. Unhandled keys do not change selection.
|
|
5
|
+
*
|
|
6
|
+
* @param {{ disabled?: boolean }[]} options
|
|
7
|
+
* @param {number} current
|
|
8
|
+
* @param {string} key
|
|
9
|
+
* @returns {number | undefined}
|
|
10
|
+
*/
|
|
11
|
+
export function nextEnabledSegment(options, current, key) {
|
|
12
|
+
if (current < 0 || current >= options.length) return undefined;
|
|
13
|
+
if (key === 'Home') {
|
|
14
|
+
const first = options.findIndex((option) => !option.disabled);
|
|
15
|
+
return first >= 0 ? first : current;
|
|
16
|
+
}
|
|
17
|
+
if (key === 'End') {
|
|
18
|
+
const fromEnd = [...options].reverse().findIndex((option) => !option.disabled);
|
|
19
|
+
return fromEnd >= 0 ? options.length - 1 - fromEnd : current;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let direction;
|
|
23
|
+
if (key === 'ArrowRight' || key === 'ArrowDown') direction = 1;
|
|
24
|
+
else if (key === 'ArrowLeft' || key === 'ArrowUp') direction = -1;
|
|
25
|
+
else return undefined;
|
|
26
|
+
|
|
27
|
+
for (let offset = 1; offset <= options.length; offset += 1) {
|
|
28
|
+
const next =
|
|
29
|
+
(((current + direction * offset) % options.length) + options.length) % options.length;
|
|
30
|
+
if (!options[next].disabled) return next;
|
|
31
|
+
}
|
|
32
|
+
return current;
|
|
33
|
+
}
|
|
@@ -73,6 +73,9 @@
|
|
|
73
73
|
--text-muted: var(--c-text-muted);
|
|
74
74
|
--text-faint: var(--c-text-faint);
|
|
75
75
|
--text-on-accent: var(--c-accent-ink);
|
|
76
|
+
/* Adaptive ink for filled positive actions: dark canvas ink on bright greens,
|
|
77
|
+
light canvas ink on the darker greens used by light themes. */
|
|
78
|
+
--text-on-success: var(--bg);
|
|
76
79
|
|
|
77
80
|
--accent: var(--c-accent);
|
|
78
81
|
--accent-dim: var(--c-accent-dim);
|
|
@@ -161,8 +164,13 @@
|
|
|
161
164
|
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.45);
|
|
162
165
|
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.55);
|
|
163
166
|
|
|
164
|
-
/* ---- Controls
|
|
165
|
-
|
|
167
|
+
/* ---- Controls ----
|
|
168
|
+
`size="sm"` is the compact toolbar contract shared by Button, Popover and
|
|
169
|
+
SegmentedControl. `control` is the roomier uniform-height contract used by
|
|
170
|
+
Button and other composer controls. Both remain rem-based for zoom. */
|
|
171
|
+
--control-height-compact: 2rem;
|
|
172
|
+
--control-height-default: 2.5rem;
|
|
173
|
+
--control-height-large: 3rem;
|
|
166
174
|
--control-height: 2.75rem;
|
|
167
175
|
|
|
168
176
|
/* ---- Layout ---- */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dorsk/tsumikit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"dev": "vite dev",
|
|
46
46
|
"build": "vite build",
|
|
47
47
|
"preview": "vite preview",
|
|
48
|
-
"test": "node --test tests
|
|
48
|
+
"test": "node --test tests/*.test.js",
|
|
49
49
|
"prepare": "svelte-kit sync && (lefthook install || true)",
|
|
50
50
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
51
51
|
"lint": "biome check .",
|