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,121 @@
1
+ /*
2
+ * Scrollspy — navigation that highlights the active section on scroll.
3
+ *
4
+ * Nav: .scrollspy containing <a href="#section-id"> links
5
+ * Target: elements referenced by the href fragments
6
+ *
7
+ * Uses IntersectionObserver. Falls back gracefully: links still work
8
+ * even if IntersectionObserver isn't available.
9
+ *
10
+ * Optional root: data-scrollspy-root="#scroll-container" observes section
11
+ * visibility within a scroll container instead of the viewport.
12
+ *
13
+ * Self-registers via enhance: injected .scrollspy navs wire automatically.
14
+ * Cleanup disconnects the IntersectionObserver and the small MutationObserver
15
+ * that refreshes the link→section map as nav links or sections are injected.
16
+ */
17
+
18
+ import enhance from "./enhance.js";
19
+ import { CLASSES } from "./selectors.js";
20
+
21
+ function rootFor(nav) {
22
+ const selector = nav.getAttribute("data-scrollspy-root");
23
+ if (!selector) return null;
24
+
25
+ try {
26
+ return nav.ownerDocument.querySelector(selector);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function sectionsFor(nav) {
33
+ const links = [...nav.querySelectorAll("a[href^='#']")];
34
+ const sections = [];
35
+
36
+ for (const link of links) {
37
+ const id = link.getAttribute("href").slice(1);
38
+ const section = id ? nav.ownerDocument.getElementById(id) : null;
39
+ if (section) sections.push({ link, section });
40
+ }
41
+
42
+ return sections;
43
+ }
44
+
45
+ const navState = new WeakMap();
46
+
47
+ export function refreshScrollspy(nav) {
48
+ navState.get(nav)?.rebuild();
49
+ }
50
+
51
+ function setupNav(nav) {
52
+ if (typeof IntersectionObserver === "undefined") return;
53
+ let io = null;
54
+ let sections = [];
55
+ const activeEntries = new Map();
56
+ let scheduled = false;
57
+
58
+ function rebuild() {
59
+ scheduled = false;
60
+ io?.disconnect();
61
+ sections = sectionsFor(nav);
62
+ activeEntries.clear();
63
+ if (!sections.length) return;
64
+
65
+ io = new IntersectionObserver(
66
+ (entries) => {
67
+ for (const entry of entries) {
68
+ if (entry.isIntersecting) {
69
+ activeEntries.set(entry.target, entry);
70
+ } else {
71
+ activeEntries.delete(entry.target);
72
+ }
73
+ }
74
+
75
+ const visible = [...activeEntries.values()].sort(
76
+ (a, b) => a.boundingClientRect.top - b.boundingClientRect.top,
77
+ );
78
+
79
+ const id = visible[0]?.target.id;
80
+ for (const { link } of sections) {
81
+ if (id && link.getAttribute("href") === `#${id}`) {
82
+ link.setAttribute("aria-current", "location");
83
+ } else {
84
+ link.removeAttribute("aria-current");
85
+ }
86
+ }
87
+ },
88
+ {
89
+ root: rootFor(nav),
90
+ rootMargin: "-20% 0px -70% 0px",
91
+ threshold: 0,
92
+ },
93
+ );
94
+
95
+ for (const { section } of sections) {
96
+ io.observe(section);
97
+ }
98
+ }
99
+
100
+ function scheduleRebuild() {
101
+ if (scheduled) return;
102
+ scheduled = true;
103
+ queueMicrotask(rebuild);
104
+ }
105
+
106
+ const mo = new MutationObserver(scheduleRebuild);
107
+ mo.observe(nav, { childList: true, subtree: true });
108
+ navState.set(nav, { rebuild: scheduleRebuild });
109
+
110
+ rebuild();
111
+
112
+ return () => {
113
+ io?.disconnect();
114
+ mo.disconnect();
115
+ navState.delete(nav);
116
+ };
117
+ }
118
+
119
+ enhance({
120
+ [`.${CLASSES.scrollspy}`]: setupNav,
121
+ });
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Selectors — the CSS class names this runtime looks for or writes, gathered
3
+ * in one file.
4
+ *
5
+ * Every module that matches against or toggles one of Actual's own class
6
+ * names imports its token from here instead of writing the string inline.
7
+ * To run this runtime against a different CSS framework, edit this file (or
8
+ * alias it in a bundler) — no other module needs to change.
9
+ *
10
+ * data-* attributes and ARIA are not listed: they are each module's own
11
+ * behavior API, not CSS vocabulary, so they stay framework-neutral by
12
+ * construction and never need remapping. A module with no entry here (see
13
+ * status.js) has no CSS class dependency at all.
14
+ */
15
+ export const CLASSES = {
16
+ // flyout.js, context-menu.js — opt-in class on the panel/menu element.
17
+ flyout: "flyout",
18
+ // tab.js — opt-in class on the tablist element.
19
+ tabs: "tabs",
20
+ // scrollspy.js — opt-in class on the nav element.
21
+ scrollspy: "scrollspy",
22
+ // validation.js — opt-in class on the <form>.
23
+ needsValidation: "needs-validation",
24
+ // validation.js — written on <form> after the first submit attempt.
25
+ wasValidated: "was-validated",
26
+ // validation.js — read on the element an aria-describedby id resolves to.
27
+ fieldError: "field-error",
28
+ // validation.js — ancestor container searched for aria-invalid/danger state.
29
+ field: "field",
30
+ // validation.js — intents.css vocabulary class toggled on the field
31
+ // container while any control inside it is invalid.
32
+ danger: "danger",
33
+ // surface.js — state class on the open flyout/menu/dialog surface.
34
+ open: "is-open",
35
+ // surface.js — state class while the surface renders as a mobile sheet.
36
+ sheet: "is-sheet",
37
+ // surface.js — class name written on the generated sheet backdrop.
38
+ backdrop: "surface-backdrop",
39
+ // dialog.js — transient class for the "can't dismiss" shake animation.
40
+ static: "is-static",
41
+ // dialog-fallback.js — marker written on a dialog controlled by the legacy
42
+ // shim (browsers without native <dialog>); dialog-fallback.css keys its
43
+ // emulated dialog presentation on it.
44
+ dialogFallback: "dialog-fallback",
45
+ // dialog-fallback.js — state class on a shimmed dialog while it is open as
46
+ // a modal; carries the fixed positioning and simulated backdrop.
47
+ fallbackModal: "is-fallback-modal",
48
+ // dialog.js — written on <html> while any modal dialog is open.
49
+ modalOpen: "has-modal-open",
50
+ // dialog.js — written on <html> when the viewport had a classic scrollbar
51
+ // before the first modal lock.
52
+ hadScrollbar: "had-scrollbar",
53
+ // tooltip.js — class name written on a shorthand-generated tooltip element.
54
+ tooltip: "tooltip",
55
+ // status.js — required alongside [data-status]. "status" is a common
56
+ // domain word (order status, task status, …); the class keeps a
57
+ // document-wide querySelector from matching an unrelated app element.
58
+ statusBar: "status-bar",
59
+ };
@@ -0,0 +1,158 @@
1
+ /*
2
+ * Status — singleton floating status bar.
3
+ *
4
+ * A status bar is a transient, non-critical feedback area. Keep one element in
5
+ * the HTML, empty by default; JavaScript only updates its text content. It is
6
+ * hidden visually when empty but stays available as a live region.
7
+ *
8
+ * Importing the module auto-wires it to Actual's validation: a form that fails
9
+ * to submit shows its data-validation-message in the status bar. The bar is a
10
+ * view, not a validation mechanism, so validation and status stay decoupled.
11
+ *
12
+ * import "actual-css/js/status";
13
+ *
14
+ * Three ways to trigger it, all funneling through the same `actual:status`
15
+ * event so any of them can be observed the same way:
16
+ *
17
+ * - Programmatic (bundlers, third-party code): `import { status } from
18
+ * "actual-css/js/status"`, then `status()` / `status.clear()`.
19
+ * - Declarative, no JS required beyond the runtime:
20
+ * <button commandfor="app-status" command="--status"
21
+ * data-status-message="Saved." data-status-intent="success">
22
+ * <button commandfor="app-status" command="--status-clear">
23
+ * (the status bar element itself needs the matching `id="app-status"`.)
24
+ * - Any other code, without importing this module: dispatch `actual:status`
25
+ * directly — `detail: { message, intent, duration }`, or `{}` to clear.
26
+ *
27
+ * The element contract is `.status-bar[data-status]`. Both are required:
28
+ * "status" is a generic domain word, so the class keeps the document-wide
29
+ * lookup from matching an unrelated app element that happens to carry its own
30
+ * `data-status`. `intent` is applied verbatim as one or more classes on the
31
+ * target (space-separated for more than one, e.g. "danger uppercase").
32
+ * status.js does not know or validate any specific names — .danger, .success,
33
+ * and friends are Actual's own intents.css vocabulary, not something this
34
+ * module hardcodes. Each call removes the intent classes the previous call
35
+ * added (tracked in data-status-classes on the target itself), then adds the
36
+ * current call's, so stale intent classes never accumulate and classes the
37
+ * app added independently are left alone.
38
+ */
39
+ import { registerCommands, targetFor } from "./command.js";
40
+ import { EVENTS } from "./events.js";
41
+ import { CLASSES } from "./selectors.js";
42
+
43
+ const STATUS_SELECTOR = `.${CLASSES.statusBar}[data-status]`;
44
+ const STATUS_CLASSES_ATTR = "statusClasses";
45
+
46
+ let statusTimer;
47
+
48
+ function statusTarget() {
49
+ if (typeof document === "undefined") return null;
50
+ return document.querySelector(STATUS_SELECTOR);
51
+ }
52
+
53
+ function intentClasses(value) {
54
+ return typeof value === "string" ? value.trim().split(/\s+/).filter(Boolean) : [];
55
+ }
56
+
57
+ function clearIntentClasses(target) {
58
+ const previous = target.dataset[STATUS_CLASSES_ATTR];
59
+ if (previous) {
60
+ target.classList.remove(...previous.split(" "));
61
+ }
62
+ delete target.dataset[STATUS_CLASSES_ATTR];
63
+ }
64
+
65
+ export function status(message, options = {}) {
66
+ const target = statusTarget();
67
+ if (!target || message == null) return;
68
+
69
+ clearTimeout(statusTimer);
70
+
71
+ clearIntentClasses(target);
72
+ const intent = intentClasses(options.intent);
73
+ if (intent.length) {
74
+ target.classList.add(...intent);
75
+ target.dataset[STATUS_CLASSES_ATTR] = intent.join(" ");
76
+ }
77
+
78
+ target.textContent = message;
79
+
80
+ if (options.duration !== false) {
81
+ statusTimer = setTimeout(() => status.clear(), options.duration ?? 3000);
82
+ }
83
+ }
84
+
85
+ status.clear = function clear() {
86
+ const target = statusTarget();
87
+ if (target) {
88
+ target.textContent = "";
89
+ clearIntentClasses(target);
90
+ }
91
+ clearTimeout(statusTimer);
92
+ };
93
+
94
+ const STATUS_SHOW_COMMANDS = ["--status"];
95
+ const STATUS_CLEAR_COMMANDS = ["--status-clear"];
96
+
97
+ function dispatchStatusEvent(source, detail) {
98
+ source.dispatchEvent(new CustomEvent(EVENTS.status, { bubbles: true, detail }));
99
+ }
100
+
101
+ function readDuration(trigger) {
102
+ const raw = trigger.getAttribute("data-status-duration");
103
+ if (raw === null) return undefined;
104
+ if (raw === "false") return false;
105
+ const parsed = Number(raw);
106
+ return Number.isFinite(parsed) ? parsed : undefined;
107
+ }
108
+
109
+ function resolveStatusTrigger(trigger) {
110
+ const target = targetFor(trigger);
111
+ return target && target === statusTarget() ? target : null;
112
+ }
113
+
114
+ function connectStatusTrigger(trigger, target) {
115
+ trigger.setAttribute("aria-controls", target.id);
116
+ }
117
+
118
+ if (typeof document !== "undefined") {
119
+ document.addEventListener(EVENTS.invalid, (event) => {
120
+ const message = event.detail?.message;
121
+ if (message) status(message, { intent: "danger" });
122
+ });
123
+
124
+ // Public contract: any code can trigger the status bar this way, without
125
+ // importing this module. A message shows it; omitting one clears it.
126
+ document.addEventListener(EVENTS.status, (event) => {
127
+ const { message, intent, duration } = event.detail ?? {};
128
+ if (message) {
129
+ status(message, { intent, duration });
130
+ } else {
131
+ status.clear();
132
+ }
133
+ });
134
+
135
+ registerCommands(STATUS_SHOW_COMMANDS, {
136
+ resolve: resolveStatusTrigger,
137
+ prepare: connectStatusTrigger,
138
+ handle: (event, trigger) => {
139
+ event.preventDefault();
140
+ dispatchStatusEvent(trigger, {
141
+ message: trigger.getAttribute("data-status-message"),
142
+ intent: trigger.getAttribute("data-status-intent") ?? undefined,
143
+ duration: readDuration(trigger),
144
+ });
145
+ },
146
+ });
147
+
148
+ registerCommands(STATUS_CLEAR_COMMANDS, {
149
+ resolve: resolveStatusTrigger,
150
+ prepare: connectStatusTrigger,
151
+ handle: (event, trigger) => {
152
+ event.preventDefault();
153
+ dispatchStatusEvent(trigger, {});
154
+ },
155
+ });
156
+ }
157
+
158
+ export default status;
@@ -0,0 +1,358 @@
1
+ import { EVENTS } from "./events.js";
2
+ import { reposition, repositionAt, track } from "./floating.js";
3
+ import { hasMenuItem, onMenuKeydown } from "./menu.js";
4
+ import { CLASSES } from "./selectors.js";
5
+
6
+ const DEFAULT_BREAKPOINT = 768;
7
+ const BREAKPOINTS = {
8
+ sm: 640,
9
+ md: 768,
10
+ lg: 1024,
11
+ };
12
+ const openSurfaces = new Set();
13
+ const surfaceMap = new WeakMap();
14
+ const mountedSurfaces = new WeakMap();
15
+
16
+ function waitForAnimations(...elements) {
17
+ const animations = elements
18
+ .filter(Boolean)
19
+ .flatMap((el) =>
20
+ typeof el.getAnimations === "function" ? el.getAnimations({ subtree: true }) : [],
21
+ );
22
+
23
+ return Promise.allSettled(animations.map((animation) => animation.finished));
24
+ }
25
+
26
+ function getSurfaceRoot(menu, anchor) {
27
+ return anchor?.closest("dialog") || menu.closest("dialog") || menu.ownerDocument.body;
28
+ }
29
+
30
+ function mountSurface(menu, anchor) {
31
+ if (mountedSurfaces.has(menu)) return;
32
+
33
+ const root = getSurfaceRoot(menu, anchor);
34
+ const parent = menu.parentNode;
35
+ if (!root || !parent || parent === root) return;
36
+
37
+ mountedSurfaces.set(menu, { parent, next: menu.nextSibling });
38
+ root.append(menu);
39
+ }
40
+
41
+ function restoreSurface(menu) {
42
+ const mount = mountedSurfaces.get(menu);
43
+ if (!mount) return;
44
+
45
+ if (mount.parent.isConnected) {
46
+ const next = mount.next?.parentNode === mount.parent ? mount.next : null;
47
+ mount.parent.insertBefore(menu, next);
48
+ } else {
49
+ menu.remove();
50
+ }
51
+
52
+ mountedSurfaces.delete(menu);
53
+ }
54
+
55
+ function getBreakpoint(menu) {
56
+ const value = menu.dataset.flyoutBreakpoint || "";
57
+ const raw = Number.parseInt(BREAKPOINTS[value] || value, 10);
58
+ return Number.isFinite(raw) ? raw : DEFAULT_BREAKPOINT;
59
+ }
60
+
61
+ function getMobileMode(menu, mode) {
62
+ return mode || menu.dataset.flyoutMobile || "auto";
63
+ }
64
+
65
+ function getPlacement(menu, placement) {
66
+ return placement || menu.dataset.flyoutPlacement || "bottom-start";
67
+ }
68
+
69
+ function getDistance(menu, distance) {
70
+ if (distance != null) return distance;
71
+ const value = Number.parseFloat(menu.dataset.flyoutDistance);
72
+ return Number.isFinite(value) ? value : 4;
73
+ }
74
+
75
+ function getAutoCloseMode(menu, value) {
76
+ const mode = String(value ?? menu.dataset.flyoutAutoClose ?? "true").toLowerCase();
77
+ if (mode === "inside" || mode === "outside" || mode === "false") return mode;
78
+ return "true";
79
+ }
80
+
81
+ function shouldUseSheet(menu, mode) {
82
+ const mobile = getMobileMode(menu, mode);
83
+ if (mobile === "none" || mobile === "anchored") return false;
84
+ if (mobile === "sheet") return true;
85
+
86
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
87
+ return false;
88
+ }
89
+
90
+ const breakpoint = getBreakpoint(menu);
91
+ return (
92
+ window.matchMedia("(pointer: coarse)").matches &&
93
+ window.matchMedia(`(max-width: ${breakpoint}px)`).matches
94
+ );
95
+ }
96
+
97
+ function linkedTriggers(menu) {
98
+ if (!menu.id) return [];
99
+ return [...menu.ownerDocument.querySelectorAll(`[aria-controls="${CSS.escape(menu.id)}"]`)];
100
+ }
101
+
102
+ function syncExpanded(menu, expanded) {
103
+ for (const trigger of linkedTriggers(menu)) {
104
+ trigger.setAttribute("aria-expanded", expanded ? "true" : "false");
105
+ }
106
+ }
107
+
108
+ function ensureBackdrop(menu, state) {
109
+ if (!state.isSheet) {
110
+ state.backdrop?.remove();
111
+ state.backdrop = null;
112
+ return;
113
+ }
114
+
115
+ if (state.backdrop?.isConnected) {
116
+ state.backdrop.hidden = false;
117
+ return;
118
+ }
119
+
120
+ const backdrop = menu.ownerDocument.createElement("div");
121
+ backdrop.className = CLASSES.backdrop;
122
+ backdrop.hidden = false;
123
+ backdrop.addEventListener("click", () => {
124
+ closeSurface(menu);
125
+ });
126
+
127
+ menu.before(backdrop);
128
+ state.backdrop = backdrop;
129
+ }
130
+
131
+ function applyPresentation(menu, state) {
132
+ state.isSheet = shouldUseSheet(menu, state.mobile);
133
+ if (state.isSheet) {
134
+ menu.style.removeProperty("left");
135
+ menu.style.removeProperty("top");
136
+ }
137
+ menu.classList.toggle(CLASSES.sheet, state.isSheet);
138
+ ensureBackdrop(menu, state);
139
+ }
140
+
141
+ function positionSurface(menu) {
142
+ const state = surfaceMap.get(menu);
143
+ if (!state || state.isSheet) return;
144
+
145
+ if (state.trigger) {
146
+ const triggerWidth = state.trigger.getBoundingClientRect().width;
147
+ menu.style.setProperty("--flyout-trigger-width", `${triggerWidth}px`);
148
+ } else {
149
+ menu.style.removeProperty("--flyout-trigger-width");
150
+ }
151
+
152
+ const opts = {
153
+ placement: state.placement || "bottom-start",
154
+ distance: state.distance ?? 4,
155
+ flip: state.flip !== false,
156
+ shift: state.shift !== false,
157
+ shiftPadding: state.shiftPadding ?? 4,
158
+ scope: state.scope,
159
+ };
160
+
161
+ if (state.point) {
162
+ repositionAt(state.point.x, state.point.y, menu, opts);
163
+ return;
164
+ }
165
+
166
+ const anchor = state.trigger || state.source;
167
+ if (anchor) reposition(anchor, menu, opts);
168
+ }
169
+
170
+ function ensureSurfaceWired(menu) {
171
+ if (surfaceMap.has(menu)) return surfaceMap.get(menu);
172
+
173
+ const controller = new AbortController();
174
+ const state = {
175
+ controller,
176
+ untrack: track(menu),
177
+ backdrop: null,
178
+ trigger: null,
179
+ source: null,
180
+ point: null,
181
+ mobile: "auto",
182
+ placement: "bottom-start",
183
+ distance: 4,
184
+ flip: true,
185
+ shift: true,
186
+ shiftPadding: 4,
187
+ autoClose: "true",
188
+ isSheet: false,
189
+ closeId: 0,
190
+ };
191
+
192
+ menu.addEventListener(
193
+ EVENTS.reposition,
194
+ (e) => {
195
+ if (menu.hidden) return;
196
+ if (e.detail?.type === "scroll" && surfaceMap.get(menu)?.point) {
197
+ closeSurface(menu);
198
+ return;
199
+ }
200
+ const state = surfaceMap.get(menu);
201
+ if (!state) return;
202
+ applyPresentation(menu, state);
203
+ positionSurface(menu);
204
+ },
205
+ { signal: controller.signal },
206
+ );
207
+
208
+ menu.addEventListener(
209
+ EVENTS.hide,
210
+ (e) => {
211
+ if (!menu.hidden) closeSurface(menu, { restoreFocus: e.detail?.type === "escape" });
212
+ },
213
+ { signal: controller.signal },
214
+ );
215
+
216
+ menu.addEventListener(
217
+ EVENTS.outOfView,
218
+ () => {
219
+ if (!menu.hidden) closeSurface(menu);
220
+ },
221
+ { signal: controller.signal },
222
+ );
223
+
224
+ menu.addEventListener(
225
+ "keydown",
226
+ (e) => onMenuKeydown(e, { close: (target) => closeSurface(target) }),
227
+ { signal: controller.signal },
228
+ );
229
+
230
+ menu.addEventListener(
231
+ "click",
232
+ (e) => {
233
+ const state = surfaceMap.get(menu);
234
+ const disabledItem = e.target.closest?.(":disabled, [aria-disabled='true']");
235
+ if (disabledItem && menu.contains(disabledItem)) {
236
+ e.preventDefault();
237
+ return;
238
+ }
239
+ if (!state || state.autoClose === "outside" || state.autoClose === "false") return;
240
+ if (hasMenuItem(e.target)) closeSurface(menu);
241
+ },
242
+ { signal: controller.signal },
243
+ );
244
+
245
+ surfaceMap.set(menu, state);
246
+ return state;
247
+ }
248
+
249
+ export function isSurfaceOpen(menu) {
250
+ return menu.classList.contains(CLASSES.open) && !menu.hidden;
251
+ }
252
+
253
+ export function prepareSurface(menu) {
254
+ if (!menu) return;
255
+ menu.style.position = "fixed";
256
+ menu.hidden = true;
257
+ syncExpanded(menu, false);
258
+ }
259
+
260
+ export function openSurface(menu, opts = {}) {
261
+ if (!menu?.isConnected) return false;
262
+ if (isSurfaceOpen(menu)) return false;
263
+
264
+ const beforeOpen = new CustomEvent(EVENTS.surfaceOpen, {
265
+ bubbles: true,
266
+ cancelable: true,
267
+ detail: { surface: menu, options: opts },
268
+ });
269
+ if (!menu.dispatchEvent(beforeOpen)) return false;
270
+
271
+ for (const other of openSurfaces) {
272
+ if (other !== menu) closeSurface(other);
273
+ }
274
+
275
+ const anchor = opts.trigger || opts.source || null;
276
+ mountSurface(menu, anchor);
277
+ prepareSurface(menu);
278
+ const state = ensureSurfaceWired(menu);
279
+ state.trigger = opts.trigger || null;
280
+ state.source = opts.source || null;
281
+ state.point =
282
+ Number.isFinite(opts.x) && Number.isFinite(opts.y) ? { x: opts.x, y: opts.y } : null;
283
+ state.mobile = getMobileMode(menu, opts.mobile);
284
+ state.autoClose = getAutoCloseMode(menu, opts.autoClose);
285
+ state.placement = getPlacement(menu, opts.placement);
286
+ state.distance = getDistance(menu, opts.distance);
287
+ state.flip = opts.flip !== false;
288
+ state.shift = opts.shift !== false;
289
+ state.shiftPadding = opts.shiftPadding ?? 4;
290
+ state.scope = opts.scope;
291
+ state.restoreFocusTo = opts.restoreFocusTo || opts.trigger || opts.source || null;
292
+ state.closeId++;
293
+
294
+ menu.classList.add(CLASSES.open);
295
+ menu.hidden = false;
296
+ openSurfaces.add(menu);
297
+ applyPresentation(menu, state);
298
+ syncExpanded(menu, true);
299
+ positionSurface(menu);
300
+ return true;
301
+ }
302
+
303
+ export function closeSurface(menu, opts = {}) {
304
+ if (!menu || !isSurfaceOpen(menu)) return;
305
+
306
+ const state = surfaceMap.get(menu);
307
+ const closeId = state ? ++state.closeId : 0;
308
+ const wasSheet = state?.isSheet === true;
309
+ const activeElement = menu.ownerDocument.activeElement;
310
+ const shouldRestoreFocus = opts.restoreFocus ?? menu.contains(activeElement);
311
+ menu.classList.remove(CLASSES.open);
312
+ if (!wasSheet) {
313
+ menu.classList.remove(CLASSES.sheet);
314
+ }
315
+ menu.hidden = true;
316
+ const backdrop = state?.backdrop || null;
317
+ if (backdrop) backdrop.hidden = true;
318
+ openSurfaces.delete(menu);
319
+ syncExpanded(menu, false);
320
+
321
+ if (shouldRestoreFocus && state?.restoreFocusTo?.isConnected) {
322
+ state.restoreFocusTo.focus({ preventScroll: true });
323
+ }
324
+
325
+ waitForAnimations(menu, backdrop).then(() => {
326
+ if (!state || state.closeId !== closeId) return;
327
+ state.isSheet = false;
328
+ menu.classList.remove(CLASSES.sheet);
329
+ backdrop?.remove();
330
+ if (state.backdrop === backdrop) state.backdrop = null;
331
+ restoreSurface(menu);
332
+ });
333
+ }
334
+
335
+ export function disconnectSurface(menu) {
336
+ if (!menu) return;
337
+ closeSurface(menu);
338
+ const state = surfaceMap.get(menu);
339
+ if (state) {
340
+ state.backdrop?.remove();
341
+ state.untrack();
342
+ state.controller.abort();
343
+ surfaceMap.delete(menu);
344
+ }
345
+ restoreSurface(menu);
346
+ }
347
+
348
+ // Global outside-click listener runs at import time; keep SSR imports inert.
349
+ if (typeof document !== "undefined") {
350
+ document.addEventListener("click", (e) => {
351
+ for (const menu of openSurfaces) {
352
+ const state = surfaceMap.get(menu);
353
+ if (!state || state.autoClose === "inside" || state.autoClose === "false") continue;
354
+ if (menu.contains(e.target) || state?.trigger?.contains(e.target)) continue;
355
+ closeSurface(menu);
356
+ }
357
+ });
358
+ }