@relements/core 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 (70) hide show
  1. package/dist/base.css +1 -0
  2. package/dist/behaviors/dialog.d.ts +39 -0
  3. package/dist/behaviors/dialog.js +1 -0
  4. package/dist/behaviors/dismissible.d.ts +37 -0
  5. package/dist/behaviors/dismissible.js +1 -0
  6. package/dist/behaviors/menu-button.d.ts +36 -0
  7. package/dist/behaviors/menu-button.js +1 -0
  8. package/dist/behaviors/popover.d.ts +28 -0
  9. package/dist/behaviors/popover.js +1 -0
  10. package/dist/behaviors/tabs.d.ts +37 -0
  11. package/dist/behaviors/tabs.js +1 -0
  12. package/dist/behaviors/toast.d.ts +42 -0
  13. package/dist/behaviors/toast.js +1 -0
  14. package/dist/chunk-GMICGIQW.js +149 -0
  15. package/dist/chunk-J4EGUBPP.js +68 -0
  16. package/dist/chunk-PIDPGDBZ.js +62 -0
  17. package/dist/chunk-PSODVT3V.js +67 -0
  18. package/dist/chunk-TC4TFP7Y.js +40 -0
  19. package/dist/chunk-ZHRJNWMH.js +174 -0
  20. package/dist/components/button.css +1 -0
  21. package/dist/components/dialog.css +1 -0
  22. package/dist/components/disclosure.css +1 -0
  23. package/dist/components/form.css +1 -0
  24. package/dist/components/link.css +1 -0
  25. package/dist/components/menu.css +1 -0
  26. package/dist/components/popover.css +1 -0
  27. package/dist/components/progress.css +1 -0
  28. package/dist/components/tabs.css +1 -0
  29. package/dist/components/toast.css +1 -0
  30. package/dist/elements/re-menu.d.ts +10 -0
  31. package/dist/elements/re-menu.js +36 -0
  32. package/dist/elements/re-popover.d.ts +12 -0
  33. package/dist/elements/re-popover.js +35 -0
  34. package/dist/elements/re-tabs.d.ts +20 -0
  35. package/dist/elements/re-tabs.js +60 -0
  36. package/dist/elements/re-toast.d.ts +15 -0
  37. package/dist/elements/re-toast.js +30 -0
  38. package/dist/index.css +1 -0
  39. package/dist/index.d.ts +6 -0
  40. package/dist/index.js +6 -0
  41. package/dist/reset.css +1 -0
  42. package/dist/themes/renascent.css +1 -0
  43. package/dist/tokens.css +1 -0
  44. package/package.json +84 -0
  45. package/src/base.css +129 -0
  46. package/src/behaviors/dialog.js +106 -0
  47. package/src/behaviors/dismissible.js +68 -0
  48. package/src/behaviors/menu-button.js +199 -0
  49. package/src/behaviors/popover.js +103 -0
  50. package/src/behaviors/tabs.js +171 -0
  51. package/src/behaviors/toast.js +97 -0
  52. package/src/components/button.css +141 -0
  53. package/src/components/dialog.css +106 -0
  54. package/src/components/disclosure.css +83 -0
  55. package/src/components/form.css +334 -0
  56. package/src/components/link.css +61 -0
  57. package/src/components/menu.css +78 -0
  58. package/src/components/popover.css +50 -0
  59. package/src/components/progress.css +112 -0
  60. package/src/components/tabs.css +86 -0
  61. package/src/components/toast.css +87 -0
  62. package/src/elements/re-menu.js +54 -0
  63. package/src/elements/re-popover.js +59 -0
  64. package/src/elements/re-tabs.js +92 -0
  65. package/src/elements/re-toast.js +46 -0
  66. package/src/index.css +30 -0
  67. package/src/index.js +13 -0
  68. package/src/reset.css +103 -0
  69. package/src/themes/renascent.css +198 -0
  70. package/src/tokens.css +196 -0
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@relements/core",
3
+ "version": "0.1.0",
4
+ "description": "HTML-first design system core: tokens, styles, and progressive enhancement.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": [
8
+ "**/*.css",
9
+ "dist/**/*.css"
10
+ ],
11
+ "files": [
12
+ "dist",
13
+ "src"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "import": "./dist/index.js",
18
+ "types": "./dist/index.d.ts"
19
+ },
20
+ "./index.css": "./dist/index.css",
21
+ "./tokens.css": "./dist/tokens.css",
22
+ "./reset.css": "./dist/reset.css",
23
+ "./base.css": "./dist/base.css",
24
+ "./components/button.css": "./dist/components/button.css",
25
+ "./components/link.css": "./dist/components/link.css",
26
+ "./components/form.css": "./dist/components/form.css",
27
+ "./components/disclosure.css": "./dist/components/disclosure.css",
28
+ "./components/dialog.css": "./dist/components/dialog.css",
29
+ "./components/progress.css": "./dist/components/progress.css",
30
+ "./components/tabs.css": "./dist/components/tabs.css",
31
+ "./components/menu.css": "./dist/components/menu.css",
32
+ "./components/popover.css": "./dist/components/popover.css",
33
+ "./components/toast.css": "./dist/components/toast.css",
34
+ "./themes/renascent.css": "./dist/themes/renascent.css",
35
+ "./behaviors/dismissible": {
36
+ "import": "./dist/behaviors/dismissible.js",
37
+ "types": "./dist/behaviors/dismissible.d.ts"
38
+ },
39
+ "./behaviors/dialog": {
40
+ "import": "./dist/behaviors/dialog.js",
41
+ "types": "./dist/behaviors/dialog.d.ts"
42
+ },
43
+ "./behaviors/tabs": {
44
+ "import": "./dist/behaviors/tabs.js",
45
+ "types": "./dist/behaviors/tabs.d.ts"
46
+ },
47
+ "./behaviors/menu-button": {
48
+ "import": "./dist/behaviors/menu-button.js",
49
+ "types": "./dist/behaviors/menu-button.d.ts"
50
+ },
51
+ "./behaviors/popover": {
52
+ "import": "./dist/behaviors/popover.js",
53
+ "types": "./dist/behaviors/popover.d.ts"
54
+ },
55
+ "./behaviors/toast": {
56
+ "import": "./dist/behaviors/toast.js",
57
+ "types": "./dist/behaviors/toast.d.ts"
58
+ },
59
+ "./elements/re-tabs": {
60
+ "import": "./dist/elements/re-tabs.js",
61
+ "types": "./dist/elements/re-tabs.d.ts"
62
+ },
63
+ "./elements/re-toast": {
64
+ "import": "./dist/elements/re-toast.js",
65
+ "types": "./dist/elements/re-toast.d.ts"
66
+ },
67
+ "./elements/re-menu": {
68
+ "import": "./dist/elements/re-menu.js",
69
+ "types": "./dist/elements/re-menu.d.ts"
70
+ },
71
+ "./elements/re-popover": {
72
+ "import": "./dist/elements/re-popover.js",
73
+ "types": "./dist/elements/re-popover.d.ts"
74
+ }
75
+ },
76
+ "publishConfig": {
77
+ "access": "public"
78
+ },
79
+ "scripts": {
80
+ "build:css": "node build-css.mjs",
81
+ "build:js": "tsup",
82
+ "build": "node build-css.mjs && tsup"
83
+ }
84
+ }
package/src/base.css ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Relements base styles.
3
+ *
4
+ * Document-level defaults driven by tokens. Applies to plain HTML without
5
+ * any `re-*` classes — loading `index.css` should already produce a readable
6
+ * page. Component styles in `re.components` take precedence.
7
+ */
8
+
9
+ @layer re.base {
10
+ :root {
11
+ color-scheme: light dark;
12
+ }
13
+
14
+ html {
15
+ font-family: var(--re-font-sans);
16
+ font-size: var(--re-size-text-md);
17
+ line-height: var(--re-line-height-normal);
18
+ color: var(--re-color-text);
19
+ background-color: var(--re-color-bg);
20
+ }
21
+
22
+ ::selection {
23
+ background-color: var(--re-color-selection-bg);
24
+ color: var(--re-color-selection-text);
25
+ }
26
+
27
+ h1,
28
+ h2,
29
+ h3,
30
+ h4,
31
+ h5,
32
+ h6 {
33
+ line-height: var(--re-line-height-tight);
34
+ font-weight: var(--re-font-weight-semibold);
35
+ color: var(--re-color-text);
36
+ }
37
+
38
+ h1 {
39
+ font-size: var(--re-size-text-4xl);
40
+ }
41
+ h2 {
42
+ font-size: var(--re-size-text-3xl);
43
+ }
44
+ h3 {
45
+ font-size: var(--re-size-text-2xl);
46
+ }
47
+ h4 {
48
+ font-size: var(--re-size-text-xl);
49
+ }
50
+ h5 {
51
+ font-size: var(--re-size-text-lg);
52
+ }
53
+ h6 {
54
+ font-size: var(--re-size-text-md);
55
+ }
56
+
57
+ p {
58
+ line-height: var(--re-line-height-normal);
59
+ color: var(--re-color-text);
60
+ }
61
+
62
+ small {
63
+ font-size: var(--re-size-text-sm);
64
+ color: var(--re-color-text-muted);
65
+ }
66
+
67
+ code,
68
+ kbd,
69
+ samp,
70
+ pre {
71
+ font-family: var(--re-font-mono);
72
+ font-size: 0.95em;
73
+ }
74
+
75
+ pre {
76
+ overflow: auto;
77
+ padding: var(--re-space-3) var(--re-space-4);
78
+ background-color: var(--re-color-bg-muted);
79
+ border-radius: var(--re-radius-md);
80
+ }
81
+
82
+ code {
83
+ padding: 0.1em 0.3em;
84
+ background-color: var(--re-color-bg-muted);
85
+ border-radius: var(--re-radius-sm);
86
+ }
87
+
88
+ pre code {
89
+ padding: 0;
90
+ background: none;
91
+ border-radius: 0;
92
+ }
93
+
94
+ hr {
95
+ border: 0;
96
+ border-top: var(--re-border-default);
97
+ margin-block: var(--re-space-6);
98
+ }
99
+
100
+ /* Native link default — `.re-link` overrides per component contract. */
101
+ a {
102
+ color: var(--re-color-link);
103
+ text-decoration-thickness: 0.08em;
104
+ text-underline-offset: 0.2em;
105
+ }
106
+
107
+ a:hover {
108
+ color: var(--re-color-link-hover);
109
+ }
110
+
111
+ a:visited {
112
+ color: var(--re-color-link-visited);
113
+ }
114
+
115
+ /* Visible focus for all interactive elements unless a component opts out. */
116
+ :focus-visible {
117
+ outline: none;
118
+ box-shadow: var(--re-shadow-focus);
119
+ border-radius: var(--re-radius-sm);
120
+ }
121
+
122
+ /* Native form controls inherit token colors. Component CSS may override. */
123
+ input,
124
+ textarea,
125
+ select {
126
+ color: var(--re-color-text);
127
+ background-color: var(--re-color-bg);
128
+ }
129
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * enhanceDialog
3
+ * -------------
4
+ * Lightweight ergonomic helpers for native <dialog>.
5
+ *
6
+ * Wires:
7
+ * - `[data-re-dialog-trigger]` → opens the <dialog> referenced by its
8
+ * `data-re-dialog-target="dialog-id"` attribute (or aria-controls).
9
+ * - `[data-re-dialog-close]` inside a <dialog> → closes the parent dialog,
10
+ * setting the dialog's returnValue to the element's value attribute.
11
+ * - Click on the dialog's backdrop closes the dialog when the dialog
12
+ * element itself has `data-re-dialog-close-on-backdrop`.
13
+ *
14
+ * Usage:
15
+ *
16
+ * <button type="button" data-re-dialog-trigger data-re-dialog-target="confirm">
17
+ * Open
18
+ * </button>
19
+ * <dialog id="confirm" data-re-dialog-close-on-backdrop>
20
+ * …
21
+ * <button data-re-dialog-close value="cancel">Cancel</button>
22
+ * </dialog>
23
+ *
24
+ * import { enhanceDialog } from "@relements/core/behaviors/dialog";
25
+ * const c = enhanceDialog(document);
26
+ * c.destroy();
27
+ *
28
+ * Native semantics are preserved: showModal/close/Escape behavior all
29
+ * come from the browser.
30
+ */
31
+
32
+ /**
33
+ * @param {Document | Element | ShadowRoot} [root=document]
34
+ * @returns {{ destroy: () => void }}
35
+ */
36
+ export function enhanceDialog(root = document) {
37
+ if (root == null) {
38
+ throw new TypeError("enhanceDialog: root must be a Document, Element, or ShadowRoot");
39
+ }
40
+
41
+ /** @param {Event} event */
42
+ const onClick = (event) => {
43
+ const target = /** @type {Element | null} */ (event.target);
44
+ if (!target) return;
45
+
46
+ // 1) Trigger → open dialog
47
+ const trigger = target.closest("[data-re-dialog-trigger]");
48
+ if (trigger) {
49
+ const id =
50
+ trigger.getAttribute("data-re-dialog-target") ?? trigger.getAttribute("aria-controls");
51
+ if (!id) return;
52
+ const ownerDoc = trigger.ownerDocument;
53
+ const dialog = /** @type {HTMLDialogElement | null} */ (ownerDoc.getElementById(id));
54
+ if (dialog && typeof dialog.showModal === "function" && !dialog.open) {
55
+ dialog.showModal();
56
+ }
57
+ return;
58
+ }
59
+
60
+ // 2) Close button inside a dialog
61
+ const closeBtn = target.closest("[data-re-dialog-close]");
62
+ if (closeBtn) {
63
+ const dialog = /** @type {HTMLDialogElement | null} */ (closeBtn.closest("dialog"));
64
+ if (dialog && dialog.open) {
65
+ const value = /** @type {HTMLButtonElement} */ (closeBtn).value || "";
66
+ dialog.close(value);
67
+ }
68
+ return;
69
+ }
70
+
71
+ // 3) Backdrop click → close (only when opted in)
72
+ if (target.tagName === "DIALOG") {
73
+ const dialog = /** @type {HTMLDialogElement} */ (target);
74
+ if (
75
+ dialog.open &&
76
+ dialog.hasAttribute("data-re-dialog-close-on-backdrop") &&
77
+ isEventOnBackdrop(/** @type {MouseEvent} */ (event), dialog)
78
+ ) {
79
+ dialog.close("backdrop");
80
+ }
81
+ }
82
+ };
83
+
84
+ root.addEventListener("click", onClick);
85
+
86
+ return {
87
+ destroy() {
88
+ root.removeEventListener("click", onClick);
89
+ },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * The native ::backdrop pseudo-element is the click target when the user
95
+ * clicks outside the dialog box. Detect by comparing the click coordinates
96
+ * against the dialog's bounding box.
97
+ *
98
+ * @param {MouseEvent} event
99
+ * @param {HTMLDialogElement} dialog
100
+ * @returns {boolean}
101
+ */
102
+ function isEventOnBackdrop(event, dialog) {
103
+ const rect = dialog.getBoundingClientRect();
104
+ const { clientX: x, clientY: y } = event;
105
+ return x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
106
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * enhanceDismissible
3
+ * ------------------
4
+ * Wires up dismiss buttons inside any element marked `[data-re-dismissible]`.
5
+ *
6
+ * Usage in HTML:
7
+ *
8
+ * <aside data-re-dismissible>
9
+ * Banner copy…
10
+ * <button type="button" data-re-dismiss aria-label="Dismiss">×</button>
11
+ * </aside>
12
+ *
13
+ * Usage in JavaScript:
14
+ *
15
+ * import { enhanceDismissible } from "@relements/core/behaviors/dismissible";
16
+ * const controller = enhanceDismissible(document);
17
+ * // …later
18
+ * controller.destroy();
19
+ *
20
+ * Behavior:
21
+ * - Click or Enter/Space on `[data-re-dismiss]` hides the closest
22
+ * `[data-re-dismissible]` ancestor (sets `hidden`).
23
+ * - Dispatches a `re-dismiss` `CustomEvent` (bubbles, cancelable) on the
24
+ * dismissible element before hiding; calling `preventDefault()` cancels.
25
+ * - `controller.destroy()` removes all listeners.
26
+ *
27
+ * Root can be a Document, Element, or ShadowRoot.
28
+ */
29
+
30
+ /**
31
+ * @param {Document | Element | ShadowRoot} [root=document]
32
+ * @returns {{ destroy: () => void }}
33
+ */
34
+ export function enhanceDismissible(root = document) {
35
+ if (root == null) {
36
+ throw new TypeError("enhanceDismissible: root must be a Document, Element, or ShadowRoot");
37
+ }
38
+
39
+ /** @type {(event: Event) => void} */
40
+ const handle = (event) => {
41
+ const target = /** @type {Element | null} */ (event.target);
42
+ if (!target) return;
43
+ const trigger = target.closest("[data-re-dismiss]");
44
+ if (!trigger) return;
45
+ if (event.type === "keydown") {
46
+ const ke = /** @type {KeyboardEvent} */ (event);
47
+ if (ke.key !== "Enter" && ke.key !== " " && ke.key !== "Spacebar") return;
48
+ ke.preventDefault();
49
+ }
50
+ const host = trigger.closest("[data-re-dismissible]");
51
+ if (!host) return;
52
+ const cancelable = host.dispatchEvent(
53
+ new CustomEvent("re-dismiss", { bubbles: true, cancelable: true }),
54
+ );
55
+ if (!cancelable) return;
56
+ /** @type {HTMLElement} */ (host).hidden = true;
57
+ };
58
+
59
+ root.addEventListener("click", handle);
60
+ root.addEventListener("keydown", handle);
61
+
62
+ return {
63
+ destroy() {
64
+ root.removeEventListener("click", handle);
65
+ root.removeEventListener("keydown", handle);
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * enhanceMenuButton
3
+ * -----------------
4
+ * ARIA menu-button pattern over markup like:
5
+ *
6
+ * <div class="re-menu" data-re-menu>
7
+ * <button aria-haspopup="menu" aria-expanded="false" aria-controls="m-1" id="b-1">…</button>
8
+ * <div role="menu" id="m-1" aria-labelledby="b-1" hidden>
9
+ * <button role="menuitem">Rename</button>
10
+ * …
11
+ * </div>
12
+ * </div>
13
+ *
14
+ * Behavior:
15
+ * - Click the button toggles the menu.
16
+ * - ArrowDown opens the menu and focuses the first item.
17
+ * - Up/Down on items moves focus within the menu (wraps).
18
+ * - Home/End jump.
19
+ * - Escape closes the menu and returns focus to the button.
20
+ * - Tab outside the menu closes it.
21
+ * - Clicking outside closes it.
22
+ *
23
+ * Dispatches `re-select` (bubbles, cancelable) when a menuitem is activated:
24
+ * detail = { item: HTMLElement, value: string }
25
+ */
26
+
27
+ /** @typedef {{ destroy: () => void }} Controller */
28
+
29
+ /**
30
+ * @param {Document | Element | ShadowRoot} [root=document]
31
+ * @returns {Controller}
32
+ */
33
+ export function enhanceMenuButton(root = document) {
34
+ if (root == null) {
35
+ throw new TypeError("enhanceMenuButton: root must be a Document, Element, or ShadowRoot");
36
+ }
37
+
38
+ /** @type {Array<() => void>} */
39
+ const cleanups = [];
40
+
41
+ if (root instanceof Element && /** @type {Element} */ (root).matches?.("[data-re-menu]")) {
42
+ cleanups.push(wireOne(/** @type {HTMLElement} */ (root)));
43
+ }
44
+
45
+ /** @type {NodeListOf<Element>} */
46
+ const hosts = root.querySelectorAll("[data-re-menu]");
47
+ hosts.forEach((host) => {
48
+ cleanups.push(wireOne(/** @type {HTMLElement} */ (host)));
49
+ });
50
+
51
+ return {
52
+ destroy() {
53
+ while (cleanups.length) {
54
+ const fn = cleanups.pop();
55
+ fn?.();
56
+ }
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * @param {HTMLElement} host
63
+ * @returns {() => void}
64
+ */
65
+ function wireOne(host) {
66
+ const button = /** @type {HTMLElement | null} */ (
67
+ host.querySelector('[aria-haspopup="menu"], [aria-haspopup="true"]')
68
+ );
69
+ const panel = /** @type {HTMLElement | null} */ (host.querySelector('[role="menu"]'));
70
+ if (!button || !panel) return () => {};
71
+
72
+ const items = () =>
73
+ /** @type {HTMLElement[]} */ (
74
+ Array.from(panel.querySelectorAll('[role="menuitem"]:not([disabled])'))
75
+ );
76
+
77
+ const isOpen = () => button.getAttribute("aria-expanded") === "true";
78
+
79
+ /** @param {boolean} open */
80
+ const setOpen = (open) => {
81
+ button.setAttribute("aria-expanded", String(open));
82
+ panel.hidden = !open;
83
+ };
84
+
85
+ /** @param {{ focusFirst?: boolean }} [opts] */
86
+ const openMenu = (opts = {}) => {
87
+ if (isOpen()) return;
88
+ setOpen(true);
89
+ if (opts.focusFirst) {
90
+ const first = items()[0];
91
+ first?.focus();
92
+ }
93
+ };
94
+
95
+ /** @param {{ returnFocus?: boolean }} [opts] */
96
+ const closeMenu = (opts = {}) => {
97
+ if (!isOpen()) return;
98
+ setOpen(false);
99
+ if (opts.returnFocus) button.focus();
100
+ };
101
+
102
+ /** @param {Event} event */
103
+ const onButtonClick = (event) => {
104
+ event.stopPropagation();
105
+ if (isOpen()) closeMenu();
106
+ else openMenu({ focusFirst: false });
107
+ };
108
+
109
+ /** @param {KeyboardEvent} event */
110
+ const onButtonKey = (event) => {
111
+ if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
112
+ event.preventDefault();
113
+ openMenu({ focusFirst: true });
114
+ } else if (event.key === "ArrowUp") {
115
+ event.preventDefault();
116
+ openMenu();
117
+ const all = items();
118
+ all[all.length - 1]?.focus();
119
+ }
120
+ };
121
+
122
+ /** @param {KeyboardEvent} event */
123
+ const onPanelKey = (event) => {
124
+ const all = items();
125
+ const active = document.activeElement;
126
+ const idx = all.findIndex((el) => el === active);
127
+ switch (event.key) {
128
+ case "ArrowDown": {
129
+ event.preventDefault();
130
+ const next = all[(idx + 1 + all.length) % all.length];
131
+ next?.focus();
132
+ return;
133
+ }
134
+ case "ArrowUp": {
135
+ event.preventDefault();
136
+ const prev = all[(idx - 1 + all.length) % all.length];
137
+ prev?.focus();
138
+ return;
139
+ }
140
+ case "Home":
141
+ event.preventDefault();
142
+ all[0]?.focus();
143
+ return;
144
+ case "End":
145
+ event.preventDefault();
146
+ all[all.length - 1]?.focus();
147
+ return;
148
+ case "Escape":
149
+ event.preventDefault();
150
+ closeMenu({ returnFocus: true });
151
+ return;
152
+ case "Tab":
153
+ closeMenu();
154
+ return;
155
+ }
156
+ };
157
+
158
+ /** @param {Event} event */
159
+ const onPanelClick = (event) => {
160
+ const target = /** @type {Element | null} */ (event.target);
161
+ if (!target) return;
162
+ const item = target.closest('[role="menuitem"]');
163
+ if (!item) return;
164
+ const value =
165
+ /** @type {HTMLElement} */ (item).dataset.value ??
166
+ /** @type {HTMLElement} */ (item).textContent?.trim() ??
167
+ "";
168
+ const allowed = host.dispatchEvent(
169
+ new CustomEvent("re-select", {
170
+ bubbles: true,
171
+ cancelable: true,
172
+ detail: { item, value },
173
+ }),
174
+ );
175
+ if (allowed) closeMenu({ returnFocus: true });
176
+ };
177
+
178
+ /** @param {Event} event */
179
+ const onOutsideClick = (event) => {
180
+ if (!isOpen()) return;
181
+ const t = /** @type {Node | null} */ (event.target);
182
+ if (t && host.contains(t)) return;
183
+ closeMenu();
184
+ };
185
+
186
+ button.addEventListener("click", onButtonClick);
187
+ button.addEventListener("keydown", /** @type {EventListener} */ (onButtonKey));
188
+ panel.addEventListener("keydown", /** @type {EventListener} */ (onPanelKey));
189
+ panel.addEventListener("click", onPanelClick);
190
+ document.addEventListener("click", onOutsideClick);
191
+
192
+ return () => {
193
+ button.removeEventListener("click", onButtonClick);
194
+ button.removeEventListener("keydown", /** @type {EventListener} */ (onButtonKey));
195
+ panel.removeEventListener("keydown", /** @type {EventListener} */ (onPanelKey));
196
+ panel.removeEventListener("click", onPanelClick);
197
+ document.removeEventListener("click", onOutsideClick);
198
+ };
199
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * enhancePopover
3
+ * --------------
4
+ * Thin helper for native [popover]. Provides:
5
+ * - Anchored positioning: places the popover below its `popovertarget`
6
+ * button when the popover opens. (Native CSS Anchor Positioning isn't
7
+ * universally supported yet; this is a tiny JS fallback.)
8
+ * - `re-toggle` CustomEvent on the popover element when it opens/closes
9
+ * (detail = { open: boolean }), mirroring the native `toggle` event so
10
+ * consumers don't need feature-detect it.
11
+ *
12
+ * <button class="re-button" popovertarget="tip">Toggle</button>
13
+ * <div class="re-popover" id="tip" popover data-re-popover>Hello</div>
14
+ *
15
+ * import { enhancePopover } from "@relements/core/behaviors/popover";
16
+ * const c = enhancePopover(document);
17
+ */
18
+
19
+ /** @typedef {{ destroy: () => void }} Controller */
20
+
21
+ /**
22
+ * @param {Document | Element | ShadowRoot} [root=document]
23
+ * @returns {Controller}
24
+ */
25
+ export function enhancePopover(root = document) {
26
+ if (root == null) {
27
+ throw new TypeError("enhancePopover: root must be a Document, Element, or ShadowRoot");
28
+ }
29
+
30
+ /** @type {Array<() => void>} */
31
+ const cleanups = [];
32
+
33
+ if (root instanceof Element && /** @type {Element} */ (root).matches?.("[data-re-popover]")) {
34
+ cleanups.push(wireOne(/** @type {HTMLElement} */ (root)));
35
+ }
36
+
37
+ /** @type {NodeListOf<HTMLElement>} */
38
+ const popovers = root.querySelectorAll("[data-re-popover]");
39
+ popovers.forEach((pop) => {
40
+ cleanups.push(wireOne(pop));
41
+ });
42
+
43
+ return {
44
+ destroy() {
45
+ while (cleanups.length) cleanups.pop()?.();
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * @param {HTMLElement} popover
52
+ * @returns {() => void}
53
+ */
54
+ function wireOne(popover) {
55
+ if (!("popover" in popover) || typeof popover.showPopover !== "function") {
56
+ // Native popover API unavailable. Bail out gracefully.
57
+ return () => {};
58
+ }
59
+
60
+ /** @param {Event} event */
61
+ const onToggle = (event) => {
62
+ const e = /** @type {ToggleEvent} */ (event);
63
+ const open = e.newState === "open";
64
+ if (open) {
65
+ positionUnderTrigger(popover);
66
+ }
67
+ popover.dispatchEvent(
68
+ new CustomEvent("re-toggle", {
69
+ bubbles: true,
70
+ detail: { open },
71
+ }),
72
+ );
73
+ };
74
+
75
+ popover.addEventListener("toggle", onToggle);
76
+
77
+ return () => {
78
+ popover.removeEventListener("toggle", onToggle);
79
+ };
80
+ }
81
+
82
+ /**
83
+ * @param {HTMLElement} popover
84
+ */
85
+ function positionUnderTrigger(popover) {
86
+ const id = popover.id;
87
+ if (!id) return;
88
+ /** @type {HTMLElement | null} */
89
+ const trigger = document.querySelector(`[popovertarget="${cssEscape(id)}"]`);
90
+ if (!trigger) return;
91
+ const tRect = trigger.getBoundingClientRect();
92
+ popover.style.position = "fixed";
93
+ popover.style.top = `${tRect.bottom + 4}px`;
94
+ popover.style.left = `${tRect.left}px`;
95
+ popover.style.right = "auto";
96
+ popover.style.bottom = "auto";
97
+ popover.style.margin = "0";
98
+ }
99
+
100
+ /** @param {string} value */
101
+ function cssEscape(value) {
102
+ return typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : value;
103
+ }