@dorsk/tsumikit 0.59.2 → 0.60.1

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
@@ -524,7 +524,8 @@ clipped to the space between the adornments). Once a `Textarea` needs more
524
524
  than one row (content, `rows`, or a dragged floor) the adornments move into a
525
525
  bar under the text: every line then runs the full width with the normal
526
526
  padding, the textarea's box (and its scrollbar) ends above the controls, and
527
- the bar continues the field's border. The textarea measures that in the
527
+ the bar continues the field's border, the controls still flush against its
528
+ bottom and side edges. The textarea measures that in the
528
529
  single-line layout, so the switch cannot flip back and forth; the single-line
529
530
  look is untouched. Tab order is leading → field → trailing; the group shows
530
531
  the focus ring around the whole unit.
@@ -613,6 +614,20 @@ stacked — or render free-form `children`, where each direct child is a cell.
613
614
  The grid is visual only: DOM order, tab order and reading order are those of
614
615
  the inline run. `--glyph-stack-gap` sets the inline gap (default `--sp-1`).
615
616
 
617
+ Folded cells are 1rem — hard to hit on touch. `expand` none | tap | slide
618
+ (default `none`, `items` only, active while stacked) makes a press open the
619
+ four glyphs as big tiles in a top-layer panel over the stack. `tap` opens it
620
+ on click and keeps it open, so the star can be clicked, a status dot hovered
621
+ and a badge read (dismiss by pressing outside or Escape); keyboard activation
622
+ still goes straight to the cell. `slide` opens it on press: drag toward a
623
+ corner to arm that tile and release to fire it — the item's `action`, else a
624
+ click on the tile's first control. The panel closes after firing unless that
625
+ opened a nested popover; a release in place, or on a tile with nothing to
626
+ fire, leaves it open as in `tap`. An item's `expanded` snippet replaces
627
+ `inline` in its tile; `--glyph-stack-tile` (default `3rem`) and
628
+ `--glyph-stack-zoom` (default `1.75`) size the tiles; `expandLabel` names the
629
+ panel.
630
+
616
631
  ```svelte
617
632
  <GlyphStack stackBelow="34rem" items={[
618
633
  { inline: pin },
@@ -620,6 +635,13 @@ the inline run. `--glyph-stack-gap` sets the inline gap (default `--sp-1`).
620
635
  { inline: machine, stacked: machineTile },
621
636
  { inline: avatar }
622
637
  ]} />
638
+
639
+ <GlyphStack stack="always" expand="slide" items={[
640
+ { inline: pin, action: togglePin },
641
+ { inline: dot },
642
+ { inline: machine, stacked: machineTile },
643
+ { inline: avatar }
644
+ ]} />
623
645
  ```
624
646
 
625
647
  ### 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;
@@ -134,46 +134,28 @@
134
134
  width: 100%;
135
135
  min-width: 0;
136
136
  }
137
+ /* The adornments fill the field inside its 1px border, their outer corners
138
+ following the field's; inner corners go square. In the bar they hug the
139
+ bottom and side borders, so only the bottom-outer corner is rounded. */
137
140
  .ig-adorn {
141
+ --ig-fuse-r: calc(var(--ig-radius) - 1px);
138
142
  position: absolute;
139
- bottom: 0;
143
+ top: 1px;
144
+ bottom: 1px;
140
145
  z-index: 1;
141
146
  display: flex;
142
147
  align-items: center;
143
148
  gap: var(--ig-inset);
144
- height: var(--ig-h);
145
- padding-inline: var(--ig-inset);
146
149
  pointer-events: none;
147
150
  }
148
- .ig-adorn > :global(*) {
149
- pointer-events: auto;
150
- }
151
151
  .ig-leading {
152
- inset-inline-start: 0;
153
- }
154
- .ig-trailing {
155
- inset-inline-end: 0;
156
- }
157
- .ig-center .ig-adorn {
158
- top: 0;
159
- height: auto;
160
- }
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
- .ig:not(.ig-bar) .ig-adorn {
164
- --ig-fuse-r: calc(var(--ig-radius) - 1px);
165
- top: 1px;
166
- bottom: 1px;
167
- height: auto;
168
- padding-inline: 0;
169
- }
170
- .ig:not(.ig-bar) .ig-leading {
171
152
  inset-inline-start: 1px;
172
153
  }
