@dorsk/tsumikit 0.59.1 → 0.60.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
CHANGED
|
@@ -613,6 +613,20 @@ stacked — or render free-form `children`, where each direct child is a cell.
|
|
|
613
613
|
The grid is visual only: DOM order, tab order and reading order are those of
|
|
614
614
|
the inline run. `--glyph-stack-gap` sets the inline gap (default `--sp-1`).
|
|
615
615
|
|
|
616
|
+
Folded cells are 1rem — hard to hit on touch. `expand` none | tap | slide
|
|
617
|
+
(default `none`, `items` only, active while stacked) makes a press open the
|
|
618
|
+
four glyphs as big tiles in a top-layer panel over the stack. `tap` opens it
|
|
619
|
+
on click and keeps it open, so the star can be clicked, a status dot hovered
|
|
620
|
+
and a badge read (dismiss by pressing outside or Escape); keyboard activation
|
|
621
|
+
still goes straight to the cell. `slide` opens it on press: drag toward a
|
|
622
|
+
corner to arm that tile and release to fire it — the item's `action`, else a
|
|
623
|
+
click on the tile's first control. The panel closes after firing unless that
|
|
624
|
+
opened a nested popover; a release in place, or on a tile with nothing to
|
|
625
|
+
fire, leaves it open as in `tap`. An item's `expanded` snippet replaces
|
|
626
|
+
`inline` in its tile; `--glyph-stack-tile` (default `3rem`) and
|
|
627
|
+
`--glyph-stack-zoom` (default `1.75`) size the tiles; `expandLabel` names the
|
|
628
|
+
panel.
|
|
629
|
+
|
|
616
630
|
```svelte
|
|
617
631
|
<GlyphStack stackBelow="34rem" items={[
|
|
618
632
|
{ inline: pin },
|
|
@@ -620,6 +634,13 @@ the inline run. `--glyph-stack-gap` sets the inline gap (default `--sp-1`).
|
|
|
620
634
|
{ inline: machine, stacked: machineTile },
|
|
621
635
|
{ inline: avatar }
|
|
622
636
|
]} />
|
|
637
|
+
|
|
638
|
+
<GlyphStack stack="always" expand="slide" items={[
|
|
639
|
+
{ inline: pin, action: togglePin },
|
|
640
|
+
{ inline: dot },
|
|
641
|
+
{ inline: machine, stacked: machineTile },
|
|
642
|
+
{ inline: avatar }
|
|
643
|
+
]} />
|
|
623
644
|
```
|
|
624
645
|
|
|
625
646
|
### SplitButton
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
inline: Snippet | string;
|
|
7
7
|
/** Compact variant for a 1rem grid cell (e.g. a tinted initial tile for a full Badge). */
|
|
8
8
|
stacked?: Snippet | string;
|
|
9
|
+
/** Content of the enlarged tile when `expand` is on; defaults to `inline`. */
|
|
10
|
+
expanded?: Snippet | string;
|
|
11
|
+
/** Fired by a slide-release on this tile, instead of clicking its first control. */
|
|
12
|
+
action?: () => void;
|
|
9
13
|
}
|
|
10
14
|
</script>
|
|
11
15
|
|
|
@@ -13,6 +17,7 @@
|
|
|
13
17
|
// Up to four small glyphs (pin, status dot, entity tag, avatar) as an inline
|
|
14
18
|
// run, folded into one 2×2 grid of 1rem cells when the row is tight. The grid
|
|
15
19
|
// is purely visual: DOM order, focus order and reading order never change.
|
|
20
|
+
import { tick } from 'svelte';
|
|
16
21
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
17
22
|
import { browser } from '../../env';
|
|
18
23
|
|
|
@@ -28,6 +33,12 @@
|
|
|
28
33
|
stackBelow?: string;
|
|
29
34
|
/** `container-name` of the ancestor to measure instead of the nearest one. */
|
|
30
35
|
container?: string;
|
|
36
|
+
/** While stacked (`items` only), a press opens the four glyphs as big tiles
|
|
37
|
+
* over the stack. `tap` keeps them open to hover, click and read; `slide`
|
|
38
|
+
* also lets you drag toward a corner and release to fire that tile. */
|
|
39
|
+
expand?: 'none' | 'tap' | 'slide';
|
|
40
|
+
/** Accessible name of the expanded panel. */
|
|
41
|
+
expandLabel?: string;
|
|
31
42
|
class?: string;
|
|
32
43
|
};
|
|
33
44
|
let {
|
|
@@ -36,6 +47,8 @@
|
|
|
36
47
|
stack = 'auto',
|
|
37
48
|
stackBelow = '30rem',
|
|
38
49
|
container,
|
|
50
|
+
expand = 'none',
|
|
51
|
+
expandLabel = 'Details',
|
|
39
52
|
class: klass = '',
|
|
40
53
|
...rest
|
|
41
54
|
}: Omit<HTMLAttributes<HTMLSpanElement>, keyof Own> & Own = $props();
|
|
@@ -65,6 +78,143 @@
|
|
|
65
78
|
return el.parentElement;
|
|
66
79
|
}
|
|
67
80
|
|
|
81
|
+
const SLIDE_THRESHOLD = 12;
|
|
82
|
+
const CONTROL = 'button, a[href], input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])';
|
|
83
|
+
|
|
84
|
+
const tiles = $derived(items?.slice(0, 4) ?? []);
|
|
85
|
+
const expandable = $derived(expand !== 'none' && stacked && tiles.length > 0);
|
|
86
|
+
let panel = $state<HTMLDivElement>();
|
|
87
|
+
let open = $state(false);
|
|
88
|
+
let armed = $state(-1);
|
|
89
|
+
let origin: { x: number; y: number } | null = null;
|
|
90
|
+
|
|
91
|
+
function show() {
|
|
92
|
+
try {
|
|
93
|
+
panel?.showPopover();
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hide() {
|
|
98
|
+
try {
|
|
99
|
+
panel?.hidePopover();
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function nestedOpen(): boolean {
|
|
104
|
+
try {
|
|
105
|
+
return !!panel?.querySelector(':popover-open');
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function position() {
|
|
112
|
+
if (!root || !panel) return;
|
|
113
|
+
const r = root.getBoundingClientRect();
|
|
114
|
+
const p = panel.getBoundingClientRect();
|
|
115
|
+
const { clientWidth: vw, clientHeight: vh } = document.documentElement;
|
|
116
|
+
const clamp = (v: number, max: number) => Math.max(8, Math.min(v, max - 8));
|
|
117
|
+
panel.style.left = `${clamp(r.left + r.width / 2 - p.width / 2, vw - p.width)}px`;
|
|
118
|
+
panel.style.top = `${clamp(r.top + r.height / 2 - p.height / 2, vh - p.height)}px`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function onToggle(e: ToggleEvent) {
|
|
122
|
+
open = e.newState === 'open';
|
|
123
|
+
if (!open) {
|
|
124
|
+
armed = -1;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await tick();
|
|
128
|
+
position();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Tile under a drag from the press point: 0 top-left, 1 top-right, 2 bottom-left, 3 bottom-right. */
|
|
132
|
+
function corner(dx: number, dy: number): number {
|
|
133
|
+
if (Math.hypot(dx, dy) < SLIDE_THRESHOLD) return -1;
|
|
134
|
+
const i = (dy < 0 ? 0 : 2) + (dx < 0 ? 0 : 1);
|
|
135
|
+
return i < tiles.length ? i : -1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function activate(i: number) {
|
|
139
|
+
const { action } = tiles[i];
|
|
140
|
+
if (action) {
|
|
141
|
+
action();
|
|
142
|
+
hide();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const control = panel?.querySelectorAll('.tile')[i]?.querySelector<HTMLElement>(CONTROL);
|
|
146
|
+
if (!control) return;
|
|
147
|
+
control.click();
|
|
148
|
+
await tick();
|
|
149
|
+
if (!nestedOpen()) hide();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function onPointerDown(e: PointerEvent) {
|
|
153
|
+
if (!expandable || expand !== 'slide' || e.button !== 0) return;
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
e.stopPropagation();
|
|
156
|
+
origin = { x: e.clientX, y: e.clientY };
|
|
157
|
+
armed = -1;
|
|
158
|
+
try {
|
|
159
|
+
root?.setPointerCapture(e.pointerId);
|
|
160
|
+
} catch {}
|
|
161
|
+
show();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function onPointerMove(e: PointerEvent) {
|
|
165
|
+
if (origin) armed = corner(e.clientX - origin.x, e.clientY - origin.y);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function onPointerUp() {
|
|
169
|
+
if (!origin) return;
|
|
170
|
+
origin = null;
|
|
171
|
+
const i = armed;
|
|
172
|
+
armed = -1;
|
|
173
|
+
if (i >= 0) activate(i);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onPointerCancel() {
|
|
177
|
+
origin = null;
|
|
178
|
+
armed = -1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Pointer clicks on the folded cells open the panel instead of hitting the
|
|
182
|
+
// 1rem targets; keyboard activation (`detail` 0) still reaches them directly.
|
|
183
|
+
function onClickCapture(e: MouseEvent) {
|
|
184
|
+
if (!expandable || e.detail === 0) return;
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
e.stopPropagation();
|
|
187
|
+
if (expand === 'tap') {
|
|
188
|
+
if (open) hide();
|
|
189
|
+
else show();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
$effect(() => {
|
|
194
|
+
if (!expandable && open) hide();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
$effect(() => {
|
|
198
|
+
if (!open) return;
|
|
199
|
+
const onDown = (e: PointerEvent) => {
|
|
200
|
+
const t = e.target as Node;
|
|
201
|
+
if (!panel?.contains(t) && !root?.contains(t)) hide();
|
|
202
|
+
};
|
|
203
|
+
const onKey = (e: KeyboardEvent) => {
|
|
204
|
+
if (e.key === 'Escape' && !nestedOpen()) hide();
|
|
205
|
+
};
|
|
206
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
207
|
+
document.addEventListener('keydown', onKey);
|
|
208
|
+
addEventListener('scroll', position, true);
|
|
209
|
+
addEventListener('resize', position);
|
|
210
|
+
return () => {
|
|
211
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
212
|
+
document.removeEventListener('keydown', onKey);
|
|
213
|
+
removeEventListener('scroll', position, true);
|
|
214
|
+
removeEventListener('resize', position);
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
68
218
|
$effect(() => {
|
|
69
219
|
if (!browser || !root || stack !== 'auto') return;
|
|
70
220
|
const el = root;
|
|
@@ -86,10 +236,16 @@
|
|
|
86
236
|
class:stacked
|
|
87
237
|
data-stack={stack}
|
|
88
238
|
data-stacked={stacked || undefined}
|
|
239
|
+
data-expand={expandable ? expand : undefined}
|
|
89
240
|
{...rest}
|
|
241
|
+
onpointerdown={onPointerDown}
|
|
242
|
+
onpointermove={onPointerMove}
|
|
243
|
+
onpointerup={onPointerUp}
|
|
244
|
+
onpointercancel={onPointerCancel}
|
|
245
|
+
onclickcapture={onClickCapture}
|
|
90
246
|
>
|
|
91
247
|
{#if items}
|
|
92
|
-
{#each
|
|
248
|
+
{#each tiles as item, i (i)}
|
|
93
249
|
{@const glyph = stacked && item.stacked !== undefined ? item.stacked : item.inline}
|
|
94
250
|
<span class="cell">
|
|
95
251
|
{#if typeof glyph === 'string'}{glyph}{:else}{@render glyph()}{/if}
|
|
@@ -100,6 +256,30 @@
|
|
|
100
256
|
{/if}
|
|
101
257
|
</span>
|
|
102
258
|
|
|
259
|
+
{#if expand !== 'none' && items}
|
|
260
|
+
<!-- `manual`: an `auto` popover would light-dismiss on the captured slide release. -->
|
|
261
|
+
<div
|
|
262
|
+
bind:this={panel}
|
|
263
|
+
popover="manual"
|
|
264
|
+
class="expand-panel"
|
|
265
|
+
data-expand-panel={expand}
|
|
266
|
+
role="group"
|
|
267
|
+
aria-label={expandLabel}
|
|
268
|
+
ontoggle={onToggle}
|
|
269
|
+
>
|
|
270
|
+
{#if open}
|
|
271
|
+
{#each tiles as item, i (i)}
|
|
272
|
+
{@const glyph = item.expanded ?? item.inline}
|
|
273
|
+
<span class="tile" class:armed={armed === i} data-armed={armed === i || undefined}>
|
|
274
|
+
<span class="tile-body">
|
|
275
|
+
{#if typeof glyph === 'string'}{glyph}{:else}{@render glyph()}{/if}
|
|
276
|
+
</span>
|
|
277
|
+
</span>
|
|
278
|
+
{/each}
|
|
279
|
+
{/if}
|
|
280
|
+
</div>
|
|
281
|
+
{/if}
|
|
282
|
+
|
|
103
283
|
<style>
|
|
104
284
|
.glyph-stack {
|
|
105
285
|
display: inline-flex;
|
|
@@ -133,4 +313,56 @@
|
|
|
133
313
|
font-size: var(--fs-xs);
|
|
134
314
|
line-height: 1;
|
|
135
315
|
}
|
|
316
|
+
.glyph-stack[data-expand] {
|
|
317
|
+
cursor: pointer;
|
|
318
|
+
}
|
|
319
|
+
.glyph-stack[data-expand='slide'] {
|
|
320
|
+
touch-action: none;
|
|
321
|
+
user-select: none;
|
|
322
|
+
-webkit-touch-callout: none;
|
|
323
|
+
}
|
|
324
|
+
.expand-panel {
|
|
325
|
+
position: fixed;
|
|
326
|
+
inset: auto;
|
|
327
|
+
margin: 0;
|
|
328
|
+
padding: var(--sp-1);
|
|
329
|
+
max-width: calc(100vw - 2 * var(--sp-2));
|
|
330
|
+
background: var(--bg-elevated);
|
|
331
|
+
color: var(--text);
|
|
332
|
+
border: 1px solid var(--border-strong);
|
|
333
|
+
border-radius: var(--r-md);
|
|
334
|
+
box-shadow: var(--shadow-md);
|
|
335
|
+
}
|
|
336
|
+
.expand-panel:popover-open {
|
|
337
|
+
display: grid;
|
|
338
|
+
grid-template-columns: repeat(2, minmax(var(--glyph-stack-tile, 3rem), 1fr));
|
|
339
|
+
grid-auto-rows: minmax(var(--glyph-stack-tile, 3rem), 1fr);
|
|
340
|
+
gap: var(--sp-1);
|
|
341
|
+
animation: glyph-expand 0.12s var(--ease);
|
|
342
|
+
}
|
|
343
|
+
@keyframes glyph-expand {
|
|
344
|
+
from {
|
|
345
|
+
opacity: 0;
|
|
346
|
+
transform: scale(0.6);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
.tile {
|
|
350
|
+
display: flex;
|
|
351
|
+
align-items: center;
|
|
352
|
+
justify-content: center;
|
|
353
|
+
padding: var(--sp-1);
|
|
354
|
+
border-radius: var(--r-sm);
|
|
355
|
+
background: var(--surface);
|
|
356
|
+
transition: background 0.08s var(--ease);
|
|
357
|
+
}
|
|
358
|
+
.tile.armed {
|
|
359
|
+
background: color-mix(in srgb, var(--accent) 18%, var(--surface));
|
|
360
|
+
box-shadow: inset 0 0 0 2px var(--accent);
|
|
361
|
+
}
|
|
362
|
+
.tile-body {
|
|
363
|
+
display: inline-flex;
|
|
364
|
+
align-items: center;
|
|
365
|
+
justify-content: center;
|
|
366
|
+
zoom: var(--glyph-stack-zoom, 1.75);
|
|
367
|
+
}
|
|
136
368
|
</style>
|
|
@@ -4,6 +4,10 @@ export interface GlyphStackItem {
|
|
|
4
4
|
inline: Snippet | string;
|
|
5
5
|
/** Compact variant for a 1rem grid cell (e.g. a tinted initial tile for a full Badge). */
|
|
6
6
|
stacked?: Snippet | string;
|
|
7
|
+
/** Content of the enlarged tile when `expand` is on; defaults to `inline`. */
|
|
8
|
+
expanded?: Snippet | string;
|
|
9
|
+
/** Fired by a slide-release on this tile, instead of clicking its first control. */
|
|
10
|
+
action?: () => void;
|
|
7
11
|
}
|
|
8
12
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
9
13
|
type Own = {
|
|
@@ -18,6 +22,12 @@ type Own = {
|
|
|
18
22
|
stackBelow?: string;
|
|
19
23
|
/** `container-name` of the ancestor to measure instead of the nearest one. */
|
|
20
24
|
container?: string;
|
|
25
|
+
/** While stacked (`items` only), a press opens the four glyphs as big tiles
|
|
26
|
+
* over the stack. `tap` keeps them open to hover, click and read; `slide`
|
|
27
|
+
* also lets you drag toward a corner and release to fire that tile. */
|
|
28
|
+
expand?: 'none' | 'tap' | 'slide';
|
|
29
|
+
/** Accessible name of the expanded panel. */
|
|
30
|
+
expandLabel?: string;
|
|
21
31
|
class?: string;
|
|
22
32
|
};
|
|
23
33
|
type $$ComponentProps = Omit<HTMLAttributes<HTMLSpanElement>, keyof Own> & Own;
|
|
@@ -158,13 +158,21 @@
|
|
|
158
158
|
top: 0;
|
|
159
159
|
height: auto;
|
|
160
160
|
}
|
|
161
|
-
/* Until the field grows into a bar, the adornments fill
|
|
162
|
-
outer
|
|
161
|
+
/* Until the field grows into a bar, the adornments fill it inside its 1px
|
|
162
|
+
border, their outer corners following the field's; inner corners go square. */
|
|
163
163
|
.ig:not(.ig-bar) .ig-adorn {
|
|
164
|
-
|
|
164
|
+
--ig-fuse-r: calc(var(--ig-radius) - 1px);
|
|
165
|
+
top: 1px;
|
|
166
|
+
bottom: 1px;
|
|
165
167
|
height: auto;
|
|
166
168
|
padding-inline: 0;
|
|
167
169
|
}
|
|
170
|
+
.ig:not(.ig-bar) .ig-leading {
|
|
171
|
+
inset-inline-start: 1px;
|
|
172
|
+
}
|
|
173
|
+
.ig:not(.ig-bar) .ig-trailing {
|
|
174
|
+
inset-inline-end: 1px;
|
|
175
|
+
}
|
|
168
176
|
.ig:not(.ig-bar) .ig-adorn > :global(*) {
|
|
169
177
|
align-self: stretch;
|
|
170
178
|
height: auto;
|
|
@@ -180,12 +188,12 @@
|
|
|
180
188
|
aspect-ratio: 1;
|
|
181
189
|
}
|
|
182
190
|
.ig:not(.ig-bar) .ig-leading > :global(:first-child) {
|
|
183
|
-
--btn-radius: var(--ig-
|
|
184
|
-
border-radius: var(--ig-
|
|
191
|
+
--btn-radius: var(--ig-fuse-r) 0 0 var(--ig-fuse-r);
|
|
192
|
+
border-radius: var(--ig-fuse-r) 0 0 var(--ig-fuse-r);
|
|
185
193
|
}
|
|
186
194
|
.ig:not(.ig-bar) .ig-trailing > :global(:last-child) {
|
|
187
|
-
--btn-radius: 0 var(--ig-
|
|
188
|
-
border-radius: 0 var(--ig-
|
|
195
|
+
--btn-radius: 0 var(--ig-fuse-r) var(--ig-fuse-r) 0;
|
|
196
|
+
border-radius: 0 var(--ig-fuse-r) var(--ig-fuse-r) 0;
|
|
189
197
|
}
|
|
190
198
|
.ig:not(.ig-bar) .ig-adorn :global(.split-main) {
|
|
191
199
|
--btn-radius: 0;
|
|
@@ -197,10 +205,10 @@
|
|
|
197
205
|
height: 100%;
|
|
198
206
|
}
|
|
199
207
|
.ig:not(.ig-bar) .ig-leading > :global(:first-child .split-main) {
|
|
200
|
-
--btn-radius: var(--ig-
|
|
208
|
+
--btn-radius: var(--ig-fuse-r) 0 0 var(--ig-fuse-r);
|
|
201
209
|
}
|
|
202
210
|
.ig:not(.ig-bar) .ig-trailing > :global(:last-child .split-caret) {
|
|
203
|
-
--pop-trigger-radius: 0 var(--ig-
|
|
211
|
+
--pop-trigger-radius: 0 var(--ig-fuse-r) var(--ig-fuse-r) 0;
|
|
204
212
|
}
|
|
205
213
|
.ig-bar .ig-field {
|
|
206
214
|
padding-bottom: var(--ig-h);
|
package/package.json
CHANGED