@dorsk/tsumikit 0.13.1 → 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 +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 +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 -2
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,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' | '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,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 .",
|