173
- .ig:not(.ig-bar) .ig-trailing {
154
+ .ig-trailing {
174
155
  inset-inline-end: 1px;
175
156
  }
176
- .ig:not(.ig-bar) .ig-adorn > :global(*) {
157
+ .ig-adorn > :global(*) {
158
+ pointer-events: auto;
177
159
  align-self: stretch;
178
160
  height: auto;
179
161
  min-height: 0;
@@ -182,34 +164,52 @@
182
164
  --pop-trigger-radius: 0;
183
165
  border-radius: 0;
184
166
  }
185
- .ig:not(.ig-bar) .ig-adorn > :global(:is(.btn-box, .btn-square, .btn-icon, .icon-only)) {
167
+ .ig-adorn > :global(:is(.btn-box, .btn-square, .btn-icon, .icon-only)) {
186
168
  width: auto;
187
169
  min-width: 0;
188
170
  aspect-ratio: 1;
189
171
  }
190
- .ig:not(.ig-bar) .ig-leading > :global(:first-child) {
172
+ .ig-leading > :global(:first-child) {
191
173
  --btn-radius: var(--ig-fuse-r) 0 0 var(--ig-fuse-r);
192
174
  border-radius: var(--ig-fuse-r) 0 0 var(--ig-fuse-r);
193
175
  }
194
- .ig:not(.ig-bar) .ig-trailing > :global(:last-child) {
176
+ .ig-trailing > :global(:last-child) {
195
177
  --btn-radius: 0 var(--ig-fuse-r) var(--ig-fuse-r) 0;
196
178
  border-radius: 0 var(--ig-fuse-r) var(--ig-fuse-r) 0;
197
179
  }
198
- .ig:not(.ig-bar) .ig-adorn :global(.split-main) {
180
+ .ig-adorn :global(.split-main) {
199
181
  --btn-radius: 0;
200
182
  }
201
- .ig:not(.ig-bar) .ig-adorn :global(.split-caret) {
183
+ .ig-adorn :global(.split-caret) {
202
184
  --pop-trigger-radius: 0;
203
185
  }
204
- .ig:not(.ig-bar) .ig-adorn :global(.split-caret .pop-trigger) {
186
+ .ig-adorn :global(.split-caret .pop-trigger) {
205
187
  height: 100%;
206
188
  }
207
- .ig:not(.ig-bar) .ig-leading > :global(:first-child .split-main) {
189
+ .ig-leading > :global(:first-child .split-main) {
208
190
  --btn-radius: var(--ig-fuse-r) 0 0 var(--ig-fuse-r);
209
191
  }
210
- .ig:not(.ig-bar) .ig-trailing > :global(:last-child .split-caret) {
192
+ .ig-trailing > :global(:last-child .split-caret) {
211
193
  --pop-trigger-radius: 0 var(--ig-fuse-r) var(--ig-fuse-r) 0;
212
194
  }
195
+ .ig-bar .ig-adorn {
196
+ top: auto;
197
+ height: calc(var(--ig-h) - 1px);
198
+ }
199
+ .ig-bar .ig-leading > :global(:first-child) {
200
+ --btn-radius: 0 0 0 var(--ig-fuse-r);
201
+ border-radius: 0 0 0 var(--ig-fuse-r);
202
+ }
203
+ .ig-bar .ig-trailing > :global(:last-child) {
204
+ --btn-radius: 0 0 var(--ig-fuse-r) 0;
205
+ border-radius: 0 0 var(--ig-fuse-r) 0;
206
+ }
207
+ .ig-bar .ig-leading > :global(:first-child .split-main) {
208
+ --btn-radius: 0 0 0 var(--ig-fuse-r);
209
+ }
210
+ .ig-bar .ig-trailing > :global(:last-child .split-caret) {
211
+ --pop-trigger-radius: 0 0 var(--ig-fuse-r) 0;
212
+ }
213
213
  .ig-bar .ig-field {
214
214
  padding-bottom: var(--ig-h);
215
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.59.2",
3
+ "version": "0.60.1",
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",