@raylang/elements 0.7.0 → 0.9.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 (58) hide show
  1. package/base.css +1 -1
  2. package/components/accordion/accordion.js +7 -4
  3. package/components/alert/alert.js +2 -1
  4. package/components/attachment/attachment.js +4 -5
  5. package/components/blockquote/blockquote.js +2 -2
  6. package/components/bottom-nav/bottom-nav.js +3 -3
  7. package/components/carousel/carousel.js +22 -10
  8. package/components/checkbox/checkbox.js +5 -2
  9. package/components/checkbox-group/checkbox-group.js +1 -1
  10. package/components/code/code.js +16 -11
  11. package/components/color-picker/color-picker.js +5 -5
  12. package/components/combobox/combobox.js +2 -1
  13. package/components/command-palette/command-palette.js +17 -2
  14. package/components/date-picker/date-picker.js +5 -3
  15. package/components/dialog/dialog.js +1 -1
  16. package/components/doc-tabs/doc-tabs.js +255 -0
  17. package/components/drawer/drawer.js +27 -9
  18. package/components/icon/icon.js +4 -1
  19. package/components/icon-button/icon-button.js +4 -0
  20. package/components/indicator/indicator.js +2 -1
  21. package/components/input/input.js +13 -1
  22. package/components/menu/menu.js +43 -8
  23. package/components/mode-input/mode-input.js +221 -0
  24. package/components/nav-link/nav-link.js +5 -2
  25. package/components/notification/notification.js +15 -12
  26. package/components/progress/progress.js +2 -2
  27. package/components/radio/radio.js +4 -2
  28. package/components/scroll-area/scroll-area.js +14 -11
  29. package/components/search/search.js +3 -3
  30. package/components/segmented-control/segmented-control.js +5 -3
  31. package/components/select/select.js +3 -3
  32. package/components/separator/separator.js +8 -3
  33. package/components/sidebar/sidebar.js +154 -11
  34. package/components/slider/slider.js +5 -2
  35. package/components/split/split.js +25 -7
  36. package/components/stepper/stepper.js +4 -4
  37. package/components/switch/switch.js +3 -3
  38. package/components/table/table.js +5 -5
  39. package/components/tabs/tabs.js +55 -14
  40. package/components/textarea/textarea.js +13 -1
  41. package/components/timeline/timeline.js +13 -8
  42. package/components/toggle-group/toggle-group.js +6 -4
  43. package/core/complete.js +252 -0
  44. package/core/core.js +6 -1
  45. package/core/direction.js +19 -0
  46. package/core/icons.js +12 -0
  47. package/core/position.js +11 -3
  48. package/core/scroll.js +85 -1
  49. package/core/styles.js +1 -1
  50. package/core/textlayer.js +214 -0
  51. package/core.js +1 -0
  52. package/custom-elements.json +1487 -143
  53. package/index.js +2 -0
  54. package/llms-full.txt +224 -28
  55. package/llms.txt +26 -4
  56. package/package.json +9 -1
  57. package/ray-elements.d.ts +190 -31
  58. package/vscode.html-custom-data.json +477 -22
@@ -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);
@@ -32,24 +32,41 @@ const styles = css`
32
32
  dialog[open] { display: flex; flex-direction: column; }
33
33
  dialog::backdrop { animation: ray-fade-in var(--ray-duration-base) var(--ray-easing-out); }
34
34
 
35
- :host(:not([position])) dialog, :host([position="right"]) dialog {
36
- inset: var(--_inset) var(--_inset) var(--_inset) auto;
35
+ /* position: start/end follow the writing direction, left/right are sides
36
+ of the screen. */
37
+ dialog {
38
+ inset-block: var(--_inset);
39
+ inset-inline: auto var(--_inset);
37
40
  width: min(var(--_size), calc(100vw - 2 * var(--_inset)));
38
41
  height: calc(100dvh - 2 * var(--_inset));
39
- border-left-width: 1px;
42
+ border-inline-start-width: 1px;
43
+ --_from: translateX(100%);
44
+ }
45
+ dialog:dir(rtl) { --_from: translateX(-100%); }
46
+ :host([position="start"]) dialog {
47
+ inset-inline: var(--_inset) auto;
48
+ border-inline-start-width: 0;
49
+ border-inline-end-width: 1px;
50
+ --_from: translateX(-100%);
51
+ }
52
+ :host([position="start"]) dialog:dir(rtl) { --_from: translateX(100%); }
53
+ :host([position="right"]) dialog {
54
+ inset: var(--_inset) var(--_inset) var(--_inset) auto; /* physical */
55
+ border-width: 0;
56
+ border-left-width: 1px; /* physical */
40
57
  --_from: translateX(100%);
41
58
  }
42
59
  :host([position="left"]) dialog {
43
- inset: var(--_inset) auto var(--_inset) var(--_inset);
44
- width: min(var(--_size), calc(100vw - 2 * var(--_inset)));
45
- height: calc(100dvh - 2 * var(--_inset));
46
- border-right-width: 1px;
60
+ inset: var(--_inset) auto var(--_inset) var(--_inset); /* physical */
61
+ border-width: 0;
62
+ border-right-width: 1px; /* physical */
47
63
  --_from: translateX(-100%);
48
64
  }
49
65
  :host([position="top"]) dialog {
50
66
  inset: var(--_inset) var(--_inset) auto var(--_inset);
51
67
  width: calc(100vw - 2 * var(--_inset));
52
68
  height: min(var(--_size), calc(100dvh - 2 * var(--_inset)));
69
+ border-width: 0;
53
70
  border-bottom-width: 1px;
54
71
  --_from: translateY(-100%);
55
72
  }
@@ -57,6 +74,7 @@ const styles = css`
57
74
  inset: auto var(--_inset) var(--_inset) var(--_inset);
