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.
Files changed (129) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/dist/actual.css +4929 -0
  5. package/dist/actual.js +3 -0
  6. package/dist/actual.js.map +31 -0
  7. package/dist/actual.min.css +1 -0
  8. package/docs/README.md +26 -0
  9. package/docs/components.md +860 -0
  10. package/docs/design-notes/accessibility-decisions.md +32 -0
  11. package/docs/design-notes/browser-support.md +93 -0
  12. package/docs/design-notes/cascade-layer.md +52 -0
  13. package/docs/design-notes/css-anchor.md +33 -0
  14. package/docs/design-notes/naming.md +37 -0
  15. package/docs/design-notes/previous-work.md +5 -0
  16. package/docs/design-notes/references.md +324 -0
  17. package/docs/forms.md +1265 -0
  18. package/docs/javascript.md +426 -0
  19. package/docs/layout.md +480 -0
  20. package/docs/mental-model.md +73 -0
  21. package/docs/patterns.md +415 -0
  22. package/docs/reserved.md +7 -0
  23. package/docs/tokens.md +455 -0
  24. package/docs/typography.md +159 -0
  25. package/docs/ui.md +937 -0
  26. package/docs/utilities.md +384 -0
  27. package/llms.txt +17 -0
  28. package/package.json +101 -0
  29. package/src/css/actual.css +17 -0
  30. package/src/css/actual.full.css +2 -0
  31. package/src/css/actual.layer.css +3 -0
  32. package/src/css/base.css +70 -0
  33. package/src/css/components/accordion.css +97 -0
  34. package/src/css/components/actions.css +21 -0
  35. package/src/css/components/alert.css +137 -0
  36. package/src/css/components/avatar.css +199 -0
  37. package/src/css/components/badge.css +143 -0
  38. package/src/css/components/breadcrumb.css +56 -0
  39. package/src/css/components/busy.css +44 -0
  40. package/src/css/components/button.css +216 -0
  41. package/src/css/components/card.css +140 -0
  42. package/src/css/components/drawer.css +122 -0
  43. package/src/css/components/flyout.css +243 -0
  44. package/src/css/components/index.css +41 -0
  45. package/src/css/components/join.css +106 -0
  46. package/src/css/components/key.css +25 -0
  47. package/src/css/components/meter.css +81 -0
  48. package/src/css/components/modal.css +162 -0
  49. package/src/css/components/navbar.css +98 -0
  50. package/src/css/components/overline.css +49 -0
  51. package/src/css/components/pagination.css +30 -0
  52. package/src/css/components/progress.css +126 -0
  53. package/src/css/components/scrollspy.css +27 -0
  54. package/src/css/components/skeleton.css +67 -0
  55. package/src/css/components/spinner.css +60 -0
  56. package/src/css/components/status-bar.css +82 -0
  57. package/src/css/components/surface.css +48 -0
  58. package/src/css/components/tab.css +128 -0
  59. package/src/css/components/table.css +64 -0
  60. package/src/css/components/tooltip.css +107 -0
  61. package/src/css/dialog-fallback.css +56 -0
  62. package/src/css/focus.css +13 -0
  63. package/src/css/forms/choice-card.css +158 -0
  64. package/src/css/forms/choice.css +196 -0
  65. package/src/css/forms/control.css +106 -0
  66. package/src/css/forms/custom-select.css +93 -0
  67. package/src/css/forms/form-actions.css +43 -0
  68. package/src/css/forms/form.css +57 -0
  69. package/src/css/forms/index.css +15 -0
  70. package/src/css/forms/input-icon.css +36 -0
  71. package/src/css/forms/native.css +68 -0
  72. package/src/css/forms/select.css +36 -0
  73. package/src/css/forms/switch.css +127 -0
  74. package/src/css/forms/validation.css +63 -0
  75. package/src/css/grid.css +95 -0
  76. package/src/css/intents.css +33 -0
  77. package/src/css/layout.css +80 -0
  78. package/src/css/optional/index.css +5 -0
  79. package/src/css/optional/layout-extra.css +55 -0
  80. package/src/css/optional/scroll-snap.css +51 -0
  81. package/src/css/optional/scroller.css +51 -0
  82. package/src/css/optional/typography-fluid.css +48 -0
  83. package/src/css/optional/utilities-extra.css +289 -0
  84. package/src/css/print.css +47 -0
  85. package/src/css/prose.css +187 -0
  86. package/src/css/reset.css +56 -0
  87. package/src/css/theme.css +186 -0
  88. package/src/css/themes/corporate.css +60 -0
  89. package/src/css/themes/cyberpunk.css +60 -0
  90. package/src/css/themes/dark.css +32 -0
  91. package/src/css/themes/dim.css +33 -0
  92. package/src/css/themes/edge.css +143 -0
  93. package/src/css/themes/forest.css +65 -0
  94. package/src/css/themes/gradient.css +97 -0
  95. package/src/css/themes/index.css +43 -0
  96. package/src/css/themes/lavender.css +66 -0
  97. package/src/css/themes/mono.css +60 -0
  98. package/src/css/themes/neon.css +66 -0
  99. package/src/css/themes/ocean.css +60 -0
  100. package/src/css/themes/petrol.css +64 -0
  101. package/src/css/themes/spruce.css +64 -0
  102. package/src/css/themes/square.css +68 -0
  103. package/src/css/themes/sunset.css +66 -0
  104. package/src/css/tokens.css +122 -0
  105. package/src/css/utilities.css +162 -0
  106. package/src/css/variants.css +118 -0
  107. package/src/js/command.js +165 -0
  108. package/src/js/context-menu.js +291 -0
  109. package/src/js/dialog-fallback.js +163 -0
  110. package/src/js/dialog.js +503 -0
  111. package/src/js/enhance.js +208 -0
  112. package/src/js/events.js +22 -0
  113. package/src/js/filter.js +122 -0
  114. package/src/js/floating.js +462 -0
  115. package/src/js/flyout.js +187 -0
  116. package/src/js/focus.js +35 -0
  117. package/src/js/index.js +26 -0
  118. package/src/js/input.js +47 -0
  119. package/src/js/keys.js +30 -0
  120. package/src/js/mask.js +184 -0
  121. package/src/js/menu.js +88 -0
  122. package/src/js/password.js +98 -0
  123. package/src/js/scrollspy.js +121 -0
  124. package/src/js/selectors.js +59 -0
  125. package/src/js/status.js +158 -0
  126. package/src/js/surface.js +358 -0
  127. package/src/js/tab.js +173 -0
  128. package/src/js/tooltip.js +374 -0
  129. package/src/js/validation.js +419 -0
