@dorsk/tsumikit 0.48.0 → 0.49.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
@@ -181,7 +181,11 @@ EmojiPicker (popover with a searchable EN+FR glyph catalogue, grouped tabs, rovi
181
181
  GitRef (branch chip + PR link tinted by state + `+N −N` diff; `collapse`
182
182
  auto/never/glyph degrades to icons inside a narrow `.cq` container),
183
183
  CapBar (consumption track with a draggable, keyboard-steppable cap handle that
184
- shows a `N%` bubble while it is moved; `oninput` live, `onchange` on commit),
184
+ shows a `N%` bubble while it is moved; `oninput` live, `onchange` on commit;
185
+ `marker`/`markerLabel`/`markerTone` draw a pace rule, `cap: null` plus
186
+ `clearAtMax`/`uncappedLabel` express "no cap", `layout`/`stackBelow` put the
187
+ readout above a full-width track, `capLabel`/`capValueText` localise the ARIA
188
+ strings),
185
189
  Drawer (side-panel `<dialog>`: `side`, `width` clamped to the viewport, full-screen
186
190
  under 48rem; `nav` page column that turns into a horizontal strip on narrow
187
191
  screens, or `navMobile`; sticky `footer`; Escape / scrim / close button all close),
package/dist/cap-bar.d.ts CHANGED
@@ -5,4 +5,9 @@ export declare function capFromPointer(clientX: number, rect: {
5
5
  width: number;
6
6
  }, step?: number, min?: number, max?: number): number;
7
7
  export declare function capKeyStep(key: string, shift: boolean, current: number, step?: number, min?: number, max?: number): number | null;
8
- export declare function capTone(value: number, cap: number, warnAt?: number): 'ok' | 'warn' | 'danger';
8
+ export declare function capTone(value: number, cap: number | null, warnAt?: number): 'ok' | 'warn' | 'danger';
9
+ export declare function capPercent(cap: number | null, min?: number, max?: number): number;
10
+ export declare function markerPercent(marker: number | null | undefined, min?: number, max?: number): number | null;
11
+ /** With `clearAtMax`, a cap dragged to `max` means "no cap" rather than a cap of `max`. */
12
+ export declare function resolveCap(next: number, clearAtMax?: boolean, max?: number): number | null;
13
+ export declare function isStacked(layout: 'row' | 'stacked', width?: number, limit?: number): boolean;
package/dist/cap-bar.js CHANGED
@@ -35,9 +35,28 @@ export function capKeyStep(key, shift, current, step = 5, min = 0, max = 100) {
35
35
  }
36
36
  }
37
37
  export function capTone(value, cap, warnAt = 75) {
38
- if (value >= cap)
38
+ if (cap !== null && value >= cap)
39
39
  return 'danger';
40
40
  if (value >= warnAt)
41
41
  return 'warn';
42
42
  return 'ok';
43
43
  }
44
+ export function capPercent(cap, min = 0, max = 100) {
45
+ if (cap === null)
46
+ return 100;
47
+ return clampCap(((cap - min) / (max - min || 1)) * 100, 0, 100);
48
+ }
49
+ export function markerPercent(marker, min = 0, max = 100) {
50
+ if (marker == null || Number.isNaN(marker))
51
+ return null;
52
+ return clampCap(((marker - min) / (max - min || 1)) * 100, 0, 100);
53
+ }
54
+ /** With `clearAtMax`, a cap dragged to `max` means "no cap" rather than a cap of `max`. */
55
+ export function resolveCap(next, clearAtMax = false, max = 100) {
56
+ return clearAtMax && next >= max ? null : next;
57
+ }
58
+ export function isStacked(layout, width = 0, limit = 0) {
59
+ if (layout === 'stacked')
60
+ return true;
61
+ return limit > 0 && width > 0 && width < limit;
62
+ }
@@ -40,6 +40,8 @@
40
40
  as?: 'div' | 'button' | 'a' | 'li' | 'section' | 'form';
41
41
  padding?: 'none' | 'sm' | 'md' | 'lg';
42
42
  surface?: 'base' | 'raised' | 'sunken';
43
+ /** Frame border style; `none` drops the frame. Applies to the stacked layers too. */
44
+ border?: 'solid' | 'dashed' | 'none';
43
45
  tone?: Tone;
44
46
  stacked?: boolean;
45
47
  stackTone?: Tone;
@@ -65,6 +67,7 @@
65
67
  as = 'div',
66
68
  padding = 'md',
67
69
  surface = 'base',
70
+ border = 'solid',
68
71
  tone = 'neutral',
69
72
  stacked = false,
70
73
  stackTone = 'neutral',
@@ -121,6 +124,8 @@
121
124
  class:pad-lg={padding === 'lg'}
122
125
  class:surface-raised={surface === 'raised'}
