actual-css 0.9.2 → 0.10.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 +96 -0
- package/README.md +65 -44
- package/components.json +33 -13
- package/dist/actual.full.css +585 -499
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/design-notes/app-ui.md +1 -1
- package/docs/design-notes/column-layout.md +103 -520
- package/docs/design-notes/forced-colors.md +12 -1
- package/docs/design-notes/naming.md +1 -1
- package/docs/design-notes/surface-reparenting.md +8 -9
- package/docs/navigation.json +2 -0
- package/docs/pages/components/accordion.md +55 -1
- package/docs/pages/components/alert.md +9 -15
- package/docs/pages/components/badge.md +6 -7
- package/docs/pages/components/bleed.md +42 -0
- package/docs/pages/components/button.md +30 -4
- package/docs/pages/components/card.md +21 -86
- package/docs/pages/components/close.md +48 -0
- package/docs/pages/components/data-list.md +26 -1
- package/docs/pages/components/dialog.md +16 -16
- package/docs/pages/components/drawer.md +9 -9
- package/docs/pages/components/navbar.md +5 -5
- package/docs/pages/components/spinner.md +3 -3
- package/docs/pages/enhancements/overview.md +5 -0
- package/docs/pages/enhancements/tooltip.md +3 -1
- package/docs/pages/examples/overview.md +2 -1
- package/docs/pages/forms/choice-cards.md +39 -0
- package/docs/pages/forms/combobox.md +23 -11
- package/docs/pages/forms/joined-controls.md +2 -5
- package/docs/pages/forms/overview.md +6 -0
- package/docs/pages/foundations/theming.md +64 -18
- package/docs/pages/foundations/tokens.md +28 -10
- package/docs/pages/guides/introduction.md +15 -23
- package/docs/pages/guides/modular-import.md +53 -6
- package/docs/pages/guides/progressive-enhancement.md +6 -3
- package/docs/pages/guides/tailwind.md +17 -5
- package/docs/pages/layout/column-layout.md +92 -54
- package/docs/pages/layout/media.md +1 -1
- package/docs/pages/layout/overview.md +1 -1
- package/docs/pages/utilities/overview.md +1 -1
- package/docs/pages/utilities/sizing-wrapping.md +5 -3
- package/llms.txt +24 -4
- package/package.json +6 -6
- package/reserved-classes.json +5 -30
- package/skills/actual-css/PROJECT.md.example +65 -0
- package/skills/actual-css/README.md +64 -0
- package/skills/actual-css/SKILL.md +234 -0
- package/skills/actual-css/references/accessibility.md +44 -0
- package/skills/actual-css/references/api-and-vocabulary.md +94 -0
- package/skills/actual-css/references/enhancements.md +59 -0
- package/skills/actual-css/references/forms.md +49 -0
- package/skills/actual-css/references/layout-and-theming.md +82 -0
- package/skills/actual-css/references/review-checklist.md +51 -0
- package/skills/actual-css/scripts/inspect-actual-css.mjs +171 -0
- package/src/css/components/accordion.css +44 -7
- package/src/css/components/alert.css +23 -43
- package/src/css/components/app-nav.css +9 -0
- package/src/css/components/badge.css +11 -39
- package/src/css/components/bleed.css +79 -0
- package/src/css/components/button.css +6 -17
- package/src/css/components/card.css +7 -40
- package/src/css/components/close.css +91 -0
- package/src/css/components/data-list.css +18 -0
- package/src/css/components/drawer.css +15 -56
- package/src/css/components/fab.css +10 -0
- package/src/css/components/flyout.css +1 -0
- package/src/css/components/index.css +2 -0
- package/src/css/components/join.css +36 -20
- package/src/css/components/menu.css +12 -0
- package/src/css/components/modal.css +10 -57
- package/src/css/components/navbar.css +27 -0
- package/src/css/core/focus.css +9 -3
- package/src/css/core/theme.css +25 -43
- package/src/css/core/tokens.css +1 -1
- package/src/css/core/variants.css +3 -33
- package/src/css/forms/choice-card.css +74 -4
- package/src/css/forms/choice.css +2 -3
- package/src/css/forms/control.css +15 -8
- package/src/css/forms/custom-select.css +4 -4
- package/src/css/forms/native.css +4 -4
- package/src/css/forms/range.css +23 -8
- package/src/css/forms/switch.css +2 -3
- package/src/css/layout/app-layout.css +1 -1
- package/src/css/layout/column-layout.css +26 -111
- package/src/css/utilities/extra.css +7 -6
- package/src/js/dialog.js +95 -24
- package/src/js/enhance.js +7 -3
- package/src/js/escape.js +1 -0
- package/src/js/flyout.js +10 -1
- package/src/js/focus-group.js +2 -1
- package/src/js/keys.js +14 -0
- package/src/js/menu.js +2 -1
- package/src/js/status.js +19 -0
- package/src/js/surface.js +12 -8
- package/src/js/tab.js +2 -1
- package/src/js/tooltip.js +18 -1
- package/src/js/validation.js +5 -6
- package/dist/actual.css +0 -876
- package/dist/actual.js +0 -1
- package/dist/actual.min.css +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Extra utility helpers — explicit property/value names beside the curated
|
|
3
|
+
* shortcuts of base.css. Both are imported by utilities/index.css and are
|
|
4
|
+
* equally public (docs/design-notes/naming.md § Utilities).
|
|
5
5
|
* They are escape hatches for layout nudges, not a full atomic scale.
|
|
6
6
|
*
|
|
7
7
|
* Core principle:
|
|
@@ -177,8 +177,9 @@
|
|
|
177
177
|
overflow-y: auto;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
/* Text
|
|
181
|
-
.
|
|
180
|
+
/* Text. .text-wrap is the counterpart of base's .text-nowrap: it restores
|
|
181
|
+
wrapping inside a component that sets nowrap (.badge). */
|
|
182
|
+
.text-wrap {
|
|
182
183
|
white-space: normal;
|
|
183
184
|
}
|
|
184
185
|
|
|
@@ -251,7 +252,7 @@
|
|
|
251
252
|
/* Surface. These are semantic surfaces, not arbitrary paint: each owns its
|
|
252
253
|
matching ink and rebinds --heading to that ink, so it stays readable when it
|
|
253
254
|
recreates a light surface inside a context that installed a dark one
|
|
254
|
-
(.
|
|
255
|
+
(e.g. a --surface-solid band). Intent ink is not inherited through a surface utility; use
|
|
255
256
|
.intent-color for that. */
|
|
256
257
|
.background-surface {
|
|
257
258
|
--heading: currentColor;
|
package/src/js/dialog.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* The runtime assumes native <dialog> with show()/showModal()/close() — the
|
|
26
26
|
* Minimal browser tier. requestClose() is newer than the floor and is used
|
|
27
|
-
* when present, falling back to close() otherwise.
|
|
27
|
+
* when present, falling back to a cancel event before close() otherwise.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { registerCommands, targetFor } from "./command.js";
|
|
@@ -48,34 +48,69 @@ function boolData(el, name) {
|
|
|
48
48
|
return el.hasAttribute(name) && el.getAttribute(name) !== "false";
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
function
|
|
51
|
+
function setOwnedAttribute(state, el, name, value) {
|
|
52
|
+
const current = el.getAttribute(name);
|
|
53
|
+
if (current === value) return;
|
|
54
|
+
|
|
55
|
+
const previous = state.ownedAttributes.find((entry) => entry.el === el && entry.name === name);
|
|
56
|
+
if (previous && current !== previous.written) previous.original = current;
|
|
57
|
+
if (value === null) el.removeAttribute(name);
|
|
58
|
+
else el.setAttribute(name, value);
|
|
59
|
+
|
|
60
|
+
if (previous) previous.written = value;
|
|
61
|
+
else state.ownedAttributes.push({ el, name, original: current, written: value });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function restoreOwnedAttributes(state) {
|
|
65
|
+
for (const { el, name, original, written } of state.ownedAttributes) {
|
|
66
|
+
// An author may have changed an attribute while the dialog was connected.
|
|
67
|
+
if (el.getAttribute(name) !== written) continue;
|
|
68
|
+
if (original === null) el.removeAttribute(name);
|
|
69
|
+
else el.setAttribute(name, original);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ensureId(el, prefix, state) {
|
|
52
74
|
if (!el.id) {
|
|
53
75
|
uid++;
|
|
54
|
-
el
|
|
76
|
+
setOwnedAttribute(state, el, "id", `${prefix}-${uid}`);
|
|
55
77
|
}
|
|
56
78
|
return el.id;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
81
|
+
function ownsCurrentAttribute(state, el, name) {
|
|
82
|
+
return state.ownedAttributes.some(
|
|
83
|
+
(entry) => entry.el === el && entry.name === name && el.getAttribute(name) === entry.written,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
65
86
|
|
|
66
|
-
|
|
87
|
+
function syncDialogSemantics(dialog, state, modal = isModal(dialog)) {
|
|
88
|
+
setOwnedAttribute(state, dialog, "aria-modal", modal ? "true" : null);
|
|
89
|
+
|
|
90
|
+
const ownsLabel = ownsCurrentAttribute(state, dialog, "aria-label");
|
|
91
|
+
const ownsLabelledby = ownsCurrentAttribute(state, dialog, "aria-labelledby");
|
|
92
|
+
if (dialog.hasAttribute("aria-labelledby") && !ownsLabelledby) {
|
|
93
|
+
if (ownsLabel) setOwnedAttribute(state, dialog, "aria-label", null);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (dialog.hasAttribute("aria-label") && !ownsLabel) {
|
|
97
|
+
if (ownsLabelledby) setOwnedAttribute(state, dialog, "aria-labelledby", null);
|
|
67
98
|
return;
|
|
68
99
|
}
|
|
69
100
|
|
|
70
101
|
const label = dialog.getAttribute("data-title")?.trim();
|
|
71
102
|
if (label) {
|
|
72
|
-
dialog
|
|
103
|
+
if (ownsLabelledby) setOwnedAttribute(state, dialog, "aria-labelledby", null);
|
|
104
|
+
setOwnedAttribute(state, dialog, "aria-label", label);
|
|
73
105
|
return;
|
|
74
106
|
}
|
|
75
107
|
|
|
76
108
|
const title = dialog.querySelector(DIALOG_TITLE_SELECTOR);
|
|
109
|
+
if (ownsLabel) setOwnedAttribute(state, dialog, "aria-label", null);
|
|
77
110
|
if (title) {
|
|
78
|
-
dialog
|
|
111
|
+
setOwnedAttribute(state, dialog, "aria-labelledby", ensureId(title, "dialog-title", state));
|
|
112
|
+
} else if (ownsLabelledby) {
|
|
113
|
+
setOwnedAttribute(state, dialog, "aria-labelledby", null);
|
|
79
114
|
}
|
|
80
115
|
}
|
|
81
116
|
|
|
@@ -262,13 +297,19 @@ export function closeDialog(dialog, returnValue = "") {
|
|
|
262
297
|
export function requestDialogClose(dialog, returnValue = "") {
|
|
263
298
|
if (!isDialogElement(dialog) || !dialog.open) return;
|
|
264
299
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
300
|
+
const state = ensureDialogWired(dialog);
|
|
301
|
+
state.returnValue = returnValue;
|
|
302
|
+
state.requestingClose = true;
|
|
303
|
+
try {
|
|
304
|
+
if (typeof dialog.requestClose === "function") {
|
|
305
|
+
dialog.requestClose(returnValue);
|
|
306
|
+
} else {
|
|
307
|
+
// Older browsers still owe consumers the same cancel lifecycle.
|
|
308
|
+
dialog.dispatchEvent(new Event("cancel", { cancelable: true }));
|
|
309
|
+
}
|
|
310
|
+
} finally {
|
|
311
|
+
state.requestingClose = false;
|
|
269
312
|
}
|
|
270
|
-
|
|
271
|
-
closeDialog(dialog, returnValue);
|
|
272
313
|
}
|
|
273
314
|
|
|
274
315
|
export function openDialog(dialog, trigger = null, forcedModal = null) {
|
|
@@ -276,7 +317,7 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
|
|
|
276
317
|
|
|
277
318
|
const state = ensureDialogWired(dialog);
|
|
278
319
|
const modal = resolveModalMode(dialog, forcedModal);
|
|
279
|
-
syncDialogSemantics(dialog, modal);
|
|
320
|
+
syncDialogSemantics(dialog, state, modal);
|
|
280
321
|
|
|
281
322
|
state.closing = false;
|
|
282
323
|
state.restoreFocusTo = trigger || dialog.ownerDocument.activeElement;
|
|
@@ -285,6 +326,7 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
|
|
|
285
326
|
// while doing so; capture the position first and restore it so the page does
|
|
286
327
|
// not jump to the top behind the modal.
|
|
287
328
|
const viewport = dialog.ownerDocument.defaultView;
|
|
329
|
+
const scrollX = viewport?.scrollX ?? 0;
|
|
288
330
|
const scrollY = viewport?.scrollY ?? 0;
|
|
289
331
|
|
|
290
332
|
if (modal) {
|
|
@@ -295,17 +337,33 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
|
|
|
295
337
|
state.modalOpen = false;
|
|
296
338
|
}
|
|
297
339
|
|
|
298
|
-
if (scrollY > 0) {
|
|
299
|
-
viewport?.scrollTo(
|
|
340
|
+
if (scrollX !== 0 || scrollY > 0) {
|
|
341
|
+
viewport?.scrollTo(scrollX, scrollY);
|
|
300
342
|
}
|
|
301
343
|
|
|
302
344
|
syncModalOpenClass(dialog.ownerDocument);
|
|
303
345
|
}
|
|
304
346
|
|
|
347
|
+
function handleDialogPointerDown(event) {
|
|
348
|
+
const dialog = event.currentTarget;
|
|
349
|
+
const state = dialogMap.get(dialog);
|
|
350
|
+
if (!state) return;
|
|
351
|
+
|
|
352
|
+
// Only a press that starts on the backdrop may dismiss on click. A
|
|
353
|
+
// text selection started inside the dialog and released over the backdrop
|
|
354
|
+
// produces a click on <dialog> with outside coordinates, which must not
|
|
355
|
+
// close (or flash static on) the dialog.
|
|
356
|
+
state.backdropPress = event.target === dialog && isOutsideDialog(dialog, event);
|
|
357
|
+
}
|
|
358
|
+
|
|
305
359
|
function handleDialogClick(event) {
|
|
306
360
|
const dialog = event.currentTarget;
|
|
361
|
+
const state = dialogMap.get(dialog);
|
|
362
|
+
const backdropPress = state?.backdropPress === true;
|
|
363
|
+
if (state) state.backdropPress = false;
|
|
307
364
|
|
|
308
365
|
if (event.target !== dialog || !isOutsideDialog(dialog, event)) return;
|
|
366
|
+
if (!backdropPress) return;
|
|
309
367
|
|
|
310
368
|
// A native closedby="any" dialog owns its light dismiss; the runtime only
|
|
311
369
|
// takes over when the author opted in via data-dialog-dismissible (which
|
|
@@ -338,7 +396,15 @@ function handleDialogCancel(event) {
|
|
|
338
396
|
const dialog = event.currentTarget;
|
|
339
397
|
const state = dialogMap.get(dialog);
|
|
340
398
|
|
|
341
|
-
//
|
|
399
|
+
// Older dialogs may dispatch cancel for Escape without implementing
|
|
400
|
+
// closedBy. Explicit requestDialogClose() remains available to close them.
|
|
401
|
+
const closedBy = "closedBy" in dialog ? dialog.closedBy : dialog.getAttribute("closedby");
|
|
402
|
+
if (closedBy === "none" && !state?.requestingClose) {
|
|
403
|
+
event.preventDefault();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Escape and close requests close regardless of the dismissible flag:
|
|
342
408
|
// data-dialog-dismissible only gates light dismiss (backdrop click, handled
|
|
343
409
|
// in handleDialogClick). Applications intercept via actual:dialog-cancel.
|
|
344
410
|
const request = new CustomEvent(EVENTS.dialogCancel, {
|
|
@@ -395,15 +461,19 @@ function ensureDialogWired(dialog) {
|
|
|
395
461
|
controller,
|
|
396
462
|
closing: false,
|
|
397
463
|
modalOpen: isModalOpen(dialog),
|
|
464
|
+
ownedAttributes: [],
|
|
465
|
+
requestingClose: false,
|
|
398
466
|
restoreFocusTo: null,
|
|
399
467
|
returnValue: "",
|
|
400
468
|
staticTimer: null,
|
|
469
|
+
backdropPress: false,
|
|
401
470
|
};
|
|
402
471
|
|
|
403
472
|
// A dialog that is already `open` in the markup is non-modal per spec,
|
|
404
473
|
// whatever data-dialog-modal says; infer semantics from its actual state.
|
|
405
|
-
syncDialogSemantics(dialog, dialog.open ? isModalOpen(dialog) : isModal(dialog));
|
|
474
|
+
syncDialogSemantics(dialog, state, dialog.open ? isModalOpen(dialog) : isModal(dialog));
|
|
406
475
|
|
|
476
|
+
dialog.addEventListener("pointerdown", handleDialogPointerDown, { signal: controller.signal });
|
|
407
477
|
dialog.addEventListener("click", handleDialogClick, { signal: controller.signal });
|
|
408
478
|
dialog.addEventListener("submit", handleDialogSubmit, { signal: controller.signal });
|
|
409
479
|
dialog.addEventListener("cancel", handleDialogCancel, { signal: controller.signal });
|
|
@@ -414,7 +484,7 @@ function ensureDialogWired(dialog) {
|
|
|
414
484
|
// closedby keeps its native meaning — "any" closes on backdrop click,
|
|
415
485
|
// "closerequest" on Escape, "none" disables both. Never override "none".
|
|
416
486
|
if (isDismissible(dialog) && "closedBy" in dialog && dialog.closedBy === "any") {
|
|
417
|
-
dialog
|
|
487
|
+
setOwnedAttribute(state, dialog, "closedby", "closerequest");
|
|
418
488
|
}
|
|
419
489
|
|
|
420
490
|
dialogMap.set(dialog, state);
|
|
@@ -437,6 +507,7 @@ function disconnectDialog(dialog) {
|
|
|
437
507
|
|
|
438
508
|
state.controller.abort();
|
|
439
509
|
dialog.classList.remove(CLASSES.static);
|
|
510
|
+
restoreOwnedAttributes(state);
|
|
440
511
|
state.modalOpen = false;
|
|
441
512
|
dialogMap.delete(dialog);
|
|
442
513
|
wiredDialogs.delete(dialog);
|
package/src/js/enhance.js
CHANGED
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
* about final state after a batch, so a moved element (removed then
|
|
9
9
|
* reinserted in the same batch) survives without a spurious disconnect.
|
|
10
10
|
*
|
|
11
|
-
* Attribute changes are intentionally not observed.
|
|
12
|
-
* adding a behavior attribute to an
|
|
13
|
-
*
|
|
11
|
+
* Attribute changes are intentionally not observed. The owner of a handle
|
|
12
|
+
* calls refresh(node) after adding a behavior attribute to an
|
|
13
|
+
* already-connected element; for a data-enhance token, applyEnhancement()
|
|
14
|
+
* does it. The built-in data-* behaviors (mask, filter, context menu, …)
|
|
15
|
+
* keep their handle private, so for them insert the element with the
|
|
16
|
+
* attribute already set. Behavior attributes are setup-time contracts, not
|
|
17
|
+
* live enable/disable switches.
|
|
14
18
|
*
|
|
15
19
|
* enhancers: { [selector]: (el) => cleanup | void }
|
|
16
20
|
* Returns: { refresh, disconnect }
|
package/src/js/escape.js
CHANGED
package/src/js/flyout.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { registerEnhancement } from "./enhance.js";
|
|
2
2
|
import { focusFirstDescendant } from "./focus.js";
|
|
3
|
+
import { shouldIgnoreKey } from "./keys.js";
|
|
3
4
|
import { connectMenu, focusFirstMenuItem, focusLastMenuItem } from "./menu.js";
|
|
4
5
|
import { closeSurface, isSurfaceOpen, openSurface, retainSurface } from "./surface.js";
|
|
5
6
|
|
|
@@ -26,6 +27,13 @@ function isMenuFlyout(panel) {
|
|
|
26
27
|
return panel.matches("menu") || panel.classList.contains("menu");
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// ARIA-wise only [role="menu"] is a menu: <menu> carries the implicit list
|
|
31
|
+
// role and .menu is a visual class. aria-haspopup="menu" must follow the
|
|
32
|
+
// role, not the tag or class.
|
|
33
|
+
function isAriaMenu(panel) {
|
|
34
|
+
return panel.getAttribute("role") === "menu";
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
function openFlyout(panel, trigger) {
|
|
30
38
|
return openSurface(panel, {
|
|
31
39
|
trigger,
|
|
@@ -57,6 +65,7 @@ function onTriggerClick(e) {
|
|
|
57
65
|
|
|
58
66
|
function onTriggerKeydown(e) {
|
|
59
67
|
const trigger = e.currentTarget;
|
|
68
|
+
if (shouldIgnoreKey(e)) return;
|
|
60
69
|
const state = triggerMap.get(trigger);
|
|
61
70
|
if (!state) return;
|
|
62
71
|
const panel = resolvePanel(trigger, state);
|
|
@@ -153,7 +162,7 @@ function resolvePanel(trigger, state) {
|
|
|
153
162
|
close: (menu) => closeSurface(menu),
|
|
154
163
|
});
|
|
155
164
|
}
|
|
156
|
-
if (!trigger.hasAttribute("aria-haspopup") &&
|
|
165
|
+
if (!trigger.hasAttribute("aria-haspopup") && isAriaMenu(panel)) {
|
|
157
166
|
trigger.setAttribute("aria-haspopup", "menu");
|
|
158
167
|
}
|
|
159
168
|
|
package/src/js/focus-group.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* candidate list; the primitive does not observe the subtree.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { itemForKey } from "./keys.js";
|
|
8
|
+
import { itemForKey, shouldIgnoreKey } from "./keys.js";
|
|
9
9
|
|
|
10
10
|
const ORIENTATIONS = new Set(["horizontal", "vertical"]);
|
|
11
11
|
|
|
@@ -82,6 +82,7 @@ export function connectFocusGroup(root, opts = {}) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function onKeydown(event) {
|
|
85
|
+
if (shouldIgnoreKey(event)) return;
|
|
85
86
|
const items = sync();
|
|
86
87
|
const current = root.ownerDocument.activeElement;
|
|
87
88
|
if (!items.includes(current)) return;
|
package/src/js/keys.js
CHANGED
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
* centralize item lookup, edge handling, and optional wrapping.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
// Shared guard for roving-focus key handlers. Composition (IME) and
|
|
9
|
+
// ctrl/alt/meta-modified keys belong to the input method or the browser
|
|
10
|
+
// (Alt+Arrow = history navigation) and must never be claimed by a widget.
|
|
11
|
+
// Shift is deliberately excluded: Shift+Tab carries focus semantics that
|
|
12
|
+
// callers handle themselves.
|
|
13
|
+
export function shouldIgnoreKey(event) {
|
|
14
|
+
return (
|
|
15
|
+
event?.isComposing === true ||
|
|
16
|
+
event?.ctrlKey === true ||
|
|
17
|
+
event?.altKey === true ||
|
|
18
|
+
event?.metaKey === true
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
export function firstItem(items) {
|
|
9
23
|
return items[0] || null;
|
|
10
24
|
}
|
package/src/js/menu.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { isElementVisible } from "./focus.js";
|
|
12
12
|
import { connectFocusGroup } from "./focus-group.js";
|
|
13
|
-
import { firstItem, itemForKey, lastItem } from "./keys.js";
|
|
13
|
+
import { firstItem, itemForKey, lastItem, shouldIgnoreKey } from "./keys.js";
|
|
14
14
|
|
|
15
15
|
const MENU_ITEM_SELECTOR = ":scope > li > .menu-item";
|
|
16
16
|
const MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
|
|
@@ -62,6 +62,7 @@ export function focusLastMenuItem(menu) {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function onMenuKeydown(e, { close, navigate = true }) {
|
|
65
|
+
if (shouldIgnoreKey(e)) return;
|
|
65
66
|
const menu = e.currentTarget;
|
|
66
67
|
if (navigate) {
|
|
67
68
|
const items = getMenuItems(menu);
|
package/src/js/status.js
CHANGED
|
@@ -67,6 +67,21 @@ let statusTimer;
|
|
|
67
67
|
let statusGeneration = 0;
|
|
68
68
|
let viewportTracker = null;
|
|
69
69
|
const statusHomes = new WeakMap();
|
|
70
|
+
// Reference kept only while the bar is temporarily mounted in a dialog. If
|
|
71
|
+
// that dialog is removed from the DOM (htmx/Turbo swap), document lookup can
|
|
72
|
+
// no longer find the bar — this reference lets the next status()/clear()
|
|
73
|
+
// restore it to its home instead of silently no-opping forever.
|
|
74
|
+
let mountedStatusTarget = null;
|
|
75
|
+
|
|
76
|
+
function healDetachedStatusTarget() {
|
|
77
|
+
if (!mountedStatusTarget || mountedStatusTarget.isConnected) return;
|
|
78
|
+
if (!statusHomes.has(mountedStatusTarget)) {
|
|
79
|
+
mountedStatusTarget = null;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
restoreStatusTarget(mountedStatusTarget);
|
|
83
|
+
mountedStatusTarget = null;
|
|
84
|
+
}
|
|
70
85
|
|
|
71
86
|
function statusTarget() {
|
|
72
87
|
if (typeof document === "undefined") return null;
|
|
@@ -83,6 +98,7 @@ function restoreStatusTarget(target) {
|
|
|
83
98
|
parent.insertBefore(target, reference);
|
|
84
99
|
}
|
|
85
100
|
statusHomes.delete(target);
|
|
101
|
+
if (mountedStatusTarget === target) mountedStatusTarget = null;
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
function mountStatusTarget(target, source) {
|
|
@@ -97,6 +113,7 @@ function mountStatusTarget(target, source) {
|
|
|
97
113
|
statusHomes.set(target, { parent: target.parentNode, nextSibling: target.nextSibling });
|
|
98
114
|
}
|
|
99
115
|
dialog.append(target);
|
|
116
|
+
mountedStatusTarget = target;
|
|
100
117
|
}
|
|
101
118
|
|
|
102
119
|
// The bar is fixed to the layout viewport, so the mobile software keyboard
|
|
@@ -149,6 +166,7 @@ function clearIntentClasses(target) {
|
|
|
149
166
|
}
|
|
150
167
|
|
|
151
168
|
export function status(message, options = {}) {
|
|
169
|
+
healDetachedStatusTarget();
|
|
152
170
|
const target = statusTarget();
|
|
153
171
|
if (!target || message == null) return;
|
|
154
172
|
|
|
@@ -186,6 +204,7 @@ export function status(message, options = {}) {
|
|
|
186
204
|
|
|
187
205
|
status.clear = function clear() {
|
|
188
206
|
clearTimeout(statusTimer);
|
|
207
|
+
healDetachedStatusTarget();
|
|
189
208
|
|
|
190
209
|
const target = statusTarget();
|
|
191
210
|
if (!target) {
|
package/src/js/surface.js
CHANGED
|
@@ -222,7 +222,10 @@ function startSurfaceResources(menu, state) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
export function isSurfaceOpen(menu) {
|
|
225
|
-
|
|
225
|
+
// The open set is the source of truth; .is-open is only its visual
|
|
226
|
+
// projection. A morph touching classes must not orphan Escape entries,
|
|
227
|
+
// position tracking, or the open set.
|
|
228
|
+
return openSurfaces.has(menu);
|
|
226
229
|
}
|
|
227
230
|
|
|
228
231
|
export function prepareSurface(menu) {
|
|
@@ -304,8 +307,14 @@ export function openSurface(menu, opts = {}) {
|
|
|
304
307
|
state.coordinateSpace = mode.space;
|
|
305
308
|
menu.style.position = mode.position;
|
|
306
309
|
|
|
310
|
+
// Register before promoting: a position failure below closes the surface
|
|
311
|
+
// again, and that close must observe the open state to tear down.
|
|
312
|
+
openSurfaces.add(menu);
|
|
307
313
|
// Promote before measuring: a closed popover has no box to position.
|
|
308
|
-
if (!showTransport(menu))
|
|
314
|
+
if (!showTransport(menu)) {
|
|
315
|
+
openSurfaces.delete(menu);
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
309
318
|
menu.classList.add(CLASSES.open);
|
|
310
319
|
syncExpanded(menu, true);
|
|
311
320
|
|
|
@@ -335,13 +344,12 @@ export function openSurface(menu, opts = {}) {
|
|
|
335
344
|
* from; placing focus in the new surface is the caller's job, and both
|
|
336
345
|
* callers in tree do it as soon as this returns.
|
|
337
346
|
*/
|
|
338
|
-
for (const other of openSurfaces) {
|
|
347
|
+
for (const other of [...openSurfaces]) {
|
|
339
348
|
if (other !== menu && other.ownerDocument === menu.ownerDocument) {
|
|
340
349
|
closeSurface(other, { restoreFocus: false });
|
|
341
350
|
}
|
|
342
351
|
}
|
|
343
352
|
|
|
344
|
-
openSurfaces.add(menu);
|
|
345
353
|
startSurfaceResources(menu, state);
|
|
346
354
|
return true;
|
|
347
355
|
}
|
|
@@ -411,7 +419,3 @@ function ensureDocumentClick(menu) {
|
|
|
411
419
|
clickBoundDocuments.add(doc);
|
|
412
420
|
doc.addEventListener("click", onDocumentClick);
|
|
413
421
|
}
|
|
414
|
-
|
|
415
|
-
export function getSurfaceAutoClose(menu) {
|
|
416
|
-
return surfaceMap.get(menu)?.autoClose ?? "outside";
|
|
417
|
-
}
|
package/src/js/tab.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { registerEnhancement } from "./enhance.js";
|
|
20
|
-
import { itemForKey } from "./keys.js";
|
|
20
|
+
import { itemForKey, shouldIgnoreKey } from "./keys.js";
|
|
21
21
|
|
|
22
22
|
const TABLIST_SELECTOR = '[data-enhance~="tabs"]';
|
|
23
23
|
|
|
@@ -100,6 +100,7 @@ function initialize(list) {
|
|
|
100
100
|
|
|
101
101
|
function onKeydown(e) {
|
|
102
102
|
const list = e.currentTarget;
|
|
103
|
+
if (shouldIgnoreKey(e)) return;
|
|
103
104
|
const tab = e.target.closest('[role="tab"]');
|
|
104
105
|
if (!tab || tab.closest(TABLIST_SELECTOR) !== list) return;
|
|
105
106
|
|
package/src/js/tooltip.js
CHANGED
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
* for tooltips that are never triggered. AJAX-loaded triggers
|
|
24
24
|
* work automatically via bubbling delegated listeners.
|
|
25
25
|
*
|
|
26
|
+
* Snapshot, not reactive: generated tooltip text is synchronized when
|
|
27
|
+
* the tooltip is shown. Changes to data-tooltip do not rewrite
|
|
28
|
+
* a tooltip that is already shown. Always-visible generated
|
|
29
|
+
* tooltips therefore keep their initial text; use an explicit
|
|
30
|
+
* tooltip when its content must update dynamically.
|
|
31
|
+
*
|
|
26
32
|
* Show: hover + focus (150ms delay), click toggle, or always visible
|
|
27
33
|
* Hide: focus leaving the trigger (focusout), pointer leave, Escape
|
|
28
34
|
*
|
|
@@ -367,7 +373,10 @@ function ensureTip(trigger) {
|
|
|
367
373
|
const text = trigger.getAttribute("data-tooltip");
|
|
368
374
|
const doc = trigger.ownerDocument;
|
|
369
375
|
|
|
370
|
-
// shorthand: data-tooltip="text" → create element lazily
|
|
376
|
+
// shorthand: data-tooltip="text" → create element lazily.
|
|
377
|
+
// Shorthand content is always text. Never use innerHTML here:
|
|
378
|
+
// data-tooltip may hold application/user-provided strings. Rich content
|
|
379
|
+
// belongs in an explicit author-owned tooltip.
|
|
371
380
|
if (text) {
|
|
372
381
|
tip = doc.createElement("div");
|
|
373
382
|
tip.className = CLASSES.tooltip;
|
|
@@ -467,6 +476,14 @@ function show(tip, ref, immediate = false) {
|
|
|
467
476
|
const state = tipStates.get(tip);
|
|
468
477
|
if (!state) return;
|
|
469
478
|
|
|
479
|
+
// Shorthand tips copy data-tooltip text at creation; re-read it on every
|
|
480
|
+
// show so a "Copy" → "Copied!" update is reflected without rewiring.
|
|
481
|
+
// Text only, never innerHTML — see the note at creation above.
|
|
482
|
+
if (state.generated) {
|
|
483
|
+
const text = ref.getAttribute("data-tooltip");
|
|
484
|
+
if (text && tip.textContent !== text) tip.textContent = text;
|
|
485
|
+
}
|
|
486
|
+
|
|
470
487
|
state.activeRef = ref;
|
|
471
488
|
const mode = positionModeFor(ref);
|
|
472
489
|
state.space = mode.space;
|
package/src/js/validation.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* DOM tears down everything, including the managed novalidate attribute.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import
|
|
20
|
+
import { enhancementSelector, registerEnhancement } from "./enhance.js";
|
|
21
21
|
import { EVENTS } from "./events.js";
|
|
22
22
|
import { CLASSES } from "./selectors.js";
|
|
23
23
|
|
|
@@ -435,10 +435,9 @@ export class FormValidator {
|
|
|
435
435
|
}
|
|
436
436
|
}
|
|
437
437
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
438
|
+
// Registered by name, not through a bare enhance(), so applyEnhancement()
|
|
439
|
+
// refreshes it like the other token behaviors. The token only means
|
|
440
|
+
// something on a form.
|
|
441
|
+
registerEnhancement("validation", (el) => (isFormElement(el) ? connectForm(el) : undefined));
|
|
443
442
|
|
|
444
443
|
export default FormValidator;
|