@dorsk/tsumikit 0.10.0 → 0.11.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.
@@ -8,33 +8,50 @@
8
8
  // only opens/closes under our control) and is positioned by the shared
9
9
  // `place()` helper — so, like Popover, it escapes ancestor overflow/transform
10
10
  // clipping and flips/clamps to stay in the viewport. Dependency-free.
11
+ //
12
+ // Rich mode: pass a `content` snippet instead of (or as well as — `content`
13
+ // wins) `text` and the panel renders arbitrary markup: key/value rows, code
14
+ // ids, CopyButton, small buttons. Closing is then hovercard-style: debounced
15
+ // by `closeDelay` and cancelled when the pointer (or focus) enters the panel,
16
+ // so users can move in to select/copy text or click a button. Note the
17
+ // role="tooltip" caveat: `aria-describedby` flattens the panel to its text.
11
18
  import type { Snippet } from 'svelte';
12
19
  import { place } from '../../floating';
13
20
 
14
21
  let {
15
22
  text,
23
+ content,
16
24
  placement = 'top',
17
25
  delay = 200,
26
+ closeDelay = 250,
18
27
  trigger
19
28
  }: {
20
- text: string;
29
+ /** Plain-text bubble. Ignored when `content` is provided. */
30
+ text?: string;
31
+ /** Rich panel content — makes the panel hover-persistent and selectable. */
32
+ content?: Snippet;
21
33
  placement?: 'top' | 'bottom' | 'left' | 'right';
34
+ /** Open delay (ms) on hover. */
22
35
  delay?: number;
36
+ /** Grace period (ms) before closing — lets the pointer travel into the panel. */
37
+ closeDelay?: number;
23
38
  trigger: Snippet;
24
39
  } = $props();
25
40
 
26
41
  const id = `tip-${Math.random().toString(36).slice(2, 8)}`;
27
42
  let wrapEl = $state<HTMLElement | null>(null);
28
43
  let tipEl = $state<HTMLElement | null>(null);
29
- let timer: ReturnType<typeof setTimeout> | undefined;
44
+ let showTimer: ReturnType<typeof setTimeout> | undefined;
45
+ let hideTimer: ReturnType<typeof setTimeout> | undefined;
30
46
 
31
47
  function reposition() {
32
48
  if (wrapEl && tipEl) place(wrapEl, tipEl, `${placement}-center`, 6);
33
49
  }
34
50
 