123
126
  class:surface-sunken={surface === 'sunken'}
127
+ class:border-dashed={border === 'dashed'}
128
+ class:border-none={border === 'none'}
124
129
  class:card-tap={tap || interactive}
125
130
  class:card-max={maxWidth !== undefined}
126
131
  class:card-ok={t === 'ok'}
@@ -168,7 +173,8 @@
168
173
  --card-pad: var(--sp-4);
169
174
  min-width: 0;
170
175
  background: var(--bg-elevated);
171
- border: 1px solid var(--border);
176
+ --card-border-style: solid;
177
+ border: 1px var(--card-border-style) var(--border);
172
178
  border-radius: var(--r-lg);
173
179
  padding: var(--card-pad);
174
180
  }
@@ -200,6 +206,12 @@
200
206
  .surface-sunken {
201
207
  background: var(--bg-elevated-2);
202
208
  }
209
+ .border-dashed {
210
+ --card-border-style: dashed;
211
+ }
212
+ .border-none {
213
+ --card-border-style: none;
214
+ }
203
215
  .pad-none {
204
216
  --card-pad: 0;
205
217
  padding: 0;
@@ -286,7 +298,7 @@
286
298
  /* Opaque fill so each layer fully hides the one behind it — only the
287
299
  bottom-right peek (and its single border line) stays visible. */
288
300
  background: var(--stack-bg);
289
- border: 1px solid var(--stack-border);
301
+ border: 1px var(--card-border-style) var(--stack-border);
290
302
  }
291
303
  /* Nearest back layer — sits just under the front surface. */
