@dorsk/tsumikit 0.22.0 → 0.24.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 +151 -8
- package/dist/components/atoms/Button.svelte +121 -12
- package/dist/components/atoms/Button.svelte.d.ts +4 -0
- package/dist/components/atoms/Scrim.svelte +81 -0
- package/dist/components/atoms/Scrim.svelte.d.ts +14 -0
- package/dist/components/layouts/Cluster.svelte +33 -0
- package/dist/components/layouts/Cluster.svelte.d.ts +1 -0
- package/dist/components/layouts/ResizablePanel.svelte +238 -120
- package/dist/components/layouts/ResizablePanel.svelte.d.ts +24 -8
- package/dist/components/layouts/resizable-panel-frame.d.ts +70 -0
- package/dist/components/layouts/resizable-panel-frame.js +161 -0
- package/dist/components/molecules/CopyButton.svelte +11 -2
- package/dist/components/molecules/CopyButton.svelte.d.ts +4 -0
- package/dist/components/molecules/FileButton.svelte +32 -3
- package/dist/components/molecules/FileButton.svelte.d.ts +4 -0
- package/dist/components/molecules/IconButton.svelte +34 -6
- package/dist/components/molecules/IconButton.svelte.d.ts +3 -0
- package/dist/components/molecules/Popover.svelte +32 -2
- package/dist/components/molecules/Popover.svelte.d.ts +5 -0
- package/dist/components/molecules/SelectButton.svelte +6 -1
- package/dist/components/molecules/SelectButton.svelte.d.ts +2 -0
- package/dist/components/molecules/ThemePicker.svelte +11 -13
- package/dist/components/organisms/DataTable.svelte +210 -10
- package/dist/components/organisms/DataTable.svelte.d.ts +27 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/stores/theme.svelte.d.ts +21 -5
- package/dist/stores/theme.svelte.js +48 -22
- package/dist/styles/app.css +7 -297
- package/dist/styles/reset.css +97 -0
- package/dist/styles/syntax.css +94 -0
- package/dist/styles/themes.css +729 -0
- package/dist/styles/tokens.css +207 -0
- package/dist/styles/utilities.css +111 -0
- package/dist/styles/variables.css +5 -915
- package/package.json +7 -2
|
@@ -61,3 +61,164 @@ export function createFrameBatcher(requestFrame, cancelFrame, apply) {
|
|
|
61
61
|
},
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a width prop that is either a pixel number or a CSS length.
|
|
67
|
+
* Plain `px` strings parse directly; anything else is handed to `measure`,
|
|
68
|
+
* which lays the length out and returns its pixel size (or `undefined` when
|
|
69
|
+
* there is no DOM to measure in).
|
|
70
|
+
*
|
|
71
|
+
* @param {number | string} value
|
|
72
|
+
* @param {(css: string) => number | undefined} measure
|
|
73
|
+
* @returns {number | undefined}
|
|
74
|
+
*/
|
|
75
|
+
export function resolveLength(value, measure) {
|
|
76
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
77
|
+
const px = /^\s*(-?\d*\.?\d+)px\s*$/.exec(value);
|
|
78
|
+
if (px) return Number(px[1]);
|
|
79
|
+
const measured = measure(value);
|
|
80
|
+
return measured !== undefined && Number.isFinite(measured) ? measured : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @typedef {object} ResizeHandleParams
|
|
85
|
+
* @property {'left' | 'right'} side Edge the resized box sits on; dragging away from it grows the box.
|
|
86
|
+
* @property {(width: number) => void} onwidth Called once per animation frame while dragging and on every keyboard step.
|
|
87
|
+
* @property {(width: number) => void} [oncommit] Called with the settled width on pointer release and after each keyboard step.
|
|
88
|
+
* @property {() => void} [onreset] Double-click on the handle.
|
|
89
|
+
* @property {(active: boolean) => void} [onactive] Drag start/end, for a `resizing` class.
|
|
90
|
+
* @property {() => number} [measure] Current width in px; defaults to the handle's parent box.
|
|
91
|
+
* @property {number} [min]
|
|
92
|
+
* @property {number} [max]
|
|
93
|
+
* @property {number} [step] Pixels per arrow key press (default 16).
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Svelte action turning any element into a pointer + keyboard width grip.
|
|
98
|
+
* One pointer-capture / rAF-coalesced implementation shared by
|
|
99
|
+
* ResizablePanel and consumer-built grips.
|
|
100
|
+
*
|
|
101
|
+
* Usage: <div role="separator" tabindex="0" use:resizeHandle={{ side, min, max, onwidth }}></div>
|
|
102
|
+
*
|
|
103
|
+
* @param {HTMLElement} node
|
|
104
|
+
* @param {ResizeHandleParams} params
|
|
105
|
+
*/
|
|
106
|
+
export function resizeHandle(node, params) {
|
|
107
|
+
let current = params;
|
|
108
|
+
let active = false;
|
|
109
|
+
let startX = 0;
|
|
110
|
+
let startWidth = 0;
|
|
111
|
+
|
|
112
|
+
const frames = createFrameBatcher(
|
|
113
|
+
(callback) => requestAnimationFrame(callback),
|
|
114
|
+
(handle) => cancelAnimationFrame(handle),
|
|
115
|
+
/** @param {number} width */
|
|
116
|
+
(width) => current.onwidth(width),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/** @param {number} width */
|
|
120
|
+
function clamp(width) {
|
|
121
|
+
const min = current.min ?? 1;
|
|
122
|
+
const max = current.max ?? Number.POSITIVE_INFINITY;
|
|
123
|
+
return Math.round(Math.max(min, Math.min(width, Math.max(min, max))));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function direction() {
|
|
127
|
+
return current.side === 'right' ? -1 : 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function measure() {
|
|
131
|
+
if (current.measure) return current.measure();
|
|
132
|
+
return node.parentElement?.getBoundingClientRect().width ?? 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {number} clientX */
|
|
136
|
+
function widthAt(clientX) {
|
|
137
|
+
return clamp(startWidth + (clientX - startX) * direction());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @param {PointerEvent} event */
|
|
141
|
+
function down(event) {
|
|
142
|
+
if (event.button !== 0) return;
|
|
143
|
+
active = true;
|
|
144
|
+
startX = event.clientX;
|
|
145
|
+
startWidth = measure();
|
|
146
|
+
node.setPointerCapture(event.pointerId);
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
current.onactive?.(true);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {PointerEvent} event */
|
|
152
|
+
function move(event) {
|
|
153
|
+
if (!active) return;
|
|
154
|
+
frames.schedule(widthAt(event.clientX));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** @param {PointerEvent} event */
|
|
158
|
+
function up(event) {
|
|
159
|
+
if (!active) return;
|
|
160
|
+
active = false;
|
|
161
|
+
const width = widthAt(event.clientX);
|
|
162
|
+
frames.flush(width);
|
|
163
|
+
try {
|
|
164
|
+
node.releasePointerCapture(event.pointerId);
|
|
165
|
+
} catch {
|
|
166
|
+
// Pointer capture may already have been released by the browser.
|
|
167
|
+
}
|
|
168
|
+
current.onactive?.(false);
|
|
169
|
+
current.oncommit?.(width);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {KeyboardEvent} event */
|
|
173
|
+
function keydown(event) {
|
|
174
|
+
const step = current.step ?? 16;
|
|
175
|
+
/** @type {number | undefined} */
|
|
176
|
+
let next;
|
|
177
|
+
switch (event.key) {
|
|
178
|
+
case 'ArrowLeft':
|
|
179
|
+
next = measure() - step * direction();
|
|
180
|
+
break;
|
|
181
|
+
case 'ArrowRight':
|
|
182
|
+
next = measure() + step * direction();
|
|
183
|
+
break;
|
|
184
|
+
case 'Home':
|
|
185
|
+
next = current.min;
|
|
186
|
+
break;
|
|
187
|
+
case 'End':
|
|
188
|
+
next = current.max;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (next === undefined) return;
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
const width = clamp(next);
|
|
194
|
+
current.onwidth(width);
|
|
195
|
+
current.oncommit?.(width);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function reset() {
|
|
199
|
+
current.onreset?.();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
node.addEventListener('pointerdown', down);
|
|
203
|
+
node.addEventListener('pointermove', move);
|
|
204
|
+
node.addEventListener('pointerup', up);
|
|
205
|
+
node.addEventListener('pointercancel', up);
|
|
206
|
+
node.addEventListener('keydown', keydown);
|
|
207
|
+
node.addEventListener('dblclick', reset);
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
/** @param {ResizeHandleParams} next */
|
|
211
|
+
update(next) {
|
|
212
|
+
current = next;
|
|
213
|
+
},
|
|
214
|
+
destroy() {
|
|
215
|
+
node.removeEventListener('pointerdown', down);
|
|
216
|
+
node.removeEventListener('pointermove', move);
|
|
217
|
+
node.removeEventListener('pointerup', up);
|
|
218
|
+
node.removeEventListener('pointercancel', up);
|
|
219
|
+
node.removeEventListener('keydown', keydown);
|
|
220
|
+
node.removeEventListener('dblclick', reset);
|
|
221
|
+
frames.discard();
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
label = 'Copy',
|
|
14
14
|
copiedLabel = 'Copied',
|
|
15
15
|
variant = 'ghost',
|
|
16
|
-
showLabel
|
|
16
|
+
showLabel,
|
|
17
|
+
box,
|
|
18
|
+
hitArea = 'auto',
|
|
17
19
|
resetMs = 1500,
|
|
18
20
|
class: klass = ''
|
|
19
21
|
}: {
|
|
@@ -22,11 +24,16 @@
|
|
|
22
24
|
label?: string;
|
|
23
25
|
copiedLabel?: string;
|
|
24
26
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
27
|
+
/** Defaults to true, or false when `box` makes it icon-only. */
|
|
25
28
|
showLabel?: boolean;
|
|
29
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`): a square icon-only copy control. */
|
|
30
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
31
|
+
hitArea?: 'auto' | 'compact';
|
|
26
32
|
resetMs?: number;
|
|
27
33
|
class?: string;
|
|
28
34
|
} = $props();
|
|
29
35
|
|
|
36
|
+
const labelShown = $derived(showLabel ?? box === undefined);
|
|
30
37
|
let copied = $state(false);
|
|
31
38
|
let status = $state('');
|
|
32
39
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -46,12 +53,14 @@
|
|
|
46
53
|
<Button
|
|
47
54
|
data-tsu="CopyButton"
|
|
48
55
|
{variant}
|
|
56
|
+
{box}
|
|
57
|
+
{hitArea}
|
|
49
58
|
class={klass}
|
|
50
59
|
onclick={copy}
|
|
51
60
|
aria-label={copied ? copiedLabel : label}
|
|
52
61
|
title={label}
|
|
53
62
|
>
|
|
54
63
|
<Icon name={copied ? 'check' : 'copy'} />
|
|
55
|
-
{#if
|
|
64
|
+
{#if labelShown}<span>{copied ? copiedLabel : label}</span>{/if}
|
|
56
65
|
</Button>
|
|
57
66
|
<span class="sr-only" role="status" aria-live="polite">{status}</span>
|
|
@@ -4,7 +4,11 @@ type $$ComponentProps = {
|
|
|
4
4
|
label?: string;
|
|
5
5
|
copiedLabel?: string;
|
|
6
6
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
7
|
+
/** Defaults to true, or false when `box` makes it icon-only. */
|
|
7
8
|
showLabel?: boolean;
|
|
9
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`): a square icon-only copy control. */
|
|
10
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
11
|
+
hitArea?: 'auto' | 'compact';
|
|
8
12
|
resetMs?: number;
|
|
9
13
|
class?: string;
|
|
10
14
|
};
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
disabled = false,
|
|
17
17
|
variant = 'default',
|
|
18
18
|
size = 'md',
|
|
19
|
+
box,
|
|
20
|
+
hitArea = 'auto',
|
|
19
21
|
class: klass = ''
|
|
20
22
|
}: {
|
|
21
23
|
onfiles: (files: File[]) => void;
|
|
@@ -36,9 +38,15 @@
|
|
|
36
38
|
/** Match the Button atom's sizes so a FileButton lines up with buttons in
|
|
37
39
|
* the same row. */
|
|
38
40
|
size?: 'sm' | 'md' | 'lg';
|
|
41
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
|
|
42
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
43
|
+
/** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
|
|
44
|
+
hitArea?: 'auto' | 'compact';
|
|
39
45
|
class?: string;
|
|
40
46
|
} = $props();
|
|
41
47
|
|
|
48
|
+
const onlyIcon = $derived(iconOnly || box !== undefined);
|
|
49
|
+
|
|
42
50
|
function onchange(e: Event) {
|
|
43
51
|
const input = e.currentTarget as HTMLInputElement;
|
|
44
52
|
const files = Array.from(input.files ?? []);
|
|
@@ -54,9 +62,12 @@
|
|
|
54
62
|
class:ghost={variant === 'ghost'}
|
|
55
63
|
class:sm={size === 'sm'}
|
|
56
64
|
class:lg={size === 'lg'}
|
|
57
|
-
class:icon-only={
|
|
65
|
+
class:icon-only={onlyIcon}
|
|
66
|
+
class:box={box !== undefined}
|
|
67
|
+
class:hit-compact={hitArea === 'compact'}
|
|
68
|
+
style:--file-box={box ? `var(--box-${box})` : undefined}
|
|
58
69
|
class:disabled
|
|
59
|
-
aria-label={
|
|
70
|
+
aria-label={onlyIcon ? label : undefined}
|
|
60
71
|
>
|
|
61
72
|
<input
|
|
62
73
|
class="sr-only"
|
|
@@ -71,7 +82,7 @@
|
|
|
71
82
|
{:else if emoji}
|
|
72
83
|
<span class="emoji" aria-hidden="true">{emoji}</span>
|
|
73
84
|
{/if}
|
|
74
|
-
<span class:sr-only={
|
|
85
|
+
<span class:sr-only={onlyIcon}>{label}</span>
|
|
75
86
|
</label>
|
|
76
87
|
|
|
77
88
|
<style>
|
|
@@ -120,6 +131,24 @@
|
|
|
120
131
|
padding: var(--sp-2);
|
|
121
132
|
aspect-ratio: 1;
|
|
122
133
|
}
|
|
134
|
+
.file-btn.box {
|
|
135
|
+
width: var(--file-box);
|
|
136
|
+
min-width: var(--file-box);
|
|
137
|
+
height: var(--file-box);
|
|
138
|
+
min-height: var(--file-box);
|
|
139
|
+
padding: 0;
|
|
140
|
+
flex: none;
|
|
141
|
+
}
|
|
142
|
+
@media (pointer: coarse) {
|
|
143
|
+
.file-btn.icon-only:not(.hit-compact) {
|
|
144
|
+
position: relative;
|
|
145
|
+
}
|
|
146
|
+
.file-btn.icon-only:not(.hit-compact)::after {
|
|
147
|
+
content: '';
|
|
148
|
+
position: absolute;
|
|
149
|
+
inset: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
123
152
|
/* Emoji glyph is bumped above the text size — at 1em a paperclip is hard to
|
|
124
153
|
read, so render it a touch larger for legibility. */
|
|
125
154
|
.emoji {
|
|
@@ -18,6 +18,10 @@ type $$ComponentProps = {
|
|
|
18
18
|
/** Match the Button atom's sizes so a FileButton lines up with buttons in
|
|
19
19
|
* the same row. */
|
|
20
20
|
size?: 'sm' | 'md' | 'lg';
|
|
21
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
|
|
22
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
23
|
+
/** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
|
|
24
|
+
hitArea?: 'auto' | 'compact';
|
|
21
25
|
class?: string;
|
|
22
26
|
};
|
|
23
27
|
declare const FileButton: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
@@ -18,9 +18,18 @@
|
|
|
18
18
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
19
19
|
// Semantic state tint (forwarded to Button). Pairs well with `chip`.
|
|
20
20
|
tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
|
|
21
|
-
//
|
|
21
|
+
// Outlined `--box-lg` square icon-chip (header/toolbar actions).
|
|
22
22
|
chip?: boolean;
|
|
23
|
+
// Shared square box scale (`--box-xs/sm/md/lg`); default `md` is the classic
|
|
24
|
+
// 2.25rem box. Forwarded to Button.
|
|
25
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
26
|
+
// SVG glyph px. Emoji text glyphs render at ×1.35 of it.
|
|
23
27
|
size?: number;
|
|
28
|
+
// Exact glyph size for SVG *and* text glyphs (px number or any CSS length),
|
|
29
|
+
// no emoji multiplier. Wins over `size`.
|
|
30
|
+
glyphSize?: number | string;
|
|
31
|
+
// Opt out of the 44px coarse-pointer hit slab in dense rows.
|
|
32
|
+
hitArea?: 'auto' | 'compact';
|
|
24
33
|
// Borderless, compact icon affordance (chip-remove ✕, inline edit ✎) —
|
|
25
34
|
// no square box; just a muted glyph that brightens on hover. Pair with
|
|
26
35
|
// `hoverDanger` to tint it red on hover (delete affordances).
|
|
@@ -46,7 +55,10 @@
|
|
|
46
55
|
variant = 'ghost',
|
|
47
56
|
tone = 'none',
|
|
48
57
|
chip = false,
|
|
58
|
+
box,
|
|
49
59
|
size = 18,
|
|
60
|
+
glyphSize,
|
|
61
|
+
hitArea = 'auto',
|
|
50
62
|
inline = false,
|
|
51
63
|
hoverDanger = false,
|
|
52
64
|
pressed,
|
|
@@ -55,6 +67,11 @@
|
|
|
55
67
|
class: klass = '',
|
|
56
68
|
...rest
|
|
57
69
|
}: IconButtonProps = $props();
|
|
70
|
+
|
|
71
|
+
const glyphCss = $derived(
|
|
72
|
+
glyphSize === undefined ? undefined : typeof glyphSize === 'number' ? `${glyphSize}px` : glyphSize
|
|
73
|
+
);
|
|
74
|
+
const emojiCss = $derived(glyphCss ?? `${size * 1.35}px`);
|
|
58
75
|
</script>
|
|
59
76
|
|
|
60
77
|
<!-- Composition: the icon-only button is a Button (canonical control styling)
|
|
@@ -65,6 +82,8 @@
|
|
|
65
82
|
{variant}
|
|
66
83
|
{tone}
|
|
67
84
|
{chip}
|
|
85
|
+
{box}
|
|
86
|
+
{hitArea}
|
|
68
87
|
{disabled}
|
|
69
88
|
{title}
|
|
70
89
|
{onclick}
|
|
@@ -76,19 +95,28 @@
|
|
|
76
95
|
aria-label={label}
|
|
77
96
|
>
|
|
78
97
|
{#if children}
|
|
79
|
-
|
|
98
|
+
{#if glyphCss}
|
|
99
|
+
<span class="glyph" style="font-size: {glyphCss}"><Icon>{@render children()}</Icon></span>
|
|
100
|
+
{:else}
|
|
101
|
+
<Icon {size}>{@render children()}</Icon>
|
|
102
|
+
{/if}
|
|
80
103
|
{:else if emoji}
|
|
81
|
-
<span class="emoji" style="font-size: {
|
|
104
|
+
<span class="emoji" style="font-size: {emojiCss}" aria-hidden="true">{emoji}</span>
|
|
82
105
|
{:else if icon}
|
|
83
|
-
|
|
106
|
+
{#if glyphCss}
|
|
107
|
+
<span class="glyph" style="font-size: {glyphCss}"><Icon name={icon} /></span>
|
|
108
|
+
{:else}
|
|
109
|
+
<Icon name={icon} {size} />
|
|
110
|
+
{/if}
|
|
84
111
|
{/if}
|
|
85
112
|
</Button>
|
|
86
113
|
|
|
87
114
|
<style>
|
|
88
115
|
/* Off-registry glyph (emoji) rendered as text rather than an SVG. Sized off the
|
|
89
116
|
`size` prop (×1.35, since an emoji reads small next to an SVG glyph of the
|
|
90
|
-
same px)
|
|
91
|
-
.emoji
|
|
117
|
+
same px) unless `glyphSize` is exact; centered so it shares the tap target. */
|
|
118
|
+
.emoji,
|
|
119
|
+
.glyph {
|
|
92
120
|
display: inline-flex;
|
|
93
121
|
align-items: center;
|
|
94
122
|
justify-content: center;
|
|
@@ -14,7 +14,10 @@ type IconButtonProps = HTMLButtonAttributes & {
|
|
|
14
14
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
15
15
|
tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
|
|
16
16
|
chip?: boolean;
|
|
17
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
17
18
|
size?: number;
|
|
19
|
+
glyphSize?: number | string;
|
|
20
|
+
hitArea?: 'auto' | 'compact';
|
|
18
21
|
inline?: boolean;
|
|
19
22
|
hoverDanger?: boolean;
|
|
20
23
|
pressed?: boolean;
|
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
variant,
|
|
28
28
|
tone = 'none',
|
|
29
29
|
size,
|
|
30
|
+
box,
|
|
30
31
|
control = false,
|
|
31
32
|
block = false,
|
|
33
|
+
hitArea = 'auto',
|
|
32
34
|
disabled = false,
|
|
33
35
|
onopen,
|
|
34
36
|
onclose
|
|
@@ -52,9 +54,14 @@
|
|
|
52
54
|
variant?: TriggerVariant;
|
|
53
55
|
tone?: TriggerTone;
|
|
54
56
|
size?: TriggerSize;
|
|
57
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger;
|
|
58
|
+
* the default trigger is the `md` 2.25rem box. */
|
|
59
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
55
60
|
/** Use the shared `--control-height` toolbar/composer contract. */
|
|
56
61
|
control?: boolean;
|
|
57
62
|
block?: boolean;
|
|
63
|
+
/** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
|
|
64
|
+
hitArea?: 'auto' | 'compact';
|
|
58
65
|
disabled?: boolean;
|
|
59
66
|
onopen?: () => void;
|
|
60
67
|
onclose?: () => void;
|
|
@@ -109,6 +116,9 @@
|
|
|
109
116
|
class:trigger-lg={size === 'lg'}
|
|
110
117
|
class:trigger-control={control}
|
|
111
118
|
class:trigger-block={block}
|
|
119
|
+
class:trigger-box={box !== undefined}
|
|
120
|
+
class:hit-compact={hitArea === 'compact'}
|
|
121
|
+
style:--pop-box={box ? `var(--box-${box})` : undefined}
|
|
112
122
|
class:trigger-tone-accent={tone === 'accent'}
|
|
113
123
|
class:trigger-tone-success={tone === 'success'}
|
|
114
124
|
class:trigger-tone-info={tone === 'info'}
|
|
@@ -143,8 +153,8 @@
|
|
|
143
153
|
display: inline-flex;
|
|
144
154
|
align-items: center;
|
|
145
155
|
justify-content: center;
|
|
146
|
-
min-height:
|
|
147
|
-
min-width:
|
|
156
|
+
min-height: var(--box-md);
|
|
157
|
+
min-width: var(--box-md);
|
|
148
158
|
padding: var(--sp-2);
|
|
149
159
|
border: 1px solid transparent;
|
|
150
160
|
border-radius: var(--r-md);
|
|
@@ -221,6 +231,26 @@
|
|
|
221
231
|
.pop-trigger.trigger-block {
|
|
222
232
|
width: 100%;
|
|
223
233
|
}
|
|
234
|
+
.pop-trigger.trigger-box {
|
|
235
|
+
width: var(--pop-box);
|
|
236
|
+
min-width: var(--pop-box);
|
|
237
|
+
height: var(--pop-box);
|
|
238
|
+
min-height: var(--pop-box);
|
|
239
|
+
padding: 0;
|
|
240
|
+
flex: none;
|
|
241
|
+
}
|
|
242
|
+
/* Coarse pointers: icon-only triggers extend their hit area to --touch-target
|
|
243
|
+
via an invisible slab; layout does not move. */
|
|
244
|
+
@media (pointer: coarse) {
|
|
245
|
+
.pop-trigger:not(.canonical, .bare, .hit-compact) {
|
|
246
|
+
position: relative;
|
|
247
|
+
}
|
|
248
|
+
.pop-trigger:not(.canonical, .bare, .hit-compact)::after {
|
|
249
|
+
content: '';
|
|
250
|
+
position: absolute;
|
|
251
|
+
inset: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
224
254
|
.pop-trigger.trigger-tone-accent {
|
|
225
255
|
--pop-trigger-tone: var(--accent);
|
|
226
256
|
}
|
|
@@ -23,9 +23,14 @@ type $$ComponentProps = {
|
|
|
23
23
|
variant?: TriggerVariant;
|
|
24
24
|
tone?: TriggerTone;
|
|
25
25
|
size?: TriggerSize;
|
|
26
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger;
|
|
27
|
+
* the default trigger is the `md` 2.25rem box. */
|
|
28
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
26
29
|
/** Use the shared `--control-height` toolbar/composer contract. */
|
|
27
30
|
control?: boolean;
|
|
28
31
|
block?: boolean;
|
|
32
|
+
/** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
|
|
33
|
+
hitArea?: 'auto' | 'compact';
|
|
29
34
|
disabled?: boolean;
|
|
30
35
|
onopen?: () => void;
|
|
31
36
|
onclose?: () => void;
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
options,
|
|
18
18
|
groups,
|
|
19
19
|
onchange,
|
|
20
|
+
box,
|
|
21
|
+
hitArea = 'auto',
|
|
20
22
|
class: klass = '',
|
|
21
23
|
...rest
|
|
22
24
|
}: {
|
|
@@ -31,6 +33,9 @@
|
|
|
31
33
|
// the theme picker to split light/dark; falls back to `options` otherwise.
|
|
32
34
|
groups?: { label: string; options: Option[] }[];
|
|
33
35
|
onchange: (value: string) => void;
|
|
36
|
+
// Shared square box scale (`--box-xs/sm/md/lg`); default is the 2.25rem icon box.
|
|
37
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
38
|
+
hitArea?: 'auto' | 'compact';
|
|
34
39
|
class?: string;
|
|
35
40
|
[key: string]: unknown;
|
|
36
41
|
} = $props();
|
|
@@ -39,7 +44,7 @@
|
|
|
39
44
|
<!-- The wrapper (owned here, so styled scoped) is the positioning context that
|
|
40
45
|
clips the transparent overlaid <select>; the icon Button shows through. -->
|
|
41
46
|
<span class="select-button {klass}" data-tsu="SelectButton" {...rest}>
|
|
42
|
-
<Button variant="ghost" icon {title} aria-label={label}>
|
|
47
|
+
<Button variant="ghost" icon {box} {hitArea} {title} aria-label={label}>
|
|
43
48
|
<span aria-hidden="true">{glyph}</span>
|
|
44
49
|
<Select
|
|
45
50
|
variant="ghost"
|
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// Theme switcher: a compact icon-button hosting a native <select> over the
|
|
3
|
-
// full theme registry, wired to the global
|
|
4
|
-
// gives correct mobile/keyboard behaviour and
|
|
5
|
-
// The button glyph reflects the active theme
|
|
3
|
+
// full theme registry (built-ins + theme.register()), wired to the global
|
|
4
|
+
// theme store. The native control gives correct mobile/keyboard behaviour and
|
|
5
|
+
// outside-click handling for free. The button glyph reflects the active theme.
|
|
6
6
|
import SelectButton from './SelectButton.svelte';
|
|
7
|
-
import {
|
|
7
|
+
import { type ThemeDef, theme } from '../../stores/theme.svelte';
|
|
8
8
|
|
|
9
9
|
let { class: klass = '' }: { class?: string } = $props();
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
// scannable. `<optgroup>` keeps native popup/keyboard behaviour for free.
|
|
13
|
-
const toOption = (t: (typeof THEMES)[number]) => ({
|
|
11
|
+
const toOption = (t: ThemeDef) => ({
|
|
14
12
|
value: t.id,
|
|
15
|
-
label: `${t.icon} ${t.label}`
|
|
13
|
+
label: `${t.icon ?? theme.fallbackIcon} ${t.label}`
|
|
16
14
|
});
|
|
17
|
-
const groups = [
|
|
18
|
-
{ label: '— light', options:
|
|
19
|
-
{ label: '— dark', options:
|
|
20
|
-
];
|
|
15
|
+
const groups = $derived([
|
|
16
|
+
{ label: '— light', options: theme.all.filter((t) => t.mode === 'light').map(toOption) },
|
|
17
|
+
{ label: '— dark', options: theme.all.filter((t) => t.mode === 'dark').map(toOption) }
|
|
18
|
+
]);
|
|
21
19
|
</script>
|
|
22
20
|
|
|
23
21
|
<SelectButton
|
|
@@ -28,5 +26,5 @@
|
|
|
28
26
|
title={`Theme: ${theme.label}`}
|
|
29
27
|
value={theme.current}
|
|
30
28
|
{groups}
|
|
31
|
-
onchange={(v) => theme.set(v
|
|
29
|
+
onchange={(v) => theme.set(v)}
|
|
32
30
|
/>
|