@raylang/elements 0.8.0 → 0.10.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.
@@ -400,6 +400,7 @@ export class RayCode extends RayElement {
400
400
  lineNumbers: { type: Boolean, reflect: true },
401
401
  highlight: { type: String },
402
402
  wrap: { type: Boolean, reflect: true },
403
+ verbatim: { type: Boolean, reflect: true },
403
404
  maxHeight: { type: String },
404
405
  expanded: { type: Boolean, reflect: true },
405
406
  hideCopy: { type: Boolean, reflect: true },
@@ -450,20 +451,22 @@ export class RayCode extends RayElement {
450
451
  this.#observer.disconnect();
451
452
  }
452
453
 
453
- /** The code shown (dedented), whatever its source. */
454
+ /** The code shown (dedented unless verbatim), whatever its source. */
454
455
  get source() {
455
456
  return this.#source;
456
457
  }
457
458
 
458
459
  #readSource() {
459
- if (typeof this.code === "string") return dedent(this.code);
460
+ // verbatim: data (a response body, a log) is shown and copied as it came.
461
+ const shape = this.verbatim ? String : dedent;
462
+ if (typeof this.code === "string") return shape(this.code);
460
463
  // A <template> is parsed as HTML: perfect for markup; its serialization
461
464
  // escapes &, < and > in text, undone here.
462
465
  const tpl = this.querySelector(":scope > template");
463
- if (tpl) return dedent(tpl.innerHTML.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, "\u00a0").replace(/&amp;/g, "&"));
466
+ if (tpl) return shape(tpl.innerHTML.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, "\u00a0").replace(/&amp;/g, "&"));
464
467
  const script = this.querySelector(':scope > script[type="text/plain"]');
465
- if (script) return dedent(script.textContent);
466
- return dedent(this.textContent);
468
+ if (script) return shape(script.textContent);
469
+ return shape(this.textContent);
467
470
  }
468
471
 
