@dorsk/tsumikit 0.13.0 → 0.14.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 +21 -0
- package/dist/components/atoms/Button.svelte +4 -3
- package/dist/components/layouts/ResizablePanel.svelte +44 -26
- 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/layouts/resizable-panel-persistence.d.ts +14 -0
- package/dist/components/layouts/resizable-panel-persistence.js +24 -0
- package/dist/components/molecules/Popover.svelte +133 -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 +7 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -108,6 +108,27 @@ 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
|
+
|
|
111
132
|
## Built on the platform
|
|
112
133
|
|
|
113
134
|
Interactive components lean on modern web features rather than reimplementing
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
justify-content: center;
|
|
90
90
|
gap: var(--sp-2);
|
|
91
91
|
padding: var(--sp-2) var(--sp-4);
|
|
92
|
-
min-height:
|
|
92
|
+
min-height: var(--control-height-default);
|
|
93
93
|
border: 1px solid var(--border-strong);
|
|
94
94
|
border-radius: var(--r-md);
|
|
95
95
|
background: var(--surface);
|
|
@@ -137,12 +137,13 @@
|
|
|
137
137
|
border-color: transparent;
|
|
138
138
|
}
|
|
139
139
|
.btn-sm {
|
|
140
|
-
|
|
140
|
+
height: var(--control-height-compact);
|
|
141
|
+
min-height: var(--control-height-compact);
|
|
141
142
|
padding: var(--sp-1) var(--sp-3);
|
|
142
143
|
font-size: var(--fs-xs);
|
|
143
144
|
}
|
|
144
145
|
.btn-lg {
|
|
145
|
-
min-height:
|
|
146
|
+
min-height: var(--control-height-large);
|
|
146
147
|
padding: var(--sp-3) var(--sp-5);
|
|
147
148
|
font-size: var(--fs-base);
|
|
148
149
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
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';
|
|
6
|
+
import { parseStoredCollapsed, parseStoredWidth } from './resizable-panel-persistence';
|
|
5
7
|
|
|
6
8
|
let {
|
|
7
9
|
panel,
|
|
@@ -64,12 +66,18 @@
|
|
|
64
66
|
restored = true;
|
|
65
67
|
if (!widthKey) return;
|
|
66
68
|
|
|
67
|
-
const savedWidth =
|
|
68
|
-
|
|
69
|
+
const savedWidth = parseStoredWidth(
|
|
70
|
+
localStorage.getItem(widthKey),
|
|
71
|
+
boundedMin,
|
|
72
|
+
boundedMax
|
|
73
|
+
);
|
|
74
|
+
if (savedWidth !== undefined) panelWidth = savedWidth;
|
|
69
75
|
|
|
70
76
|
if (persistCollapsed) {
|
|
71
|
-
const savedCollapsed =
|
|
72
|
-
|
|
77
|
+
const savedCollapsed = parseStoredCollapsed(
|
|
78
|
+
localStorage.getItem(`${widthKey}:collapsed`)
|
|
79
|
+
);
|
|
80
|
+
if (savedCollapsed !== undefined) collapsed = savedCollapsed;
|
|
73
81
|
}
|
|
74
82
|
});
|
|
75
83
|
|
|
@@ -82,6 +90,14 @@
|
|
|
82
90
|
if (persist) persistWidth();
|
|
83
91
|
}
|
|
84
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
|
+
|
|
85
101
|
function toggle() {
|
|
86
102
|
collapsed = !collapsed;
|
|
87
103
|
if (browser && widthKey && persistCollapsed) {
|
|
@@ -102,18 +118,22 @@
|
|
|
102
118
|
|
|
103
119
|
function resize(event: PointerEvent) {
|
|
104
120
|
if (!resizing) return;
|
|
105
|
-
|
|
121
|
+
pointerWidths.schedule(widthFromPointer(event.clientX));
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
function finishResize(event: PointerEvent) {
|
|
109
125
|
if (!resizing) return;
|
|
126
|
+
const finalWidth = Math.round(
|
|
127
|
+
Math.max(boundedMin, Math.min(widthFromPointer(event.clientX), boundedMax))
|
|
128
|
+
);
|
|
129
|
+
pointerWidths.flush(finalWidth);
|
|
110
130
|
resizing = false;
|
|
111
131
|
try {
|
|
112
132
|
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
113
133
|
} catch {
|
|
114
134
|
// Pointer capture may already have been released by the browser.
|
|
115
135
|
}
|
|
116
|
-
|
|
136
|
+
if (browser && widthKey) localStorage.setItem(widthKey, String(finalWidth));
|
|
117
137
|
}
|
|
118
138
|
|
|
119
139
|
function resizeWithKeyboard(event: KeyboardEvent) {
|
|
@@ -184,7 +204,6 @@
|
|
|
184
204
|
onclick={toggle}
|
|
185
205
|
>
|
|
186
206
|
<Icon name={toggleIcon} size={20} />
|
|
187
|
-
{#if !collapsed}<span>{toggleLabel}</span>{/if}
|
|
188
207
|
</button>
|
|
189
208
|
</aside>
|
|
190
209
|
|
|
@@ -193,7 +212,6 @@
|
|
|
193
212
|
|
|
194
213
|
<style>
|
|
195
214
|
.panel-layout {
|
|
196
|
-
--panel-collapsed-width: var(--control-height);
|
|
197
215
|
position: relative;
|
|
198
216
|
display: grid;
|
|
199
217
|
grid-template-columns: var(--panel-current-width) minmax(0, 1fr);
|
|
@@ -209,7 +227,7 @@
|
|
|
209
227
|
--panel-current-width: var(--panel-width);
|
|
210
228
|
}
|
|
211
229
|
.panel-layout.collapsed {
|
|
212
|
-
--panel-current-width:
|
|
230
|
+
--panel-current-width: 0px;
|
|
213
231
|
}
|
|
214
232
|
.panel {
|
|
215
233
|
position: relative;
|
|
@@ -243,23 +261,28 @@
|
|
|
243
261
|
overflow: auto;
|
|
244
262
|
}
|
|
245
263
|
.collapse-control {
|
|
264
|
+
position: absolute;
|
|
265
|
+
right: calc(-1 * var(--control-height));
|
|
266
|
+
bottom: var(--sp-3);
|
|
267
|
+
z-index: 3;
|
|
246
268
|
display: flex;
|
|
247
269
|
align-items: center;
|
|
248
270
|
justify-content: center;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
margin-top: auto;
|
|
253
|
-
padding: var(--sp-2) var(--sp-3);
|
|
271
|
+
width: var(--control-height);
|
|
272
|
+
height: var(--control-height);
|
|
273
|
+
padding: 0;
|
|
254
274
|
color: var(--text-muted);
|
|
255
275
|
font: inherit;
|
|
256
|
-
font-size: var(--fs-sm);
|
|
257
|
-
font-weight: var(--fw-medium);
|
|
258
276
|
background: var(--bg-elevated-2);
|
|
259
|
-
border:
|
|
260
|
-
border-
|
|
277
|
+
border: 1px solid var(--border);
|
|
278
|
+
border-radius: var(--r-sm);
|
|
279
|
+
box-shadow: var(--shadow-sm);
|
|
261
280
|
cursor: pointer;
|
|
262
281
|
}
|
|
282
|
+
.right .collapse-control {
|
|
283
|
+
right: auto;
|
|
284
|
+
left: calc(-1 * var(--control-height));
|
|
285
|
+
}
|
|
263
286
|
.collapse-control:hover {
|
|
264
287
|
color: var(--text);
|
|
265
288
|
background: var(--surface);
|
|
@@ -273,7 +296,7 @@
|
|
|
273
296
|
position: absolute;
|
|
274
297
|
top: 0;
|
|
275
298
|
right: -6px;
|
|
276
|
-
bottom:
|
|
299
|
+
bottom: 0;
|
|
277
300
|
z-index: 2;
|
|
278
301
|
width: 12px;
|
|
279
302
|
padding: 0;
|
|
@@ -310,7 +333,7 @@
|
|
|
310
333
|
}
|
|
311
334
|
|
|
312
335
|
/* In a narrow host, the expanded panel overlays the main area instead of
|
|
313
|
-
squeezing it.
|
|
336
|
+
squeezing it. */
|
|
314
337
|
@container resizable-panel (max-width: 40rem) {
|
|
315
338
|
.panel-layout {
|
|
316
339
|
display: block;
|
|
@@ -332,11 +355,6 @@
|
|
|
332
355
|
}
|
|
333
356
|
.main {
|
|
334
357
|
height: 100%;
|
|
335
|
-
padding-left: var(--panel-collapsed-width);
|
|
336
|
-
}
|
|
337
|
-
.right .main {
|
|
338
|
-
padding-right: var(--panel-collapsed-width);
|
|
339
|
-
padding-left: 0;
|
|
340
358
|
}
|
|
341
359
|
}
|
|
342
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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a persisted panel width without treating a missing or blank value as zero.
|
|
3
|
+
* @param {string | null} value
|
|
4
|
+
* @param {number} minWidth
|
|
5
|
+
* @param {number} maxWidth
|
|
6
|
+
* @returns {number | undefined}
|
|
7
|
+
*/
|
|
8
|
+
export function parseStoredWidth(value: string | null, minWidth: number, maxWidth: number): number | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Only values written by the panel itself may restore its collapsed state.
|
|
11
|
+
* @param {string | null} value
|
|
12
|
+
* @returns {boolean | undefined}
|
|
13
|
+
*/
|
|
14
|
+
export function parseStoredCollapsed(value: string | null): boolean | undefined;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a persisted panel width without treating a missing or blank value as zero.
|
|
3
|
+
* @param {string | null} value
|
|
4
|
+
* @param {number} minWidth
|
|
5
|
+
* @param {number} maxWidth
|
|
6
|
+
* @returns {number | undefined}
|
|
7
|
+
*/
|
|
8
|
+
export function parseStoredWidth(value, minWidth, maxWidth) {
|
|
9
|
+
if (value === null || value.trim() === '') return undefined;
|
|
10
|
+
|
|
11
|
+
const width = Number(value);
|
|
12
|
+
return Number.isFinite(width) ? Math.max(minWidth, Math.min(width, maxWidth)) : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Only values written by the panel itself may restore its collapsed state.
|
|
17
|
+
* @param {string | null} value
|
|
18
|
+
* @returns {boolean | undefined}
|
|
19
|
+
*/
|
|
20
|
+
export function parseStoredCollapsed(value) {
|
|
21
|
+
if (value === 'true') return true;
|
|
22
|
+
if (value === 'false') return false;
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
@@ -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' | '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,21 @@
|
|
|
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-info={tone === 'info'}
|
|
105
|
+
class:trigger-tone-warn={tone === 'warn'}
|
|
106
|
+
class:trigger-tone-danger={tone === 'danger'}
|
|
73
107
|
popovertarget={id}
|
|
74
108
|
aria-label={label}
|
|
109
|
+
{disabled}
|
|
75
110
|
>
|
|
76
111
|
{@render trigger()}
|
|
77
112
|
</button>
|
|
@@ -107,18 +142,114 @@
|
|
|
107
142
|
background 0.12s var(--ease),
|
|
108
143
|
border-color 0.12s var(--ease);
|
|
109
144
|
}
|
|
110
|
-
.pop-trigger:hover {
|
|
145
|
+
.pop-trigger:hover:not(:disabled) {
|
|
146
|
+
background: var(--bg-elevated-2);
|
|
147
|
+
}
|
|
148
|
+
/* Supplying canonical chrome props opts into the same dimensions, variants
|
|
149
|
+
and state tones as Button while keeping this one native trigger element. */
|
|
150
|
+
.pop-trigger.canonical {
|
|
151
|
+
min-width: 0;
|
|
152
|
+
min-height: var(--control-height-default);
|
|
153
|
+
padding: var(--sp-2) var(--sp-4);
|
|
154
|
+
border-color: var(--border-strong);
|
|
155
|
+
background: var(--surface);
|
|
156
|
+
font-weight: var(--fw-medium);
|
|
157
|
+
font-size: var(--fs-sm);
|
|
158
|
+
line-height: 1;
|
|
159
|
+
user-select: none;
|
|
160
|
+
white-space: nowrap;
|
|
161
|
+
}
|
|
162
|
+
.pop-trigger.canonical:hover:not(:disabled) {
|
|
163
|
+
border-color: var(--accent);
|
|
164
|
+
background: var(--surface);
|
|
165
|
+
}
|
|
166
|
+
.pop-trigger.trigger-primary {
|
|
167
|
+
background: var(--accent);
|
|
168
|
+
border-color: var(--accent);
|
|
169
|
+
color: var(--text-on-accent);
|
|
170
|
+
font-weight: var(--fw-semibold);
|
|
171
|
+
}
|
|
172
|
+
.pop-trigger.trigger-primary:hover:not(:disabled) {
|
|
173
|
+
background: var(--accent);
|
|
174
|
+
border-color: var(--accent);
|
|
175
|
+
filter: brightness(1.08);
|
|
176
|
+
}
|
|
177
|
+
.pop-trigger.trigger-ghost {
|
|
178
|
+
background: transparent;
|
|
179
|
+
border-color: transparent;
|
|
180
|
+
}
|
|
181
|
+
.pop-trigger.trigger-ghost:hover:not(:disabled) {
|
|
111
182
|
background: var(--bg-elevated-2);
|
|
183
|
+
border-color: transparent;
|
|
184
|
+
}
|
|
185
|
+
.pop-trigger.trigger-danger {
|
|
186
|
+
color: var(--danger);
|
|
187
|
+
border-color: color-mix(in srgb, var(--danger) 50%, var(--border));
|
|
188
|
+
}
|
|
189
|
+
.pop-trigger.trigger-danger:hover:not(:disabled) {
|
|
190
|
+
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
|
191
|
+
border-color: var(--danger);
|
|
192
|
+
}
|
|
193
|
+
.pop-trigger.trigger-sm {
|
|
194
|
+
height: var(--control-height-compact);
|
|
195
|
+
min-height: var(--control-height-compact);
|
|
196
|
+
padding: var(--sp-1) var(--sp-3);
|
|
197
|
+
font-size: var(--fs-xs);
|
|
198
|
+
}
|
|
199
|
+
.pop-trigger.trigger-lg {
|
|
200
|
+
min-height: var(--control-height-large);
|
|
201
|
+
padding: var(--sp-3) var(--sp-5);
|
|
202
|
+
font-size: var(--fs-base);
|
|
203
|
+
}
|
|
204
|
+
.pop-trigger.trigger-control {
|
|
205
|
+
height: var(--control-height);
|
|
206
|
+
min-height: var(--control-height);
|
|
207
|
+
padding: 0 var(--sp-3);
|
|
208
|
+
}
|
|
209
|
+
.pop-trigger.trigger-block {
|
|
210
|
+
width: 100%;
|
|
211
|
+
}
|
|
212
|
+
.pop-trigger.trigger-tone-accent {
|
|
213
|
+
--pop-trigger-tone: var(--accent);
|
|
214
|
+
}
|
|
215
|
+
.pop-trigger.trigger-tone-info {
|
|
216
|
+
--pop-trigger-tone: var(--info);
|
|
217
|
+
}
|
|
218
|
+
.pop-trigger.trigger-tone-warn {
|
|
219
|
+
--pop-trigger-tone: var(--warn);
|
|
220
|
+
}
|
|
221
|
+
.pop-trigger.trigger-tone-danger {
|
|
222
|
+
--pop-trigger-tone: var(--danger);
|
|
223
|
+
}
|
|
224
|
+
.pop-trigger.trigger-tone-accent,
|
|
225
|
+
.pop-trigger.trigger-tone-info,
|
|
226
|
+
.pop-trigger.trigger-tone-warn,
|
|
227
|
+
.pop-trigger.trigger-tone-danger {
|
|
228
|
+
color: var(--pop-trigger-tone);
|
|
229
|
+
border-color: color-mix(in srgb, var(--pop-trigger-tone) 50%, var(--border));
|
|
230
|
+
}
|
|
231
|
+
.pop-trigger.trigger-tone-accent:hover:not(:disabled),
|
|
232
|
+
.pop-trigger.trigger-tone-info:hover:not(:disabled),
|
|
233
|
+
.pop-trigger.trigger-tone-warn:hover:not(:disabled),
|
|
234
|
+
.pop-trigger.trigger-tone-danger:hover:not(:disabled) {
|
|
235
|
+
background: color-mix(in srgb, var(--pop-trigger-tone) 14%, transparent);
|
|
236
|
+
border-color: var(--pop-trigger-tone);
|
|
237
|
+
}
|
|
238
|
+
.pop-trigger:disabled {
|
|
239
|
+
opacity: 0.45;
|
|
240
|
+
cursor: not-allowed;
|
|
112
241
|
}
|
|
113
242
|
/* `bare`: strip the chrome down to a plain button the consumer styles. */
|
|
114
243
|
.pop-trigger.bare {
|
|
244
|
+
height: auto;
|
|
115
245
|
min-height: 0;
|
|
116
246
|
min-width: 0;
|
|
247
|
+
width: auto;
|
|
117
248
|
padding: 0;
|
|
118
249
|
border: 0;
|
|
119
250
|
background: none;
|
|
120
251
|
}
|
|
121
|
-
.pop-trigger.bare:hover {
|
|
252
|
+
.pop-trigger.bare:hover:not(:disabled) {
|
|
122
253
|
background: none;
|
|
123
254
|
}
|
|
124
255
|
.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' | '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
|
+
}
|
|
@@ -161,8 +161,13 @@
|
|
|
161
161
|
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.45);
|
|
162
162
|
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.55);
|
|
163
163
|
|
|
164
|
-
/* ---- Controls
|
|
165
|
-
|
|
164
|
+
/* ---- Controls ----
|
|
165
|
+
`size="sm"` is the compact toolbar contract shared by Button, Popover and
|
|
166
|
+
SegmentedControl. `control` is the roomier uniform-height contract used by
|
|
167
|
+
Button and other composer controls. Both remain rem-based for zoom. */
|
|
168
|
+
--control-height-compact: 2rem;
|
|
169
|
+
--control-height-default: 2.5rem;
|
|
170
|
+
--control-height-large: 3rem;
|
|
166
171
|
--control-height: 2.75rem;
|
|
167
172
|
|
|
168
173
|
/* ---- Layout ---- */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dorsk/tsumikit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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,6 +45,7 @@
|
|
|
45
45
|
"dev": "vite dev",
|
|
46
46
|
"build": "vite build",
|
|
47
47
|
"preview": "vite preview",
|
|
48
|
+
"test": "node --test tests/*.test.js",
|
|
48
49
|
"prepare": "svelte-kit sync && (lefthook install || true)",
|
|
49
50
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
50
51
|
"lint": "biome check .",
|