actual-css 0.6.0 → 0.8.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 +214 -1
- package/README.md +13 -11
- package/components.json +668 -0
- package/dist/actual.css +166 -37
- package/dist/actual.full.css +1005 -550
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/dist/actual.js +1 -1
- package/dist/actual.min.css +1 -1
- package/docs/design-notes/accessibility-decisions.md +33 -0
- package/docs/design-notes/browser-support.md +82 -25
- package/docs/design-notes/column-layout.md +3 -3
- package/docs/design-notes/css-anchor.md +75 -21
- package/docs/design-notes/enhancement-contract.md +14 -6
- package/docs/design-notes/grid.md +4 -4
- package/docs/design-notes/naming.md +6 -1
- package/docs/design-notes/platform-alignment.md +99 -2
- package/docs/design-notes/popover-manual-poc.md +328 -0
- package/docs/design-notes/surface-reparenting.md +131 -0
- package/docs/design-notes/tooltip-coordinate-space.md +132 -0
- package/docs/design-notes/widget-primitives.md +9 -6
- package/docs/pages/components/accordion.md +41 -2
- package/docs/pages/components/alert.md +80 -30
- package/docs/pages/components/app-navigation.md +6 -0
- package/docs/pages/components/avatar.md +6 -6
- package/docs/pages/components/badge.md +101 -16
- package/docs/pages/components/button.md +16 -8
- package/docs/pages/components/card.md +10 -10
- package/docs/pages/components/dialog.md +16 -6
- package/docs/pages/components/fab.md +4 -3
- package/docs/pages/components/list.md +42 -4
- package/docs/pages/components/navbar.md +17 -1
- package/docs/pages/components/pagination.md +2 -2
- package/docs/pages/components/steps.md +19 -0
- package/docs/pages/components/table.md +3 -2
- package/docs/pages/enhancements/flyout.md +144 -24
- package/docs/pages/enhancements/overview.md +25 -0
- package/docs/pages/enhancements/status-bar.md +20 -0
- package/docs/pages/enhancements/tabs.md +45 -37
- package/docs/pages/enhancements/tooltip.md +40 -2
- package/docs/pages/examples/overview.md +38 -5
- package/docs/pages/forms/choice-cards.md +6 -2
- package/docs/pages/forms/combobox.md +1 -1
- package/docs/pages/forms/floating-field.md +4 -3
- package/docs/pages/forms/joined-controls.md +22 -0
- package/docs/pages/forms/otp.md +2 -1
- package/docs/pages/forms/overview.md +1 -1
- package/docs/pages/forms/states-and-sizes.md +16 -18
- package/docs/pages/forms/sticky-actions.md +2 -2
- package/docs/pages/forms/switch.md +1 -1
- package/docs/pages/foundations/philosophy.md +67 -22
- package/docs/pages/foundations/theming.md +10 -2
- package/docs/pages/foundations/tokens.md +118 -21
- package/docs/pages/foundations/typography.md +35 -14
- package/docs/pages/guides/introduction.md +11 -9
- package/docs/pages/guides/progressive-enhancement.md +136 -49
- package/docs/pages/guides/tailwind.md +3 -2
- package/docs/pages/layout/media.md +1 -1
- package/docs/pages/layout/overview.md +11 -1
- package/docs/pages/layout/scroll-snap.md +3 -3
- package/docs/pages/layout/stack.md +17 -9
- package/docs/pages/patterns/icon-input.md +4 -2
- package/docs/pages/patterns/icon-slot.md +2 -0
- package/docs/pages/patterns/intent-nesting.md +1 -1
- package/docs/pages/utilities/overview.md +23 -2
- package/docs/pages/utilities/sizing-wrapping.md +1 -1
- package/docs/pages/utilities/spacing.md +10 -8
- package/llms.txt +2 -1
- package/package.json +19 -8
- package/{scripts/reserved-classes.json → reserved-classes.json} +4 -6
- package/src/css/components/accordion.css +42 -3
- package/src/css/components/alert.css +25 -13
- package/src/css/components/app-nav.css +18 -8
- package/src/css/components/avatar.css +13 -1
- package/src/css/components/badge.css +78 -12
- package/src/css/components/breadcrumb.css +2 -3
- package/src/css/components/button.css +9 -16
- package/src/css/components/card.css +9 -2
- package/src/css/components/chat.css +1 -0
- package/src/css/components/data-list.css +1 -1
- package/src/css/components/drawer.css +79 -25
- package/src/css/components/fab.css +0 -6
- package/src/css/components/flyout.css +46 -63
- package/src/css/components/index.css +0 -1
- package/src/css/components/indicator.css +21 -0
- package/src/css/components/join.css +7 -0
- package/src/css/components/key.css +10 -0
- package/src/css/components/list.css +40 -4
- package/src/css/components/menu.css +18 -11
- package/src/css/components/modal.css +48 -33
- package/src/css/components/navbar.css +37 -4
- package/src/css/components/pagination.css +1 -0
- package/src/css/components/spinner.css +6 -6
- package/src/css/components/status-bar.css +0 -5
- package/src/css/components/steps.css +45 -59
- package/src/css/components/tab.css +29 -9
- package/src/css/components/table.css +5 -5
- package/src/css/components/tooltip.css +45 -13
- package/src/css/core/base.css +23 -1
- package/src/css/core/intents.css +17 -6
- package/src/css/core/theme.css +27 -0
- package/src/css/core/tokens.css +35 -15
- package/src/css/core/variants.css +64 -15
- package/src/css/forms/choice-card.css +4 -4
- package/src/css/forms/choice.css +6 -3
- package/src/css/forms/control.css +2 -1
- package/src/css/forms/floating-field.css +3 -3
- package/src/css/forms/form.css +4 -3
- package/src/css/forms/input-icon.css +20 -14
- package/src/css/forms/native.css +6 -1
- package/src/css/forms/otp.css +9 -1
- package/src/css/forms/range.css +2 -0
- package/src/css/forms/select.css +29 -9
- package/src/css/forms/switch.css +19 -5
- package/src/css/layout/app-layout.css +1 -1
- package/src/css/layout/grid.css +50 -76
- package/src/css/layout/scroll-snap.css +11 -7
- package/src/css/layout/scroller.css +3 -4
- package/src/css/layout/stack.css +2 -2
- package/src/css/typography/fluid.css +4 -3
- package/src/css/typography/prose.css +6 -5
- package/src/css/utilities/base.css +2 -1
- package/src/css/utilities/extra.css +20 -17
- package/src/js/context-menu.js +27 -26
- package/src/js/dialog.js +2 -1
- package/src/js/dismiss.js +5 -0
- package/src/js/enhance.js +34 -2
- package/src/js/enhancement-loader.js +36 -43
- package/src/js/events.js +0 -3
- package/src/js/filter.js +0 -1
- package/src/js/floating-space.js +42 -0
- package/src/js/flyout.js +9 -44
- package/src/js/focus-group.js +7 -0
- package/src/js/focus.js +6 -0
- package/src/js/menu.js +10 -1
- package/src/js/selectors.js +5 -9
- package/src/js/status.js +44 -7
- package/src/js/surface.js +106 -132
- package/src/js/tab.js +6 -1
- package/src/js/tooltip.js +152 -53
- package/src/js/validation.js +1 -1
- package/src/css/components/surface.css +0 -49
- package/src/js/floating.js +0 -432
package/src/js/enhance.js
CHANGED
|
@@ -146,8 +146,6 @@ function createRegistry(root) {
|
|
|
146
146
|
if (hasRemoval) sweepDisconnected();
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
-
observer.observe(root, { childList: true, subtree: true });
|
|
150
|
-
|
|
151
149
|
return {
|
|
152
150
|
add(enhancers) {
|
|
153
151
|
const selectors = Object.keys(enhancers).filter(isValidSelector);
|
|
@@ -164,6 +162,9 @@ function createRegistry(root) {
|
|
|
164
162
|
selectorString: selectors.join(","),
|
|
165
163
|
};
|
|
166
164
|
records.add(record);
|
|
165
|
+
// Observe only once there is a record to act on; an enhance() call with
|
|
166
|
+
// no valid selector must not leave an idle observer behind.
|
|
167
|
+
if (records.size === 1) observer.observe(root, { childList: true, subtree: true });
|
|
167
168
|
updateSelectorString();
|
|
168
169
|
scanFor(record, root);
|
|
169
170
|
return record;
|
|
@@ -201,6 +202,37 @@ export function hasEnhancement(el, name) {
|
|
|
201
202
|
return el.matches(enhancementSelector(name));
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Apply a named opt-in once; presentation selectors remain application-owned.
|
|
207
|
+
* @param {string} name
|
|
208
|
+
* @param {string} selector
|
|
209
|
+
* @param {Document | Element | DocumentFragment} [root] Query scope, excluding itself.
|
|
210
|
+
* @returns {Element[]} Matched descendants.
|
|
211
|
+
*/
|
|
212
|
+
export function applyEnhancement(name, selector, root) {
|
|
213
|
+
if (typeof document === "undefined") return [];
|
|
214
|
+
|
|
215
|
+
enhancementSelector(name);
|
|
216
|
+
root ??= document.documentElement;
|
|
217
|
+
const elements = [...root.querySelectorAll(selector)];
|
|
218
|
+
|
|
219
|
+
for (const el of elements) {
|
|
220
|
+
if (hasEnhancement(el, name)) continue;
|
|
221
|
+
const tokens = el.getAttribute("data-enhance");
|
|
222
|
+
el.setAttribute("data-enhance", tokens ? `${tokens} ${name}` : name);
|
|
223
|
+
}
|
|
224
|
+
// Refresh the scope once per owner; parentNode stops at shadow boundaries.
|
|
225
|
+
// A Document scope can own registrations, but its default root —
|
|
226
|
+
// documentElement — is a descendant the ancestor walk never reaches.
|
|
227
|
+
for (let owner = root; owner; owner = owner.parentNode) {
|
|
228
|
+
ownedNames.get(owner)?.get(name)?.refresh(root);
|
|
229
|
+
if (owner === root && owner.nodeType === DOCUMENT_NODE) {
|
|
230
|
+
ownedNames.get(owner.documentElement)?.get(name)?.refresh(root);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return elements;
|
|
234
|
+
}
|
|
235
|
+
|
|
204
236
|
export function registerEnhancement(name, init, root) {
|
|
205
237
|
if (typeof document === "undefined") return noopRuntime();
|
|
206
238
|
|
|
@@ -29,51 +29,45 @@ const styleLoads = new Map();
|
|
|
29
29
|
|
|
30
30
|
let moduleImporter = (url) => import(url);
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
function loadAsset(url, loads, kind) {
|
|
33
33
|
const absoluteUrl = new URL(url, document.baseURI).href;
|
|
34
34
|
|
|
35
|
-
if (!
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
35
|
+
if (!loads.has(absoluteUrl)) {
|
|
36
|
+
const pending = new Promise((resolve, reject) => {
|
|
37
|
+
const element = document.createElement(kind === "script" ? "script" : "link");
|
|
38
|
+
if (kind === "script") {
|
|
39
|
+
element.src = absoluteUrl;
|
|
40
|
+
} else {
|
|
41
|
+
element.rel = "stylesheet";
|
|
42
|
+
element.href = absoluteUrl;
|
|
43
|
+
}
|
|
44
|
+
element.onload = resolve;
|
|
45
|
+
element.onerror = () => {
|
|
46
|
+
element.remove();
|
|
47
|
+
reject(new Error(`Unable to load ${kind}: ${absoluteUrl}`));
|
|
48
|
+
};
|
|
49
|
+
document.head.append(element);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The DOM clears the failed element; the cache evicts the rejected
|
|
53
|
+
// promise. The guard keeps a finished load from wiping out a newer one.
|
|
54
|
+
// A catch never runs synchronously, so the eviction always lands after
|
|
55
|
+
// the `set` above, even when the transport rejects during append.
|
|
56
|
+
loads.set(absoluteUrl, pending);
|
|
57
|
+
pending.catch(() => {
|
|
58
|
+
if (loads.get(absoluteUrl) === pending) loads.delete(absoluteUrl);
|
|
59
|
+
});
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
return
|
|
62
|
+
return loads.get(absoluteUrl);
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
export function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (!styleLoads.has(absoluteUrl)) {
|
|
59
|
-
styleLoads.set(
|
|
60
|
-
absoluteUrl,
|
|
61
|
-
new Promise((resolve, reject) => {
|
|
62
|
-
const link = document.createElement("link");
|
|
63
|
-
link.rel = "stylesheet";
|
|
64
|
-
link.href = absoluteUrl;
|
|
65
|
-
link.onload = resolve;
|
|
66
|
-
link.onerror = () => {
|
|
67
|
-
styleLoads.delete(absoluteUrl);
|
|
68
|
-
link.remove();
|
|
69
|
-
reject(new Error(`Unable to load style: ${absoluteUrl}`));
|
|
70
|
-
};
|
|
71
|
-
document.head.append(link);
|
|
72
|
-
}),
|
|
73
|
-
);
|
|
74
|
-
}
|
|
65
|
+
export function loadScript(url) {
|
|
66
|
+
return loadAsset(url, scriptLoads, "script");
|
|
67
|
+
}
|
|
75
68
|
|
|
76
|
-
|
|
69
|
+
export function loadStyle(url) {
|
|
70
|
+
return loadAsset(url, styleLoads, "style");
|
|
77
71
|
}
|
|
78
72
|
|
|
79
73
|
function makeInit(name, connect) {
|
|
@@ -177,8 +171,7 @@ export function loadEnhancement(name, url) {
|
|
|
177
171
|
return promise;
|
|
178
172
|
}
|
|
179
173
|
|
|
180
|
-
async function loadManifest(
|
|
181
|
-
const entries = Object.entries(manifest);
|
|
174
|
+
async function loadManifest(entries) {
|
|
182
175
|
const results = await Promise.allSettled(
|
|
183
176
|
entries.map(([name, url]) => loadEnhancement(name, url)),
|
|
184
177
|
);
|
|
@@ -239,7 +232,7 @@ export async function loadEnhancements(scope = document) {
|
|
|
239
232
|
}
|
|
240
233
|
}
|
|
241
234
|
|
|
242
|
-
const result = await loadManifest(
|
|
235
|
+
const result = await loadManifest([...manifest]);
|
|
243
236
|
|
|
244
237
|
if (result.failed.length === 0) {
|
|
245
238
|
for (const block of blocks) block.remove();
|
|
@@ -250,7 +243,7 @@ export async function loadEnhancements(scope = document) {
|
|
|
250
243
|
|
|
251
244
|
async function loadManifestBlock(block) {
|
|
252
245
|
const entries = parseManifestEntries(block.textContent);
|
|
253
|
-
const result = await loadManifest(
|
|
246
|
+
const result = await loadManifest(entries);
|
|
254
247
|
|
|
255
248
|
for (const failure of result.failed) {
|
|
256
249
|
console.error(`Enhancement "${failure.name}" failed to load:`, failure.error);
|
|
@@ -286,7 +279,7 @@ export async function loadResponse(response) {
|
|
|
286
279
|
throw new TypeError("Enhance-Modules header must be a JSON object.");
|
|
287
280
|
}
|
|
288
281
|
|
|
289
|
-
return loadManifest(manifest);
|
|
282
|
+
return loadManifest(Object.entries(manifest));
|
|
290
283
|
}
|
|
291
284
|
|
|
292
285
|
export function __setModuleImporter(fn) {
|
package/src/js/events.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
export const ACTUAL_EVENT_PREFIX = "actual";
|
|
2
2
|
|
|
3
3
|
export const EVENTS = {
|
|
4
|
-
reposition: `${ACTUAL_EVENT_PREFIX}:reposition`,
|
|
5
|
-
hide: `${ACTUAL_EVENT_PREFIX}:hide`,
|
|
6
|
-
outOfView: `${ACTUAL_EVENT_PREFIX}:out-of-view`,
|
|
7
4
|
// Cancelable: dispatched by a surface before it opens.
|
|
8
5
|
// detail: { surface, options }
|
|
9
6
|
surfaceOpen: `${ACTUAL_EVENT_PREFIX}:surface-open`,
|
package/src/js/filter.js
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The coordinate space and CSS positioning scheme are one decision. Keeping
|
|
3
|
+
* them in one value prevents a caller from writing document coordinates to a
|
|
4
|
+
* fixed box, or viewport coordinates to an absolute one.
|
|
5
|
+
*
|
|
6
|
+
* An open top-layer element with position: absolute uses the initial
|
|
7
|
+
* containing block, so only the anchor determines the correct mode.
|
|
8
|
+
*/
|
|
9
|
+
const VIEWPORT_ANCHORED = "dialog:modal, :popover-open";
|
|
10
|
+
|
|
11
|
+
function isViewportAnchored(anchor) {
|
|
12
|
+
const win = anchor?.ownerDocument?.defaultView;
|
|
13
|
+
if (!win) return true;
|
|
14
|
+
|
|
15
|
+
// Modal dialogs and open popovers are viewport-anchored without computing
|
|
16
|
+
// position: fixed, so the ancestor walk below cannot identify them.
|
|
17
|
+
try {
|
|
18
|
+
if (anchor.closest(VIEWPORT_ANCHORED)) return true;
|
|
19
|
+
} catch {
|
|
20
|
+
/* An engine without :modal / :popover-open: the walk is all there is. */
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (let el = anchor; el; el = el.parentElement) {
|
|
24
|
+
const { position } = win.getComputedStyle(el);
|
|
25
|
+
// Resolve sticky once even while unstuck: changing mode when it sticks
|
|
26
|
+
// would jump the floating element between coordinate systems mid-scroll.
|
|
27
|
+
if (position === "fixed" || position === "sticky") return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {Element | null | undefined} anchor
|
|
34
|
+
* @returns {{ space: "viewport" | "document", position: "fixed" | "absolute" }}
|
|
35
|
+
*/
|
|
36
|
+
export function positionModeFor(anchor) {
|
|
37
|
+
const space = isViewportAnchored(anchor) ? "viewport" : "document";
|
|
38
|
+
return {
|
|
39
|
+
space,
|
|
40
|
+
position: space === "document" ? "absolute" : "fixed",
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/js/flyout.js
CHANGED
|
@@ -3,15 +3,8 @@ import { focusFirstDescendant } from "./focus.js";
|
|
|
3
3
|
import { connectMenu, focusFirstMenuItem, focusLastMenuItem } from "./menu.js";
|
|
4
4
|
import { closeSurface, isSurfaceOpen, openSurface, retainSurface } from "./surface.js";
|
|
5
5
|
|
|
6
|
-
const panelRefs = new WeakMap();
|
|
7
6
|
const triggerMap = new WeakMap();
|
|
8
7
|
|
|
9
|
-
function normalizeBreakpoint(raw) {
|
|
10
|
-
const BREAKPOINTS = { sm: 640, md: 768, lg: 1024 };
|
|
11
|
-
const value = Number.parseInt(BREAKPOINTS[raw] || raw, 10);
|
|
12
|
-
return Number.isFinite(value) ? value : undefined;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
8
|
function readFlyoutOptions(panel) {
|
|
16
9
|
const opts = { autoClose: "true" };
|
|
17
10
|
const ds = panel.dataset;
|
|
@@ -20,11 +13,6 @@ function readFlyoutOptions(panel) {
|
|
|
20
13
|
const d = Number.parseFloat(ds.flyoutDistance);
|
|
21
14
|
if (Number.isFinite(d)) opts.distance = d;
|
|
22
15
|
}
|
|
23
|
-
if (ds.flyoutMobile) opts.mobile = ds.flyoutMobile;
|
|
24
|
-
if (ds.flyoutBreakpoint) {
|
|
25
|
-
const bp = normalizeBreakpoint(ds.flyoutBreakpoint);
|
|
26
|
-
if (bp != null) opts.breakpoint = bp;
|
|
27
|
-
}
|
|
28
16
|
if (ds.flyoutAutoClose) opts.autoClose = ds.flyoutAutoClose;
|
|
29
17
|
return opts;
|
|
30
18
|
}
|
|
@@ -93,20 +81,12 @@ function onTriggerKeydown(e) {
|
|
|
93
81
|
|
|
94
82
|
switch (e.key) {
|
|
95
83
|
case "ArrowDown":
|
|
96
|
-
e.preventDefault();
|
|
97
|
-
if (!isSurfaceOpen(panel) && !openFlyout(panel, trigger)) break;
|
|
98
|
-
focusFirstMenuItem(panel);
|
|
99
|
-
break;
|
|
100
|
-
case "ArrowUp":
|
|
101
|
-
e.preventDefault();
|
|
102
|
-
if (!isSurfaceOpen(panel) && !openFlyout(panel, trigger)) break;
|
|
103
|
-
focusLastMenuItem(panel);
|
|
104
|
-
break;
|
|
105
84
|
case "Home":
|
|
106
85
|
e.preventDefault();
|
|
107
86
|
if (!isSurfaceOpen(panel) && !openFlyout(panel, trigger)) break;
|
|
108
87
|
focusFirstMenuItem(panel);
|
|
109
88
|
break;
|
|
89
|
+
case "ArrowUp":
|
|
110
90
|
case "End":
|
|
111
91
|
e.preventDefault();
|
|
112
92
|
if (!isSurfaceOpen(panel) && !openFlyout(panel, trigger)) break;
|
|
@@ -131,7 +111,7 @@ function connectTrigger(trigger) {
|
|
|
131
111
|
if (triggerMap.has(trigger)) return;
|
|
132
112
|
|
|
133
113
|
const controller = new AbortController();
|
|
134
|
-
const state = { panel: null, controller, releaseMenu: null };
|
|
114
|
+
const state = { panel: null, controller, releaseMenu: null, releaseSurface: null };
|
|
135
115
|
triggerMap.set(trigger, state);
|
|
136
116
|
|
|
137
117
|
resolvePanel(trigger, state);
|
|
@@ -144,38 +124,23 @@ function connectTrigger(trigger) {
|
|
|
144
124
|
trigger.addEventListener("keydown", onTriggerKeydown, { signal: controller.signal });
|
|
145
125
|
}
|
|
146
126
|
|
|
147
|
-
function releasePanelReference(
|
|
127
|
+
function releasePanelReference(state) {
|
|
148
128
|
state.releaseMenu?.();
|
|
149
129
|
state.releaseMenu = null;
|
|
150
|
-
|
|
151
|
-
|
|
130
|
+
state.releaseSurface?.();
|
|
131
|
+
state.releaseSurface = null;
|
|
152
132
|
state.panel = null;
|
|
153
|
-
if (!panel) return;
|
|
154
|
-
|
|
155
|
-
const panelEntry = panelRefs.get(panel);
|
|
156
|
-
if (!panelEntry) return;
|
|
157
|
-
|
|
158
|
-
panelEntry.triggers.delete(trigger);
|
|
159
|
-
if (panelEntry.triggers.size === 0) {
|
|
160
|
-
panelEntry.release();
|
|
161
|
-
panelRefs.delete(panel);
|
|
162
|
-
}
|
|
163
133
|
}
|
|
164
134
|
|
|
165
135
|
function resolvePanel(trigger, state) {
|
|
166
136
|
const panel = panelFor(trigger);
|
|
167
137
|
if (state.panel === panel && panel?.isConnected) return panel;
|
|
168
138
|
|
|
169
|
-
releasePanelReference(
|
|
139
|
+
releasePanelReference(state);
|
|
170
140
|
if (!panel) return null;
|
|
171
141
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const release = retainSurface(panel);
|
|
175
|
-
panelEntry = { triggers: new Set(), release };
|
|
176
|
-
panelRefs.set(panel, panelEntry);
|
|
177
|
-
}
|
|
178
|
-
panelEntry.triggers.add(trigger);
|
|
142
|
+
// Surface retention owns the shared lifetime across all triggers and consumers.
|
|
143
|
+
state.releaseSurface = retainSurface(panel);
|
|
179
144
|
state.panel = panel;
|
|
180
145
|
|
|
181
146
|
if (isMenuFlyout(panel)) {
|
|
@@ -197,7 +162,7 @@ function disconnectTrigger(trigger) {
|
|
|
197
162
|
if (state.panel?.isConnected && isSurfaceOpen(state.panel)) {
|
|
198
163
|
closeSurface(state.panel, { restoreFocus: false });
|
|
199
164
|
}
|
|
200
|
-
releasePanelReference(
|
|
165
|
+
releasePanelReference(state);
|
|
201
166
|
triggerMap.delete(trigger);
|
|
202
167
|
}
|
|
203
168
|
|
package/src/js/focus-group.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Focus group — one remembered tab stop and Arrow/Home/End navigation for a
|
|
3
|
+
* composite widget. The consumer supplies getItems(); the controller only
|
|
4
|
+
* updates tabindex and focus, never activation or ARIA. sync() re-reads the
|
|
5
|
+
* candidate list; the primitive does not observe the subtree.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import { itemForKey } from "./keys.js";
|
|
2
9
|
|
|
3
10
|
const ORIENTATIONS = new Set(["horizontal", "vertical"]);
|
package/src/js/focus.js
CHANGED
|
@@ -9,6 +9,12 @@ const FOCUSABLE_SELECTOR = [
|
|
|
9
9
|
"[tabindex]",
|
|
10
10
|
].join(",");
|
|
11
11
|
|
|
12
|
+
/*
|
|
13
|
+
* The branch below checkVisibility() is not dead code: checkVisibility() is
|
|
14
|
+
* Safari 17.4 and the floor is Safari 17, so it is the branch that runs at the
|
|
15
|
+
* bottom of the supported range — where it also decides whether a closed
|
|
16
|
+
* popover's contents are reachable. Measured there; do not drop it.
|
|
17
|
+
*/
|
|
12
18
|
export function isElementVisible(el) {
|
|
13
19
|
if (!el || el.hidden) return false;
|
|
14
20
|
if (typeof el.checkVisibility === "function") return el.checkVisibility();
|
package/src/js/menu.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Menu — the shared `.menu > li > .menu-item` vocabulary and wiring.
|
|
3
|
+
*
|
|
4
|
+
* getMenuItems() filters to usable items; onMenuKeydown() adds directional
|
|
5
|
+
* navigation and activation; connectMenu() owns the ref-counted listeners a
|
|
6
|
+
* menu shares across its triggers. Wiring is per menu, not per trigger, so a
|
|
7
|
+
* menu retained by several triggers (or by flyout and context-menu at once)
|
|
8
|
+
* tears down only when the last one releases it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
1
11
|
import { isElementVisible } from "./focus.js";
|
|
2
12
|
import { connectFocusGroup } from "./focus-group.js";
|
|
3
13
|
import { firstItem, itemForKey, lastItem } from "./keys.js";
|
|
@@ -134,7 +144,6 @@ export function connectMenu(menu, { close }) {
|
|
|
134
144
|
released = true;
|
|
135
145
|
entry.count--;
|
|
136
146
|
if (entry.count <= 0) {
|
|
137
|
-
entry.focusGroup?.disconnect();
|
|
138
147
|
entry.controller.abort();
|
|
139
148
|
menuConnections.delete(menu);
|
|
140
149
|
}
|
package/src/js/selectors.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Selectors — the state-class vocabulary the runtime writes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The file lists the class names modules write as state (is-open,
|
|
5
|
+
* was-validated, …) and validation's two read-side presentation adapters
|
|
6
|
+
* (field, fieldError). Behavior discovery is not listed here: it uses
|
|
7
|
+
* data-enhance tokens. To run this runtime against a different CSS
|
|
8
|
+
* framework, edit this file (or alias it in a bundler).
|
|
9
9
|
*
|
|
10
10
|
* data-* attributes and ARIA are not listed: they are each module's own
|
|
11
11
|
* behavior API, not CSS vocabulary, so they stay framework-neutral by
|
|
@@ -27,10 +27,6 @@ export const CLASSES = {
|
|
|
27
27
|
// surface.js — state class on the open flyout/menu/dialog surface.
|
|
28
28
|
// status.js writes the same class on the status bar while a message shows.
|
|
29
29
|
open: "is-open",
|
|
30
|
-
// surface.js — state class while the surface renders as a mobile sheet.
|
|
31
|
-
sheet: "is-sheet",
|
|
32
|
-
// surface.js — class name written on the generated sheet backdrop.
|
|
33
|
-
backdrop: "surface-backdrop",
|
|
34
30
|
// dialog.js — transient class for the "can't dismiss" shake animation.
|
|
35
31
|
static: "is-static",
|
|
36
32
|
// dialog.js — written on <html> while any modal dialog is open.
|
package/src/js/status.js
CHANGED
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
* - Any other code, without importing this module: dispatch `actual:status`
|
|
32
32
|
* directly — `detail: { message, intent, duration }`, or `{}` to clear.
|
|
33
33
|
*
|
|
34
|
+
* A message originating inside an open dialog temporarily mounts the singleton
|
|
35
|
+
* bar in that dialog. Content outside a modal dialog is inert and cannot sit
|
|
36
|
+
* above its top layer, so this keeps both the visual feedback and live region
|
|
37
|
+
* in the active subtree. Programmatic callers pass the originating element as
|
|
38
|
+
* `options.source`; event and command entry points propagate it automatically.
|
|
39
|
+
*
|
|
34
40
|
* The element contract is `[data-status][role="status"]`. Both are required:
|
|
35
41
|
* "status" is a generic domain word, so `role="status"` keeps the
|
|
36
42
|
* document-wide lookup from matching an unrelated app element that happens to
|
|
@@ -52,6 +58,7 @@ import { waitForTransitions } from "./transition.js";
|
|
|
52
58
|
const STATUS_SELECTOR = '[data-status][role="status"]';
|
|
53
59
|
const STATUS_CLASSES_ATTR = "statusClasses";
|
|
54
60
|
const VIEWPORT_OFFSET_PROPERTY = "--status-viewport-offset";
|
|
61
|
+
const DEFAULT_DURATION_MS = 3000;
|
|
55
62
|
|
|
56
63
|
let statusTimer;
|
|
57
64
|
// Bumped by every show and every clear. A deferred exit cleanup only runs when
|
|
@@ -59,12 +66,39 @@ let statusTimer;
|
|
|
59
66
|
// never emptied by the previous message's cleanup.
|
|
60
67
|
let statusGeneration = 0;
|
|
61
68
|
let viewportTracker = null;
|
|
69
|
+
const statusHomes = new WeakMap();
|
|
62
70
|
|
|
63
71
|
function statusTarget() {
|
|
64
72
|
if (typeof document === "undefined") return null;
|
|
65
73
|
return document.querySelector(STATUS_SELECTOR);
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
function restoreStatusTarget(target) {
|
|
77
|
+
const home = statusHomes.get(target);
|
|
78
|
+
if (!home) return;
|
|
79
|
+
|
|
80
|
+
const parent = home.parent?.isConnected ? home.parent : target.ownerDocument?.body;
|
|
81
|
+
if (parent) {
|
|
82
|
+
const reference = home.nextSibling?.parentNode === parent ? home.nextSibling : null;
|
|
83
|
+
parent.insertBefore(target, reference);
|
|
84
|
+
}
|
|
85
|
+
statusHomes.delete(target);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function mountStatusTarget(target, source) {
|
|
89
|
+
const dialog = source?.closest?.("dialog[open]");
|
|
90
|
+
if (!dialog || dialog.ownerDocument !== target.ownerDocument) {
|
|
91
|
+
restoreStatusTarget(target);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (dialog.contains(target)) return;
|
|
95
|
+
|
|
96
|
+
if (!statusHomes.has(target)) {
|
|
97
|
+
statusHomes.set(target, { parent: target.parentNode, nextSibling: target.nextSibling });
|
|
98
|
+
}
|
|
99
|
+
dialog.append(target);
|
|
100
|
+
}
|
|
101
|
+
|
|
68
102
|
// The bar is fixed to the layout viewport, so the mobile software keyboard
|
|
69
103
|
// covers it. visualViewport reports the occluded band; the CSS adds it to the
|
|
70
104
|
// bottom inset. Tracking runs only while a message is on screen.
|
|
@@ -118,10 +152,10 @@ export function status(message, options = {}) {
|
|
|
118
152
|
const target = statusTarget();
|
|
119
153
|
if (!target || message == null) return;
|
|
120
154
|
|
|
121
|
-
// A live region with nothing to announce is not a message.
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
155
|
+
// A live region with nothing to announce is not a message. The open state
|
|
156
|
+
// is explicit, so an empty message has to mean "close" or it would open a
|
|
157
|
+
// blank pill. This also keeps status(await response.text()) from leaving a
|
|
158
|
+
// stale message on screen.
|
|
125
159
|
if (message === "") {
|
|
126
160
|
status.clear();
|
|
127
161
|
return;
|
|
@@ -130,6 +164,8 @@ export function status(message, options = {}) {
|
|
|
130
164
|
clearTimeout(statusTimer);
|
|
131
165
|
statusGeneration++;
|
|
132
166
|
|
|
167
|
+
mountStatusTarget(target, options.source);
|
|
168
|
+
|
|
133
169
|
clearIntentClasses(target);
|
|
134
170
|
const intent = intentClasses(options.intent);
|
|
135
171
|
if (intent.length) {
|
|
@@ -144,7 +180,7 @@ export function status(message, options = {}) {
|
|
|
144
180
|
trackViewport(target);
|
|
145
181
|
|
|
146
182
|
if (options.duration !== false) {
|
|
147
|
-
statusTimer = setTimeout(() => status.clear(), options.duration ??
|
|
183
|
+
statusTimer = setTimeout(() => status.clear(), options.duration ?? DEFAULT_DURATION_MS);
|
|
148
184
|
}
|
|
149
185
|
}
|
|
150
186
|
|
|
@@ -168,6 +204,7 @@ status.clear = function clear() {
|
|
|
168
204
|
target.textContent = "";
|
|
169
205
|
clearIntentClasses(target);
|
|
170
206
|
releaseViewport();
|
|
207
|
+
restoreStatusTarget(target);
|
|
171
208
|
});
|
|
172
209
|
};
|
|
173
210
|
|
|
@@ -198,7 +235,7 @@ function connectStatusTrigger(trigger, target) {
|
|
|
198
235
|
if (typeof document !== "undefined") {
|
|
199
236
|
document.addEventListener(EVENTS.invalid, (event) => {
|
|
200
237
|
const message = event.detail?.message;
|
|
201
|
-
if (message) status(message, { intent: "danger" });
|
|
238
|
+
if (message) status(message, { intent: "danger", source: event.target });
|
|
202
239
|
});
|
|
203
240
|
|
|
204
241
|
// Public contract: any code can trigger the status bar this way, without
|
|
@@ -209,7 +246,7 @@ if (typeof document !== "undefined") {
|
|
|
209
246
|
// paths end in the same exit, so a detail that lost its message never
|
|
210
247
|
// strands a stale one on screen.
|
|
211
248
|
if (message != null) {
|
|
212
|
-
status(message, { intent, duration });
|
|
249
|
+
status(message, { intent, duration, source: event.target });
|
|
213
250
|
} else {
|
|
214
251
|
status.clear();
|
|
215
252
|
}
|