469
472
  update() {
@@ -86,6 +86,7 @@ const styles = css`
86
86
  .icon { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 1.25rem; height: 1.25rem; color: var(--ray-muted-foreground); }
87
87
  .option[aria-selected="true"] .icon { color: inherit; }
88
88
  .icon svg { width: 1rem; height: 1rem; }
89
+ .prefix { flex: none; display: inline-flex; align-items: center; font-size: var(--ray-font-size-xs); color: var(--ray-muted-foreground); }
89
90
  .text { display: flex; flex: 1; flex-direction: column; min-width: 0; }
90
91
  .label, .description { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
91
92
  .label { font-size: var(--ray-font-size-sm); }
@@ -187,6 +188,9 @@ export class RayCommandPalette extends RayElement {
187
188
  emptyLabel: { type: String, default: "Nothing found" },
188
189
  label: { type: String, default: "Command palette" },
189
190
  hideHint: { type: Boolean, reflect: true },
191
+ navigateLabel: { type: String, default: "navigate" },
192
+ runLabel: { type: String, default: "run" },
193
+ closeLabel: { type: String, default: "close" },
190
194
  overlayBlur: { type: String },
191
195
  nativeMenus: { type: Boolean, attribute: "native-menus", reflect: true },
192
196
  };
@@ -196,7 +200,7 @@ export class RayCommandPalette extends RayElement {
196
200
  <div class="search" part="search">${SEARCH_ICON}<input id="input" part="input" role="combobox" aria-expanded="true" aria-controls="list" aria-autocomplete="list" autocomplete="off" spellcheck="false"></div>
197
201
  <div class="list" id="list" role="listbox" part="list"></div>
198
202
  <div class="empty" part="empty" hidden></div>
199
- <div class="hint" part="hint"><span><b>↑↓</b> navigate</span><span><b>↵</b> run</span><span><b>esc</b> close</span></div>
203
+ <div class="hint" part="hint"><span><b>↑↓</b> <span id="hint-navigate"></span></span><span><b>↵</b> <span id="hint-run"></span></span><span><b>esc</b> <span id="hint-close"></span></span></div>
200
204
  <slot name="overlay"></slot>
201
205
  </dialog>`;
202
206
 
@@ -281,6 +285,9 @@ export class RayCommandPalette extends RayElement {
281
285
  this.dialog.setAttribute("aria-label", this.label ?? "Command palette");
282
286
  if (this.input.value !== (this.query ?? "")) this.input.value = this.query ?? "";
283
287
  this.$(".hint").hidden = Boolean(this.hideHint);
288
+ this.$("#hint-navigate").textContent = this.navigateLabel ?? "";
289
+ this.$("#hint-run").textContent = this.runLabel ?? "";
290
+ this.$("#hint-close").textContent = this.closeLabel ?? "";
284
291
  this._render();
285
292
  if (changed.has("open") || this._first === undefined) {
286
293
  this._first = false;
@@ -399,6 +406,14 @@ export class RayCommandPalette extends RayElement {
399
406
  else icon.textContent = String(a.icon);
400
407
  opt.append(icon);
401
408
  }
409
+ // Content of its own before the text, any width: a colored method tag.
410
+ if (a.prefix !== undefined && a.prefix !== null && a.prefix !== "") {
411
+ const prefix = document.createElement("span");
412
+ prefix.className = "prefix";
413
+ if (a.prefix instanceof Node) prefix.append(a.prefix);
414
+ else prefix.textContent = String(a.prefix);
415
+ opt.append(prefix);
416
+ }
402
417
  const text = document.createElement("span");
403
418
  text.className = "text";
404
419
  const label = document.createElement("span");
@@ -0,0 +1,255 @@
1
+ // <ray-doc-tabs> + <ray-doc-tab>: the tabs of open documents (an editor's,
2
+ // a browser's), each closable and with an unsaved mark, all over one shared
3
+ // panel (WAI-ARIA tabs: `panel` is that element's id).
4
+ //
5
+ // Closing is the app's: the × (for the pointer only: a tab holds no
6
+ // focusable control), a middle click, or Delete/Backspace on the focused tab
7
+ // fire `ray-request-close` ({ value, tab, source }); the app asks if it must
8
+ // and removes the tab. When the selected tab goes, the one that takes its
9
+ // place is selected (`change`), and focused if the closed tab had the focus.
10
+ //
11
+ // Too many tabs for the width: the bar scrolls sideways like ray-tabs.
12
+
13
+ import { RayElement, css, define, uid } from "../../core/core.js";
14
+ import { inlineKey } from "../../core/direction.js";
15
+ import { SideScroller, sideScrollerStyles } from "../../core/scroll.js";
16
+
17
+ const barStyles = css`
18
+ :host { display: flex; align-items: center; gap: var(--ray-space-1); min-width: 0; }
19
+ .list { display: flex; flex: 0 1 auto; align-items: center; gap: 2px; min-width: 0; overflow-x: auto; scrollbar-width: none; }
20
+ .list::-webkit-scrollbar { display: none; }
21
+ .list ::slotted(ray-doc-tab) { flex: none; }
22
+ .end { display: inline-flex; flex: none; align-items: center; }
23
+ .end:not([data-filled]) { display: none; }
24
+ `;
25
+
26
+ export class RayDocTabs extends RayElement {
27
+ static properties = {
28
+ value: { type: String },
29
+ panel: { type: String },
30
+ label: { type: String },
31
+ closeLabel: { type: String, default: "Close" },
32
+ dirtyLabel: { type: String, default: "Unsaved changes" },
33
+ };
34
+ static styles = [sideScrollerStyles, barStyles];
35
+ static template = `<div class="list" part="list" role="tablist"><slot></slot></div><span class="end" part="end"><slot name="end"></slot></span>`;
36
+
37
+ constructor() {
38
+ super();
39
+ this._order = [];
40
+ this._focused = null;
41
+ this.addEventListener("click", (e) => {
42
+ const tab = this._tabOf(e);
43
+ if (!tab) return;
44
+ if (e.composedPath().some((n) => n.part?.contains?.("close"))) this._requestClose(tab, "button");
45
+ else this._select(tab, true);
46
+ });
47
+ // The middle button closes, without the browser's autoscroll.
48
+ this.addEventListener("mousedown", (e) => {
49
+ if (e.button === 1 && this._tabOf(e)) e.preventDefault();
50
+ });
51
+ this.addEventListener("auxclick", (e) => {
52
+ const tab = this._tabOf(e);
53
+ if (tab && e.button === 1) this._requestClose(tab, "middle");
54
+ });
55
+ this.addEventListener("keydown", (e) => this._key(e));
56
+ // Which tab has the focus, kept while it is removed (no focusout then).
57
+ this.addEventListener("focusin", (e) => {
58
+ this._focused = e.target.closest?.("ray-doc-tab") ?? null;
59
+ });
60
+ this.addEventListener("focusout", (e) => {
61
+ if (e.relatedTarget) this._focused = null;
62
+ });
63
+ const end = this.$('slot[name="end"]');
64
+ end.addEventListener("slotchange", () => end.parentElement.toggleAttribute("data-filled", end.assignedNodes().length > 0));
65
+ this.$(".list slot").addEventListener("slotchange", () => this.requestUpdate());
66
+ this._scroller = new SideScroller(this, this.$(".list"), { onResize: () => this.requestUpdate() });
67
+ }
68
+
69
+ connectedCallback() {
70
+ super.connectedCallback();
71
+ this._scroller.connect();
72
+ }
73
+
74
+ disconnectedCallback() {
75
+ super.disconnectedCallback();
76
+ this._scroller.disconnect();
77
+ }
78
+
79
+ get tabs() {
80
+ return [...this.querySelectorAll("ray-doc-tab")].filter((t) => t.closest("ray-doc-tabs") === this);
81
+ }
82
+
83
+ /** The shared panel (`panel` is its id), or null. */
84
+ get panelElement() {
85
+ return this.panel ? this.getRootNode().getElementById?.(this.panel) ?? null : null;
86
+ }
87
+
88
+ update() {
89
+ const tabs = this.tabs;
90
+ if (!tabs.some((t) => t.value === this.value)) this._replaceSelected(tabs);
91
+ const list = this.$(".list");
92
+ if (this.label) list.setAttribute("aria-label", this.label);
93
+ else list.removeAttribute("aria-label");
94
+ const panel = this.panelElement;
95
+ for (const tab of tabs) {
96
+ const active = tab.value === this.value;
97
+ tab.id ||= uid("doc-tab");
98
+ tab.setAttribute("role", "tab");
99
+ tab.setAttribute("aria-selected", String(active));
100
+ tab.tabIndex = active ? 0 : -1;
101
+ tab.toggleAttribute("data-active", active);
102
+ if (panel) tab.setAttribute("aria-controls", panel.id);
103
+ else tab.removeAttribute("aria-controls");
104
+ tab.requestUpdate();
105
+ }
106
+ if (panel) {
107
+ panel.setAttribute("role", "tabpanel");
108
+ const active = tabs.find((t) => t.value === this.value);
109
+ if (active) panel.setAttribute("aria-labelledby", active.id);
110
+ else panel.removeAttribute("aria-labelledby");
111
+ }
112
+ this._order = tabs;
113
+ if (this.value !== this._revealed && this._scroller.reveal(tabs.find((t) => t.value === this.value))) {
114
+ this._revealed = this.value;
115
+ }
116
+ this._scroller.update();
117
+ }
118
+
119
+ // The selected tab is gone (or none was chosen yet): the one at its place
120
+ // takes over, else the one before it; the first when nothing was selected.
121
+ _replaceSelected(tabs) {
122
+ const before = this._order.findIndex((t) => t.value === this.value);
123
+ const next = before < 0 ? tabs[0] : tabs[Math.min(before, tabs.length - 1)];
124
+ const closed = before >= 0 ? this._order[before] : null;
125
+ if (!next) {
126
+ if (this.value !== undefined && before >= 0) {
127
+ this.commitProperty("value", undefined);
128
+ this.dispatchEvent(new Event("change", { bubbles: true }));
129
+ }
130
+ return;
131
+ }
132
+ if (this.value === undefined || before < 0) {
133
+ this.commitProperty("value", next.value);
134
+ return;
135
+ }
136
+ this.commitProperty("value", next.value);
137
+ this.dispatchEvent(new Event("change", { bubbles: true }));
138
+ if (closed && closed === this._focused) {
139
+ this._focused = next;
140
+ queueMicrotask(() => next.focus());
141
+ }
142
+ }
143
+
144
+ _tabOf(e) {
145
+ const tab = e.target.closest?.("ray-doc-tab");
146
+ return tab && tab.closest("ray-doc-tabs") === this ? tab : null;
147
+ }
148
+
149
+ _select(tab, user) {
150
+ const changed = tab.value !== this.value;
151
+ this.value = tab.value;
152
+ if (user && changed) this.dispatchEvent(new Event("change", { bubbles: true }));
153
+ }
154
+
155
+ _requestClose(tab, source) {
156
+ if (tab.hideClose) return;
157
+ this.emit("ray-request-close", { value: tab.value, tab, source });
158
+ }
159
+
160
+ _key(e) {
161
+ const tab = this._tabOf(e);
162
+ if (!tab || e.target !== tab) return;
163
+ const tabs = this.tabs;
164
+ const i = tabs.indexOf(tab);
165
+ // Right to left, ArrowLeft is the next tab.
166
+ const key = inlineKey(e.key, this);
167
+ let target = null;
168
+ if (key === "ArrowRight") target = tabs[(i + 1) % tabs.length];
169
+ else if (key === "ArrowLeft") target = tabs[(i - 1 + tabs.length) % tabs.length];
170
+ else if (e.key === "Home") target = tabs[0];
171
+ else if (e.key === "End") target = tabs[tabs.length - 1];
172
+ else if (e.key === "Delete" || e.key === "Backspace") {
173
+ e.preventDefault();
174
+ this._requestClose(tab, "key");
175
+ return;
176
+ }
177
+ if (!target) return;
178
+ e.preventDefault();
179
+ target.focus();
180
+ this._select(target, true);
181
+ }
182
+ }
183
+
184
+ const tabStyles = css`
185
+ :host {
186
+ display: inline-flex;
187
+ align-items: center;
188
+ gap: var(--ray-space-1);
189
+ box-sizing: border-box;
190
+ max-width: 14rem;
191
+ height: var(--ray-control-height-xs);
192
+ padding-inline: var(--ray-space-2) var(--ray-space-1);
193
+ font-size: var(--ray-font-size-sm);
194
+ color: var(--ray-muted-foreground);
195
+ white-space: nowrap;
196
+ border: 1px solid transparent;
197
+ border-radius: var(--ray-radius-sm);
198
+ cursor: default;
199
+ user-select: none;
200
+ outline: none;
201
+ transition: color var(--ray-duration-fast) var(--ray-easing-standard), background-color var(--ray-duration-fast) var(--ray-easing-standard);
202
+ }
203
+ :host(:hover) { color: var(--ray-foreground); background: var(--ray-muted); }
204
+ :host([data-active]) { color: var(--ray-foreground); background: var(--ray-card); border-color: var(--ray-border); }
205
+ :host(:focus-visible) { outline: var(--ray-focus-ring-width) solid var(--ray-ring); outline-offset: -2px; }
206
+ .section { display: inline-flex; flex: none; align-items: center; }
207
+ .section:not([data-filled]) { display: none; }
208
+ ::slotted(svg) { width: 1rem; height: 1rem; }
209
+ .label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
210
+
211
+ /* The end of the tab: the × (on hover and on the selected tab), or the
212
+ unsaved dot, which turns into the × under the pointer. */
213
+ .mark { position: relative; display: inline-flex; flex: none; align-items: center; justify-content: center; width: 1.125rem; height: 1.125rem; }
214
+ .close { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--ray-muted-foreground); border-radius: var(--ray-radius-xs); visibility: hidden; }
215
+ .close svg { width: 0.75rem; height: 0.75rem; }
216
+ .close:hover { color: var(--ray-foreground); background: var(--ray-accent); }
217
+ :host(:hover) .close, :host([data-active]) .close { visibility: visible; }
218
+ .dot { position: absolute; display: none; width: 0.4375rem; height: 0.4375rem; background: var(--ray-primary); border-radius: 50%; }
219
+ :host([dirty]) .dot { display: block; }
220
+ :host([dirty]) .close { visibility: hidden; }
221
+ :host([dirty]) .mark:hover .close { visibility: visible; }
222
+ :host([dirty]) .mark:hover .dot { display: none; }
223
+ :host([hide-close]) .close { display: none; }
224
+ :host([hide-close]:not([dirty])) .mark { display: none; }
225
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
226
+ `;
227
+
228
+ export class RayDocTab extends RayElement {
229
+ static properties = {
230
+ value: { type: String, reflect: true },
231
+ dirty: { type: Boolean, reflect: true },
232
+ hideClose: { type: Boolean, reflect: true },
233
+ };
234
+ static styles = [tabStyles];
235
+ static template = `<span class="section" part="left-section"><slot name="left"></slot></span><span class="label" part="label"><slot></slot></span><span class="mark" aria-hidden="true"><span class="dot" part="dot"></span><span class="close" part="close" data-ray-icon="ui.close"></span></span><span class="sr"></span>`;
236
+
237
+ constructor() {
238
+ super();
239
+ const slot = this.$('slot[name="left"]');
240
+ slot.addEventListener("slotchange", () => slot.parentElement.toggleAttribute("data-filled", slot.assignedNodes().length > 0));
241
+ }
242
+
243
+ update(changed) {
244
+ const bar = this.closest("ray-doc-tabs");
245
+ this.$(".close").title = bar?.closeLabel ?? "Close";
246
+ // The unsaved mark is part of the tab's name for assistive technology.
247
+ this.$(".sr").textContent = this.dirty ? `, ${bar?.dirtyLabel ?? "Unsaved changes"}` : "";
248
+ if (this.hideClose) this.removeAttribute("aria-keyshortcuts");
249
+ else this.setAttribute("aria-keyshortcuts", "Delete");
250
+ if (changed.has("value")) bar?.requestUpdate();
251
+ }
252
+ }
253
+
254
+ define("ray-doc-tabs", RayDocTabs);
255
+ define("ray-doc-tab", RayDocTab);
@@ -5,6 +5,10 @@ import { css, define } from "../../core/core.js";
5
5
  import { RayButton } from "../button/button.js";
6
6
 
7
7
  const styles = css`
8
+ /* The square on the host too: the inner button's flex: 1 (a 0 basis) gives
9
+ it no width of its own to an auto grid track or a shrink-to-fit box. A
10
+ width set on the element from outside still wins. */
11
+ :host { width: var(--_h); }
8
12
  .button { width: var(--_h); padding: 0; }
9
13
  .label { font-size: calc(var(--_fs) * 1.15); }
10
14
  ::slotted(svg) { width: 1.15em; height: 1.15em; }
@@ -4,6 +4,8 @@
4
4
  import { css, define } from "../../core/core.js";
5
5
  import { sizeStyles, radiusStyles } from "../../core/styles.js";
6
6
  import { FieldControl, fieldStyles, boxStyles, fieldTemplate } from "../../core/form.js";
7
+ import { syncHighlight, textLayerStyles } from "../../core/textlayer.js";
8
+ import { syncComplete, completeStyles } from "../../core/complete.js";
7
9
 
8
10
  const styles = css`
9
11
  .control[type="search"]::-webkit-search-cancel-button { cursor: pointer; }
@@ -23,8 +25,11 @@ export class RayInput extends FieldControl {
23
25
  placeholder: { type: String },
24
26
  readonly: { type: Boolean, reflect: true },
25
27
  autofocus: { type: Boolean },
28
+ highlight: { attribute: false },
29
+ complete: { attribute: false },
30
+ suggestionsLabel: { type: String, default: "Suggestions" },
26
31
  };
27
- static styles = [sizeStyles, radiusStyles, fieldStyles, boxStyles, styles];
32
+ static styles = [sizeStyles, radiusStyles, fieldStyles, boxStyles, textLayerStyles, completeStyles, styles];
28
33
  static template = fieldTemplate(`<input id="control" class="control" part="input">`);
29
34
 
30
35
  static get observedAttributes() {
@@ -64,6 +69,13 @@ export class RayInput extends FieldControl {
64
69
  c.readOnly = Boolean(this.readonly);
65
70
  this.syncField();
66
71
  this._commit();
72
+ syncHighlight(this);
73
+ syncComplete(this);
74
+ }
75
+
76
+ /** Runs `highlight` again (what it marks changed, not the text). */
77
+ refreshHighlight() {
78
+ syncHighlight(this);
67
79
  }
68
80
 
69
81
  _commit() {
@@ -3,7 +3,11 @@
3
3
  // context menu opened at the pointer over the trigger area.
4
4
  //
5
5
  // Choosing an item fires `ray-select` ({ value, item }) on the menu and
6
- // closes it (unless keep-open).
6
+ // closes it (unless keep-open). An item of type="checkbox" toggles its
7
+ // `checked` first; one of type="radio" checks itself and unchecks the radio
8
+ // items of the menu with the same `name`. Before its trigger opens it, the
9
+ // menu fires a cancelable `ray-request-open` ({ event }): the place to fill
10
+ // items that depend on what was clicked, read only after it.
7
11
  //
8
12
  // With `native` and a native host that offers "popup_menu" (core/host.js; a
9
13
  // std/ui window with core/raylang.js), it opens as the system's context menu
@@ -64,6 +68,7 @@ export class RayMenu extends Floating {
64
68
  if (this.context) {
65
69
  t.addEventListener("contextmenu", (e) => {
66
70
  e.preventDefault();
71
+ if (!this._requestOpen(e)) return;
67
72
  if (this._native()) {
68
73
  this.popupNative();
69
74
  return;
@@ -84,12 +89,12 @@ export class RayMenu extends Floating {
84
89
  });
85
90
  return;
86
91
  }
87
- t.addEventListener("click", () => {
92
+ t.addEventListener("click", (e) => {
88
93
  if (this._native()) {
89
- this.popupNative();
94
+ if (this._requestOpen(e)) this.popupNative();
90
95
  return;
91
96
  }
92
- if (!this.open && this.justClosed()) return;
97
+ if (!this.open && (this.justClosed() || !this._requestOpen(e))) return;
93
98
  this._focusOnOpen = "first";
94
99
  this.toggle();
95
100
  });
@@ -98,11 +103,20 @@ export class RayMenu extends Floating {
98
103
  e.preventDefault();
99
104
  this._focusOnOpen = e.key === "ArrowDown" ? "first" : "last";
100
105
  if (this.open) this._focus(this._focusOnOpen);
101
- else this.open = true;
106
+ else if (this._requestOpen(e)) this.open = true;
102
107
  }
103
108
  });
104
109
  }
105
110
 
111
+ /**
112
+ * Fires ray-request-open before the trigger opens the menu; false when a
113
+ * listener cancelled it. It comes before the items are read: an app's own
114
+ * listener on the trigger may run after the menu's.
115
+ */
116
+ _requestOpen(event) {
117
+ return this.emit("ray-request-open", { event }, { cancelable: true });
118
+ }
119
+
106
120
  _native() {
107
121
  return Boolean(this.native) && hostCan("popup_menu");
108
122
  }
@@ -116,7 +130,7 @@ export class RayMenu extends Floating {
116
130
  const entries = [...this.querySelectorAll("ray-menu-item, ray-menu-divider")].filter((el) => el.closest("ray-menu") === this);
117
131
  const items = entries.map((el) => (el.localName === "ray-menu-divider"
118
132
  ? { separator: true }
119
- : { title: itemLabel(el), enabled: !el.disabled }));
133
+ : { title: itemLabel(el), enabled: !el.disabled, ...(el.checkable ? { checked: Boolean(el.checked) } : {}) }));
120
134
  const index = await getHost().popupMenu(items);
121
135
  const item = index === null || index === undefined ? null : entries[index];
122
136
  if (!item || item.localName !== "ray-menu-item") return null;
@@ -193,6 +207,13 @@ export class RayMenu extends Floating {
193
207
 
194
208
  _choose(item) {
195
209
  if (item.disabled) return;
210
+ if (item.type === "checkbox") item.checked = !item.checked;
211
+ else if (item.type === "radio") {
212
+ for (const other of this.items) {
213
+ if (other !== item && other.type === "radio" && (other.name ?? "") === (item.name ?? "")) other.checked = false;
214
+ }
215
+ item.checked = true;
216
+ }
196
217
  this.emit("ray-select", { value: item.value ?? item.textContent.trim(), item });
197
218
  if (!this.keepOpen) this.open = false;
198
219
  }
@@ -227,6 +248,10 @@ const itemStyles = css`
227
248
  :host([color]:hover), :host([color]:focus) { background: var(--_light); color: var(--_light-color); }
228
249
  :host([disabled]) { color: var(--ray-muted-foreground); background: transparent; cursor: not-allowed; opacity: 0.6; }
229
250
  .label { flex: 1; white-space: nowrap; }
251
+ /* A checkbox or radio item keeps a column for its mark, checked or not. */
252
+ .mark { display: none; flex: none; width: 1rem; height: 1rem; visibility: hidden; }
253
+ :host([type="checkbox"]) .mark, :host([type="radio"]) .mark { display: inline-flex; }
254
+ :host([checked]) .mark { visibility: visible; }
230
255
  .section { display: inline-flex; align-items: center; color: var(--ray-muted-foreground); }
231
256
  :host([color]) .section { color: inherit; }
232
257
  .section:not([data-filled]) { display: none; }
@@ -239,9 +264,12 @@ export class RayMenuItem extends RayElement {
239
264
  value: { type: String, reflect: true },
240
265
  disabled: { type: Boolean, reflect: true },
241
266
  color: { type: String, reflect: true },
267
+ type: { type: String, reflect: true, default: "item" },
268
+ checked: { type: Boolean, reflect: true },
269
+ name: { type: String, reflect: true },
242
270
  };
243
271
  static styles = [itemStyles];
244
- static template = `<span class="section left" part="left-section"><slot name="left"></slot></span><span class="label" part="label"><slot></slot></span><span class="section right" part="right-section"><slot name="right"></slot></span>`;
272
+ static template = `<span class="mark" part="mark" aria-hidden="true" data-ray-icon="ui.checked"></span><span class="section left" part="left-section"><slot name="left"></slot></span><span class="label" part="label"><slot></slot></span><span class="section right" part="right-section"><slot name="right"></slot></span>`;
245
273
 
246
274
  constructor() {
247
275
  super();
@@ -252,8 +280,15 @@ export class RayMenuItem extends RayElement {
252
280
  }
253
281
  }
254
282
 
283
+ /** Whether it is a checkbox or radio item. */
284
+ get checkable() {
285
+ return this.type === "checkbox" || this.type === "radio";
286
+ }
287
+
255
288
  update() {
256
289
  this.tabIndex = -1;
290
+ this.internals.role = this.type === "checkbox" ? "menuitemcheckbox" : this.type === "radio" ? "menuitemradio" : "menuitem";
291
+ this.internals.ariaChecked = this.checkable ? String(Boolean(this.checked)) : null;
257
292
  this.internals.ariaDisabled = this.disabled ? "true" : "false";
258
293
  if (this.color) applyColor(this, this.color);
259
294
  }