@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
|
|
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
|
|
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(
|
|
37
|
-
|
|
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
|
|
46
|
-
clearTimeout(
|
|
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' &&
|
|
89
|
+
const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hideNow();
|
|
62
90
|
node.addEventListener('mouseenter', show);
|
|
63
|
-
node.addEventListener('mouseleave',
|
|
91
|
+
node.addEventListener('mouseleave', scheduleHide);
|
|
64
92
|
node.addEventListener('focusin', show);
|
|
65
|
-
node.addEventListener('focusout',
|
|
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',
|
|
99
|
+
node.removeEventListener('mouseleave', scheduleHide);
|
|
72
100
|
node.removeEventListener('focusin', show);
|
|
73
|
-
node.removeEventListener('focusout',
|
|
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
|
-
<
|
|
85
|
-
{
|
|
86
|
-
|
|
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
|
|
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