@dorsk/tsumikit 0.59.2 → 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 items.slice(0, 4) as item, i (i)}
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.59.2",
3
+ "version": "0.60.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",