actual-css 0.1.0 → 0.3.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 (215) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +25 -8
  3. package/dist/actual-themes.min.css +1 -0
  4. package/dist/actual.css +1492 -931
  5. package/dist/actual.js +1 -3
  6. package/dist/actual.min.css +1 -1
  7. package/dist/optional.css +1048 -0
  8. package/dist/optional.min.css +1 -0
  9. package/docs/design-notes/browser-support.md +95 -6
  10. package/docs/design-notes/cascade-layer.md +1 -1
  11. package/docs/design-notes/enhancement-contract.md +101 -0
  12. package/docs/design-notes/forced-colors.md +105 -0
  13. package/docs/design-notes/naming.md +3 -1
  14. package/docs/design-notes/platform-alignment.md +492 -0
  15. package/docs/design-notes/references.md +8 -0
  16. package/docs/design-notes/widget-primitives.md +119 -0
  17. package/docs/navigation.json +135 -0
  18. package/docs/pages/components/accordion.md +80 -0
  19. package/docs/pages/components/alert.md +203 -0
  20. package/docs/pages/components/aura.md +35 -0
  21. package/docs/pages/components/avatar.md +95 -0
  22. package/docs/pages/components/badge.md +102 -0
  23. package/docs/pages/components/breadcrumb.md +19 -0
  24. package/docs/pages/components/button.md +205 -0
  25. package/docs/pages/components/card.md +183 -0
  26. package/docs/pages/components/chat.md +50 -0
  27. package/docs/pages/components/dialog.md +367 -0
  28. package/docs/pages/components/drawer.md +113 -0
  29. package/docs/pages/components/fab.md +118 -0
  30. package/docs/pages/components/key.md +33 -0
  31. package/docs/pages/components/meter.md +23 -0
  32. package/docs/pages/components/navbar.md +92 -0
  33. package/docs/pages/components/pagination.md +55 -0
  34. package/docs/pages/components/progress.md +29 -0
  35. package/docs/pages/components/skeleton.md +38 -0
  36. package/docs/pages/components/spinner.md +93 -0
  37. package/docs/pages/components/table.md +102 -0
  38. package/docs/pages/enhancements/flyout.md +391 -0
  39. package/docs/pages/enhancements/overview.md +36 -0
  40. package/docs/pages/enhancements/scrollspy.md +71 -0
  41. package/docs/pages/enhancements/status-bar.md +72 -0
  42. package/docs/pages/enhancements/tabs.md +80 -0
  43. package/docs/pages/enhancements/tooltip.md +130 -0
  44. package/docs/pages/examples/overview.md +41 -0
  45. package/docs/pages/forms/choice-cards.md +102 -0
  46. package/docs/pages/forms/controls.md +32 -0
  47. package/docs/pages/forms/detached-actions.md +59 -0
  48. package/docs/pages/forms/floating-field.md +88 -0
  49. package/docs/pages/forms/form-actions.md +56 -0
  50. package/docs/pages/forms/input-policy.md +91 -0
  51. package/docs/pages/forms/joined-controls.md +37 -0
  52. package/docs/pages/forms/labels.md +42 -0
  53. package/docs/pages/forms/otp.md +50 -0
  54. package/docs/pages/forms/overview.md +188 -0
  55. package/docs/pages/forms/password.md +40 -0
  56. package/docs/pages/forms/range.md +17 -0
  57. package/docs/pages/forms/states-and-sizes.md +321 -0
  58. package/docs/pages/forms/sticky-actions.md +187 -0
  59. package/docs/pages/forms/switch.md +31 -0
  60. package/docs/pages/forms/validation.md +241 -0
  61. package/docs/{mental-model.md → pages/foundations/philosophy.md} +33 -18
  62. package/docs/pages/foundations/theming.md +92 -0
  63. package/docs/pages/foundations/tokens.md +421 -0
  64. package/docs/{typography.md → pages/foundations/typography.md} +27 -9
  65. package/docs/pages/guides/bootstrap.md +438 -0
  66. package/docs/pages/guides/introduction.md +157 -0
  67. package/docs/pages/guides/optional-components.md +33 -0
  68. package/docs/pages/guides/progressive-enhancement.md +686 -0
  69. package/docs/pages/guides/tailwind.md +455 -0
  70. package/docs/pages/layout/app-shell.md +25 -0
  71. package/docs/pages/layout/center.md +29 -0
  72. package/docs/pages/layout/cluster.md +57 -0
  73. package/docs/pages/layout/frame.md +30 -0
  74. package/docs/pages/layout/grid.md +142 -0
  75. package/docs/pages/layout/list.md +41 -0
  76. package/docs/pages/layout/media.md +21 -0
  77. package/docs/pages/layout/overview.md +37 -0
  78. package/docs/pages/layout/scroll-snap.md +54 -0
  79. package/docs/pages/layout/sidebar-layout.md +27 -0
  80. package/docs/pages/layout/stack.md +18 -0
  81. package/docs/pages/layout/switcher.md +36 -0
  82. package/docs/pages/layout/topbar.md +23 -0
  83. package/docs/pages/patterns/actions.md +52 -0
  84. package/docs/pages/patterns/code-block.md +36 -0
  85. package/docs/pages/patterns/exclusive-accordion.md +16 -0
  86. package/docs/pages/patterns/hierarchical-nav.md +26 -0
  87. package/docs/pages/patterns/icon-input.md +25 -0
  88. package/docs/pages/patterns/intent-nesting.md +3 -0
  89. package/docs/pages/patterns/navigation-list.md +235 -0
  90. package/docs/pages/patterns/overview.md +58 -0
  91. package/docs/pages/patterns/structured-lists.md +47 -0
  92. package/docs/pages/utilities/accessibility.md +34 -0
  93. package/docs/pages/utilities/flex.md +105 -0
  94. package/docs/pages/utilities/links.md +18 -0
  95. package/docs/pages/utilities/list.md +31 -0
  96. package/docs/pages/utilities/overflow.md +43 -0
  97. package/docs/pages/utilities/overview.md +18 -0
  98. package/docs/pages/utilities/shape.md +25 -0
  99. package/docs/pages/utilities/sizing-wrapping.md +115 -0
  100. package/docs/pages/utilities/spacing.md +79 -0
  101. package/docs/pages/utilities/text.md +43 -0
  102. package/llms.txt +10 -4
  103. package/package.json +30 -16
  104. package/src/css/actual.css +12 -1
  105. package/src/css/base.css +3 -9
  106. package/src/css/components/accordion.css +18 -30
  107. package/src/css/components/actions.css +4 -1
  108. package/src/css/components/alert.css +162 -49
  109. package/src/css/components/avatar.css +16 -10
  110. package/src/css/components/badge.css +82 -39
  111. package/src/css/components/breadcrumb.css +7 -15
  112. package/src/css/components/busy.css +7 -0
  113. package/src/css/components/button.css +82 -38
  114. package/src/css/components/card.css +17 -37
  115. package/src/css/components/drawer.css +30 -13
  116. package/src/css/components/flyout.css +39 -110
  117. package/src/css/components/index.css +1 -0
  118. package/src/css/components/join.css +14 -3
  119. package/src/css/components/key.css +2 -0
  120. package/src/css/components/menu.css +125 -0
  121. package/src/css/components/meter.css +0 -23
  122. package/src/css/components/modal.css +162 -7
  123. package/src/css/components/navbar.css +17 -18
  124. package/src/css/components/overline.css +0 -7
  125. package/src/css/components/pagination.css +1 -1
  126. package/src/css/components/progress.css +8 -26
  127. package/src/css/components/scrollspy.css +2 -2
  128. package/src/css/components/skeleton.css +7 -0
  129. package/src/css/components/spinner.css +10 -11
  130. package/src/css/components/status-bar.css +18 -28
  131. package/src/css/components/surface.css +2 -8
  132. package/src/css/components/tab.css +20 -48
  133. package/src/css/components/table.css +7 -5
  134. package/src/css/components/tooltip.css +14 -7
  135. package/src/css/focus.css +16 -3
  136. package/src/css/forms/choice-card.css +13 -15
  137. package/src/css/forms/choice.css +21 -28
  138. package/src/css/forms/control.css +16 -26
  139. package/src/css/forms/custom-select.css +9 -11
  140. package/src/css/forms/form-actions.css +14 -3
  141. package/src/css/forms/form.css +7 -4
  142. package/src/css/forms/index.css +5 -4
  143. package/src/css/forms/input-icon.css +30 -12
  144. package/src/css/forms/native.css +48 -33
  145. package/src/css/forms/range.css +103 -0
  146. package/src/css/forms/select.css +8 -8
  147. package/src/css/forms/switch.css +14 -13
  148. package/src/css/forms/validation.css +32 -5
  149. package/src/css/grid.css +19 -18
  150. package/src/css/intents.css +2 -2
  151. package/src/css/layout.css +29 -1
  152. package/src/css/optional/aura.css +93 -0
  153. package/src/css/optional/chat.css +105 -0
  154. package/src/css/optional/fab.css +104 -0
  155. package/src/css/optional/floating-field.css +87 -0
  156. package/src/css/optional/index.css +5 -0
  157. package/src/css/optional/layout-extra.css +7 -4
  158. package/src/css/optional/otp.css +115 -0
  159. package/src/css/optional/scroll-snap.css +6 -0
  160. package/src/css/optional/scroller.css +19 -7
  161. package/src/css/optional/typography-fluid.css +5 -0
  162. package/src/css/optional/utilities-extra.css +17 -2
  163. package/src/css/prose.css +18 -8
  164. package/src/css/reset.css +0 -10
  165. package/src/css/theme.css +29 -13
  166. package/src/css/themes/cyberpunk.css +1 -1
  167. package/src/css/themes/dim.css +3 -3
  168. package/src/css/themes/edge.css +11 -37
  169. package/src/css/themes/index.css +3 -3
  170. package/src/css/themes/{dark.css → indigo.css} +5 -4
  171. package/src/css/themes/neon.css +5 -5
  172. package/src/css/themes/square.css +0 -3
  173. package/src/css/tokens.css +81 -21
  174. package/src/css/utilities.css +8 -10
  175. package/src/css/variants.css +37 -65
  176. package/src/js/command.js +1 -3
  177. package/src/js/context-menu.js +72 -53
  178. package/src/js/dialog.js +68 -79
  179. package/src/js/dismiss.js +15 -0
  180. package/src/js/enhance.js +76 -19
  181. package/src/js/enhancement-loader.js +301 -0
  182. package/src/js/events.js +3 -0
  183. package/src/js/filter.js +3 -3
  184. package/src/js/floating.js +101 -131
  185. package/src/js/flyout.js +106 -96
  186. package/src/js/focus-group.js +116 -0
  187. package/src/js/focus.js +6 -1
  188. package/src/js/index.js +4 -2
  189. package/src/js/input.js +1 -1
  190. package/src/js/keys.js +27 -0
  191. package/src/js/mask.js +7 -6
  192. package/src/js/menu.js +89 -35
  193. package/src/js/parse-config.js +47 -0
  194. package/src/js/password.js +9 -1
  195. package/src/js/scrollspy.js +133 -62
  196. package/src/js/selectors.js +14 -31
  197. package/src/js/status.js +16 -14
  198. package/src/js/surface.js +219 -125
  199. package/src/js/tab.js +46 -66
  200. package/src/js/tooltip.js +53 -31
  201. package/src/js/validation.js +45 -51
  202. package/dist/actual.js.map +0 -31
  203. package/docs/README.md +0 -26
  204. package/docs/components.md +0 -860
  205. package/docs/design-notes/previous-work.md +0 -5
  206. package/docs/forms.md +0 -1265
  207. package/docs/javascript.md +0 -426
  208. package/docs/layout.md +0 -480
  209. package/docs/patterns.md +0 -415
  210. package/docs/reserved.md +0 -7
  211. package/docs/tokens.md +0 -455
  212. package/docs/ui.md +0 -937
  213. package/docs/utilities.md +0 -384
  214. package/src/css/dialog-fallback.css +0 -56
  215. package/src/js/dialog-fallback.js +0 -163
