@rcarls/rc-menu 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/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # `@rcarls/rc-menu`
2
+
3
+ A WAI-ARIA compliant menu component implementing the [Menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu/). Manages roving tabindex across slotted items and dispatches typed events on activation and close. Built with [Lit 3](https://lit.dev).
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @rcarls/rc-menu
11
+ ```
12
+
13
+ ## Import
14
+
15
+ ```js
16
+ import '@rcarls/rc-menu'; // side-effect: registers <rc-menu>
17
+ import { RCMenu } from '@rcarls/rc-menu'; // named class export
18
+ ```
19
+
20
+ ---
21
+
22
+ ## Basic usage
23
+
24
+ Slot any focusable elements as menu items. Non-focusable elements (separators, headings) are rendered but excluded from keyboard navigation.
25
+
26
+ ```html
27
+ <rc-menu label="File">
28
+ <button>New</button>
29
+ <button>Open…</button>
30
+ <hr aria-hidden="true" />
31
+ <button disabled>Save</button>
32
+ <button>Save As…</button>
33
+ </rc-menu>
34
+ ```
35
+
36
+ `rc-menu` is typically used inside an [`rc-menu-button`](../rc-menu-button/README.md) or [`rc-menubar`](../rc-menubar/README.md), which handle positioning and trigger wiring. It can also be used standalone as a context menu.
37
+
38
+ ---
39
+
40
+ ## API
41
+
42
+ ### Properties / attributes
43
+
44
+ | Property | Attribute | Type | Default | Description |
45
+ |---|---|---|---|---|
46
+ | `label` | `label` | `string` | `''` | Value of the `aria-label` on the menu container. Required for accessibility when no visible label is present. |
47
+
48
+ ### CSS custom properties
49
+
50
+ | Property | Default | Description |
51
+ |---|---|---|
52
+ | `--rc-menu-min-width` | `10em` | Minimum width of the menu container |
53
+ | `--rc-menu-padding-block` | `0.25em` | Block-axis (top/bottom) padding inside the root |
54
+ | `--rc-menu-background` | `Canvas` | Menu background color (system color keyword) |
55
+ | `--rc-menu-border` | `1px solid ButtonBorder` | Menu border |
56
+ | `--rc-menu-shadow` | `0 2px 8px rgba(0,0,0,0.15)` | Box shadow |
57
+
58
+ ### CSS parts
59
+
60
+ | Part | Element | Description |
61
+ |---|---|---|
62
+ | `root` | `div[role="menu"]` | The inner menu container |
63
+
64
+ ### Slots
65
+
66
+ | Slot | Description |
67
+ |---|---|
68
+ | *(default)* | Menu items. Only focusable elements participate in keyboard navigation. `<button>`, `<a href>`, elements with `tabindex`, and `<input>`/`<select>`/`<textarea>` are recognised as focusable; all others are skipped. |
69
+
70
+ ### Events
71
+
72
+ | Event | Bubbles | Cancelable | Detail | When |
73
+ |---|---|---|---|---|
74
+ | `rc-menu-activate` | Yes (composed) | No | `{ item: HTMLElement }` | User presses Enter or Space on a focused item |
75
+ | `rc-menu-close` | Yes (composed) | No | — | User presses Escape |
76
+
77
+ ### Public methods
78
+
79
+ ```ts
80
+ focusFirst(): void // Focus the first navigable item
81
+ focusLast(): void // Focus the last navigable item
82
+ focusItem(item: HTMLElement): void // Focus a specific item
83
+ focusItemAt(index: number): void // Focus item at zero-based index
84
+ ```
85
+
86
+ ### Read-only getters
87
+
88
+ ```ts
89
+ items: HTMLElement[] // All currently navigable (focusable) slotted items
90
+ firstItem: HTMLElement | undefined
91
+ lastItem: HTMLElement | undefined
92
+ nextItem: HTMLElement | undefined // Relative to the currently focused item
93
+ previousItem: HTMLElement | undefined
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Keyboard behaviour
99
+
100
+ | Key | Action |
101
+ |---|---|
102
+ | `ArrowDown` | Focus next item (wraps) |
103
+ | `ArrowUp` | Focus previous item (wraps) |
104
+ | `Home` | Focus first item |
105
+ | `End` | Focus last item |
106
+ | `Enter` / `Space` | Activate focused item (fires `rc-menu-activate`) |
107
+ | `Escape` | Fire `rc-menu-close` |
108
+
109
+ ---
110
+
111
+ ## ARIA
112
+
113
+ | Attribute | Where | Value |
114
+ |---|---|---|
115
+ | `role="menu"` | Root `div` | — |
116
+ | `aria-label` | Root `div` | Value of `label` property |
117
+ | `role="menuitem"` | Each slotted focusable item | Set dynamically on slot change |
118
+ | `tabindex="0"` | Active item | One item is tabbable at a time (roving tabindex) |
119
+ | `tabindex="-1"` | All other items | Removed from tab order |
120
+
121
+ ---
122
+
123
+ ## Browser support
124
+
125
+ All modern browsers. Requires Web Components support (Chrome 67+, Firefox 63+, Safari 12.1+).
@@ -0,0 +1,253 @@
1
+ {
2
+ "schemaVersion": "1.0.0",
3
+ "readme": "",
4
+ "modules": [
5
+ {
6
+ "kind": "javascript-module",
7
+ "path": "src/define.ts",
8
+ "declarations": [],
9
+ "exports": [
10
+ {
11
+ "kind": "custom-element-definition",
12
+ "name": "rc-menu",
13
+ "declaration": {
14
+ "name": "RCMenu",
15
+ "module": "/src/index.js"
16
+ }
17
+ },
18
+ {
19
+ "kind": "js",
20
+ "name": "*",
21
+ "declaration": {
22
+ "name": "*",
23
+ "module": "src/index.js"
24
+ }
25
+ }
26
+ ]
27
+ },
28
+ {
29
+ "kind": "javascript-module",
30
+ "path": "src/index.ts",
31
+ "declarations": [],
32
+ "exports": [
33
+ {
34
+ "kind": "js",
35
+ "name": "*",
36
+ "declaration": {
37
+ "name": "*",
38
+ "module": "src/rc-menu"
39
+ }
40
+ }
41
+ ]
42
+ },
43
+ {
44
+ "kind": "javascript-module",
45
+ "path": "src/rc-menu.ts",
46
+ "declarations": [
47
+ {
48
+ "kind": "class",
49
+ "description": "A menu component, as defined in WAI-ARIA",
50
+ "name": "RCMenu",
51
+ "cssProperties": [
52
+ {
53
+ "description": "Minimum width of the menu panel",
54
+ "name": "--rc-menu-min-width",
55
+ "default": "10em"
56
+ },
57
+ {
58
+ "description": "Block (top/bottom) padding inside the panel",
59
+ "name": "--rc-menu-padding-block",
60
+ "default": "0.25em"
61
+ },
62
+ {
63
+ "description": "Background color; falls back through --rc-surface",
64
+ "name": "--rc-menu-background",
65
+ "default": "Canvas"
66
+ },
67
+ {
68
+ "description": "Border; falls back through --rc-border",
69
+ "name": "--rc-menu-border",
70
+ "default": "1px solid ButtonBorder"
71
+ },
72
+ {
73
+ "description": "Menu panel border radius",
74
+ "name": "--rc-menu-radius",
75
+ "default": "var(--rc-control-radius)"
76
+ },
77
+ {
78
+ "description": "Box shadow; falls back through --rc-shadow",
79
+ "name": "--rc-menu-shadow",
80
+ "default": "0 2px 8px color-mix(in srgb, CanvasText 15%, transparent)"
81
+ },
82
+ {
83
+ "description": "Menu item block-axis padding",
84
+ "name": "--rc-menu-item-padding-block",
85
+ "default": "var(--rc-item-padding-block)"
86
+ },
87
+ {
88
+ "description": "Menu item inline-axis padding",
89
+ "name": "--rc-menu-item-padding-inline",
90
+ "default": "var(--rc-item-padding-inline)"
91
+ },
92
+ {
93
+ "description": "Gap between icon and text content in flex menu items",
94
+ "name": "--rc-menu-item-gap",
95
+ "default": "var(--rc-item-gap)"
96
+ },
97
+ {
98
+ "description": "Size clamp for direct slotted icon media",
99
+ "name": "--rc-menu-icon-size",
100
+ "default": "1.2em"
101
+ },
102
+ {
103
+ "description": "Disabled menu item text color",
104
+ "name": "--rc-menu-disabled-color",
105
+ "default": "GrayText"
106
+ },
107
+ {
108
+ "description": "Disabled menu item opacity",
109
+ "name": "--rc-menu-disabled-opacity",
110
+ "default": "var(--rc-disabled-opacity)"
111
+ },
112
+ {
113
+ "description": "Separator border",
114
+ "name": "--rc-menu-separator-border",
115
+ "default": "var(--rc-border)"
116
+ },
117
+ {
118
+ "description": "Separator block-axis margin",
119
+ "name": "--rc-menu-separator-margin-block",
120
+ "default": "var(--rc-control-gap)"
121
+ }
122
+ ],
123
+ "cssParts": [
124
+ {
125
+ "description": "The root container element",
126
+ "name": "root"
127
+ }
128
+ ],
129
+ "slots": [
130
+ {
131
+ "description": "any number of child elements to display in the menu. Only focusable elements are navigable.",
132
+ "name": "Takes"
133
+ }
134
+ ],
135
+ "members": [
136
+ {
137
+ "kind": "field",
138
+ "name": "label",
139
+ "type": {
140
+ "text": "string"
141
+ },
142
+ "default": "''",
143
+ "description": "Accessible label for this menu.",
144
+ "attribute": "label"
145
+ },
146
+ {
147
+ "kind": "field",
148
+ "name": "_$root",
149
+ "type": {
150
+ "text": "HTMLDivElement"
151
+ },
152
+ "privacy": "protected"
153
+ },
154
+ {
155
+ "kind": "method",
156
+ "name": "_collectItems",
157
+ "privacy": "protected",
158
+ "return": {
159
+ "type": {
160
+ "text": "FocusableElement[]"
161
+ }
162
+ },
163
+ "parameters": [
164
+ {
165
+ "name": "slot",
166
+ "type": {
167
+ "text": "HTMLSlotElement"
168
+ }
169
+ }
170
+ ],
171
+ "description": "Collects navigable menu items from the slot, supporting `role=\"group\"`\ncontainers and skipping `role=\"separator\"` elements.\n\nDirect focusable children are included as-is. Elements with\n`role=\"group\"` are traversed one level deep so their focusable children\nparticipate in arrow-key navigation. Separators and non-focusable\ncontainer elements are excluded."
172
+ },
173
+ {
174
+ "kind": "method",
175
+ "name": "_initItems",
176
+ "privacy": "protected"
177
+ },
178
+ {
179
+ "kind": "method",
180
+ "name": "_onNavigate",
181
+ "privacy": "protected",
182
+ "parameters": [
183
+ {
184
+ "name": "action",
185
+ "type": {
186
+ "text": "KeyboardNavigationAction"
187
+ }
188
+ }
189
+ ]
190
+ }
191
+ ],
192
+ "events": [
193
+ {
194
+ "name": "rc-menu-activate",
195
+ "type": {
196
+ "text": "CustomEvent"
197
+ },
198
+ "description": "Fired when a menu item is activated via Enter or Space"
199
+ },
200
+ {
201
+ "name": "rc-menu-close",
202
+ "type": {
203
+ "text": "CustomEvent"
204
+ },
205
+ "description": "Fired when Escape is pressed"
206
+ }
207
+ ],
208
+ "attributes": [
209
+ {
210
+ "name": "label",
211
+ "type": {
212
+ "text": "string"
213
+ },
214
+ "default": "''",
215
+ "description": "Accessible label for this menu.",
216
+ "fieldName": "label"
217
+ }
218
+ ],
219
+ "mixins": [
220
+ {
221
+ "name": "RovingTabIndexMixin",
222
+ "package": "@rcarls/rc-common"
223
+ }
224
+ ],
225
+ "superclass": {
226
+ "name": "LitElement",
227
+ "package": "lit"
228
+ },
229
+ "tagName": "rc-menu",
230
+ "customElement": true
231
+ }
232
+ ],
233
+ "exports": [
234
+ {
235
+ "kind": "js",
236
+ "name": "RCMenu",
237
+ "declaration": {
238
+ "name": "RCMenu",
239
+ "module": "src/rc-menu.ts"
240
+ }
241
+ },
242
+ {
243
+ "kind": "js",
244
+ "name": "default",
245
+ "declaration": {
246
+ "name": "RCMenu",
247
+ "module": "src/rc-menu.ts"
248
+ }
249
+ }
250
+ ]
251
+ }
252
+ ]
253
+ }
package/dist/demo.css ADDED
@@ -0,0 +1,85 @@
1
+ *, *::before, *::after {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ :root {
6
+ color-scheme: light dark;
7
+ --rc-accent: Highlight; /* Chrome doesn't support AccentColor on the open web */
8
+ }
9
+
10
+ @supports (color: AccentColor) {
11
+ :root {
12
+ --rc-accent: AccentColor;
13
+ }
14
+ }
15
+
16
+ [data-theme="light"] { color-scheme: light; }
17
+ [data-theme="dark"] { color-scheme: dark; }
18
+
19
+ [data-theme="solarized-light"] {
20
+ color-scheme: light;
21
+ --rc-surface: #fdf6e3;
22
+ --rc-text: #657b83;
23
+ --rc-border: 1px solid #93a1a1;
24
+ --rc-shadow: 0 2px 8px rgba(0, 0, 0, .12);
25
+ }
26
+
27
+ [data-theme="solarized-dark"] {
28
+ color-scheme: dark;
29
+ --rc-surface: #002b36;
30
+ --rc-text: #839496;
31
+ --rc-border: 1px solid #586e75;
32
+ --rc-shadow: 0 2px 8px rgba(0, 0, 0, .3);
33
+ }
34
+
35
+ body {
36
+ font-family: system-ui, sans-serif;
37
+ margin: 0;
38
+ }
39
+
40
+ .demo-page {
41
+ max-width: 60rem;
42
+ margin: 0 auto;
43
+ padding: 2rem 1.5rem;
44
+ }
45
+
46
+ .demo-controls {
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 1rem;
50
+ margin-bottom: 2rem;
51
+ padding-bottom: 1rem;
52
+ border-bottom: 1px solid ButtonBorder;
53
+ }
54
+
55
+ .demo-controls .demo-title {
56
+ flex: 1;
57
+ }
58
+
59
+ .demo-controls h1 {
60
+ margin: 0 0 0.15rem;
61
+ font-size: 1.5rem;
62
+ }
63
+
64
+ .demo-controls a {
65
+ font-size: 0.8rem;
66
+ }
67
+
68
+ .demo-section {
69
+ margin-bottom: 2rem;
70
+ }
71
+
72
+ .demo-section h2 {
73
+ margin: 0 0 0.5rem;
74
+ }
75
+
76
+ .theme-picker {
77
+ padding: 0.35em 0.75em;
78
+ font-family: inherit;
79
+ font-size: 0.8rem;
80
+ cursor: pointer;
81
+ border: 1px solid ButtonBorder;
82
+ border-radius: 4px;
83
+ background: Canvas;
84
+ color: CanvasText;
85
+ }
package/dist/demo.js ADDED
@@ -0,0 +1,40 @@
1
+ const THEMES = ['', 'light', 'dark', 'solarized-light', 'solarized-dark'];
2
+ const LABELS = ['Auto', 'Light', 'Dark', 'Solarized ☀', 'Solarized ☾'];
3
+
4
+ const stored = localStorage.getItem('rc-demo-theme') ?? '';
5
+ applyTheme(stored);
6
+
7
+ function applyTheme(theme) {
8
+ if (theme) {
9
+ document.documentElement.dataset.theme = theme;
10
+ } else {
11
+ delete document.documentElement.dataset.theme;
12
+ }
13
+ }
14
+
15
+ function currentIndex() {
16
+ const current = document.documentElement.dataset.theme ?? '';
17
+ const idx = THEMES.indexOf(current);
18
+ return idx === -1 ? 0 : idx;
19
+ }
20
+
21
+ function updateButtons() {
22
+ const label = LABELS[currentIndex()];
23
+ document.querySelectorAll('.theme-picker').forEach((btn) => {
24
+ btn.textContent = label;
25
+ });
26
+ }
27
+
28
+ window.cycleTheme = function () {
29
+ const next = THEMES[(currentIndex() + 1) % THEMES.length];
30
+ applyTheme(next);
31
+ localStorage.setItem('rc-demo-theme', next);
32
+ updateButtons();
33
+ };
34
+
35
+ document.addEventListener('DOMContentLoaded', () => {
36
+ updateButtons();
37
+ document.querySelectorAll('.theme-picker').forEach((btn) => {
38
+ btn.addEventListener('click', window.cycleTheme);
39
+ });
40
+ });
@@ -0,0 +1,187 @@
1
+ import { css as m, LitElement as u, html as v } from "lit";
2
+ import { property as h, query as p } from "lit/decorators.js";
3
+ import { RovingTabIndexMixin as b, isFocusable as l, keyNavigation as f } from "@rcarls/rc-common";
4
+ const g = m`
5
+ :host {
6
+ display: block;
7
+ }
8
+
9
+ #root {
10
+ display: flex;
11
+ flex-direction: column;
12
+ min-width: var(--rc-menu-min-width, 10em);
13
+ padding-block: var(--rc-menu-padding-block, var(--rc-control-padding-block, 0.25em));
14
+ background: var(--rc-menu-background, var(--rc-surface, Canvas));
15
+ border: var(--rc-menu-border, var(--rc-border, 1px solid ButtonBorder));
16
+ border-radius: var(--rc-menu-radius, var(--rc-control-radius, 0));
17
+ box-shadow: var(--rc-menu-shadow, var(--rc-shadow, 0 2px 8px color-mix(in srgb, CanvasText 15%, transparent)));
18
+ color: var(--rc-menu-color, var(--rc-field-text, CanvasText));
19
+ font-family: var(--rc-font-family, inherit);
20
+ font-size: var(--rc-font-size, inherit);
21
+ line-height: var(--rc-line-height, normal);
22
+ overflow: hidden;
23
+
24
+ /* Focus styling for keyboard navigation */
25
+ &[data-interaction-mode='keyboard']:focus-within {
26
+ outline: var(--rc-focus-ring, auto);
27
+ outline-offset: var(--rc-focus-ring-offset, 0);
28
+ }
29
+ }
30
+
31
+ #slot-wrap {
32
+ display: contents;
33
+ }
34
+
35
+ ::slotted(button),
36
+ ::slotted([role='menuitem']),
37
+ ::slotted([role='menuitemradio']),
38
+ ::slotted([role='menuitemcheckbox']) {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: var(--rc-menu-item-gap, var(--rc-item-gap, 0.5em));
42
+ width: 100%;
43
+ text-align: start;
44
+ padding: var(--rc-menu-item-padding-block, var(--rc-item-padding-block, 0.3em))
45
+ var(--rc-menu-item-padding-inline, var(--rc-item-padding-inline, 0.75em));
46
+ }
47
+
48
+ ::slotted(svg),
49
+ ::slotted(img) {
50
+ flex: 0 0 auto;
51
+ inline-size: var(--rc-menu-icon-size, 1.2em);
52
+ block-size: var(--rc-menu-icon-size, 1.2em);
53
+ max-inline-size: var(--rc-menu-icon-size, 1.2em);
54
+ max-block-size: var(--rc-menu-icon-size, 1.2em);
55
+ }
56
+
57
+ ::slotted(button:disabled),
58
+ ::slotted([aria-disabled='true']) {
59
+ color: var(--rc-menu-disabled-color, var(--rc-text-disabled, GrayText));
60
+ opacity: var(--rc-menu-disabled-opacity, var(--rc-disabled-opacity, 0.55));
61
+ cursor: default;
62
+ }
63
+
64
+ /* Groups stack their children vertically and stretch them to full width. */
65
+ ::slotted([role='group']) {
66
+ display: flex;
67
+ flex-direction: column;
68
+ align-items: stretch;
69
+ }
70
+
71
+ ::slotted([role='separator']),
72
+ ::slotted(hr) {
73
+ border: none;
74
+ border-block-start: var(--rc-menu-separator-border, var(--rc-border, 1px solid ButtonBorder));
75
+ margin-block: var(--rc-menu-separator-margin-block, var(--rc-control-gap, 0.25em));
76
+ }
77
+ `;
78
+ var x = Object.defineProperty, d = (n, e, t, o) => {
79
+ for (var r = void 0, i = n.length - 1, c; i >= 0; i--)
80
+ (c = n[i]) && (r = c(e, t, r) || r);
81
+ return r && x(e, t, r), r;
82
+ };
83
+ const k = ["menuitem", "menuitemcheckbox", "menuitemradio"], s = class s extends b(u) {
84
+ constructor() {
85
+ super(...arguments), this.label = "";
86
+ }
87
+ /**
88
+ * Collects navigable menu items from the slot, supporting `role="group"`
89
+ * containers and skipping `role="separator"` elements.
90
+ *
91
+ * Direct focusable children are included as-is. Elements with
92
+ * `role="group"` are traversed one level deep so their focusable children
93
+ * participate in arrow-key navigation. Separators and non-focusable
94
+ * container elements are excluded.
95
+ */
96
+ _collectItems(e) {
97
+ const t = [];
98
+ for (const o of e.assignedElements()) {
99
+ const r = o.getAttribute("role");
100
+ if (r !== "separator") {
101
+ if (r === "group") {
102
+ for (const i of o.children)
103
+ l(i) && t.push(i);
104
+ continue;
105
+ }
106
+ l(o) && t.push(o);
107
+ }
108
+ }
109
+ return t;
110
+ }
111
+ _initItems() {
112
+ super._initItems(), this.items.forEach((e) => {
113
+ const t = e.getAttribute("role");
114
+ (!t || !k.includes(t)) && e.setAttribute("role", "menuitem");
115
+ });
116
+ }
117
+ _onNavigate(e) {
118
+ switch (e) {
119
+ case "next":
120
+ this.focusItem(this.nextItem);
121
+ break;
122
+ case "prev":
123
+ this.focusItem(this.previousItem);
124
+ break;
125
+ case "start":
126
+ this.focusItem(this.firstItem);
127
+ break;
128
+ case "end":
129
+ this.focusItem(this.lastItem);
130
+ break;
131
+ case "activate":
132
+ this._lastFocused && (this._lastFocused.click(), this.dispatchEvent(
133
+ new CustomEvent("rc-menu-activate", {
134
+ bubbles: !0,
135
+ composed: !0,
136
+ detail: {
137
+ item: this._lastFocused,
138
+ value: this._lastFocused.dataset.value ?? this._lastFocused.getAttribute("value") ?? "",
139
+ text: this._lastFocused.textContent?.trim() ?? ""
140
+ }
141
+ })
142
+ ));
143
+ break;
144
+ case "escape":
145
+ this.dispatchEvent(
146
+ new CustomEvent("rc-menu-close", {
147
+ bubbles: !0,
148
+ composed: !0,
149
+ detail: { reason: "escape" }
150
+ })
151
+ );
152
+ break;
153
+ }
154
+ }
155
+ render() {
156
+ return v`
157
+ <div
158
+ id="root"
159
+ part="root"
160
+ ${f(this._onNavigate, {
161
+ handleEscape: !0,
162
+ handleActivate: !0
163
+ })}
164
+ data-interaction-mode="keyboard"
165
+ role="menu"
166
+ aria-label=${this.label}
167
+ tabindex="-1"
168
+ >
169
+ <div id="slot-wrap" @focusin=${this._handleItemFocus}>
170
+ <slot id="items" @slotchange=${this._onSlotChange}></slot>
171
+ </div>
172
+ </div>
173
+ `;
174
+ }
175
+ };
176
+ s.styles = [g];
177
+ let a = s;
178
+ d([
179
+ h({ type: String })
180
+ ], a.prototype, "label");
181
+ d([
182
+ p("#root", !0)
183
+ ], a.prototype, "_$root");
184
+ export {
185
+ a as R
186
+ };
187
+ //# sourceMappingURL=rc-menu-Bd1Ytmx9.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rc-menu-Bd1Ytmx9.js","sources":["../src/rc-menu.styles.ts","../src/rc-menu.ts"],"sourcesContent":["import { css } from 'lit';\n\nexport const menuStyles = css`\n :host {\n display: block;\n }\n\n #root {\n display: flex;\n flex-direction: column;\n min-width: var(--rc-menu-min-width, 10em);\n padding-block: var(--rc-menu-padding-block, var(--rc-control-padding-block, 0.25em));\n background: var(--rc-menu-background, var(--rc-surface, Canvas));\n border: var(--rc-menu-border, var(--rc-border, 1px solid ButtonBorder));\n border-radius: var(--rc-menu-radius, var(--rc-control-radius, 0));\n box-shadow: var(--rc-menu-shadow, var(--rc-shadow, 0 2px 8px color-mix(in srgb, CanvasText 15%, transparent)));\n color: var(--rc-menu-color, var(--rc-field-text, CanvasText));\n font-family: var(--rc-font-family, inherit);\n font-size: var(--rc-font-size, inherit);\n line-height: var(--rc-line-height, normal);\n overflow: hidden;\n\n /* Focus styling for keyboard navigation */\n &[data-interaction-mode='keyboard']:focus-within {\n outline: var(--rc-focus-ring, auto);\n outline-offset: var(--rc-focus-ring-offset, 0);\n }\n }\n\n #slot-wrap {\n display: contents;\n }\n\n ::slotted(button),\n ::slotted([role='menuitem']),\n ::slotted([role='menuitemradio']),\n ::slotted([role='menuitemcheckbox']) {\n display: flex;\n align-items: center;\n gap: var(--rc-menu-item-gap, var(--rc-item-gap, 0.5em));\n width: 100%;\n text-align: start;\n padding: var(--rc-menu-item-padding-block, var(--rc-item-padding-block, 0.3em))\n var(--rc-menu-item-padding-inline, var(--rc-item-padding-inline, 0.75em));\n }\n\n ::slotted(svg),\n ::slotted(img) {\n flex: 0 0 auto;\n inline-size: var(--rc-menu-icon-size, 1.2em);\n block-size: var(--rc-menu-icon-size, 1.2em);\n max-inline-size: var(--rc-menu-icon-size, 1.2em);\n max-block-size: var(--rc-menu-icon-size, 1.2em);\n }\n\n ::slotted(button:disabled),\n ::slotted([aria-disabled='true']) {\n color: var(--rc-menu-disabled-color, var(--rc-text-disabled, GrayText));\n opacity: var(--rc-menu-disabled-opacity, var(--rc-disabled-opacity, 0.55));\n cursor: default;\n }\n\n /* Groups stack their children vertically and stretch them to full width. */\n ::slotted([role='group']) {\n display: flex;\n flex-direction: column;\n align-items: stretch;\n }\n\n ::slotted([role='separator']),\n ::slotted(hr) {\n border: none;\n border-block-start: var(--rc-menu-separator-border, var(--rc-border, 1px solid ButtonBorder));\n margin-block: var(--rc-menu-separator-margin-block, var(--rc-control-gap, 0.25em));\n }\n`;\n\nexport default menuStyles;\n","import { LitElement, html } from 'lit';\nimport { property, query } from 'lit/decorators.js';\n\nimport {\n isFocusable,\n keyNavigation,\n type FocusableElement,\n type KeyboardNavigationAction,\n RovingTabIndexMixin,\n} from '@rcarls/rc-common';\n\nimport menuStyles from './rc-menu.styles';\n\nexport interface RCMenuActivateEvent {\n item: HTMLElement;\n value: string;\n text: string;\n}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n 'rc-menu': RCMenu;\n }\n}\n\n/** WAI-ARIA roles that are valid direct children of `role=\"menu\"`. */\nconst MENU_ITEM_ROLES = ['menuitem', 'menuitemcheckbox', 'menuitemradio'] as const;\n\n/**\n * A menu component, as defined in WAI-ARIA\n *\n * @see https://www.w3.org/WAI/ARIA/apg/patterns/menu/\n * @slot Takes any number of child elements to display in the menu. Only focusable elements are navigable.\n * @fires rc-menu-activate - Fired when a menu item is activated via Enter or Space\n * @fires rc-menu-close - Fired when Escape is pressed\n * @cssprop [--rc-menu-min-width=10em] - Minimum width of the menu panel\n * @cssprop [--rc-menu-padding-block=0.25em] - Block (top/bottom) padding inside the panel\n * @cssprop [--rc-menu-background=Canvas] - Background color; falls back through --rc-surface\n * @cssprop [--rc-menu-border=1px solid ButtonBorder] - Border; falls back through --rc-border\n * @cssprop [--rc-menu-radius=var(--rc-control-radius)] - Menu panel border radius\n * @cssprop [--rc-menu-shadow=0 2px 8px color-mix(in srgb, CanvasText 15%, transparent)] - Box shadow; falls back through --rc-shadow\n * @cssprop [--rc-menu-item-padding-block=var(--rc-item-padding-block)] - Menu item block-axis padding\n * @cssprop [--rc-menu-item-padding-inline=var(--rc-item-padding-inline)] - Menu item inline-axis padding\n * @cssprop [--rc-menu-item-gap=var(--rc-item-gap)] - Gap between icon and text content in flex menu items\n * @cssprop [--rc-menu-icon-size=1.2em] - Size clamp for direct slotted icon media\n * @cssprop [--rc-menu-disabled-color=GrayText] - Disabled menu item text color\n * @cssprop [--rc-menu-disabled-opacity=var(--rc-disabled-opacity)] - Disabled menu item opacity\n * @cssprop [--rc-menu-separator-border=var(--rc-border)] - Separator border\n * @cssprop [--rc-menu-separator-margin-block=var(--rc-control-gap)] - Separator block-axis margin\n * @csspart root - The root container element\n */\nexport class RCMenu extends RovingTabIndexMixin(LitElement) {\n static styles = [menuStyles];\n\n /** Accessible label for this menu. */\n @property({ type: String })\n label = '';\n\n @query('#root', true)\n protected _$root!: HTMLDivElement;\n\n /**\n * Collects navigable menu items from the slot, supporting `role=\"group\"`\n * containers and skipping `role=\"separator\"` elements.\n *\n * Direct focusable children are included as-is. Elements with\n * `role=\"group\"` are traversed one level deep so their focusable children\n * participate in arrow-key navigation. Separators and non-focusable\n * container elements are excluded.\n */\n protected override _collectItems(slot: HTMLSlotElement): FocusableElement[] {\n const items: FocusableElement[] = [];\n\n for (const el of slot.assignedElements()) {\n const role = el.getAttribute('role');\n\n if (role === 'separator') continue;\n\n if (role === 'group') {\n for (const child of el.children) {\n if (isFocusable(child)) items.push(child as FocusableElement);\n }\n continue;\n }\n\n if (isFocusable(el)) items.push(el as FocusableElement);\n }\n\n return items;\n }\n\n protected override _initItems() {\n super._initItems(); // sets tabindex 0/−1\n\n this.items.forEach((el) => {\n const existingRole = el.getAttribute('role');\n\n // Preserve explicit WAI-ARIA menu item roles set by the consumer.\n // Only assign the menuitem default when no valid role is present.\n if (!existingRole || !MENU_ITEM_ROLES.includes(existingRole as typeof MENU_ITEM_ROLES[number])) {\n el.setAttribute('role', 'menuitem');\n }\n });\n }\n\n protected _onNavigate(action: KeyboardNavigationAction) {\n switch (action) {\n case 'next':\n this.focusItem(this.nextItem);\n break;\n case 'prev':\n this.focusItem(this.previousItem);\n break;\n case 'start':\n this.focusItem(this.firstItem);\n break;\n case 'end':\n this.focusItem(this.lastItem);\n break;\n case 'activate':\n if (this._lastFocused) {\n this._lastFocused.click();\n this.dispatchEvent(\n new CustomEvent<RCMenuActivateEvent>('rc-menu-activate', {\n bubbles: true,\n composed: true,\n detail: {\n item: this._lastFocused,\n value: this._lastFocused.dataset.value ?? this._lastFocused.getAttribute('value') ?? '',\n text: this._lastFocused.textContent?.trim() ?? '',\n },\n }),\n );\n }\n break;\n case 'escape':\n this.dispatchEvent(\n new CustomEvent('rc-menu-close', {\n bubbles: true,\n composed: true,\n detail: { reason: 'escape' },\n }),\n );\n break;\n }\n }\n\n protected render() {\n return html`\n <div\n id=\"root\"\n part=\"root\"\n ${keyNavigation(this._onNavigate, {\n handleEscape: true,\n handleActivate: true,\n })}\n data-interaction-mode=\"keyboard\"\n role=\"menu\"\n aria-label=${this.label}\n tabindex=\"-1\"\n >\n <div id=\"slot-wrap\" @focusin=${this._handleItemFocus}>\n <slot id=\"items\" @slotchange=${this._onSlotChange}></slot>\n </div>\n </div>\n `;\n }\n}\n\nexport default RCMenu;\n"],"names":["menuStyles","css","MENU_ITEM_ROLES","_RCMenu","RovingTabIndexMixin","LitElement","slot","items","el","role","child","isFocusable","existingRole","action","html","keyNavigation","RCMenu","__decorateClass","property","query"],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import { R as e } from "./rc-menu-Bd1Ytmx9.js";
2
+ customElements.get("rc-menu") || customElements.define("rc-menu", e);
3
+ export {
4
+ e as RCMenu
5
+ };
6
+ //# sourceMappingURL=rc-menu-define.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rc-menu-define.js","sources":["../src/define.ts"],"sourcesContent":["import { RCMenu } from './index.js';\n\ncustomElements.get('rc-menu') || customElements.define('rc-menu', RCMenu);\n\nexport * from './index.js';\n"],"names":["RCMenu"],"mappings":";AAEA,eAAe,IAAI,SAAS,KAAK,eAAe,OAAO,WAAWA,CAAM;"}
@@ -0,0 +1,5 @@
1
+ import { R as r } from "./rc-menu-Bd1Ytmx9.js";
2
+ export {
3
+ r as RCMenu
4
+ };
5
+ //# sourceMappingURL=rc-menu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rc-menu.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
@@ -0,0 +1 @@
1
+ export * from './index.js';
@@ -0,0 +1 @@
1
+ export * from './rc-menu';
@@ -0,0 +1,56 @@
1
+ import { LitElement } from 'lit';
2
+ import { FocusableElement, KeyboardNavigationAction } from '@rcarls/rc-common';
3
+ export interface RCMenuActivateEvent {
4
+ item: HTMLElement;
5
+ value: string;
6
+ text: string;
7
+ }
8
+ declare global {
9
+ interface HTMLElementTagNameMap {
10
+ 'rc-menu': RCMenu;
11
+ }
12
+ }
13
+ declare const RCMenu_base: typeof LitElement & (new (...args: any[]) => import('@rcarls/rc-common').RovingTabIndexMixinBase);
14
+ /**
15
+ * A menu component, as defined in WAI-ARIA
16
+ *
17
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/menu/
18
+ * @slot Takes any number of child elements to display in the menu. Only focusable elements are navigable.
19
+ * @fires rc-menu-activate - Fired when a menu item is activated via Enter or Space
20
+ * @fires rc-menu-close - Fired when Escape is pressed
21
+ * @cssprop [--rc-menu-min-width=10em] - Minimum width of the menu panel
22
+ * @cssprop [--rc-menu-padding-block=0.25em] - Block (top/bottom) padding inside the panel
23
+ * @cssprop [--rc-menu-background=Canvas] - Background color; falls back through --rc-surface
24
+ * @cssprop [--rc-menu-border=1px solid ButtonBorder] - Border; falls back through --rc-border
25
+ * @cssprop [--rc-menu-radius=var(--rc-control-radius)] - Menu panel border radius
26
+ * @cssprop [--rc-menu-shadow=0 2px 8px color-mix(in srgb, CanvasText 15%, transparent)] - Box shadow; falls back through --rc-shadow
27
+ * @cssprop [--rc-menu-item-padding-block=var(--rc-item-padding-block)] - Menu item block-axis padding
28
+ * @cssprop [--rc-menu-item-padding-inline=var(--rc-item-padding-inline)] - Menu item inline-axis padding
29
+ * @cssprop [--rc-menu-item-gap=var(--rc-item-gap)] - Gap between icon and text content in flex menu items
30
+ * @cssprop [--rc-menu-icon-size=1.2em] - Size clamp for direct slotted icon media
31
+ * @cssprop [--rc-menu-disabled-color=GrayText] - Disabled menu item text color
32
+ * @cssprop [--rc-menu-disabled-opacity=var(--rc-disabled-opacity)] - Disabled menu item opacity
33
+ * @cssprop [--rc-menu-separator-border=var(--rc-border)] - Separator border
34
+ * @cssprop [--rc-menu-separator-margin-block=var(--rc-control-gap)] - Separator block-axis margin
35
+ * @csspart root - The root container element
36
+ */
37
+ export declare class RCMenu extends RCMenu_base {
38
+ static styles: import('lit').CSSResult[];
39
+ /** Accessible label for this menu. */
40
+ label: string;
41
+ protected _$root: HTMLDivElement;
42
+ /**
43
+ * Collects navigable menu items from the slot, supporting `role="group"`
44
+ * containers and skipping `role="separator"` elements.
45
+ *
46
+ * Direct focusable children are included as-is. Elements with
47
+ * `role="group"` are traversed one level deep so their focusable children
48
+ * participate in arrow-key navigation. Separators and non-focusable
49
+ * container elements are excluded.
50
+ */
51
+ protected _collectItems(slot: HTMLSlotElement): FocusableElement[];
52
+ protected _initItems(): void;
53
+ protected _onNavigate(action: KeyboardNavigationAction): void;
54
+ protected render(): import('lit').TemplateResult<1>;
55
+ }
56
+ export default RCMenu;
@@ -0,0 +1,2 @@
1
+ export declare const menuStyles: import('lit').CSSResult;
2
+ export default menuStyles;
File without changes
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@rcarls/rc-menu",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.1.0",
7
+ "description": "Headless WAI-ARIA menu popup web component built with Lit",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/richardcarls/rc-webcomponents.git",
11
+ "directory": "packages/rc-menu"
12
+ },
13
+ "homepage": "https://github.com/richardcarls/rc-webcomponents#readme",
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "module": "./dist/rc-menu.js",
20
+ "exports": {
21
+ ".": {
22
+ "import": {
23
+ "types": "./dist/types/index.d.ts",
24
+ "default": "./dist/rc-menu.js"
25
+ }
26
+ },
27
+ "./define": {
28
+ "import": {
29
+ "types": "./dist/types/define.d.ts",
30
+ "default": "./dist/rc-menu-define.js"
31
+ }
32
+ }
33
+ },
34
+ "types": "./dist/types/index.d.ts",
35
+ "sideEffects": [
36
+ "./dist/rc-menu-define.js"
37
+ ],
38
+ "customElements": "dist/custom-elements.json",
39
+ "scripts": {
40
+ "dev": "vite",
41
+ "build": "tsc && vite build && cem analyze",
42
+ "cem:analyze": "cem analyze",
43
+ "preview": "vite preview",
44
+ "test:browser": "vitest",
45
+ "test:browser:chrome": "vitest --project=chromium",
46
+ "test:browser:firefox": "vitest --project=firefox",
47
+ "yalc:publish": "yalc publish --push"
48
+ },
49
+ "dependencies": {
50
+ "@rcarls/rc-common": "workspace:^"
51
+ },
52
+ "devDependencies": {
53
+ "@custom-elements-manifest/analyzer": "0.11.0",
54
+ "@guanghechen/rollup-plugin-copy": "^6.0.9",
55
+ "@vitest/browser-playwright": "4.1.5",
56
+ "lit": "^3.0.0",
57
+ "playwright": "^1.56.0",
58
+ "rollup": "^4.60.2",
59
+ "typescript": "~5.9.3",
60
+ "vite": "^7.1.7",
61
+ "vite-plugin-dts": "^4.5.4",
62
+ "vitest": "^4.0.6",
63
+ "vitest-browser-lit": "^1.0.1"
64
+ },
65
+ "peerDependencies": {
66
+ "lit": "^3.0.0"
67
+ }
68
+ }