35
51
  function show() {
36
- clearTimeout(timer);
37
- timer = setTimeout(() => {
52
+ clearTimeout(hideTimer); // pointer came back within the grace period
53
+ clearTimeout(showTimer);
54
+ showTimer = setTimeout(() => {
38
55
  if (!tipEl || tipEl.matches(':popover-open')) return; // re-entry guard
39
56
  tipEl.showPopover(); // top layer — displayed before we measure it
40
57
  reposition();
@@ -42,12 +59,23 @@
42
59
  addEventListener('resize', reposition);
43
60
  }, delay);
44
61
  }
45
- function hide() {
46
- clearTimeout(timer);
62
+ function hideNow() {
63
+ clearTimeout(showTimer);
64
+ clearTimeout(hideTimer);
47
65
  if (tipEl?.matches(':popover-open')) tipEl.hidePopover();
48
66
  removeEventListener('scroll', reposition, true);
49
67
  removeEventListener('resize', reposition);
50
68
  }
69
+ // Debounced close: cancelled if the pointer/focus enters the panel (rich
70
+ // mode) or returns to the trigger before `closeDelay` elapses.
71
+ function scheduleHide() {
72
+ clearTimeout(showTimer);
73
+ clearTimeout(hideTimer);
74
+ hideTimer = setTimeout(hideNow, closeDelay);
75
+ }
76
+ function cancelHide() {
77
+ clearTimeout(hideTimer);
78
+ }
51
79
 
52
80
  const FOCUSABLE = 'a[href],button,input,select,textarea,[tabindex]';
53
81
  // Action: wire aria-describedby onto the trigger's focusable element AND the
@@ -58,19 +86,39 @@
58
86
  | HTMLElement
59
87
  | null;
60
88
  target?.setAttribute('aria-describedby', id);
61
- const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
89
+ const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hideNow();
62
90
  node.addEventListener('mouseenter', show);
63
- node.addEventListener('mouseleave', hide);
91
+ node.addEventListener('mouseleave', scheduleHide);
64
92
  node.addEventListener('focusin', show);
65
- node.addEventListener('focusout', hide);
93
+ node.addEventListener('focusout', scheduleHide);
66
94
  node.addEventListener('keydown', onkey);
67
95
  return {
68
96
  destroy() {
69
97
  target?.removeAttribute('aria-describedby');
70
98
  node.removeEventListener('mouseenter', show);
71
- node.removeEventListener('mouseleave', hide);
99
+ node.removeEventListener('mouseleave', scheduleHide);
72
100
  node.removeEventListener('focusin', show);
73
- node.removeEventListener('focusout', hide);
101
+ node.removeEventListener('focusout', scheduleHide);
102
+ node.removeEventListener('keydown', onkey);
103
+ }
104
+ };
105
+ }
106
+
107
+ // Action for the panel: in rich mode it accepts the pointer/focus, which
108
+ // cancels the pending close (hovercard persistence).
109
+ function panel(node: HTMLElement) {
110
+ const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hideNow();
111
+ node.addEventListener('mouseenter', cancelHide);
112
+ node.addEventListener('mouseleave', scheduleHide);
113
+ node.addEventListener('focusin', cancelHide);
114
+ node.addEventListener('focusout', scheduleHide);
115
+ node.addEventListener('keydown', onkey);
116
+ return {
117
+ destroy() {
118
+ node.removeEventListener('mouseenter', cancelHide);
119
+ node.removeEventListener('mouseleave', scheduleHide);
120
+ node.removeEventListener('focusin', cancelHide);
121
+ node.removeEventListener('focusout', scheduleHide);
74
122
  node.removeEventListener('keydown', onkey);
75
123
  }
76
124
  };
@@ -81,9 +129,17 @@
81
129
  {@render trigger()}
82
130
  </span>
83
131
 
84
- <span bind:this={tipEl} {id} role="tooltip" popover="manual" class="tip">
85
- {text}
86
- </span>
132
+ <div
133
+ bind:this={tipEl}
134
+ {id}
135
+ role="tooltip"
136
+ popover="manual"
137
+ class="tip"
138
+ class:rich={!!content}
139
+ use:panel
140
+ >
141
+ {#if content}{@render content()}{:else}{text}{/if}
142
+ </div>
87
143
 
88
144
  <style>
89
145
  .tip-wrap {
@@ -106,6 +162,15 @@
106
162
  white-space: normal;
107
163
  pointer-events: none; /* non-interactive: never steals hover/clicks */
108
164
  }
165
+ /* Rich panel: interactive + selectable so the user can move the pointer in
166
+ to copy an id or click a button (the close grace period allows the trip). */
167
+ .tip.rich {
168
+ pointer-events: auto;
169
+ user-select: text;
170
+ max-width: min(22rem, calc(100vw - 2 * var(--sp-3)));
171
+ padding: var(--sp-2);
172
+ border-radius: var(--r-md);
173
+ }
109
174
  /* Fade/scale in when shown (skipped under reduced-motion via the global rule). */
110
175
  .tip:popover-open {
111
176
  animation: tip-in 0.12s var(--ease);
@@ -1,8 +1,14 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
- text: string;
3
+ /** Plain-text bubble. Ignored when `content` is provided. */
4
+ text?: string;
5
+ /** Rich panel content — makes the panel hover-persistent and selectable. */
6
+ content?: Snippet;
4
7
  placement?: 'top' | 'bottom' | 'left' | 'right';
8
+ /** Open delay (ms) on hover. */
5
9
  delay?: number;
10
+ /** Grace period (ms) before closing — lets the pointer travel into the panel. */
11
+ closeDelay?: number;
6
12
  trigger: Snippet;
7
13
  };
8
14
  declare const Tooltip: import("svelte").Component<$$ComponentProps, {}, "">;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",