@@ -0,0 +1,116 @@
1
+ import { itemForKey } from "./keys.js";
2
+
3
+ const ORIENTATIONS = new Set(["horizontal", "vertical"]);
4
+
5
+ function restoreTabIndex(item, value) {
6
+ if (value == null) item.removeAttribute("tabindex");
7
+ else item.setAttribute("tabindex", value);
8
+ }
9
+
10
+ export function connectFocusGroup(root, opts = {}) {
11
+ if (!root?.addEventListener || !root?.contains) {
12
+ throw new TypeError("connectFocusGroup() requires a root element.");
13
+ }
14
+ if (typeof opts.getItems !== "function") {
15
+ throw new TypeError("connectFocusGroup() requires getItems().");
16
+ }
17
+
18
+ const orientation = opts.orientation || "horizontal";
19
+ if (!ORIENTATIONS.has(orientation)) {
20
+ throw new TypeError(`Invalid focus-group orientation: ${orientation}`);
21
+ }
22
+
23
+ const controller = new AbortController();
24
+ const externalSignal = opts.signal || null;
25
+ const originalTabIndex = new Map();
26
+ let remembered = null;
27
+ let disconnected = false;
28
+
29
+ function getItems() {
30
+ const seen = new Set();
31
+ return [...(opts.getItems() || [])].filter((item) => {
32
+ if (typeof item?.focus !== "function" || !root.contains(item) || seen.has(item)) return false;
33
+ seen.add(item);
34
+ return true;
35
+ });
36
+ }
37
+
38
+ function restore(item) {
39
+ if (!originalTabIndex.has(item)) return;
40
+ restoreTabIndex(item, originalTabIndex.get(item));
41
+ originalTabIndex.delete(item);
42
+ }
43
+
44
+ function sync(preferred = remembered) {
45
+ if (disconnected) return [];
46
+
47
+ const items = getItems();
48
+ const itemSet = new Set(items);
49
+ for (const item of originalTabIndex.keys()) {
50
+ if (!itemSet.has(item)) restore(item);
51
+ }
52
+
53
+ const active = root.ownerDocument.activeElement;
54
+ const target =
55
+ (itemSet.has(preferred) && preferred) ||
56
+ (itemSet.has(active) && active) ||
57
+ items.find((item) => item.tabIndex === 0) ||
58
+ items[0] ||
59
+ null;
60
+
61
+ for (const item of items) {
62
+ if (!originalTabIndex.has(item)) {
63
+ originalTabIndex.set(item, item.getAttribute("tabindex"));
64
+ }
65
+ const tabIndex = item === target ? 0 : -1;
66
+ item.tabIndex = tabIndex;
67
+ }
68
+ remembered = target;
69
+ return items;
70
+ }
71
+
72
+ function onFocusIn(event) {
73
+ const items = getItems();
74
+ if (items.includes(event.target)) sync(event.target);
75
+ }
76
+
77
+ function onKeydown(event) {
78
+ const items = sync();
79
+ const current = root.ownerDocument.activeElement;
80
+ if (!items.includes(current)) return;
81
+
82
+ const style = root.ownerDocument.defaultView?.getComputedStyle?.(root);
83
+ const direction = style?.direction === "rtl" ? "rtl" : "ltr";
84
+ const target = itemForKey(items, current, event.key, {
85
+ orientation,
86
+ wrap: opts.wrap === true,
87
+ direction,
88
+ });
89
+ if (!target) return;
90
+
91
+ event.preventDefault();
92
+ sync(target);
93
+ target.focus();
94
+ }
95
+
96
+ root.addEventListener("focusin", onFocusIn, { signal: controller.signal });
97
+ root.addEventListener("keydown", onKeydown, { signal: controller.signal });
98
+
99
+ sync();
100
+
101
+ function disconnect() {
102
+ if (disconnected) return;
103
+ disconnected = true;
104
+ externalSignal?.removeEventListener("abort", disconnect);
105
+ controller.abort();
106
+ for (const item of originalTabIndex.keys()) restore(item);
107
+ remembered = null;
108
+ }
109
+
110
+ if (externalSignal) {
111
+ if (externalSignal.aborted) disconnect();
112
+ else externalSignal.addEventListener("abort", disconnect, { once: true });
113
+ }
114
+
115
+ return { sync, disconnect };
116
+ }
package/src/js/focus.js CHANGED
@@ -12,7 +12,12 @@ const FOCUSABLE_SELECTOR = [
12
12
  export function isElementVisible(el) {
13
13
  if (!el || el.hidden) return false;
14
14
  if (typeof el.checkVisibility === "function") return el.checkVisibility();
15
- return el.getClientRects().length > 0;
15
+ if (el.getClientRects().length === 0) return false;
16
+ // checkVisibility() covers visibility:hidden; without it, read the
17
+ // computed direction of the cascade so a hidden element is not focusable.
18
+ const style = el.ownerDocument?.defaultView?.getComputedStyle?.(el);
19
+ if (!style) return true;
20
+ return style.visibility !== "hidden" && style.visibility !== "collapse";
16
21
  }
17
22
 
18
23
  function isFocusable(el) {
package/src/js/index.js CHANGED
@@ -14,8 +14,7 @@
14
14
  import "./flyout.js";
15
15
  import "./context-menu.js";
16
16
  import "./dialog.js";
17
- // Legacy <dialog> shim for browsers below Baseline 2023; omit in custom builds.
18
- import "./dialog-fallback.js";
17
+ import "./dismiss.js";
19
18
  import "./tab.js";
20
19
  import "./tooltip.js";
21
20
  import "./scrollspy.js";
@@ -24,3 +23,6 @@ import "./mask.js";
24
23
  import "./password.js";
25
24
  import "./validation.js";
26
25
  import "./status.js";
26
+ import { watchEnhancementManifests } from "./enhancement-loader.js";
27
+
28
+ watchEnhancementManifests();
package/src/js/input.js CHANGED
@@ -7,7 +7,7 @@ export function selectionStart(el) {
7
7
  }
8
8
 
9
9
  export function setCaret(el, position) {
10
- if (document.activeElement !== el) return;
10
+ if (el.ownerDocument.activeElement !== el) return;
11
11
  if (typeof el.setSelectionRange !== "function") return;
12
12
  try {
13
13
  el.setSelectionRange(position, position);
package/src/js/keys.js CHANGED
@@ -28,3 +28,30 @@ export function nextItem(items, current, delta, opts = {}) {
28
28
  const wrapped = ((next % items.length) + items.length) % items.length;
29
29
  return items[wrapped];
30
30
  }
31
+
32
+ export function itemForKey(items, current, key, opts = {}) {
33
+ const orientation = opts.orientation || "horizontal";
34
+ const wrap = opts.wrap === true;
35
+ const direction = opts.direction || "ltr";
36
+
37
+ switch (key) {
38
+ case "Home":
39
+ return firstItem(items);
40
+ case "End":
41
+ return lastItem(items);
42
+ case "ArrowRight":
43
+ if (orientation !== "horizontal") return null;
44
+ return nextItem(items, current, direction === "rtl" ? -1 : 1, { wrap });
45
+ case "ArrowLeft":
46
+ if (orientation !== "horizontal") return null;
47
+ return nextItem(items, current, direction === "rtl" ? 1 : -1, { wrap });
48
+ case "ArrowDown":
49
+ if (orientation !== "vertical") return null;
50
+ return nextItem(items, current, 1, { wrap });
51
+ case "ArrowUp":
52
+ if (orientation !== "vertical") return null;
53
+ return nextItem(items, current, -1, { wrap });
54
+ default:
55
+ return null;
56
+ }
57
+ }
package/src/js/mask.js CHANGED
@@ -55,21 +55,22 @@ function rawMaskChars(value, mask) {
55
55
  return parseMaskValue(value, mask).raw;
56
56
  }
57
57
 
58
- function appendFollowingLiterals(mask, start) {
58
+ function appendFollowingLiterals(pattern, start) {
59
59
  let out = "";
60
- for (let i = start; i < mask.length && !isToken(mask[i]); i++) {
61
- out += mask[i];
60
+ for (let i = start; i < pattern.length && !isToken(pattern[i]); i++) {
61
+ out += pattern[i];
62
62
  }
63
63
  return out;
64
64
  }
65
65
 
66
66
  function formatRaw(raw, mask, inputType = "") {
67
67
  const autoLiteral = inputType.startsWith("insert");
68
+ const pattern = [...mask];
68
69
  let rawIndex = 0;
69
70
  let out = "";
70
71
 
71
- for (let i = 0; i < mask.length; i++) {
72
- const token = mask[i];
72
+ for (let i = 0; i < pattern.length; i++) {
73
+ const token = pattern[i];
73
74
 
74
75
  if (!isToken(token)) {
75
76
  if (rawIndex < raw.length) out += token;
@@ -89,7 +90,7 @@ function formatRaw(raw, mask, inputType = "") {
89
90
  if (!matched) break;
90
91
 
91
92
  if (autoLiteral && rawIndex >= raw.length) {
92
- out += appendFollowingLiterals(mask, i + 1);
93
+ out += appendFollowingLiterals(pattern, i + 1);
93
94
  break;
94
95
  }
95
96
  }
package/src/js/menu.js CHANGED
@@ -1,15 +1,9 @@
1
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(",");
2
+ import { connectFocusGroup } from "./focus-group.js";
3
+ import { firstItem, itemForKey, lastItem } from "./keys.js";
4
+
5
+ const MENU_ITEM_SELECTOR = ":scope > li > .menu-item";
6
+ const MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
13
7
 
14
8
  function isUsableMenuItem(item) {
15
9
  return (
@@ -25,11 +19,11 @@ export function getMenuItems(menu) {
25
19
  }
26
20
 
27
21
  function isAriaMenuItem(item) {
28
- return item.getAttribute("role") === "menuitem";
22
+ return MENU_ITEM_ROLES.has(item.getAttribute("role"));
29
23
  }
30
24
 
31
25
  function activateCurrentItem(menu) {
32
- const item = document.activeElement;
26
+ const item = menu.ownerDocument.activeElement;
33
27
  if (!menu.contains(item) || !isAriaMenuItem(item)) return false;
34
28
 
35
29
  item.click();
@@ -40,8 +34,13 @@ export function hasMenuItems(menu) {
40
34
  return getMenuItems(menu).length > 0;
41
35
  }
42
36
 
43
- export function hasMenuItem(target) {
44
- return !!target.closest?.(MENU_ITEM_SELECTOR);
37
+ function getMenuItem(menu, target) {
38
+ const item = target?.closest?.(".menu-item");
39
+ return item?.parentElement?.parentElement === menu ? item : null;
40
+ }
41
+
42
+ export function hasMenuItem(menu, target) {
43
+ return !!getMenuItem(menu, target);
45
44
  }
46
45
 
47
46
  export function focusFirstMenuItem(menu) {
@@ -52,28 +51,22 @@ export function focusLastMenuItem(menu) {
52
51
  lastItem(getMenuItems(menu))?.focus();
53
52
  }
54
53
 
55
- export function onMenuKeydown(e, { close }) {
54
+ export function onMenuKeydown(e, { close, navigate = true }) {
56
55
  const menu = e.currentTarget;
57
- const items = getMenuItems(menu);
58
- if (!items.length) return;
56
+ if (navigate) {
57
+ const items = getMenuItems(menu);
58
+ const target = itemForKey(items, menu.ownerDocument.activeElement, e.key, {
59
+ orientation: "vertical",
60
+ wrap: true,
61
+ });
62
+ if (target) {
63
+ e.preventDefault();
64
+ target.focus();
65
+ return;
66
+ }
67
+ }
59
68
 
60
69
  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
70
  case "Tab":
78
71
  close(menu);
79
72
  break;
@@ -81,8 +74,69 @@ export function onMenuKeydown(e, { close }) {
81
74
  case " ":
82
75
  if (activateCurrentItem(menu)) {
83
76
  e.preventDefault();
84
- close(menu);
85
77
  }
86
78
  break;
87
79
  }
88
80
  }
81
+
82
+ // Menu wiring is a shared, ref-counted resource: a menu may be retained by
83
+ // several triggers (or by flyout and context-menu at once), so the listeners
84
+ // and their AbortController belong to the menu, not to any single caller.
85
+ // Each retain returns a release(); the wiring is torn down on the last one.
86
+ const menuConnections = new WeakMap();
87
+
88
+ export function connectMenu(menu, { close }) {
89
+ let entry = menuConnections.get(menu);
90
+
91
+ if (!entry) {
92
+ const controller = new AbortController();
93
+ const focusGroup = menu.matches('[role="menu"]')
94
+ ? connectFocusGroup(menu, {
95
+ getItems: () => getMenuItems(menu).filter(isAriaMenuItem),
96
+ orientation: "vertical",
97
+ wrap: true,
98
+ signal: controller.signal,
99
+ })
100
+ : null;
101
+ menu.addEventListener(
102
+ "keydown",
103
+ (event) =>
104
+ onMenuKeydown(event, {
105
+ close: () => close(menu),
106
+ navigate: !focusGroup,
107
+ }),
108
+ { signal: controller.signal },
109
+ );
110
+ menu.addEventListener(
111
+ "click",
112
+ (event) => {
113
+ const item = getMenuItem(menu, event.target);
114
+ if (!item) return;
115
+
116
+ if (item.matches(":disabled, [aria-disabled='true']")) {
117
+ event.preventDefault();
118
+ // A disabled command must not reach the surface-level inside-click
119
+ // policy and dismiss the menu as if it had been activated.
120
+ event.stopPropagation();
121
+ return;
122
+ }
123
+ },
124
+ { signal: controller.signal },
125
+ );
126
+ entry = { count: 0, controller, focusGroup };
127
+ menuConnections.set(menu, entry);
128
+ }
129
+
130
+ entry.count++;
131
+ let released = false;
132
+ return () => {
133
+ if (released) return;
134
+ released = true;
135
+ entry.count--;
136
+ if (entry.count <= 0) {
137
+ entry.focusGroup?.disconnect();
138
+ entry.controller.abort();
139
+ menuConnections.delete(menu);
140
+ }
141
+ };
142
+ }
@@ -0,0 +1,47 @@
1
+ /*
2
+ * parse-config.js — Configuration parsing for data-enhance-config.
3
+ *
4
+ * Strict JSON is parsed as-is, never rewritten. When strict JSON fails, a
5
+ * small, deliberate relax step accepts the ergonomic subset that is common in
6
+ * HTML attributes: unquoted keys and single-quoted strings. The relaxed
7
+ * subset is intentionally tiny and is NOT a second grammar — anything it
8
+ * cannot transform cleanly is rejected by the final JSON.parse. The root
9
+ * value must be an object; bare string values (other than true/false/null)
10
+ * stay invalid, and trailing commas are rejected.
11
+ */
12
+
13
+ function relax(source) {
14
+ return source.replace(
15
+ /([a-zA-Z_$][\w$-]*)\s*:|'((?:\\'|[^'])*)'/g,
16
+ (_match, key, stringContent) => {
17
+ if (key) return `"${key}":`;
18
+ const unescaped = stringContent.replace(/\\'/g, "'");
19
+ const escaped = unescaped.replace(/"/g, '\\"');
20
+ return `"${escaped}"`;
21
+ },
22
+ );
23
+ }
24
+
25
+ export function parseConfig(source) {
26
+ if (source == null || typeof source !== "string") return {};
27
+ const str = source.trim();
28
+ if (str === "") return {};
29
+
30
+ let text = str;
31
+ let value;
32
+ try {
33
+ value = JSON.parse(text);
34
+ } catch {
35
+ const relaxed = relax(text);
36
+ text = relaxed.startsWith("{") ? relaxed : `{${relaxed}}`;
37
+ value = JSON.parse(text);
38
+ }
39
+
40
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
41
+ throw new TypeError("Enhancement config must be an object");
42
+ }
43
+
44
+ return value;
45
+ }
46
+
47
+ export default parseConfig;
@@ -93,6 +93,14 @@ if (typeof document !== "undefined") {
93
93
  document.addEventListener("submit", handleSubmit);
94
94
 
95
95
  window.addEventListener("pagehide", () => {
96
- for (const input of revealed) setRevealed(input, false);
96
+ for (const input of revealed) {
97
+ // Opportunistic housekeeping: an input removed from the DOM before
98
+ // navigation no longer needs reverting and can leave the set.
99
+ if (!input.isConnected) {
100
+ revealed.delete(input);
101
+ continue;
102
+ }
103
+ setRevealed(input, false);
104
+ }
97
105
  });
98
106
  }