@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.
- package/dist/base.css +1 -0
- package/dist/behaviors/dialog.d.ts +39 -0
- package/dist/behaviors/dialog.js +1 -0
- package/dist/behaviors/dismissible.d.ts +37 -0
- package/dist/behaviors/dismissible.js +1 -0
- package/dist/behaviors/menu-button.d.ts +36 -0
- package/dist/behaviors/menu-button.js +1 -0
- package/dist/behaviors/popover.d.ts +28 -0
- package/dist/behaviors/popover.js +1 -0
- package/dist/behaviors/tabs.d.ts +37 -0
- package/dist/behaviors/tabs.js +1 -0
- package/dist/behaviors/toast.d.ts +42 -0
- package/dist/behaviors/toast.js +1 -0
- package/dist/chunk-GMICGIQW.js +149 -0
- package/dist/chunk-J4EGUBPP.js +68 -0
- package/dist/chunk-PIDPGDBZ.js +62 -0
- package/dist/chunk-PSODVT3V.js +67 -0
- package/dist/chunk-TC4TFP7Y.js +40 -0
- package/dist/chunk-ZHRJNWMH.js +174 -0
- package/dist/components/button.css +1 -0
- package/dist/components/dialog.css +1 -0
- package/dist/components/disclosure.css +1 -0
- package/dist/components/form.css +1 -0
- package/dist/components/link.css +1 -0
- package/dist/components/menu.css +1 -0
- package/dist/components/popover.css +1 -0
- package/dist/components/progress.css +1 -0
- package/dist/components/tabs.css +1 -0
- package/dist/components/toast.css +1 -0
- package/dist/elements/re-menu.d.ts +10 -0
- package/dist/elements/re-menu.js +36 -0
- package/dist/elements/re-popover.d.ts +12 -0
- package/dist/elements/re-popover.js +35 -0
- package/dist/elements/re-tabs.d.ts +20 -0
- package/dist/elements/re-tabs.js +60 -0
- package/dist/elements/re-toast.d.ts +15 -0
- package/dist/elements/re-toast.js +30 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/reset.css +1 -0
- package/dist/themes/renascent.css +1 -0
- package/dist/tokens.css +1 -0
- package/package.json +84 -0
- package/src/base.css +129 -0
- package/src/behaviors/dialog.js +106 -0
- package/src/behaviors/dismissible.js +68 -0
- package/src/behaviors/menu-button.js +199 -0
- package/src/behaviors/popover.js +103 -0
- package/src/behaviors/tabs.js +171 -0
- package/src/behaviors/toast.js +97 -0
- package/src/components/button.css +141 -0
- package/src/components/dialog.css +106 -0
- package/src/components/disclosure.css +83 -0
- package/src/components/form.css +334 -0
- package/src/components/link.css +61 -0
- package/src/components/menu.css +78 -0
- package/src/components/popover.css +50 -0
- package/src/components/progress.css +112 -0
- package/src/components/tabs.css +86 -0
- package/src/components/toast.css +87 -0
- package/src/elements/re-menu.js +54 -0
- package/src/elements/re-popover.js +59 -0
- package/src/elements/re-tabs.js +92 -0
- package/src/elements/re-toast.js +46 -0
- package/src/index.css +30 -0
- package/src/index.js +13 -0
- package/src/reset.css +103 -0
- package/src/themes/renascent.css +198 -0
- 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
|
+
}
|