actual-css 0.1.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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/actual.css +4929 -0
- package/dist/actual.js +3 -0
- package/dist/actual.js.map +31 -0
- package/dist/actual.min.css +1 -0
- package/docs/README.md +26 -0
- package/docs/components.md +860 -0
- package/docs/design-notes/accessibility-decisions.md +32 -0
- package/docs/design-notes/browser-support.md +93 -0
- package/docs/design-notes/cascade-layer.md +52 -0
- package/docs/design-notes/css-anchor.md +33 -0
- package/docs/design-notes/naming.md +37 -0
- package/docs/design-notes/previous-work.md +5 -0
- package/docs/design-notes/references.md +324 -0
- package/docs/forms.md +1265 -0
- package/docs/javascript.md +426 -0
- package/docs/layout.md +480 -0
- package/docs/mental-model.md +73 -0
- package/docs/patterns.md +415 -0
- package/docs/reserved.md +7 -0
- package/docs/tokens.md +455 -0
- package/docs/typography.md +159 -0
- package/docs/ui.md +937 -0
- package/docs/utilities.md +384 -0
- package/llms.txt +17 -0
- package/package.json +101 -0
- package/src/css/actual.css +17 -0
- package/src/css/actual.full.css +2 -0
- package/src/css/actual.layer.css +3 -0
- package/src/css/base.css +70 -0
- package/src/css/components/accordion.css +97 -0
- package/src/css/components/actions.css +21 -0
- package/src/css/components/alert.css +137 -0
- package/src/css/components/avatar.css +199 -0
- package/src/css/components/badge.css +143 -0
- package/src/css/components/breadcrumb.css +56 -0
- package/src/css/components/busy.css +44 -0
- package/src/css/components/button.css +216 -0
- package/src/css/components/card.css +140 -0
- package/src/css/components/drawer.css +122 -0
- package/src/css/components/flyout.css +243 -0
- package/src/css/components/index.css +41 -0
- package/src/css/components/join.css +106 -0
- package/src/css/components/key.css +25 -0
- package/src/css/components/meter.css +81 -0
- package/src/css/components/modal.css +162 -0
- package/src/css/components/navbar.css +98 -0
- package/src/css/components/overline.css +49 -0
- package/src/css/components/pagination.css +30 -0
- package/src/css/components/progress.css +126 -0
- package/src/css/components/scrollspy.css +27 -0
- package/src/css/components/skeleton.css +67 -0
- package/src/css/components/spinner.css +60 -0
- package/src/css/components/status-bar.css +82 -0
- package/src/css/components/surface.css +48 -0
- package/src/css/components/tab.css +128 -0
- package/src/css/components/table.css +64 -0
- package/src/css/components/tooltip.css +107 -0
- package/src/css/dialog-fallback.css +56 -0
- package/src/css/focus.css +13 -0
- package/src/css/forms/choice-card.css +158 -0
- package/src/css/forms/choice.css +196 -0
- package/src/css/forms/control.css +106 -0
- package/src/css/forms/custom-select.css +93 -0
- package/src/css/forms/form-actions.css +43 -0
- package/src/css/forms/form.css +57 -0
- package/src/css/forms/index.css +15 -0
- package/src/css/forms/input-icon.css +36 -0
- package/src/css/forms/native.css +68 -0
- package/src/css/forms/select.css +36 -0
- package/src/css/forms/switch.css +127 -0
- package/src/css/forms/validation.css +63 -0
- package/src/css/grid.css +95 -0
- package/src/css/intents.css +33 -0
- package/src/css/layout.css +80 -0
- package/src/css/optional/index.css +5 -0
- package/src/css/optional/layout-extra.css +55 -0
- package/src/css/optional/scroll-snap.css +51 -0
- package/src/css/optional/scroller.css +51 -0
- package/src/css/optional/typography-fluid.css +48 -0
- package/src/css/optional/utilities-extra.css +289 -0
- package/src/css/print.css +47 -0
- package/src/css/prose.css +187 -0
- package/src/css/reset.css +56 -0
- package/src/css/theme.css +186 -0
- package/src/css/themes/corporate.css +60 -0
- package/src/css/themes/cyberpunk.css +60 -0
- package/src/css/themes/dark.css +32 -0
- package/src/css/themes/dim.css +33 -0
- package/src/css/themes/edge.css +143 -0
- package/src/css/themes/forest.css +65 -0
- package/src/css/themes/gradient.css +97 -0
- package/src/css/themes/index.css +43 -0
- package/src/css/themes/lavender.css +66 -0
- package/src/css/themes/mono.css +60 -0
- package/src/css/themes/neon.css +66 -0
- package/src/css/themes/ocean.css +60 -0
- package/src/css/themes/petrol.css +64 -0
- package/src/css/themes/spruce.css +64 -0
- package/src/css/themes/square.css +68 -0
- package/src/css/themes/sunset.css +66 -0
- package/src/css/tokens.css +122 -0
- package/src/css/utilities.css +162 -0
- package/src/css/variants.css +118 -0
- package/src/js/command.js +165 -0
- package/src/js/context-menu.js +291 -0
- package/src/js/dialog-fallback.js +163 -0
- package/src/js/dialog.js +503 -0
- package/src/js/enhance.js +208 -0
- package/src/js/events.js +22 -0
- package/src/js/filter.js +122 -0
- package/src/js/floating.js +462 -0
- package/src/js/flyout.js +187 -0
- package/src/js/focus.js +35 -0
- package/src/js/index.js +26 -0
- package/src/js/input.js +47 -0
- package/src/js/keys.js +30 -0
- package/src/js/mask.js +184 -0
- package/src/js/menu.js +88 -0
- package/src/js/password.js +98 -0
- package/src/js/scrollspy.js +121 -0
- package/src/js/selectors.js +59 -0
- package/src/js/status.js +158 -0
- package/src/js/surface.js +358 -0
- package/src/js/tab.js +173 -0
- package/src/js/tooltip.js +374 -0
- package/src/js/validation.js +419 -0
package/src/js/tab.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tabs — in-place panel switcher using real tab semantics.
|
|
3
|
+
*
|
|
4
|
+
* Tab list: .tabs[role="tablist"]
|
|
5
|
+
* Tab: [role="tab"] with aria-selected, aria-controls, id, tabindex
|
|
6
|
+
* Panel: [role="tabpanel"] with id matching aria-controls
|
|
7
|
+
*
|
|
8
|
+
* Keyboard: ArrowLeft/Right, Home/End (select tab, wrapping)
|
|
9
|
+
* ArrowUp/Down for aria-orientation="vertical"
|
|
10
|
+
* ArrowDown (focus selected panel)
|
|
11
|
+
*
|
|
12
|
+
* Self-registers via enhance: injected tablists wire automatically.
|
|
13
|
+
* The tab→panel map is rebuilt from the live tablist on each interaction,
|
|
14
|
+
* so tabs added after connect are picked up with no extra work.
|
|
15
|
+
* Cleanup is the AbortController.abort() returned to enhance.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import enhance from "./enhance.js";
|
|
19
|
+
import { firstItem, lastItem, nextItem } from "./keys.js";
|
|
20
|
+
import { CLASSES } from "./selectors.js";
|
|
21
|
+
|
|
22
|
+
const TABLIST_SELECTOR = `.${CLASSES.tabs}[role="tablist"]`;
|
|
23
|
+
|
|
24
|
+
function tabsOf(list) {
|
|
25
|
+
return [...list.querySelectorAll('[role="tab"]')];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function panelsOf(tabs) {
|
|
29
|
+
const panels = new Map();
|
|
30
|
+
for (const tab of tabs) {
|
|
31
|
+
const panel = panelFor(tab);
|
|
32
|
+
if (panel) panels.set(tab, panel);
|
|
33
|
+
}
|
|
34
|
+
return panels;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function panelFor(tab) {
|
|
38
|
+
const panelId = tab.getAttribute("aria-controls");
|
|
39
|
+
return panelId ? document.getElementById(panelId) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isTabDisabled(tab) {
|
|
43
|
+
return tab.disabled || tab.getAttribute("aria-disabled") === "true";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function activatableTabs(list) {
|
|
47
|
+
return tabsOf(list).filter((tab) => !isTabDisabled(tab) && panelFor(tab));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makePanelFocusable(panel) {
|
|
51
|
+
if (!panel.hasAttribute("tabindex")) {
|
|
52
|
+
panel.tabIndex = -1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function activate(tab) {
|
|
57
|
+
if (!tab || isTabDisabled(tab) || !panelFor(tab)) return;
|
|
58
|
+
const list = tab.closest(TABLIST_SELECTOR);
|
|
59
|
+
if (!list) return;
|
|
60
|
+
const tabs = tabsOf(list);
|
|
61
|
+
const panels = panelsOf(tabs);
|
|
62
|
+
|
|
63
|
+
for (const t of tabs) {
|
|
64
|
+
t.setAttribute("aria-selected", "false");
|
|
65
|
+
t.tabIndex = -1;
|
|
66
|
+
}
|
|
67
|
+
for (const [, panel] of panels) {
|
|
68
|
+
panel.hidden = true;
|
|
69
|
+
}
|
|
70
|
+
tab.setAttribute("aria-selected", "true");
|
|
71
|
+
tab.tabIndex = 0;
|
|
72
|
+
const panel = panels.get(tab);
|
|
73
|
+
if (panel) {
|
|
74
|
+
makePanelFocusable(panel);
|
|
75
|
+
panel.hidden = false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function activateAndFocus(tab) {
|
|
80
|
+
if (!tab) return;
|
|
81
|
+
activate(tab);
|
|
82
|
+
tab.focus();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function initialize(list) {
|
|
86
|
+
const tabs = activatableTabs(list);
|
|
87
|
+
if (!tabs.length) return;
|
|
88
|
+
const selected = tabs.find((tab) => tab.getAttribute("aria-selected") === "true") || tabs[0];
|
|
89
|
+
activate(selected);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onKeydown(e) {
|
|
93
|
+
const tab = e.target.closest('[role="tab"]');
|
|
94
|
+
if (!tab) return;
|
|
95
|
+
const list = tab.closest(TABLIST_SELECTOR);
|
|
96
|
+
if (!list) return;
|
|
97
|
+
|
|
98
|
+
const tabs = activatableTabs(list);
|
|
99
|
+
if (!tabs.length || !tabs.includes(tab)) return;
|
|
100
|
+
const isVertical = list.getAttribute("aria-orientation") === "vertical";
|
|
101
|
+
let next;
|
|
102
|
+
|
|
103
|
+
switch (e.key) {
|
|
104
|
+
case "ArrowRight":
|
|
105
|
+
if (isVertical) break;
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
next = nextItem(tabs, tab, 1, { wrap: true });
|
|
108
|
+
if (next) activateAndFocus(next);
|
|
109
|
+
break;
|
|
110
|
+
case "ArrowLeft":
|
|
111
|
+
if (isVertical) break;
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
next = nextItem(tabs, tab, -1, { wrap: true });
|
|
114
|
+
if (next) activateAndFocus(next);
|
|
115
|
+
break;
|
|
116
|
+
case "ArrowDown":
|
|
117
|
+
if (!isVertical) {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
const panel = panelFor(tab);
|
|
120
|
+
if (panel) {
|
|
121
|
+
makePanelFocusable(panel);
|
|
122
|
+
panel.focus();
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
next = nextItem(tabs, tab, 1, { wrap: true });
|
|
128
|
+
if (next) activateAndFocus(next);
|
|
129
|
+
break;
|
|
130
|
+
case "ArrowUp":
|
|
131
|
+
if (!isVertical) break;
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
next = nextItem(tabs, tab, -1, { wrap: true });
|
|
134
|
+
if (next) activateAndFocus(next);
|
|
135
|
+
break;
|
|
136
|
+
case "Home":
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
activateAndFocus(firstItem(tabs));
|
|
139
|
+
break;
|
|
140
|
+
case "End":
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
activateAndFocus(lastItem(tabs));
|
|
143
|
+
break;
|
|
144
|
+
case "Enter":
|
|
145
|
+
case " ":
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
activate(tab);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function onClick(e) {
|
|
153
|
+
const tab = e.target.closest('[role="tab"]');
|
|
154
|
+
if (!tab) return;
|
|
155
|
+
if (isTabDisabled(tab)) {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
if (tab.getAttribute("aria-selected") === "true") return;
|
|
161
|
+
activate(tab);
|
|
162
|
+
tab.focus();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
enhance({
|
|
166
|
+
[TABLIST_SELECTOR]: (list) => {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
initialize(list);
|
|
169
|
+
list.addEventListener("click", onClick, { signal: controller.signal });
|
|
170
|
+
list.addEventListener("keydown", onKeydown, { signal: controller.signal });
|
|
171
|
+
return () => controller.abort();
|
|
172
|
+
},
|
|
173
|
+
});
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tooltip — supplemental label shown on hover and focus.
|
|
3
|
+
*
|
|
4
|
+
* Shorthand: <button data-tooltip="Help text" data-tooltip-placement="right">
|
|
5
|
+
* Explicit: <button data-tooltip aria-describedby="tooltip-id">
|
|
6
|
+
* <div role="tooltip" id="tooltip-id" hidden>Help <strong>text</strong></div>
|
|
7
|
+
* Options: data-tooltip-click toggles on click instead of hover/focus.
|
|
8
|
+
* data-tooltip-visible keeps the tooltip visible.
|
|
9
|
+
*
|
|
10
|
+
* Discovery: [data-tooltip] only. An empty data-tooltip marks an explicit
|
|
11
|
+
* tooltip that points at an aria-describedby target; a non-empty
|
|
12
|
+
* data-tooltip generates the tooltip element lazily.
|
|
13
|
+
* Plain aria-describedby (form help, error text) is never
|
|
14
|
+
* treated as a tooltip trigger.
|
|
15
|
+
*
|
|
16
|
+
* Sharing: an explicit tooltip may be referenced by several triggers. The
|
|
17
|
+
* tooltip element is wired once (floating listeners + tracking);
|
|
18
|
+
* each trigger only registers itself and the wiring is torn down
|
|
19
|
+
* when the last trigger leaves the DOM.
|
|
20
|
+
*
|
|
21
|
+
* Lazy: tooltip elements are created (shorthand) or wired (explicit) on
|
|
22
|
+
* first mouseover / focusin. No page-load scan, no DOM overhead
|
|
23
|
+
* for tooltips that are never triggered. AJAX-loaded triggers
|
|
24
|
+
* work automatically via bubbling delegated listeners.
|
|
25
|
+
*
|
|
26
|
+
* Show: hover + focus (150ms delay), click toggle, or always visible
|
|
27
|
+
* Hide: blur, pointer leave, Escape
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import enhance from "./enhance.js";
|
|
31
|
+
import { EVENTS } from "./events.js";
|
|
32
|
+
import { reposition, track } from "./floating.js";
|
|
33
|
+
import { CLASSES } from "./selectors.js";
|
|
34
|
+
|
|
35
|
+
const SHOW_DELAY_MS = 150;
|
|
36
|
+
const HIDE_DELAY_MS = 100;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} TooltipState
|
|
40
|
+
* @property {Set<Element>} refs Triggers that currently reference this tip.
|
|
41
|
+
* @property {Element | null} activeRef Trigger that last showed this tip.
|
|
42
|
+
* @property {AbortController} controller Tip-level event listener cleanup.
|
|
43
|
+
* @property {() => void} untrack Floating-position cleanup.
|
|
44
|
+
* @property {boolean} generated True when the tip was created from data-tooltip text.
|
|
45
|
+
* @property {{ parent: Node, next: ChildNode | null } | null} mount Original DOM position for explicit tips moved into a dialog/body root.
|
|
46
|
+
* @property {ReturnType<typeof setTimeout> | null} timer Pending delayed show.
|
|
47
|
+
* @property {ReturnType<typeof setTimeout> | null} hideTimer Pending delayed hide.
|
|
48
|
+
* @property {boolean} overRef Pointer is over the active trigger.
|
|
49
|
+
* @property {boolean} overTip Pointer is over the tooltip.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
let uid = 0;
|
|
53
|
+
const triggerStates = new WeakMap(); // trigger -> { tip, cleanup }
|
|
54
|
+
/** @type {WeakMap<Element, TooltipState>} */
|
|
55
|
+
const tipStates = new WeakMap(); // tip -> { refs, activeRef, controller, untrack, generated, mount, timer }
|
|
56
|
+
|
|
57
|
+
function mountTip(tip, trigger) {
|
|
58
|
+
const root = trigger.closest("dialog") || trigger.ownerDocument.body;
|
|
59
|
+
const parent = tip.parentNode;
|
|
60
|
+
if (!root || !parent || parent === root) return null;
|
|
61
|
+
|
|
62
|
+
const mount = { parent, next: tip.nextSibling };
|
|
63
|
+
root.append(tip);
|
|
64
|
+
return mount;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function restoreTip(tip, state) {
|
|
68
|
+
const mount = state.mount;
|
|
69
|
+
if (!mount) return;
|
|
70
|
+
|
|
71
|
+
if (mount.parent.isConnected) {
|
|
72
|
+
const next = mount.next?.parentNode === mount.parent ? mount.next : null;
|
|
73
|
+
mount.parent.insertBefore(tip, next);
|
|
74
|
+
} else {
|
|
75
|
+
tip.remove();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function placementFor(ref) {
|
|
80
|
+
return ref?.getAttribute("data-tooltip-placement") || "top";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hasOption(trigger, name) {
|
|
84
|
+
const value = trigger?.getAttribute(name);
|
|
85
|
+
return value !== null && value.toLowerCase() !== "false";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isClickTrigger(trigger) {
|
|
89
|
+
return hasOption(trigger, "data-tooltip-click");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isAlwaysVisible(trigger) {
|
|
93
|
+
return hasOption(trigger, "data-tooltip-visible");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function describedByIds(trigger) {
|
|
97
|
+
return (trigger.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function addDescription(trigger, id) {
|
|
101
|
+
const ids = describedByIds(trigger);
|
|
102
|
+
if (ids.includes(id)) return false;
|
|
103
|
+
trigger.setAttribute("aria-describedby", [...ids, id].join(" "));
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function removeDescription(trigger, id) {
|
|
108
|
+
const ids = describedByIds(trigger).filter((candidate) => candidate !== id);
|
|
109
|
+
if (ids.length) {
|
|
110
|
+
trigger.setAttribute("aria-describedby", ids.join(" "));
|
|
111
|
+
} else {
|
|
112
|
+
trigger.removeAttribute("aria-describedby");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function explicitTipFor(trigger) {
|
|
117
|
+
const doc = trigger.ownerDocument;
|
|
118
|
+
for (const id of describedByIds(trigger)) {
|
|
119
|
+
const candidate = doc.getElementById(id);
|
|
120
|
+
if (candidate?.getAttribute("role") === "tooltip") return candidate;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function nextTooltipId(doc) {
|
|
126
|
+
let id;
|
|
127
|
+
do {
|
|
128
|
+
uid++;
|
|
129
|
+
id = `tooltip-${uid}`;
|
|
130
|
+
} while (doc.getElementById(id));
|
|
131
|
+
return id;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function repositionTip(ref, tip) {
|
|
135
|
+
reposition(ref, tip, {
|
|
136
|
+
placement: placementFor(ref),
|
|
137
|
+
distance: 6,
|
|
138
|
+
flip: true,
|
|
139
|
+
shift: true,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hideTip(tip, force = false) {
|
|
144
|
+
const state = tipStates.get(tip);
|
|
145
|
+
if (!force && isAlwaysVisible(state?.activeRef)) return;
|
|
146
|
+
if (state?.timer) {
|
|
147
|
+
clearTimeout(state.timer);
|
|
148
|
+
state.timer = null;
|
|
149
|
+
}
|
|
150
|
+
if (state?.hideTimer) {
|
|
151
|
+
clearTimeout(state.hideTimer);
|
|
152
|
+
state.hideTimer = null;
|
|
153
|
+
}
|
|
154
|
+
tip.hidden = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function clearHide(tip) {
|
|
158
|
+
const state = tipStates.get(tip);
|
|
159
|
+
if (!state?.hideTimer) return;
|
|
160
|
+
clearTimeout(state.hideTimer);
|
|
161
|
+
state.hideTimer = null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function scheduleHide(tip) {
|
|
165
|
+
const state = tipStates.get(tip);
|
|
166
|
+
if (!state) return;
|
|
167
|
+
if (isClickTrigger(state.activeRef) || isAlwaysVisible(state.activeRef)) return;
|
|
168
|
+
if (state.overRef || state.overTip) return;
|
|
169
|
+
if (state.hideTimer) clearTimeout(state.hideTimer);
|
|
170
|
+
state.hideTimer = setTimeout(() => {
|
|
171
|
+
state.hideTimer = null;
|
|
172
|
+
if (!state.overRef && !state.overTip) hideTip(tip);
|
|
173
|
+
}, HIDE_DELAY_MS);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Tip-level wiring happens once per tooltip element, even when an explicit
|
|
177
|
+
// tooltip is shared by several triggers. activeRef is the trigger that last
|
|
178
|
+
// showed the tip, so reposition follows the right reference.
|
|
179
|
+
function wireTip(tip, options = {}) {
|
|
180
|
+
let state = tipStates.get(tip);
|
|
181
|
+
if (state) return state;
|
|
182
|
+
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
state = {
|
|
185
|
+
refs: new Set(),
|
|
186
|
+
activeRef: null,
|
|
187
|
+
controller,
|
|
188
|
+
untrack: track(tip),
|
|
189
|
+
generated: options.generated === true,
|
|
190
|
+
mount: options.mount || null,
|
|
191
|
+
timer: null,
|
|
192
|
+
hideTimer: null,
|
|
193
|
+
overRef: false,
|
|
194
|
+
overTip: false,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const onHide = () => hideTip(tip);
|
|
198
|
+
const onOutOfView = () => hideTip(tip, true);
|
|
199
|
+
const onReposition = () => {
|
|
200
|
+
if (isAlwaysVisible(state.activeRef)) tip.hidden = false;
|
|
201
|
+
if (!tip.hidden && state.activeRef) repositionTip(state.activeRef, tip);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
tip.addEventListener(EVENTS.reposition, onReposition, { signal: controller.signal });
|
|
205
|
+
tip.addEventListener(EVENTS.hide, onHide, { signal: controller.signal });
|
|
206
|
+
tip.addEventListener(EVENTS.outOfView, onOutOfView, { signal: controller.signal });
|
|
207
|
+
tip.addEventListener(
|
|
208
|
+
"mouseenter",
|
|
209
|
+
() => {
|
|
210
|
+
state.overTip = true;
|
|
211
|
+
clearHide(tip);
|
|
212
|
+
},
|
|
213
|
+
{ signal: controller.signal },
|
|
214
|
+
);
|
|
215
|
+
tip.addEventListener(
|
|
216
|
+
"mouseleave",
|
|
217
|
+
() => {
|
|
218
|
+
state.overTip = false;
|
|
219
|
+
scheduleHide(tip);
|
|
220
|
+
},
|
|
221
|
+
{ signal: controller.signal },
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
tipStates.set(tip, state);
|
|
225
|
+
return state;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function ensureTip(trigger) {
|
|
229
|
+
const existing = triggerStates.get(trigger);
|
|
230
|
+
if (existing) return existing.tip;
|
|
231
|
+
|
|
232
|
+
let tip;
|
|
233
|
+
let generated = false;
|
|
234
|
+
let mount = null;
|
|
235
|
+
let descriptionAdded = false;
|
|
236
|
+
const text = trigger.getAttribute("data-tooltip");
|
|
237
|
+
const doc = trigger.ownerDocument;
|
|
238
|
+
|
|
239
|
+
// shorthand: data-tooltip="text" → create element lazily
|
|
240
|
+
if (text) {
|
|
241
|
+
tip = doc.createElement("div");
|
|
242
|
+
tip.className = CLASSES.tooltip;
|
|
243
|
+
tip.role = "tooltip";
|
|
244
|
+
tip.id = nextTooltipId(doc);
|
|
245
|
+
tip.textContent = text;
|
|
246
|
+
tip.hidden = true;
|
|
247
|
+
tip.style.position = "fixed";
|
|
248
|
+
generated = true;
|
|
249
|
+
|
|
250
|
+
const parent = trigger.closest("dialog") || doc.body;
|
|
251
|
+
parent.appendChild(tip);
|
|
252
|
+
descriptionAdded = addDescription(trigger, tip.id);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// explicit: data-tooltip (empty) + aria-describedby → find existing element
|
|
256
|
+
if (!tip) {
|
|
257
|
+
tip = explicitTipFor(trigger);
|
|
258
|
+
if (!tip) return null;
|
|
259
|
+
tip.hidden = true;
|
|
260
|
+
tip.style.position = "fixed";
|
|
261
|
+
|
|
262
|
+
if (!tipStates.has(tip)) {
|
|
263
|
+
mount = mountTip(tip, trigger);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const state = wireTip(tip, { generated, mount });
|
|
268
|
+
state.refs.add(trigger);
|
|
269
|
+
|
|
270
|
+
const triggerController = new AbortController();
|
|
271
|
+
const onLeave = () => {
|
|
272
|
+
state.overRef = false;
|
|
273
|
+
scheduleHide(tip);
|
|
274
|
+
};
|
|
275
|
+
trigger.addEventListener("mouseleave", onLeave, { signal: triggerController.signal });
|
|
276
|
+
trigger.addEventListener("blur", onLeave, { signal: triggerController.signal });
|
|
277
|
+
|
|
278
|
+
const cleanup = () => {
|
|
279
|
+
triggerController.abort();
|
|
280
|
+
triggerStates.delete(trigger);
|
|
281
|
+
if (descriptionAdded) removeDescription(trigger, tip.id);
|
|
282
|
+
|
|
283
|
+
state.refs.delete(trigger);
|
|
284
|
+
if (state.activeRef === trigger) {
|
|
285
|
+
hideTip(tip, true);
|
|
286
|
+
state.activeRef = null;
|
|
287
|
+
}
|
|
288
|
+
// Other triggers still share this tooltip: keep the tip wiring alive.
|
|
289
|
+
if (state.refs.size > 0) return;
|
|
290
|
+
|
|
291
|
+
hideTip(tip, true);
|
|
292
|
+
state.controller.abort();
|
|
293
|
+
state.untrack();
|
|
294
|
+
tipStates.delete(tip);
|
|
295
|
+
if (state.generated) tip.remove();
|
|
296
|
+
else restoreTip(tip, state);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
triggerStates.set(trigger, { tip, cleanup });
|
|
300
|
+
return tip;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function cleanupTrigger(trigger) {
|
|
304
|
+
if (trigger.isConnected) return;
|
|
305
|
+
triggerStates.get(trigger)?.cleanup?.();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function show(tip, ref, immediate = false) {
|
|
309
|
+
const state = tipStates.get(tip);
|
|
310
|
+
if (!state) return;
|
|
311
|
+
|
|
312
|
+
state.activeRef = ref;
|
|
313
|
+
state.overRef = true;
|
|
314
|
+
clearHide(tip);
|
|
315
|
+
if (state.timer) clearTimeout(state.timer);
|
|
316
|
+
const reveal = () => {
|
|
317
|
+
state.timer = null;
|
|
318
|
+
tip.hidden = false;
|
|
319
|
+
repositionTip(ref, tip);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
if (immediate) reveal();
|
|
323
|
+
else state.timer = setTimeout(reveal, SHOW_DELAY_MS);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Delegated discovery (mouseover + focusin bubble) ───
|
|
327
|
+
|
|
328
|
+
const SEL = "[data-tooltip]";
|
|
329
|
+
|
|
330
|
+
function handleTriggerIntent(e) {
|
|
331
|
+
const trigger = e.target.closest?.(SEL);
|
|
332
|
+
if (!trigger) return;
|
|
333
|
+
if (isClickTrigger(trigger) || isAlwaysVisible(trigger)) return;
|
|
334
|
+
if (
|
|
335
|
+
e.type === "mouseover" &&
|
|
336
|
+
e.relatedTarget instanceof Node &&
|
|
337
|
+
trigger.contains(e.relatedTarget)
|
|
338
|
+
)
|
|
339
|
+
return;
|
|
340
|
+
|
|
341
|
+
const tip = ensureTip(trigger);
|
|
342
|
+
if (tip) show(tip, trigger);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function handleTriggerClick(e) {
|
|
346
|
+
const trigger = e.target.closest?.(SEL);
|
|
347
|
+
if (!trigger || !isClickTrigger(trigger) || isAlwaysVisible(trigger)) return;
|
|
348
|
+
|
|
349
|
+
const tip = ensureTip(trigger);
|
|
350
|
+
if (!tip) return;
|
|
351
|
+
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
if (tip.hidden) show(tip, trigger, true);
|
|
354
|
+
else hideTip(tip, true);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Delegated discovery listeners run at import time; keep SSR imports inert.
|
|
358
|
+
if (typeof document !== "undefined") {
|
|
359
|
+
document.addEventListener("mouseover", handleTriggerIntent);
|
|
360
|
+
document.addEventListener("focusin", handleTriggerIntent);
|
|
361
|
+
document.addEventListener("click", handleTriggerClick);
|
|
362
|
+
|
|
363
|
+
enhance({
|
|
364
|
+
[SEL]: (trigger) => {
|
|
365
|
+
// Ordinary and click-triggered tooltips stay lazy. The always-visible
|
|
366
|
+
// option necessarily opts into eager creation/wiring.
|
|
367
|
+
if (isAlwaysVisible(trigger)) {
|
|
368
|
+
const tip = ensureTip(trigger);
|
|
369
|
+
if (tip) show(tip, trigger, true);
|
|
370
|
+
}
|
|
371
|
+
return () => cleanupTrigger(trigger);
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
}
|