@@ -0,0 +1,187 @@
1
+ /*
2
+ * Flyout trigger — trigger adapter over the shared action-surface runtime.
3
+ *
4
+ * App menus keep roving focus and menuitem activation. Nav panels reuse the
5
+ * same surface lifecycle without menu semantics.
6
+ */
7
+
8
+ import enhance from "./enhance.js";
9
+ import { focusFirstDescendant } from "./focus.js";
10
+ import { focusFirstMenuItem, focusLastMenuItem, getMenuItems } from "./menu.js";
11
+ import { CLASSES } from "./selectors.js";
12
+ import {
13
+ closeSurface,
14
+ disconnectSurface,
15
+ isSurfaceOpen,
16
+ openSurface,
17
+ prepareSurface,
18
+ } from "./surface.js";
19
+
20
+ // trigger -> { flyout, controller }
21
+ const triggerMap = new WeakMap();
22
+ const FLYOUT_SELECTOR = `.${CLASSES.flyout}`;
23
+ const FLYOUT_TRIGGER_SELECTOR = "[aria-controls][aria-expanded]";
24
+
25
+ function openFlyout(flyout, trigger) {
26
+ openSurface(flyout, { trigger, source: trigger });
27
+ }
28
+
29
+ function flyoutFor(trigger) {
30
+ const flyoutId = trigger.getAttribute("aria-controls");
31
+ const flyout = flyoutId && trigger.ownerDocument.getElementById(flyoutId);
32
+ return flyout?.matches(FLYOUT_SELECTOR) ? flyout : null;
33
+ }
34
+
35
+ function isMenuFlyout(flyout) {
36
+ return flyout.matches("menu");
37
+ }
38
+
39
+ function resolveCurrentFlyout(trigger, state) {
40
+ if (state.flyout?.isConnected) return state.flyout;
41
+
42
+ const flyout = flyoutFor(trigger);
43
+ if (!flyout) return null;
44
+
45
+ state.flyout = flyout;
46
+ prepareSurface(flyout);
47
+ if (!trigger.hasAttribute("aria-haspopup") && isMenuFlyout(flyout)) {
48
+ trigger.setAttribute("aria-haspopup", "menu");
49
+ }
50
+ return flyout;
51
+ }
52
+
53
+ function openAndFocusPanel(flyout, trigger) {
54
+ if (!isSurfaceOpen(flyout)) openFlyout(flyout, trigger);
55
+ focusFirstDescendant(flyout);
56
+ }
57
+
58
+ function onTriggerClick(e) {
59
+ const trigger = e.currentTarget;
60
+ const state = triggerMap.get(trigger);
61
+ if (!state) return;
62
+ e.stopPropagation();
63
+ const flyout = resolveCurrentFlyout(trigger, state);
64
+ if (!flyout) return;
65
+ if (isSurfaceOpen(flyout)) closeSurface(flyout);
66
+ else openFlyout(flyout, trigger);
67
+ }
68
+
69
+ function onTriggerKeydown(e) {
70
+ const trigger = e.currentTarget;
71
+ const state = triggerMap.get(trigger);
72
+ if (!state) return;
73
+ const flyout = resolveCurrentFlyout(trigger, state);
74
+ if (!flyout) return;
75
+ const items = getMenuItems(flyout);
76
+ const isActionList = items.length > 0;
77
+
78
+ if (!isActionList) {
79
+ switch (e.key) {
80
+ case "ArrowDown":
81
+ e.preventDefault();
82
+ openAndFocusPanel(flyout, trigger);
83
+ break;
84
+ case "Enter":
85
+ case " ":
86
+ e.preventDefault();
87
+ if (isSurfaceOpen(flyout)) closeSurface(flyout);
88
+ else openAndFocusPanel(flyout, trigger);
89
+ break;
90
+ case "Tab":
91
+ if (isSurfaceOpen(flyout) && !e.shiftKey && focusFirstDescendant(flyout)) {
92
+ e.preventDefault();
93
+ }
94
+ break;
95
+ }
96
+ return;
97
+ }
98
+
99
+ switch (e.key) {
100
+ case "ArrowDown":
101
+ e.preventDefault();
102
+ if (!isSurfaceOpen(flyout)) openFlyout(flyout, trigger);
103
+ focusFirstMenuItem(flyout);
104
+ break;
105
+ case "ArrowUp":
106
+ e.preventDefault();
107
+ if (!isSurfaceOpen(flyout)) openFlyout(flyout, trigger);
108
+ focusLastMenuItem(flyout);
109
+ break;
110
+ case "Home":
111
+ e.preventDefault();
112
+ if (!isSurfaceOpen(flyout)) openFlyout(flyout, trigger);
113
+ focusFirstMenuItem(flyout);
114
+ break;
115
+ case "End":
116
+ e.preventDefault();
117
+ if (!isSurfaceOpen(flyout)) openFlyout(flyout, trigger);
118
+ focusLastMenuItem(flyout);
119
+ break;
120
+ case "Enter":
121
+ case " ":
122
+ e.preventDefault();
123
+ if (!isSurfaceOpen(flyout)) {
124
+ openFlyout(flyout, trigger);
125
+ focusFirstMenuItem(flyout);
126
+ } else {
127
+ closeSurface(flyout);
128
+ }
129
+ break;
130
+ case "Tab":
131
+ if (isSurfaceOpen(flyout)) closeSurface(flyout);
132
+ break;
133
+ }
134
+ }
135
+
136
+ function connectTrigger(trigger) {
137
+ if (triggerMap.has(trigger)) return;
138
+ const flyout = flyoutFor(trigger);
139
+ if (!flyout) return;
140
+
141
+ const controller = new AbortController();
142
+ triggerMap.set(trigger, { flyout, controller });
143
+
144
+ prepareSurface(flyout);
145
+ if (!trigger.hasAttribute("aria-expanded")) {
146
+ trigger.setAttribute("aria-expanded", "false");
147
+ }
148
+ if (!trigger.hasAttribute("aria-haspopup") && isMenuFlyout(flyout)) {
149
+ trigger.setAttribute("aria-haspopup", "menu");
150
+ }
151
+
152
+ trigger.addEventListener("click", onTriggerClick, { signal: controller.signal });
153
+ trigger.addEventListener("keydown", onTriggerKeydown, { signal: controller.signal });
154
+ }
155
+
156
+ function disconnectTrigger(trigger) {
157
+ const state = triggerMap.get(trigger);
158
+ if (!state) return;
159
+ state.controller.abort();
160
+ triggerMap.delete(trigger);
161
+ }
162
+
163
+ function connectFlyout(flyout) {
164
+ if (!flyout.id) return;
165
+ const triggers = flyout.ownerDocument.querySelectorAll(
166
+ `${FLYOUT_TRIGGER_SELECTOR}[aria-controls="${CSS.escape(flyout.id)}"]`,
167
+ );
168
+ for (const trigger of triggers) {
169
+ if (!triggerMap.has(trigger)) connectTrigger(trigger);
170
+ }
171
+ }
172
+
173
+ function disconnectFlyout(flyout) {
174
+ if (!flyout) return;
175
+ disconnectSurface(flyout);
176
+ }
177
+
178
+ enhance({
179
+ [FLYOUT_TRIGGER_SELECTOR]: (trigger) => {
180
+ connectTrigger(trigger);
181
+ return () => disconnectTrigger(trigger);
182
+ },
183
+ [FLYOUT_SELECTOR]: (flyout) => {
184
+ connectFlyout(flyout);
185
+ return () => disconnectFlyout(flyout);
186
+ },
187
+ });
@@ -0,0 +1,35 @@
1
+ const FOCUSABLE_SELECTOR = [
2
+ "a[href]",
3
+ "button",
4
+ "input",
5
+ "select",
6
+ "textarea",
7
+ "summary",
8
+ "[contenteditable]:not([contenteditable='false'])",
9
+ "[tabindex]",
10
+ ].join(",");
11
+
12
+ export function isElementVisible(el) {
13
+ if (!el || el.hidden) return false;
14
+ if (typeof el.checkVisibility === "function") return el.checkVisibility();
15
+ return el.getClientRects().length > 0;
16
+ }
17
+
18
+ function isFocusable(el) {
19
+ return (
20
+ el.tabIndex >= 0 &&
21
+ !el.matches(":disabled") &&
22
+ isElementVisible(el) &&
23
+ !el.closest('[inert], [aria-hidden="true"]')
24
+ );
25
+ }
26
+
27
+ export function getFocusable(root) {
28
+ return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
29
+ }
30
+
31
+ export function focusFirstDescendant(root) {
32
+ const target = getFocusable(root)[0];
33
+ target?.focus();
34
+ return !!target;
35
+ }
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Actual CSS - JavaScript runtime.
3
+ *
4
+ * Each module self-registers via observer: importing the default runtime
5
+ * enables common behaviors. No DOMContentLoaded ceremony, no init calls.
6
+ * Injected content wires automatically; removal cleans up automatically.
7
+ *
8
+ * A plain `<script src="actual.js"></script>` (no bundler, no import) still
9
+ * gets the full runtime, including declarative triggers like status's
10
+ * `command="--status"` and the `actual:status` event — no global needed to
11
+ * drive them from other inline scripts either.
12
+ */
13
+
14
+ import "./flyout.js";
15
+ import "./context-menu.js";
16
+ import "./dialog.js";
17
+ // Legacy <dialog> shim for browsers below Baseline 2023; omit in custom builds.
18
+ import "./dialog-fallback.js";
19
+ import "./tab.js";
20
+ import "./tooltip.js";
21
+ import "./scrollspy.js";
22
+ import "./filter.js";
23
+ import "./mask.js";
24
+ import "./password.js";
25
+ import "./validation.js";
26
+ import "./status.js";
@@ -0,0 +1,47 @@
1
+ /*
2
+ * Input helpers shared by text-entry enhancers.
3
+ */
4
+
5
+ export function selectionStart(el) {
6
+ return typeof el.selectionStart === "number" ? el.selectionStart : el.value.length;
7
+ }
8
+
9
+ export function setCaret(el, position) {
10
+ if (document.activeElement !== el) return;
11
+ if (typeof el.setSelectionRange !== "function") return;
12
+ try {
13
+ el.setSelectionRange(position, position);
14
+ } catch {
15
+ // Some input types do not support text selection.
16
+ }
17
+ }
18
+
19
+ export function dispatchInput(el) {
20
+ el.dispatchEvent(new Event("input", { bubbles: true }));
21
+ }
22
+
23
+ export function onTextInput(el, handler, signal) {
24
+ let composing = false;
25
+
26
+ function run(event) {
27
+ if (composing || event.isComposing) return;
28
+ handler(event);
29
+ }
30
+
31
+ el.addEventListener(
32
+ "compositionstart",
33
+ () => {
34
+ composing = true;
35
+ },
36
+ { signal },
37
+ );
38
+ el.addEventListener(
39
+ "compositionend",
40
+ (event) => {
41
+ composing = false;
42
+ run(event);
43
+ },
44
+ { signal },
45
+ );
46
+ el.addEventListener("input", run, { signal });
47
+ }
package/src/js/keys.js ADDED
@@ -0,0 +1,30 @@
1
+ /*
2
+ * Keyboard helpers for small roving-focus widgets.
3
+ *
4
+ * Components still own key dispatch and side effects. These helpers only
5
+ * centralize item lookup, edge handling, and optional wrapping.
6
+ */
7
+
8
+ export function firstItem(items) {
9
+ return items[0] || null;
10
+ }
11
+
12
+ export function lastItem(items) {
13
+ return items[items.length - 1] || null;
14
+ }
15
+
16
+ export function nextItem(items, current, delta, opts = {}) {
17
+ if (!items.length) return null;
18
+
19
+ const wrap = opts.wrap !== false;
20
+ const idx = items.indexOf(current);
21
+ if (idx === -1) {
22
+ return delta > 0 ? firstItem(items) : lastItem(items);
23
+ }
24
+
25
+ const next = idx + delta;
26
+ if (!wrap) return items[next] || null;
27
+
28
+ const wrapped = ((next % items.length) + items.length) % items.length;
29
+ return items[wrapped];
30
+ }
package/src/js/mask.js ADDED
@@ -0,0 +1,184 @@
1
+ /*
2
+ * Mask — token-based input masks for fixed-shape values.
3
+ *
4
+ * data-mask="999-aaa" applies a token mask.
5
+ */
6
+
7
+ import enhance from "./enhance.js";
8
+ import { dispatchInput, onTextInput, selectionStart, setCaret } from "./input.js";
9
+
10
+ const SELECTOR = "input[data-mask]";
11
+ const TOKEN_TESTS = {
12
+ 9: (char) => /^\d$/u.test(char),
13
+ a: (char) => /^\p{L}$/u.test(char),
14
+ "*": () => true,
15
+ };
16
+
17
+ function isToken(char) {
18
+ return Object.hasOwn(TOKEN_TESTS, char);
19
+ }
20
+
21
+ function parseMaskValue(value, mask) {
22
+ const pattern = [...mask];
23
+ const raw = [];
24
+ const positions = [];
25
+ let maskIndex = 0;
26
+ let valueIndex = 0;
27
+
28
+ valueLoop: for (const char of value) {
29
+ valueIndex += char.length;
30
+
31
+ while (maskIndex < pattern.length) {
32
+ const part = pattern[maskIndex];
33
+
34
+ if (!isToken(part)) {
35
+ maskIndex++;
36
+ if (char === part) continue valueLoop;
37
+ continue;
38
+ }
39
+
40
+ if (TOKEN_TESTS[part](char)) {
41
+ raw.push(char);
42
+ positions.push(valueIndex);
43
+ maskIndex++;
44
+ }
45
+ continue valueLoop;
46
+ }
47
+
48
+ break;
49
+ }
50
+
51
+ return { positions, raw };
52
+ }
53
+
54
+ function rawMaskChars(value, mask) {
55
+ return parseMaskValue(value, mask).raw;
56
+ }
57
+
58
+ function appendFollowingLiterals(mask, start) {
59
+ let out = "";
60
+ for (let i = start; i < mask.length && !isToken(mask[i]); i++) {
61
+ out += mask[i];
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function formatRaw(raw, mask, inputType = "") {
67
+ const autoLiteral = inputType.startsWith("insert");
68
+ let rawIndex = 0;
69
+ let out = "";
70
+
71
+ for (let i = 0; i < mask.length; i++) {
72
+ const token = mask[i];
73
+
74
+ if (!isToken(token)) {
75
+ if (rawIndex < raw.length) out += token;
76
+ continue;
77
+ }
78
+
79
+ let matched = false;
80
+ while (rawIndex < raw.length) {
81
+ const char = raw[rawIndex];
82
+ rawIndex++;
83
+ if (!TOKEN_TESTS[token](char)) continue;
84
+ out += char;
85
+ matched = true;
86
+ break;
87
+ }
88
+
89
+ if (!matched) break;
90
+
91
+ if (autoLiteral && rawIndex >= raw.length) {
92
+ out += appendFollowingLiterals(mask, i + 1);
93
+ break;
94
+ }
95
+ }
96
+
97
+ return out;
98
+ }
99
+
100
+ function applyMask(value, mask, inputType = "") {
101
+ if (!mask) return value;
102
+ return formatRaw(rawMaskChars(value, mask), mask, inputType);
103
+ }
104
+
105
+ function caretForMask(value, mask, rawCount) {
106
+ if (rawCount <= 0) return 0;
107
+
108
+ const position = parseMaskValue(value, mask).positions[rawCount - 1];
109
+ if (position == null) return value.length;
110
+
111
+ const pattern = [...mask];
112
+ let tokenCount = 0;
113
+ let patternIndex = 0;
114
+ for (; patternIndex < pattern.length; patternIndex++) {
115
+ if (!isToken(pattern[patternIndex])) continue;
116
+ tokenCount++;
117
+ if (tokenCount === rawCount) break;
118
+ }
119
+
120
+ let caret = position;
121
+ for (patternIndex++; patternIndex < pattern.length; patternIndex++) {
122
+ const literal = pattern[patternIndex];
123
+ if (isToken(literal) || !value.startsWith(literal, caret)) break;
124
+ caret += literal.length;
125
+ }
126
+ return caret;
127
+ }
128
+
129
+ function connectMask(el) {
130
+ const mask = el.dataset.mask || "";
131
+ if (!mask) return;
132
+ const controller = new AbortController();
133
+ let masking = false;
134
+ // Last value this handler produced/saw. Distinguishes "backspaced only a
135
+ // literal" (delete the raw character before it) from a selection deletion
136
+ // that removed raw characters (just reformat, literals may come back).
137
+ let lastValue = el.value;
138
+
139
+ onTextInput(
140
+ el,
141
+ (event) => {
142
+ if (masking) return;
143
+
144
+ const inputType = event.inputType || "";
145
+ const caret = selectionStart(el);
146
+ let rawBeforeCaret = rawMaskChars(el.value.slice(0, caret), mask).length;
147
+ const previous = el.value;
148
+ let next = applyMask(previous, mask, inputType);
149
+ const onlyLiteralsRemoved =
150
+ rawMaskChars(previous, mask).length === rawMaskChars(lastValue, mask).length;
151
+
152
+ if (inputType.startsWith("delete") && onlyLiteralsRemoved && next.length > previous.length) {
153
+ const raw = rawMaskChars(previous, mask);
154
+ const deleteIndex =
155
+ inputType === "deleteContentForward" ? rawBeforeCaret : rawBeforeCaret - 1;
156
+
157
+ if (deleteIndex >= 0 && deleteIndex < raw.length) {
158
+ raw.splice(deleteIndex, 1);
159
+ rawBeforeCaret = Math.max(0, deleteIndex);
160
+ next = formatRaw(raw, mask, inputType);
161
+ }
162
+ }
163
+
164
+ lastValue = next;
165
+ if (next === previous) return;
166
+
167
+ el.value = next;
168
+ setCaret(el, caretForMask(next, mask, rawBeforeCaret));
169
+ try {
170
+ masking = true;
171
+ dispatchInput(el);
172
+ } finally {
173
+ masking = false;
174
+ }
175
+ },
176
+ controller.signal,
177
+ );
178
+
179
+ return () => controller.abort();
180
+ }
181
+
182
+ enhance({
183
+ [SELECTOR]: connectMask,
184
+ });
package/src/js/menu.js ADDED
@@ -0,0 +1,88 @@
1
+ import { isElementVisible } from "./focus.js";
2
+ import { firstItem, lastItem, nextItem } from "./keys.js";
3
+
4
+ const MENU_ITEM_TARGET_SELECTORS = ["button", "a", '[role="menuitem"]'];
5
+ // Item contract, mirrored from flyout.css: a button, link, or menuitem
6
+ // directly in the flyout or directly in any of its <li>. Prefixed with
7
+ // .flyout rather than :scope because hasMenuItem() matches it via closest(),
8
+ // where :scope would resolve to the target element instead of the flyout.
9
+ export const MENU_ITEM_SELECTOR = MENU_ITEM_TARGET_SELECTORS.flatMap((target) => [
10
+ `.flyout > ${target}`,
11
+ `.flyout li > ${target}`,
12
+ ]).join(",");
13
+
14
+ function isUsableMenuItem(item) {
15
+ return (
16
+ !item.disabled &&
17
+ item.getAttribute("aria-disabled") !== "true" &&
18
+ isElementVisible(item) &&
19
+ !item.closest("[hidden], [inert], [aria-hidden='true']")
20
+ );
21
+ }
22
+
23
+ export function getMenuItems(menu) {
24
+ return [...menu.querySelectorAll(MENU_ITEM_SELECTOR)].filter(isUsableMenuItem);
25
+ }
26
+
27
+ function isAriaMenuItem(item) {
28
+ return item.getAttribute("role") === "menuitem";
29
+ }
30
+
31
+ function activateCurrentItem(menu) {
32
+ const item = document.activeElement;
33
+ if (!menu.contains(item) || !isAriaMenuItem(item)) return false;
34
+
35
+ item.click();
36
+ return true;
37
+ }
38
+
39
+ export function hasMenuItems(menu) {
40
+ return getMenuItems(menu).length > 0;
41
+ }
42
+
43
+ export function hasMenuItem(target) {
44
+ return !!target.closest?.(MENU_ITEM_SELECTOR);
45
+ }
46
+
47
+ export function focusFirstMenuItem(menu) {
48
+ firstItem(getMenuItems(menu))?.focus();
49
+ }
50
+
51
+ export function focusLastMenuItem(menu) {
52
+ lastItem(getMenuItems(menu))?.focus();
53
+ }
54
+
55
+ export function onMenuKeydown(e, { close }) {
56
+ const menu = e.currentTarget;
57
+ const items = getMenuItems(menu);
58
+ if (!items.length) return;
59
+
60
+ switch (e.key) {
61
+ case "ArrowDown":
62
+ e.preventDefault();
63
+ nextItem(items, document.activeElement, 1, { wrap: true })?.focus();
64
+ break;
65
+ case "ArrowUp":
66
+ e.preventDefault();
67
+ nextItem(items, document.activeElement, -1, { wrap: true })?.focus();
68
+ break;
69
+ case "Home":
70
+ e.preventDefault();
71
+ firstItem(items)?.focus();
72
+ break;
73
+ case "End":
74
+ e.preventDefault();
75
+ lastItem(items)?.focus();
76
+ break;
77
+ case "Tab":
78
+ close(menu);
79
+ break;
80
+ case "Enter":
81
+ case " ":
82
+ if (activateCurrentItem(menu)) {
83
+ e.preventDefault();
84
+ close(menu);
85
+ }
86
+ break;
87
+ }
88
+ }
@@ -0,0 +1,98 @@
1
+ /*
2
+ * Password — show/hide toggle for password inputs.
3
+ *
4
+ * Declarative, through the command invoker pattern:
5
+ *
6
+ * <div class="join">
7
+ * <input class="input" type="password" id="pw" autocomplete="current-password">
8
+ * <button class="btn outline" type="button" commandfor="pw"
9
+ * command="--password-toggle" aria-controls="pw"
10
+ * aria-label="Show password" aria-pressed="false"></button>
11
+ * </div>
12
+ *
13
+ * The module flips the input between type="password" and type="text" and
14
+ * mirrors the state on every linked trigger through aria-pressed (styled by
15
+ * .btn[aria-pressed="true"]). Keep the trigger label stable — aria-pressed
16
+ * already announces the state, so a label that flips to "Hide" reads as a
17
+ * double negation to screen readers.
18
+ *
19
+ * A revealed value does not survive navigation: a non-canceled form submit and
20
+ * pagehide (bfcache) both revert the input to type="password", so returning to
21
+ * the page cannot land on a readable password. A canceled submit (validation,
22
+ * ajax) keeps the value revealed — nothing navigated away.
23
+ */
24
+ import { commandSelector, registerCommands, targetFor } from "./command.js";
25
+
26
+ const PASSWORD_COMMANDS = ["--password-toggle"];
27
+ const TRIGGER_SELECTOR = commandSelector(PASSWORD_COMMANDS);
28
+
29
+ // Inputs currently revealed by this module — the only ones ever reverted.
30
+ // This set is intentionally iterable so pagehide also covers closed shadow
31
+ // roots. Entries leave the set as soon as their input is masked again.
32
+ const revealed = new Set();
33
+ const submitRoots = new WeakSet();
34
+
35
+ function resolvePasswordInput(trigger) {
36
+ const target = targetFor(trigger);
37
+ const isPassword =
38
+ target?.localName === "input" && (target.type === "password" || revealed.has(target));
39
+ return isPassword ? target : null;
40
+ }
41
+
42
+ function linkedTriggers(input) {
43
+ if (!input.id) return [];
44
+ return input
45
+ .getRootNode()
46
+ .querySelectorAll(`${TRIGGER_SELECTOR}[commandfor="${CSS.escape(input.id)}"]`);
47
+ }
48
+
49
+ function setRevealed(input, reveal) {
50
+ input.type = reveal ? "text" : "password";
51
+ if (reveal) {
52
+ revealed.add(input);
53
+ } else {
54
+ revealed.delete(input);
55
+ }
56
+ for (const trigger of linkedTriggers(input)) {
57
+ trigger.setAttribute("aria-pressed", String(reveal));
58
+ }
59
+ }
60
+
61
+ function handleSubmit(event) {
62
+ if (event.defaultPrevented) return;
63
+ for (const element of event.target.elements ?? []) {
64
+ if (revealed.has(element)) setRevealed(element, false);
65
+ }
66
+ }
67
+
68
+ function watchSubmitRoot(input) {
69
+ const root = input.getRootNode();
70
+ if (submitRoots.has(root)) return;
71
+ submitRoots.add(root);
72
+ root.addEventListener("submit", handleSubmit);
73
+ }
74
+
75
+ registerCommands(PASSWORD_COMMANDS, {
76
+ resolve: resolvePasswordInput,
77
+ prepare: (trigger, input) => {
78
+ trigger.setAttribute("aria-controls", input.id);
79
+ if (!trigger.hasAttribute("aria-pressed")) {
80
+ trigger.setAttribute("aria-pressed", "false");
81
+ }
82
+ },
83
+ handle: (event, _trigger, input) => {
84
+ event.preventDefault();
85
+ const reveal = input.type === "password";
86
+ if (reveal) watchSubmitRoot(input);
87
+ setRevealed(input, reveal);
88
+ },
89
+ });
90
+
91
+ if (typeof document !== "undefined") {
92
+ submitRoots.add(document);
93
+ document.addEventListener("submit", handleSubmit);
94
+
95
+ window.addEventListener("pagehide", () => {
96
+ for (const input of revealed) setRevealed(input, false);
97
+ });
98
+ }