292
304
  .card-stacked::before {
@@ -13,6 +13,8 @@ type Own = {
13
13
  as?: 'div' | 'button' | 'a' | 'li' | 'section' | 'form';
14
14
  padding?: 'none' | 'sm' | 'md' | 'lg';
15
15
  surface?: 'base' | 'raised' | 'sunken';
16
+ /** Frame border style; `none` drops the frame. Applies to the stacked layers too. */
17
+ border?: 'solid' | 'dashed' | 'none';
16
18
  tone?: Tone;
17
19
  stacked?: boolean;
18
20
  stackTone?: Tone;
@@ -34,6 +34,7 @@
34
34
  dangerAt?: number;
35
35
  label?: string;
36
36
  as?: 'div' | 'a' | 'button';
37
+ href?: string;
37
38
  width?: string;
38
39
  height?: string;
39
40
  corner?: Snippet;
@@ -61,6 +62,10 @@
61
62
  const resolvedTone = $derived(tone ?? gaugeTone(pct, warnAt, dangerAt));
62
63
  const lit = $derived(litSegments(pct, segments));
63
64
  const segmentCount = $derived(Math.max(0, Math.floor(segments)));
65
+ const isMeter = $derived(as === 'div');
66
+ const ariaLabel = $derived(
67
+ isMeter ? label : label ? `${label} (${Math.round(pct)}%)` : `${Math.round(pct)}%`
68
+ );
64
69
  </script>
65
70
 
66
71
  <svelte:element
@@ -69,11 +74,11 @@
69
74
  class="gauge tone-{resolvedTone} {klass}"
70
75
  class:interactive={as !== 'div'}
71
76
  type={as === 'button' ? 'button' : undefined}
72
- role="meter"
73
- aria-label={label}
74
- aria-valuemin={0}
75
- aria-valuemax={100}
76
- aria-valuenow={pct}
77
+ role={isMeter ? 'meter' : undefined}
78
+ aria-label={ariaLabel}
79
+ aria-valuemin={isMeter ? 0 : undefined}
80
+ aria-valuemax={isMeter ? 100 : undefined}
81
+ aria-valuenow={isMeter ? pct : undefined}
77
82
  style:--gauge-w={width}
78
83
  style:--gauge-h={height}
79
84
  {style}
@@ -13,6 +13,7 @@ type Own = {
13
13
  dangerAt?: number;
14
14
  label?: string;
15
15
  as?: 'div' | 'a' | 'button';
16
+ href?: string;
16
17
  width?: string;
17
18
  height?: string;
18
19
  corner?: Snippet;
@@ -1,6 +1,17 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { capFromPointer, capKeyStep, capTone, clampCap, snapCap } from '../../cap-bar';
3
+ import {
4
+ capFromPointer,
5
+ capKeyStep,
6
+ capPercent,
7
+ capTone,
8
+ clampCap,
9
+ isStacked,
10
+ markerPercent,
11
+ resolveCap,
12
+ snapCap
13
+ } from '../../cap-bar';
14
+ import { browser } from '../../env';
4
15
 
5
16
  let {
6
17
  value = 0,
@@ -10,6 +21,15 @@
10
21
  step = 5,
11
22
  warnAt = 75,
12
23
  size = 'md',
24
+ layout = 'row',
25
+ stackBelow,
26
+ marker,
27
+ markerLabel,
28
+ markerTone = 'neutral',
29
+ uncappedLabel = 'no cap',
30
+ clearAtMax = false,
31
+ capLabel,
32
+ capValueText,
13
33
  label,
14
34
  labelWidth = '96px',
15
35
  readout,
@@ -25,8 +45,8 @@
25
45
  }: {
26
46
  /** Consumption, in percent of `max`. */
27
47
  value?: number;
28
- /** Cap position; bindable, moved by drag/keyboard. */
29
- cap?: number;
48
+ /** Cap position; bindable, moved by drag/keyboard. `null` is uncapped. */
49
+ cap?: number | null;
30
50
  min?: number;
31
51
  max?: number;
32
52
  /** Snap increment for drag and keyboard. */
@@ -34,6 +54,22 @@
34
54
  /** Consumption at which the fill turns `--warn`. */
35
55
  warnAt?: number;
36
56
  size?: 'md' | 'lg';
57
+ /** `'stacked'` puts label + readout on a line above a full-width track. */
58
+ layout?: 'row' | 'stacked';
59
+ /** Own-width threshold (px/em/rem) below which `layout` becomes `'stacked'`. */
60
+ stackBelow?: string;
61
+ /** Reference position on the track, in the same units as `value`. */
62
+ marker?: number | null;
63
+ markerLabel?: string;
64
+ markerTone?: 'neutral' | 'ok' | 'warn' | 'danger';
65
+ /** Spoken and readout text for `cap === null`. */
66
+ uncappedLabel?: string;
67
+ /** Moving the cap to `max` yields `null` instead of `max`. */
68
+ clearAtMax?: boolean;
69
+ /** Accessible name of the cap handle. Defaults to `${label} cap`. */
70
+ capLabel?: string;
71
+ /** Spoken value of the cap handle. Defaults to `cap ${cap}%`. */
72
+ capValueText?: string | ((cap: number | null) => string);
37
73
  label?: string | Snippet;
38
74
  labelWidth?: string;
39
75
  /** Overrides the default `{value}% · {hint}` readout. */
@@ -45,30 +81,64 @@
45
81
  tooltip?: string;
46
82
  readonly?: boolean;
47
83
  /** Fires live while dragging or stepping. */
48
- oninput?: (cap: number) => void;
84
+ oninput?: (cap: number | null) => void;
49
85
  /** Fires once the cap is committed (pointer release / key-up). */
50
- onchange?: (cap: number) => void;
86
+ onchange?: (cap: number | null) => void;
51
87
  class?: string;
52
88
  style?: string;
53
89
  } = $props();
54
90
 
91
+ const uncapped = $derived(cap === null);
55
92
  const range = $derived(max - min || 1);
56
93
  const pct = $derived(clampCap(((value - min) / range) * 100, 0, 100));
57
- const capPct = $derived(clampCap(((cap - min) / range) * 100, 0, 100));
94
+ const capPct = $derived(capPercent(cap, min, max));
95
+ const markerPct = $derived(markerPercent(marker, min, max));
58
96
  const tone = $derived(capTone(value, cap, warnAt));
59
97
  const readoutText = $derived(hint ? `${value}% · ${hint}` : `${value}%`);
60
- const tip = $derived(tooltip ?? `cap ${cap}% — drag the bar`);
98
+ const capText = $derived(uncapped ? uncappedLabel : `cap ${cap}%`);
99
+ const tip = $derived(tooltip ?? `${capText} — drag the bar`);
61
100
  const readoutTitle = $derived(readout === undefined ? readoutText : typeof readout === 'string' ? readout : undefined);
62
- const ariaLabel = $derived(typeof label === 'string' ? `${label} cap` : 'cap');
101
+ const ariaLabel = $derived(capLabel ?? (typeof label === 'string' ? `${label} cap` : 'cap'));
102
+ const ariaValueText = $derived(
103
+ capValueText === undefined ? capText : typeof capValueText === 'string' ? capValueText : capValueText(cap)
104
+ );
63
105
 
106
+ const uid = $props.id();
107
+ const markerId = $derived(markerLabel ? `${uid}-marker` : undefined);
108
+
109
+ let root = $state<HTMLElement | null>(null);
64
110
  let trackEl = $state<HTMLDivElement | null>(null);
111
+ let width = $state(0);
112
+ let limit = $state(0);
65
113
  let dragging = $state(false);
66
114
  let keying = $state(false);
67
115
  let committed = cap;
68
116
 
117
+ const stacked = $derived(isStacked(layout, width, limit));
118
+
119
+ function toPx(length: string, el: HTMLElement): number {
120
+ const n = Number.parseFloat(length);
121
+ const fontSize = (node: Element) => Number.parseFloat(getComputedStyle(node).fontSize);
122
+ if (length.endsWith('rem')) return n * fontSize(document.documentElement);
123
+ if (length.endsWith('em')) return n * fontSize(el);
124
+ return n;
125
+ }
126
+
127
+ $effect(() => {
128
+ if (!browser || !root || !stackBelow) return;
129
+ const el = root;
130
+ limit = toPx(stackBelow, el);
131
+ const observer = new ResizeObserver(([entry]) => {
132
+ width = entry.contentRect.width;
133
+ });
134
+ observer.observe(el);
135
+ return () => observer.disconnect();
136
+ });
137
+
69
138
  function setCap(next: number) {
70
- if (next === cap) return;
71
- cap = next;
139
+ const resolved = resolveCap(next, clearAtMax, max);
140
+ if (resolved === cap) return;
141
+ cap = resolved;
72
142
  oninput?.(cap);
73
143
  }
74
144
  function commit() {
@@ -96,25 +166,29 @@
96
166
  }
97
167
  function keyDown(e: KeyboardEvent) {
98
168
  if (readonly) return;
99
- const next = capKeyStep(e.key, e.shiftKey, snapCap(cap, step, min, max), step, min, max);
169
+ const next = capKeyStep(e.key, e.shiftKey, snapCap(cap ?? max, step, min, max), step, min, max);
100
170
  if (next === null) return;
101
171
  e.preventDefault();
102
172
  keying = true;
103
173
  setCap(next);
104
174
  }
105
175
  function keyUp(e: KeyboardEvent) {
106
- if (readonly || capKeyStep(e.key, e.shiftKey, cap, step, min, max) === null) return;
176
+ if (readonly || capKeyStep(e.key, e.shiftKey, cap ?? max, step, min, max) === null) return;
107
177
  commit();
108
178
  }
109
179
  </script>
110
180
 
111
181
  <div
112
182
  data-tsu="CapBar"
183
+ bind:this={root}
113
184
  class="cap-bar size-{size} tone-{tone} {klass}"
114
185
  class:readonly
115
186
  class:dragging
116
187
  class:keying
117
- style="--label-w: {labelWidth}; --readout-w: {readoutWidth}; --pct: {pct}%; --cap: {capPct}%; {styleProp}"
188
+ class:stacked
189
+ class:uncapped
190
+ style="--label-w: {labelWidth}; --readout-w: {readoutWidth}; --pct: {pct}%; --cap: {capPct}%; --marker: {markerPct ??
191
+ 0}%; {styleProp}"
118
192
  >
119
193
  {#if label}
120
194
  <div class="label">
@@ -132,6 +206,12 @@
132
206
  onpointercancel={pointerUp}
133
207
  >
134
208
  <div class="fill"></div>
209
+ {#if markerPct !== null}
210
+ <div class="marker marker-{markerTone}" title={markerLabel} aria-hidden="true"></div>
211
+ {#if markerLabel}
212
+ <span class="sr-only" id={markerId}>{markerLabel}</span>
213
+ {/if}
214
+ {/if}
135
215
  <div
136
216
  class="handle"
137
217
  role="slider"
@@ -139,14 +219,15 @@
139
219
  aria-label={ariaLabel}
140
220
  aria-valuemin={min}
141
221
  aria-valuemax={max}
142
- aria-valuenow={cap}
143
- aria-valuetext="cap {cap}%"
222
+ aria-valuenow={cap ?? max}
223
+ aria-valuetext={ariaValueText}
224
+ aria-describedby={markerId}
144
225
  aria-disabled={readonly ? 'true' : undefined}
145
226
  onkeydown={keyDown}
146
227
  onkeyup={keyUp}
147
228
  onblur={() => (keying = false)}
148
229
  ></div>
149
- <div class="bubble" aria-hidden="true">{cap}%</div>
230
+ <div class="bubble" aria-hidden="true">{capText}</div>
150
231
  </div>
151
232
  <div class="readout" title={readoutTitle}>
152
233
  {#if readout === undefined}{readoutText}{:else if typeof readout === 'string'}{readout}{:else}{@render readout()}{/if}
@@ -213,6 +294,41 @@
213
294
  transition: width 0.2s var(--ease);
214
295
  z-index: 1;
215
296
  }
297
+ .uncapped .track::after {
298
+ display: none;
299
+ }
300
+ .marker {
301
+ position: absolute;
302
+ top: 50%;
303
+ left: var(--marker);
304
+ width: 2px;
305
+ height: calc(var(--track-h) + 6px);
306
+ transform: translate(-50%, -50%);
307
+ border-radius: 1px;
308
+ background: var(--marker-color, var(--text-muted));
309
+ pointer-events: none;
310
+ z-index: 2;
311
+ }
312
+ .marker-ok {
313
+ --marker-color: var(--ok);
314
+ }
315
+ .marker-warn {
316
+ --marker-color: var(--warn);
317
+ }
318
+ .marker-danger {
319
+ --marker-color: var(--danger);
320
+ }
321
+ .sr-only {
322
+ position: absolute;
323
+ width: 1px;
324
+ height: 1px;
325
+ margin: -1px;
326
+ padding: 0;
327
+ overflow: hidden;
328
+ clip-path: inset(50%);
329
+ white-space: nowrap;
330
+ border: 0;
331
+ }
216
332
  .handle {
217
333
  position: absolute;
218
334
  top: 50%;
@@ -226,6 +342,9 @@
226
342
  cursor: ew-resize;
227
343
  z-index: 2;
228
344
  }
345
+ .uncapped .handle {
346
+ opacity: 0.4;
347
+ }
229
348
  .handle:focus-visible {
230
349
  outline: 2px solid var(--accent);
231
350
  outline-offset: 2px;
@@ -283,6 +402,21 @@
283
402
  .cap-bar:not(:has(.label)) .caption {
284
403
  grid-column: 1 / 3;
285
404
  }
405
+ .cap-bar.stacked {
406
+ grid-template-columns: 1fr auto;
407
+ }
408
+ .cap-bar.stacked .label {
409
+ grid-area: 1 / 1;
410
+ }
411
+ .cap-bar.stacked .readout {
412
+ grid-area: 1 / 2;
413
+ }
414
+ .cap-bar.stacked .track {
415
+ grid-area: 2 / 1 / 3 / -1;
416
+ }
417
+ .cap-bar.stacked .caption {
418
+ grid-column: 1 / -1;
419
+ }
286
420
  @media (prefers-reduced-motion: reduce) {
287
421
  .fill,
288
422
  .bubble {
@@ -2,8 +2,8 @@ import type { Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  /** Consumption, in percent of `max`. */
4
4
  value?: number;
5
- /** Cap position; bindable, moved by drag/keyboard. */
6
- cap?: number;
5
+ /** Cap position; bindable, moved by drag/keyboard. `null` is uncapped. */
6
+ cap?: number | null;
7
7
  min?: number;
8
8
  max?: number;
9
9
  /** Snap increment for drag and keyboard. */
@@ -11,6 +11,22 @@ type $$ComponentProps = {
11
11
  /** Consumption at which the fill turns `--warn`. */
12
12
  warnAt?: number;
13
13
  size?: 'md' | 'lg';
14
+ /** `'stacked'` puts label + readout on a line above a full-width track. */
15
+ layout?: 'row' | 'stacked';
16
+ /** Own-width threshold (px/em/rem) below which `layout` becomes `'stacked'`. */
17
+ stackBelow?: string;
18
+ /** Reference position on the track, in the same units as `value`. */
19
+ marker?: number | null;
20
+ markerLabel?: string;
21
+ markerTone?: 'neutral' | 'ok' | 'warn' | 'danger';
22
+ /** Spoken and readout text for `cap === null`. */
23
+ uncappedLabel?: string;
24
+ /** Moving the cap to `max` yields `null` instead of `max`. */
25
+ clearAtMax?: boolean;
26
+ /** Accessible name of the cap handle. Defaults to `${label} cap`. */
27
+ capLabel?: string;
28
+ /** Spoken value of the cap handle. Defaults to `cap ${cap}%`. */
29
+ capValueText?: string | ((cap: number | null) => string);
14
30
  label?: string | Snippet;
15
31
  labelWidth?: string;
16
32
  /** Overrides the default `{value}% · {hint}` readout. */
@@ -22,9 +38,9 @@ type $$ComponentProps = {
22
38
  tooltip?: string;
23
39
  readonly?: boolean;
24
40
  /** Fires live while dragging or stepping. */
25
- oninput?: (cap: number) => void;
41
+ oninput?: (cap: number | null) => void;
26
42
  /** Fires once the cap is committed (pointer release / key-up). */
27
- onchange?: (cap: number) => void;
43
+ onchange?: (cap: number | null) => void;
28
44
  class?: string;
29
45
  style?: string;
30
46
  };
@@ -2,7 +2,9 @@
2
2
  // Bordered zone whose legend rides the top border. Real <fieldset>/<legend>
3
3
  // for semantics. `droppable` turns it into an HTML5 drop target: a hovering
4
4
  // drag that passes `accepts` highlights the zone and shows `dropHint`;
5
- // `ondrop` receives the payload read from `dataTransfer` under `mime`.
5
+ // `ondrop` receives the payload read from `dataTransfer` under `mime`. That payload
6
+ // is unreadable while hovering (HTML protected mode), so per-item acceptance rules
7
+ // need `dragPayload` or the `types` handed to `accepts`.
6
8
  // Keyboard users cannot drag — the consumer must offer a non-drag path
7
9
  // (a move menu, a select) to the same action. Pointer-based DnD can drive
8
10
  // the highlight through the bindable `over` prop.
@@ -18,6 +20,7 @@
18
20
  droppable = false,
19
21
  mime = 'text/plain',
20
22
  accepts,
23
+ dragPayload = '',
21
24
  ondrop,
22
25
  dropHint,
23
26
  over = $bindable(false),
@@ -33,7 +36,13 @@
33
36
  padding?: 'sm' | 'md' | 'lg';
34
37
  droppable?: boolean;
35
38
  mime?: string;
36
- accepts?: (data: string, e: DragEvent) => boolean;
39
+ /** `data` is the dataTransfer payload on drop, but during dragenter/dragover the
40
+ * dataTransfer is in protected mode and `getData` returns `''` — there `data` is
41
+ * `dragPayload` when given, otherwise `''`, and `types` is the only other signal. */
42
+ accepts?: (data: string, e: DragEvent, types: readonly string[]) => boolean;
43
+ /** Payload of the drag in flight when the app already knows it (a dragstart handler
44
+ * or a pointer-DnD store); stands in for the unreadable dataTransfer during dragover. */
45
+ dragPayload?: string;
37
46
  ondrop?: (data: string, e: DragEvent) => void;
38
47
  dropHint?: string | Snippet;
39
48
  over?: boolean;
@@ -47,12 +56,13 @@
47
56
  let depth = 0;
48
57
 
49
58
  function payload(e: DragEvent): string {
50
- return e.dataTransfer?.getData(mime) ?? '';
59
+ return e.dataTransfer?.getData(mime) || dragPayload;
51
60
  }
52
61
  function valid(e: DragEvent): boolean {
53
62
  if (!droppable || disabled) return false;
54
- if (!Array.from(e.dataTransfer?.types ?? []).includes(mime)) return false;
55
- return accepts ? accepts(payload(e), e) : true;
63
+ const types = Array.from(e.dataTransfer?.types ?? []);
64
+ if (!types.includes(mime)) return false;
65
+ return accepts ? accepts(payload(e), e, types) : true;
56
66
  }
57
67
 
58
68
  function onDragEnter(e: DragEvent) {
@@ -7,7 +7,13 @@ type $$ComponentProps = {
7
7
  padding?: 'sm' | 'md' | 'lg';
8
8
  droppable?: boolean;
9
9
  mime?: string;
10
- accepts?: (data: string, e: DragEvent) => boolean;
10
+ /** `data` is the dataTransfer payload on drop, but during dragenter/dragover the
11
+ * dataTransfer is in protected mode and `getData` returns `''` — there `data` is
12
+ * `dragPayload` when given, otherwise `''`, and `types` is the only other signal. */
13
+ accepts?: (data: string, e: DragEvent, types: readonly string[]) => boolean;
14
+ /** Payload of the drag in flight when the app already knows it (a dragstart handler
15
+ * or a pointer-DnD store); stands in for the unreadable dataTransfer during dragover. */
16
+ dragPayload?: string;
11
17
  ondrop?: (data: string, e: DragEvent) => void;
12
18
  dropHint?: string | Snippet;
13
19
  over?: boolean;
@@ -11,6 +11,7 @@
11
11
  // are broadly supported today.)
12
12
  import { tick, type Snippet } from 'svelte';
13
13
  import { place } from '../../floating';
14
+ import { HOVER_CLOSE_GRACE, HOVER_OPEN_DELAY, createHoverIntent, opensOnHover } from './popover-hover.js';
14
15
 
15
16
  type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
16
17
  type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
@@ -35,6 +36,10 @@
35
36
  block = false,
36
37
  hitArea = 'auto',
37
38
  disabled = false,
39
+ openOn = 'click',
40
+ hoverDelay = HOVER_OPEN_DELAY,
41
+ as = 'button',
42
+ href,
38
43
  role = 'dialog',
39
44
  haspopup = 'dialog',
40
45
  onopen,
@@ -75,6 +80,15 @@
75
80
  /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
76
81
  hitArea?: 'auto' | 'compact';
77
82
  disabled?: boolean;
83
+ /** `hover` adds pointer-driven opening on fine pointers; click, Enter and
84
+ * touch keep working so keyboard and touch users reach the same panel. */
85
+ openOn?: 'click' | 'hover';
86
+ /** Delay before a hover opens the panel. */
87
+ hoverDelay?: number;
88
+ /** Render the trigger as a link so it can navigate while still revealing
89
+ * the panel; an `a` without `href` toggles the panel like a button. */
90
+ as?: 'button' | 'a';
91
+ href?: string;
78
92
  /** ARIA role of the panel. */
79
93
  role?: PanelRole;
80
94
  /** `aria-haspopup` announced on the trigger. */
@@ -93,7 +107,7 @@
93
107
  );
94
108
 
95
109
  const id = `pop-${Math.random().toString(36).slice(2, 8)}`;
96
- let triggerEl = $state<HTMLButtonElement | null>(null);
110
+ let triggerEl = $state<HTMLElement | null>(null);
97
111
  let panelEl = $state<HTMLDivElement | null>(null);
98
112
  // Panel content is mounted only after the first open, then kept alive so
99
113
  // reopening is instant. The panel element itself always renders — the native
@@ -107,6 +121,70 @@
107
121
  } catch {}
108
122
  }
109
123
 
124
+ function show() {
125
+ try {
126
+ panelEl?.showPopover();
127
+ } catch {}
128
+ }
129
+
130
+ function toggle() {
131
+ if (open) close();
132
+ else show();
133
+ }
134
+
135
+ const hover = createHoverIntent({ open: show, close });
136
+ $effect(() => () => hover.cancel());
137
+
138
+ function onPointerEnter(e: PointerEvent) {
139
+ if (disabled || !opensOnHover(openOn, e.pointerType)) return;
140
+ if (open) hover.cancel();
141
+ else hover.enter(hoverDelay);
142
+ }
143
+
144
+ function onPointerLeave() {
145
+ if (openOn !== 'hover') return;
146
+ hover.leave(HOVER_CLOSE_GRACE);
147
+ }
148
+
149
+ function onPanelPointerEnter(e: PointerEvent) {
150
+ if (opensOnHover(openOn, e.pointerType)) hover.cancel();
151
+ }
152
+
153
+ // A link trigger cannot carry `popovertarget`, so it toggles by hand — unless
154
+ // it has an href, where the click belongs to the navigation.
155
+ function onTriggerClick(e: MouseEvent) {
156
+ if (as !== 'a') return;
157
+ if (disabled) {
158
+ e.preventDefault();
159
+ return;
160
+ }
161
+ hover.cancel();
162
+ if (href === undefined) {
163
+ e.preventDefault();
164
+ toggle();
165
+ }
166
+ }
167
+
168
+ function onTriggerKeydown(e: KeyboardEvent) {
169
+ if (as !== 'a' || href !== undefined || disabled) return;
170
+ if (e.key === 'Enter' || e.key === ' ') {
171
+ e.preventDefault();
172
+ hover.cancel();
173
+ toggle();
174
+ }
175
+ }
176
+
177
+ const triggerAttrs = $derived(
178
+ as === 'a'
179
+ ? {
180
+ href: disabled ? undefined : href,
181
+ tabindex: 0,
182
+ role: href === undefined ? 'button' : undefined,
183
+ 'aria-disabled': disabled ? ('true' as const) : undefined,
184
+ }
185
+ : { type: 'button' as const, popovertarget: id, disabled }
186
+ );
187
+
110
188
  function reposition() {
111
189
  if (triggerEl && panelEl) place(triggerEl, panelEl, placement, gap);
112
190
  }
@@ -132,10 +210,10 @@
132
210
  }
133
211
  </script>
134
212
 
135
- <button
213
+ <svelte:element
214
+ this={as}
136
215
  bind:this={triggerEl}
137
216
  data-tsu="Popover"
138
- type="button"
139
217
  class="pop-trigger {triggerClass} {klass}"
140
218
  style={styleProp}
141
219
  class:bare
@@ -155,14 +233,18 @@
155
233
  class:trigger-tone-info={tone === 'info'}
156
234
  class:trigger-tone-warn={tone === 'warn'}
157
235
  class:trigger-tone-danger={tone === 'danger'}
158
- popovertarget={id}
236
+ class:is-disabled={disabled && as === 'a'}
159
237
  aria-label={label}
160
238
  aria-haspopup={haspopup}
161
239
  aria-expanded={open}
162
- {disabled}
240
+ {...triggerAttrs}
241
+ onpointerenter={onPointerEnter}
242
+ onpointerleave={onPointerLeave}
243
+ onclick={onTriggerClick}
244
+ onkeydown={onTriggerKeydown}
163
245
  >
164
246
  {@render trigger()}
165
- </button>
247
+ </svelte:element>
166
248
 
167
249
  <div
168
250
  bind:this={panelEl}
@@ -173,6 +255,8 @@
173
255
  {role}
174
256
  aria-label={label}
175
257
  ontoggle={onToggle}
258
+ onpointerenter={onPanelPointerEnter}
259
+ onpointerleave={onPointerLeave}
176
260
  >
177
261
  {#if opened}
178
262
  {@render children({ close })}
@@ -332,10 +416,18 @@
332
416
  border-color: var(--ok);
333
417
  filter: brightness(1.08);
334
418
  }
335
- .pop-trigger:disabled {
419
+ .pop-trigger:disabled,
420
+ .pop-trigger.is-disabled {
336
421
  opacity: 0.45;
337
422
  cursor: not-allowed;
338
423
  }
424
+ .pop-trigger.is-disabled {
425
+ pointer-events: none;
426
+ }
427
+ :where(a.pop-trigger) {
428
+ cursor: pointer;
429
+ text-decoration: none;
430
+ }
339
431
  /* `bare`: strip the chrome down to a plain button the consumer styles. */
340
432
  :where(.pop-trigger.bare) {
341
433
  display: inline;
@@ -39,6 +39,15 @@ type $$ComponentProps = {
39
39
  /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
40
40
  hitArea?: 'auto' | 'compact';
41
41
  disabled?: boolean;
42
+ /** `hover` adds pointer-driven opening on fine pointers; click, Enter and
43
+ * touch keep working so keyboard and touch users reach the same panel. */
44
+ openOn?: 'click' | 'hover';
45
+ /** Delay before a hover opens the panel. */
46
+ hoverDelay?: number;
47
+ /** Render the trigger as a link so it can navigate while still revealing
48
+ * the panel; an `a` without `href` toggles the panel like a button. */
49
+ as?: 'button' | 'a';
50
+ href?: string;
42
51
  /** ARIA role of the panel. */
43
52
  role?: PanelRole;
44
53
  /** `aria-haspopup` announced on the trigger. */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @param {'click' | 'hover'} openOn
3
+ * @param {string | undefined} pointerType
4
+ */
5
+ export function opensOnHover(openOn: "click" | "hover", pointerType: string | undefined): boolean;
6
+ /**
7
+ * @param {object} o
8
+ * @param {() => void} o.open
9
+ * @param {() => void} o.close
10
+ * @param {(fn: () => void, ms: number) => any} [o.schedule]
11
+ * @param {(handle: any) => void} [o.cancel]
12
+ */
13
+ export function createHoverIntent({ open, close, schedule, cancel }: {
14
+ open: () => void;
15
+ close: () => void;
16
+ schedule?: ((fn: () => void, ms: number) => any) | undefined;
17
+ cancel?: ((handle: any) => void) | undefined;
18
+ }): {
19
+ /** @param {number} [delay] */
20
+ enter(delay?: number): void;
21
+ /** @param {number} [grace] */
22
+ leave(grace?: number): void;
23
+ cancel: () => void;
24
+ readonly pending: boolean;
25
+ };
26
+ export const HOVER_OPEN_DELAY: 150;
27
+ export const HOVER_CLOSE_GRACE: 120;
@@ -0,0 +1,62 @@
1
+ // Hover-open intent for Popover: fine pointers only (a touch "pointerenter"
2
+ // fires on tap and would fight the click), with a delay before opening and a
3
+ // grace period before closing so the pointer can travel trigger -> panel.
4
+
5
+ export const HOVER_OPEN_DELAY = 150;
6
+ export const HOVER_CLOSE_GRACE = 120;
7
+
8
+ const FINE_POINTERS = new Set(['mouse', 'pen']);
9
+
10
+ /**
11
+ * @param {'click' | 'hover'} openOn
12
+ * @param {string | undefined} pointerType
13
+ */
14
+ export function opensOnHover(openOn, pointerType) {
15
+ return openOn === 'hover' && FINE_POINTERS.has(pointerType ?? '');
16
+ }
17
+
18
+ /**
19
+ * @param {object} o
20
+ * @param {() => void} o.open
21
+ * @param {() => void} o.close
22
+ * @param {(fn: () => void, ms: number) => any} [o.schedule]
23
+ * @param {(handle: any) => void} [o.cancel]
24
+ */
25
+ export function createHoverIntent({ open, close, schedule = setTimeout, cancel = clearTimeout }) {
26
+ /** @type {any} */
27
+ let timer = null;
28
+
29
+ function clear() {
30
+ if (timer !== null) {
31
+ cancel(timer);
32
+ timer = null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {() => void} fn
38
+ * @param {number} ms
39
+ */
40
+ function later(fn, ms) {
41
+ clear();
42
+ timer = schedule(() => {
43
+ timer = null;
44
+ fn();
45
+ }, ms);
46
+ }
47
+
48
+ return {
49
+ /** @param {number} [delay] */
50
+ enter(delay = HOVER_OPEN_DELAY) {
51
+ later(open, Math.max(0, delay));
52
+ },
53
+ /** @param {number} [grace] */
54
+ leave(grace = HOVER_CLOSE_GRACE) {
55
+ later(close, Math.max(0, grace));
56
+ },
57
+ cancel: clear,
58
+ get pending() {
59
+ return timer !== null;
60
+ },
61
+ };
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.48.0",
3
+ "version": "0.49.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",