58
75
  width: calc(100vw - 2 * var(--_inset));
59
76
  height: min(var(--_size), calc(100dvh - 2 * var(--_inset)));
77
+ border-width: 0;
60
78
  border-top-width: 1px;
61
79
  --_from: translateY(100%);
62
80
  }
@@ -83,7 +101,7 @@ const styles = css`
83
101
  justify-content: center;
84
102
  width: var(--ray-control-height-xs);
85
103
  height: var(--ray-control-height-xs);
86
- margin-left: auto;
104
+ margin-inline-start: auto;
87
105
  padding: 0;
88
106
  color: var(--ray-muted-foreground);
89
107
  background: transparent;
@@ -101,7 +119,7 @@ const styles = css`
101
119
  export class RayDrawer extends RayDialog {
102
120
  static properties = {
103
121
  ...RayDialog.properties,
104
- position: { type: String, reflect: true, default: "right" },
122
+ position: { type: String, reflect: true, default: "end" },
105
123
  size: { type: String, reflect: true, default: "md" },
106
124
  modeless: { type: Boolean, reflect: true },
107
125
  floating: { type: Boolean, reflect: true },
@@ -1,7 +1,8 @@
1
1
  // <ray-icon name="…">: an icon of the registry (setIcons), so the app's own
2
2
  // markup uses the same set as the components. Decorative unless `label`.
3
3
  // `filled` draws the icon's filled version (a state: active, selected,
4
- // favorite), or the outline one when it has none.
4
+ // favorite), or the outline one when it has none. `mirror` mirrors it right
5
+ // to left (a back arrow), as the roles that point along the text (ui.next…) are.
5
6
 
6
7
  import { RayElement, css, define } from "../../core/core.js";
7
8
 
@@ -13,6 +14,7 @@ const styles = css`
13
14
  size) must not reach the icon. size= overrides it inline. */
14
15
  :host { --_size: 1em; display: inline-flex; vertical-align: -0.125em; line-height: 1; }
15
16
  .icon svg { width: var(--_size); height: var(--_size); }
17
+ :host([mirror]) .icon:dir(rtl) > svg { transform: scaleX(-1); }
16
18
  `;
17
19
 
18
20
  export class RayIcon extends RayElement {
@@ -23,6 +25,7 @@ export class RayIcon extends RayElement {
23
25
  color: { type: String, reflect: true },
24
26
  stroke: { type: Number },
25
27
  filled: { type: Boolean, reflect: true },
28
+ mirror: { type: Boolean, reflect: true },
26
29
  };
27
30
  static styles = [styles];
28
31
  static template = `<span class="icon" part="icon" data-ray-icon=""></span>`;
@@ -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; }
@@ -21,7 +21,7 @@ const styles = css`
21
21
  position: absolute;
22
22
  z-index: 1;
23
23
  top: var(--_top);
24
- left: var(--_left);
24
+ inset-inline-start: var(--_left);
25
25
  display: inline-flex;
26
26
  align-items: center;
27
27
  justify-content: center;
@@ -34,6 +34,7 @@ const styles = css`
34
34
  transform: translate(-50%, -50%);
35
35
  pointer-events: none;
36
36
  }
37
+ .indicator:dir(rtl) { transform: translate(50%, -50%); }
37
38
  .indicator[data-label] {
38
39
  height: auto;
39
40
  min-height: var(--_size);
@@ -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,10 +248,14 @@ 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; }
233
- .right { margin-left: var(--ray-space-6); font-size: var(--ray-font-size-xs); }
258
+ .right { margin-inline-start: var(--ray-space-6); font-size: var(--ray-font-size-xs); }
234
259
  ::slotted(svg) { width: 1rem; height: 1rem; }
235
260
  `;
236
261
 
@@ -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
  }