@raylang/elements 0.0.0-stage → 0.4.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 (95) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +12 -2
  4. package/base.css +111 -0
  5. package/components/accordion/accordion.js +197 -0
  6. package/components/alert/alert.js +112 -0
  7. package/components/app-bar/app-bar.js +168 -0
  8. package/components/app-shell/app-shell.js +198 -0
  9. package/components/aspect-ratio/aspect-ratio.js +59 -0
  10. package/components/attachment/attachment.js +333 -0
  11. package/components/avatar/avatar.js +96 -0
  12. package/components/badge/badge.js +63 -0
  13. package/components/blockquote/blockquote.js +128 -0
  14. package/components/bottom-nav/bottom-nav.js +309 -0
  15. package/components/breadcrumbs/breadcrumbs.js +175 -0
  16. package/components/button/button.js +283 -0
  17. package/components/button-group/button-group.js +72 -0
  18. package/components/card/card.js +73 -0
  19. package/components/carousel/carousel.js +435 -0
  20. package/components/center/center.js +26 -0
  21. package/components/checkbox/checkbox.js +152 -0
  22. package/components/checkbox-group/checkbox-group.js +152 -0
  23. package/components/code/code.js +574 -0
  24. package/components/color-picker/color-picker.js +775 -0
  25. package/components/combobox/combobox.js +563 -0
  26. package/components/command-palette/command-palette.js +510 -0
  27. package/components/container/container.js +41 -0
  28. package/components/date-picker/date-picker.js +594 -0
  29. package/components/dialog/dialog.js +329 -0
  30. package/components/drawer/drawer.js +139 -0
  31. package/components/empty-state/empty-state.js +66 -0
  32. package/components/file-upload/file-upload.js +589 -0
  33. package/components/grid/grid.js +96 -0
  34. package/components/group/group.js +40 -0
  35. package/components/icon/icon.js +58 -0
  36. package/components/icon-button/icon-button.js +30 -0
  37. package/components/indicator/indicator.js +112 -0
  38. package/components/input/input.js +85 -0
  39. package/components/kbd/kbd.js +60 -0
  40. package/components/menu/menu.js +296 -0
  41. package/components/nav-link/nav-link.js +159 -0
  42. package/components/notification/notification.js +591 -0
  43. package/components/number-input/number-input.js +241 -0
  44. package/components/pagination/pagination.js +194 -0
  45. package/components/password-input/password-input.js +85 -0
  46. package/components/path-input/path-input.js +137 -0
  47. package/components/popover/popover.js +85 -0
  48. package/components/progress/progress.js +109 -0
  49. package/components/radio/radio.js +253 -0
  50. package/components/scroll-area/scroll-area.js +158 -0
  51. package/components/search/search.js +157 -0
  52. package/components/segmented-control/segmented-control.js +190 -0
  53. package/components/select/select.js +506 -0
  54. package/components/separator/separator.js +58 -0
  55. package/components/sidebar/sidebar.js +252 -0
  56. package/components/skeleton/skeleton.js +90 -0
  57. package/components/slider/slider.js +155 -0
  58. package/components/spinner/spinner.js +39 -0
  59. package/components/split/split.js +221 -0
  60. package/components/stack/stack.js +46 -0
  61. package/components/stat/stat.js +79 -0
  62. package/components/statusbar/statusbar.js +110 -0
  63. package/components/stepper/stepper.js +239 -0
  64. package/components/switch/switch.js +45 -0
  65. package/components/table/table.js +328 -0
  66. package/components/tabs/tabs.js +224 -0
  67. package/components/textarea/textarea.js +105 -0
  68. package/components/theme-toggle/theme-toggle.js +78 -0
  69. package/components/timeline/timeline.js +150 -0
  70. package/components/toggle/toggle.js +130 -0
  71. package/components/toggle-group/toggle-group.js +216 -0
  72. package/components/tooltip/tooltip.js +101 -0
  73. package/core/charts.js +107 -0
  74. package/core/color.js +110 -0
  75. package/core/core.js +556 -0
  76. package/core/dismiss.js +69 -0
  77. package/core/floating.js +195 -0
  78. package/core/focus.js +70 -0
  79. package/core/form.js +235 -0
  80. package/core/host.js +200 -0
  81. package/core/hotkey.js +40 -0
  82. package/core/icons.js +104 -0
  83. package/core/motion.js +73 -0
  84. package/core/position.js +91 -0
  85. package/core/raylang.js +167 -0
  86. package/core/scroll.js +58 -0
  87. package/core/styles.js +211 -0
  88. package/core.js +16 -0
  89. package/custom-elements.json +18545 -0
  90. package/index.js +74 -0
  91. package/package.json +312 -4
  92. package/ray-elements.d.ts +2570 -0
  93. package/tokens.css +796 -0
  94. package/tokens.json +2083 -0
  95. package/vscode.html-custom-data.json +5499 -0
@@ -0,0 +1,40 @@
1
+ // <ray-group>: its children in a row (Mantine's Group): spaced by the space
2
+ // scale, centered on the cross axis, wrapping when they do not fit. `grow`
3
+ // shares the width among them; `nowrap` keeps them on one line.
4
+
5
+ import { RayElement, css, define } from "../../core/core.js";
6
+ import { applyFlex } from "../stack/stack.js";
7
+
8
+ const styles = css`
9
+ :host {
10
+ --_gap: var(--ray-space-md);
11
+ display: flex;
12
+ flex-direction: row;
13
+ flex-wrap: wrap;
14
+ gap: var(--_gap);
15
+ align-items: var(--_align, center);
16
+ justify-content: var(--_justify, flex-start);
17
+ min-width: 0;
18
+ }
19
+ :host([hidden]) { display: none; }
20
+ :host([nowrap]) { flex-wrap: nowrap; }
21
+ :host([grow]) ::slotted(*) { flex: 1 1 0; min-width: 0; }
22
+ `;
23
+
24
+ export class RayGroup extends RayElement {
25
+ static properties = {
26
+ gap: { type: String, reflect: true, default: "md" },
27
+ align: { type: String, reflect: true, default: "center" },
28
+ justify: { type: String, reflect: true, default: "start" },
29
+ nowrap: { type: Boolean, reflect: true },
30
+ grow: { type: Boolean, reflect: true },
31
+ };
32
+ static styles = [styles];
33
+ static template = `<slot></slot>`;
34
+
35
+ update() {
36
+ applyFlex(this, this, "center");
37
+ }
38
+ }
39
+
40
+ define("ray-group", RayGroup);
@@ -0,0 +1,58 @@
1
+ // <ray-icon name="…">: an icon of the registry (setIcons), so the app's own
2
+ // markup uses the same set as the components. Decorative unless `label`.
3
+
4
+ import { RayElement, css, define } from "../../core/core.js";
5
+
6
+ const SIZES = { xs: "0.75rem", sm: "0.875rem", md: "1rem", lg: "1.25rem", xl: "1.5rem" };
7
+
8
+ const styles = css`
9
+ /* --_size is set here, not only read: private custom properties inherit
10
+ through shadow roots, and an ancestor's own --_size (ray-split's pane
11
+ size) must not reach the icon. size= overrides it inline. */
12
+ :host { --_size: 1em; display: inline-flex; vertical-align: -0.125em; line-height: 1; }
13
+ .icon svg { width: var(--_size); height: var(--_size); }
14
+ `;
15
+
16
+ export class RayIcon extends RayElement {
17
+ static properties = {
18
+ name: { type: String, reflect: true },
19
+ size: { type: String, reflect: true },
20
+ label: { type: String },
21
+ color: { type: String, reflect: true },
22
+ stroke: { type: Number },
23
+ };
24
+ static styles = [styles];
25
+ static template = `<span class="icon" part="icon" data-ray-icon=""></span>`;
26
+
27
+ constructor() {
28
+ super();
29
+ this.internals = this.attachInternals();
30
+ }
31
+
32
+ update() {
33
+ const icon = this.$(".icon");
34
+ if (icon.getAttribute("data-ray-icon") !== (this.name ?? "")) {
35
+ icon.setAttribute("data-ray-icon", this.name ?? "");
36
+ this.paintIcons();
37
+ }
38
+ // xs…xl from the scale, or any CSS length.
39
+ const size = this.size ? (SIZES[this.size] ?? this.size) : "";
40
+ if (size) this.style.setProperty("--_size", size);
41
+ else this.style.removeProperty("--_size");
42
+ // A palette color (red), the primary, or any CSS color.
43
+ if (this.color) {
44
+ const value = /^[a-z]+$/.test(this.color) && this.color !== "currentcolor" ? `var(--ray-${this.color}-light-color, ${this.color})` : this.color;
45
+ this.style.color = value;
46
+ } else {
47
+ this.style.removeProperty("color");
48
+ }
49
+ if (this.stroke) this.style.setProperty("--ray-icon-stroke", String(this.stroke));
50
+ else this.style.removeProperty("--ray-icon-stroke");
51
+ // Named: an image; otherwise hidden from assistive technology.
52
+ this.internals.role = this.label ? "img" : null;
53
+ this.internals.ariaLabel = this.label || null;
54
+ this.internals.ariaHidden = this.label ? null : "true";
55
+ }
56
+ }
57
+
58
+ define("ray-icon", RayIcon);
@@ -0,0 +1,30 @@
1
+ // <ray-icon-button>: a square button for an icon (Mantine's ActionIcon).
2
+ // The icon goes in the default slot; `label` is its accessible name.
3
+
4
+ import { css, define } from "../../core/core.js";
5
+ import { RayButton } from "../button/button.js";
6
+
7
+ const styles = css`
8
+ .button { width: var(--_h); padding: 0; }
9
+ .label { font-size: calc(var(--_fs) * 1.15); }
10
+ ::slotted(svg) { width: 1.15em; height: 1.15em; }
11
+ `;
12
+
13
+ export class RayIconButton extends RayButton {
14
+ static properties = {
15
+ ...RayButton.properties,
16
+ variant: { type: String, reflect: true, default: "subtle" },
17
+ label: { type: String, reflect: true },
18
+ };
19
+ static styles = [...RayButton.styles, styles];
20
+
21
+ update() {
22
+ super.update();
23
+ if (this.label) {
24
+ this._el.setAttribute("aria-label", this.label);
25
+ this._el.title = this.label;
26
+ }
27
+ }
28
+ }
29
+
30
+ define("ray-icon-button", RayIconButton);
@@ -0,0 +1,112 @@
1
+ // <ray-indicator>: a dot or a small label on the edge of any element
2
+ // (Mantine's Indicator): "New" on an avatar, an unread count on a bell, an
3
+ // online dot on a profile picture.
4
+ //
5
+ // <ray-indicator label="New" offset="7"><ray-avatar …></ray-avatar></ray-indicator>
6
+ //
7
+ // It wraps its content as tight as it is (width: fit-content; inline for
8
+ // inline content) and places the indicator on one of nine points of its
9
+ // box, `offset` pixels inward. A numeric label over `max` shows "max+".
10
+
11
+ import { RayElement, css, define } from "../../core/core.js";
12
+ import { applyColor } from "../../core/styles.js";
13
+
14
+ const POSITIONS = ["top-start", "top-center", "top-end", "middle-start", "middle-center", "middle-end", "bottom-start", "bottom-center", "bottom-end"];
15
+ const RADII = ["xs", "sm", "md", "lg", "xl"];
16
+
17
+ const styles = css`
18
+ :host { position: relative; display: block; width: fit-content; --_size: 10px; --_offset: 0px; }
19
+ :host([inline]) { display: inline-block; vertical-align: middle; }
20
+ .indicator {
21
+ position: absolute;
22
+ z-index: 1;
23
+ top: var(--_top);
24
+ left: var(--_left);
25
+ display: inline-flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ box-sizing: border-box;
29
+ min-width: var(--_size);
30
+ height: var(--_size);
31
+ color: var(--_contrast);
32
+ background: var(--_filled);
33
+ border-radius: var(--_radius, 999px);
34
+ transform: translate(-50%, -50%);
35
+ pointer-events: none;
36
+ }
37
+ .indicator[data-label] {
38
+ height: auto;
39
+ min-height: var(--_size);
40
+ padding: 0.1875rem 0.4375rem;
41
+ font-size: var(--ray-font-size-xs);
42
+ font-weight: var(--ray-font-weight-semibold);
43
+ line-height: 1;
44
+ white-space: nowrap;
45
+ font-variant-numeric: tabular-nums;
46
+ }
47
+ :host([with-border]) .indicator { box-shadow: 0 0 0 2px var(--ray-background); }
48
+ :host([disabled]) .indicator { display: none; }
49
+ /* processing: a ring that grows and fades, like a live signal. */
50
+ :host([processing]) .indicator::before {
51
+ content: "";
52
+ position: absolute;
53
+ inset: 0;
54
+ border-radius: inherit;
55
+ background: var(--_filled);
56
+ animation: ray-indicator-pulse 1.4s var(--ray-easing-out) infinite;
57
+ z-index: -1;
58
+ }
59
+ @keyframes ray-indicator-pulse {
60
+ from { opacity: 0.6; transform: scale(1); }
61
+ to { opacity: 0; transform: scale(2.6); }
62
+ }
63
+ @media (prefers-reduced-motion: reduce) {
64
+ :host([processing]) .indicator::before { animation-duration: 2.8s; }
65
+ }
66
+ `;
67
+
68
+ export class RayIndicator extends RayElement {
69
+ static properties = {
70
+ label: { type: String },
71
+ position: { type: String, reflect: true, default: "top-end" },
72
+ offset: { type: String },
73
+ color: { type: String, reflect: true },
74
+ size: { type: String },
75
+ radius: { type: String, reflect: true },
76
+ max: { type: Number },
77
+ inline: { type: Boolean, reflect: true },
78
+ withBorder: { type: Boolean, reflect: true },
79
+ processing: { type: Boolean, reflect: true },
80
+ disabled: { type: Boolean, reflect: true },
81
+ };
82
+ static styles = [styles];
83
+ static template = `<slot></slot><span class="indicator" part="indicator"></span>`;
84
+
85
+ update() {
86
+ const dot = this.$(".indicator");
87
+ applyColor(dot, this.color);
88
+ // The label: text, or a count capped at max ("99+").
89
+ let text = this.label ?? "";
90
+ const n = Number(text);
91
+ if (text !== "" && Number.isFinite(n) && this.max !== undefined && this.max !== null && n > this.max) text = `${this.max}+`;
92
+ dot.textContent = text;
93
+ dot.toggleAttribute("data-label", text !== "");
94
+ // Where: one of nine points of the box, `offset` inward.
95
+ const [v, h] = (POSITIONS.includes(this.position) ? this.position : "top-end").split("-");
96
+ const off = "var(--_offset)";
97
+ this.style.setProperty("--_top", v === "top" ? off : v === "middle" ? "50%" : `calc(100% - ${off})`);
98
+ this.style.setProperty("--_left", h === "start" ? off : h === "center" ? "50%" : `calc(100% - ${off})`);
99
+ const length = (x) => (x === undefined || x === null || x === "" ? null : /^-?\d+(\.\d+)?$/.test(String(x)) ? `${x}px` : String(x));
100
+ const offset = length(this.offset);
101
+ if (offset) this.style.setProperty("--_offset", offset);
102
+ else this.style.removeProperty("--_offset");
103
+ const size = length(this.size);
104
+ if (size) this.style.setProperty("--_size", size);
105
+ else this.style.removeProperty("--_size");
106
+ const r = this.radius;
107
+ if (r) dot.style.setProperty("--_radius", RADII.includes(r) ? `var(--ray-radius-${r})` : r === "none" ? "0" : r);
108
+ else dot.style.removeProperty("--_radius");
109
+ }
110
+ }
111
+
112
+ define("ray-indicator", RayIndicator);
@@ -0,0 +1,85 @@
1
+ // <ray-input>: a text field with label, description and error (Mantine's
2
+ // TextInput). Takes part in <form> under `name`.
3
+
4
+ import { css, define } from "../../core/core.js";
5
+ import { sizeStyles, radiusStyles } from "../../core/styles.js";
6
+ import { FieldControl, fieldStyles, boxStyles, fieldTemplate } from "../../core/form.js";
7
+
8
+ const styles = css`
9
+ .control[type="search"]::-webkit-search-cancel-button { cursor: pointer; }
10
+ .control[type="number"] { font-variant-numeric: tabular-nums; }
11
+ `;
12
+
13
+ // Attributes copied as-is to the inner <input>.
14
+ const PASSTHROUGH = ["placeholder", "autocomplete", "inputmode", "pattern", "min", "max", "step", "minlength", "maxlength", "spellcheck", "autocapitalize", "enterkeyhint", "list"];
15
+
16
+ export class RayInput extends FieldControl {
17
+ static properties = {
18
+ ...FieldControl.fieldProperties,
19
+ type: { type: String, reflect: true, default: "text" },
20
+ value: { type: String },
21
+ placeholder: { type: String },
22
+ readonly: { type: Boolean, reflect: true },
23
+ autofocus: { type: Boolean },
24
+ };
25
+ static styles = [sizeStyles, radiusStyles, fieldStyles, boxStyles, styles];
26
+ static template = fieldTemplate(`<input id="control" class="control" part="input">`);
27
+
28
+ static get observedAttributes() {
29
+ return [...super.observedAttributes, ...PASSTHROUGH];
30
+ }
31
+
32
+ attributeChangedCallback(attr, old, value) {
33
+ if (PASSTHROUGH.includes(attr)) {
34
+ if (value === null) this.control?.removeAttribute(attr);
35
+ else this.control?.setAttribute(attr, value);
36
+ this.requestUpdate();
37
+ return;
38
+ }
39
+ super.attributeChangedCallback(attr, old, value);
40
+ }
41
+
42
+ wire() {
43
+ for (const attr of PASSTHROUGH) {
44
+ const v = this.getAttribute(attr);
45
+ if (v !== null) this.control.setAttribute(attr, v);
46
+ }
47
+ this._default = this.getAttribute("value") ?? "";
48
+ this.control.addEventListener("input", () => {
49
+ this.commitProperty("value", this.control.value);
50
+ this._commit();
51
+ });
52
+ this.control.addEventListener("change", (e) => this.forward(e));
53
+ if (this.autofocus) queueMicrotask(() => this.control.focus());
54
+ }
55
+
56
+ update() {
57
+ const c = this.control;
58
+ if (c.type !== (this.type || "text")) c.type = this.type || "text";
59
+ const v = this.value ?? "";
60
+ if (c.value !== v) c.value = v;
61
+ c.readOnly = Boolean(this.readonly);
62
+ this.syncField();
63
+ this._commit();
64
+ }
65
+
66
+ _commit() {
67
+ this.internals.setFormValue(this.value ?? "");
68
+ this.syncValidity();
69
+ }
70
+
71
+ formResetCallback() {
72
+ this.value = this._default;
73
+ }
74
+
75
+ formStateRestoreCallback(state) {
76
+ this.value = String(state ?? "");
77
+ }
78
+
79
+ /** Selects the text, like HTMLInputElement.select(). */
80
+ select() {
81
+ this.control.select();
82
+ }
83
+ }
84
+
85
+ define("ray-input", RayInput);
@@ -0,0 +1,60 @@
1
+ // <ray-kbd>: a keyboard key. variant="default" is a raised keycap (thicker
2
+ // bottom edge); "filled" a flat muted chip and "outline" a flat chip with a
3
+ // border (shadcn's Kbd) — both lighter, for hints inside menus and buttons.
4
+
5
+ import { RayElement, css, define } from "../../core/core.js";
6
+
7
+ const styles = css`
8
+ :host {
9
+ display: inline-block;
10
+ vertical-align: baseline;
11
+ --_fs: var(--ray-font-size-xs);
12
+ }
13
+ :host([size="xs"]) { --_fs: 0.625rem; }
14
+ :host([size="md"]) { --_fs: var(--ray-font-size-sm); }
15
+ :host([size="lg"]) { --_fs: var(--ray-font-size-md); }
16
+ :host([size="xl"]) { --_fs: var(--ray-font-size-lg); }
17
+ kbd {
18
+ display: inline-block;
19
+ min-width: 1.6em;
20
+ padding: 0.125em 0.4em;
21
+ font-family: var(--ray-font-mono);
22
+ font-size: var(--_fs);
23
+ font-weight: var(--ray-font-weight-bold);
24
+ line-height: var(--ray-line-height-md);
25
+ text-align: center;
26
+ color: var(--ray-foreground);
27
+ /* A keycap lit from above: the face, a slightly darker rim and a much
28
+ darker bottom edge (its shadow) — in both schemes. In dark the face
29
+ is the raised surface, lighter than the page, and the edge sits
30
+ between the two: darker than the key, still visible on the page. */
31
+ --_face: light-dark(var(--ray-muted), var(--ray-raised));
32
+ background: var(--_face);
33
+ border: 1px solid light-dark(color-mix(in srgb, var(--_face), black 12%), color-mix(in srgb, var(--_face) 82%, var(--ray-background)));
34
+ border-bottom: 3px solid light-dark(color-mix(in srgb, var(--_face), black 24%), color-mix(in srgb, var(--_face) 62%, var(--ray-background)));
35
+ border-radius: var(--ray-radius-sm);
36
+ box-shadow: inset 0 1px 0 light-dark(rgb(255 255 255 / 0.7), rgb(255 255 255 / 0.08));
37
+ }
38
+ /* Flat variants: the same box height, quieter text. */
39
+ :host([variant="filled"]) kbd, :host([variant="outline"]) kbd {
40
+ padding: 0.125em 0.45em;
41
+ font-family: var(--ray-font-sans);
42
+ font-weight: var(--ray-font-weight-medium);
43
+ color: var(--ray-muted-foreground);
44
+ border-bottom-width: 1px;
45
+ box-shadow: none;
46
+ }
47
+ :host([variant="filled"]) kbd { background: var(--ray-muted); border-color: transparent; }
48
+ :host([variant="outline"]) kbd { background: transparent; border-color: var(--ray-border); }
49
+ `;
50
+
51
+ export class RayKbd extends RayElement {
52
+ static properties = {
53
+ size: { type: String, reflect: true, default: "sm" },
54
+ variant: { type: String, reflect: true, default: "default" },
55
+ };
56
+ static styles = [styles];
57
+ static template = `<kbd part="kbd"><slot></slot></kbd>`;
58
+ }
59
+
60
+ define("ray-kbd", RayKbd);
@@ -0,0 +1,296 @@
1
+ // <ray-menu> + <ray-menu-item>, <ray-menu-label>, <ray-menu-divider>:
2
+ // a dropdown menu (WAI-ARIA menu button pattern) or, with `context`, a
3
+ // context menu opened at the pointer over the trigger area.
4
+ //
5
+ // Choosing an item fires `ray-select` ({ value, item }) on the menu and
6
+ // closes it (unless keep-open).
7
+ //
8
+ // With `native` and a native host that offers "popup_menu" (core/host.js; a
9
+ // std/ui window with core/raylang.js), it opens as the system's context menu
10
+ // at the pointer instead: its items and dividers (not labels nor nested
11
+ // menus), with the same ray-select.
12
+
13
+ import { RayElement, css, define } from "../../core/core.js";
14
+ import { Floating, floatingStyles } from "../../core/floating.js";
15
+ import { applyColor } from "../../core/styles.js";
16
+ import { pointRect } from "../../core/position.js";
17
+ import { getHost, hostCan } from "../../core/host.js";
18
+
19
+ const menuStyles = css`
20
+ .panel { min-width: 12rem; padding: var(--ray-space-1); }
21
+ :host([context]) { display: contents; }
22
+ `;
23
+
24
+ export class RayMenu extends Floating {
25
+ static properties = {
26
+ ...Floating.properties,
27
+ placement: { type: String, reflect: true, default: "bottom-start" },
28
+ offset: { type: Number, default: 4 },
29
+ context: { type: Boolean, reflect: true },
30
+ keepOpen: { type: Boolean, reflect: true },
31
+ native: { type: Boolean, reflect: true },
32
+ };
33
+ static styles = [floatingStyles, menuStyles];
34
+ static haspopup = "menu";
35
+ static template = `<slot name="trigger"></slot><div class="panel" part="dropdown" role="menu" tabindex="-1"><slot></slot></div>`;
36
+
37
+ constructor() {
38
+ super();
39
+ this._typed = "";
40
+ this.addEventListener("click", (e) => {
41
+ const item = e.target.closest?.("ray-menu-item");
42
+ if (item && this.items.includes(item)) this._choose(item);
43
+ });
44
+ }
45
+
46
+ connectedCallback() {
47
+ super.connectedCallback();
48
+ if (!this._keys) {
49
+ this._keys = true;
50
+ this.panel.addEventListener("keydown", (e) => this._menuKey(e));
51
+ }
52
+ }
53
+
54
+ popupKind() {
55
+ return this.context ? null : "menu";
56
+ }
57
+
58
+ /** The enabled and disabled items of this menu (not of nested menus). */
59
+ get items() {
60
+ return [...this.querySelectorAll("ray-menu-item")].filter((i) => i.closest("ray-menu") === this);
61
+ }
62
+
63
+ wireTrigger(t) {
64
+ if (this.context) {
65
+ t.addEventListener("contextmenu", (e) => {
66
+ e.preventDefault();
67
+ if (this._native()) {
68
+ this.popupNative();
69
+ return;
70
+ }
71
+ this._point = pointRect(e.clientX, e.clientY);
72
+ this._focusOnOpen = "first";
73
+ const openNow = () => {
74
+ if (this.open) this.reposition();
75
+ else this.open = true;
76
+ };
77
+ // macOS/Linux fire contextmenu on press: the release that follows,
78
+ // outside the just-opened panel, would light-dismiss it. Open after it.
79
+ if (e.buttons & 2) {
80
+ window.addEventListener("pointerup", () => setTimeout(openNow, 0), { once: true, capture: true });
81
+ } else {
82
+ openNow();
83
+ }
84
+ });
85
+ return;
86
+ }
87
+ t.addEventListener("click", () => {
88
+ if (this._native()) {
89
+ this.popupNative();
90
+ return;
91
+ }
92
+ if (!this.open && this.justClosed()) return;
93
+ this._focusOnOpen = "first";
94
+ this.toggle();
95
+ });
96
+ t.addEventListener("keydown", (e) => {
97
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
98
+ e.preventDefault();
99
+ this._focusOnOpen = e.key === "ArrowDown" ? "first" : "last";
100
+ if (this.open) this._focus(this._focusOnOpen);
101
+ else this.open = true;
102
+ }
103
+ });
104
+ }
105
+
106
+ _native() {
107
+ return Boolean(this.native) && hostCan("popup_menu");
108
+ }
109
+
110
+ /**
111
+ * Opens the items as the system's context menu at the pointer (needs a
112
+ * native host); resolves to the chosen item, or null when dismissed.
113
+ */
114
+ async popupNative() {
115
+ if (!hostCan("popup_menu")) return null;
116
+ const entries = [...this.querySelectorAll("ray-menu-item, ray-menu-divider")].filter((el) => el.closest("ray-menu") === this);
117
+ const items = entries.map((el) => (el.localName === "ray-menu-divider"
118
+ ? { separator: true }
119
+ : { title: itemLabel(el), enabled: !el.disabled }));
120
+ const index = await getHost().popupMenu(items);
121
+ const item = index === null || index === undefined ? null : entries[index];
122
+ if (!item || item.localName !== "ray-menu-item") return null;
123
+ this._choose(item);
124
+ return item;
125
+ }
126
+
127
+ anchorRect() {
128
+ return this.context && this._point ? this._point : super.anchorRect();
129
+ }
130
+
131
+ toggled(open) {
132
+ if (open) {
133
+ this._focus(this._focusOnOpen ?? "first");
134
+ } else {
135
+ this._point = null;
136
+ // Focus back to the trigger when it was inside the menu.
137
+ if (this.focusWasInside && !this.context) this.trigger?.focus();
138
+ }
139
+ }
140
+
141
+ _enabled() {
142
+ return this.items.filter((i) => !i.disabled);
143
+ }
144
+
145
+ _focus(which) {
146
+ const items = this._enabled();
147
+ if (!items.length) {
148
+ this.panel.focus();
149
+ return;
150
+ }
151
+ const current = items.indexOf(document.activeElement);
152
+ let i;
153
+ if (which === "first") i = 0;
154
+ else if (which === "last") i = items.length - 1;
155
+ else if (which === "next") i = current < 0 ? 0 : (current + 1) % items.length;
156
+ else i = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
157
+ items[i].focus();
158
+ }
159
+
160
+ _menuKey(e) {
161
+ switch (e.key) {
162
+ case "ArrowDown": e.preventDefault(); this._focus("next"); return;
163
+ case "ArrowUp": e.preventDefault(); this._focus("prev"); return;
164
+ case "Home": e.preventDefault(); this._focus("first"); return;
165
+ case "End": e.preventDefault(); this._focus("last"); return;
166
+ case "Tab": this.open = false; return;
167
+ case "Enter":
168
+ case " ": {
169
+ const item = e.target.closest?.("ray-menu-item");
170
+ if (item) {
171
+ e.preventDefault();
172
+ this._choose(item);
173
+ }
174
+ return;
175
+ }
176
+ default:
177
+ this._typeahead(e);
178
+ }
179
+ }
180
+
181
+ // Typing letters jumps to the next item starting with them.
182
+ _typeahead(e) {
183
+ if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey) return;
184
+ clearTimeout(this._typedTimer);
185
+ this._typed += e.key.toLowerCase();
186
+ this._typedTimer = setTimeout(() => { this._typed = ""; }, 500);
187
+ const items = this._enabled();
188
+ const start = Math.max(0, items.indexOf(document.activeElement));
189
+ const order = [...items.slice(start + (this._typed.length === 1 ? 1 : 0)), ...items.slice(0, start + 1)];
190
+ const hit = order.find((i) => i.textContent.trim().toLowerCase().startsWith(this._typed));
191
+ hit?.focus();
192
+ }
193
+
194
+ _choose(item) {
195
+ if (item.disabled) return;
196
+ this.emit("ray-select", { value: item.value ?? item.textContent.trim(), item });
197
+ if (!this.keepOpen) this.open = false;
198
+ }
199
+ }
200
+
201
+ // The text of an item without its left and right sections (icon, shortcut).
202
+ function itemLabel(item) {
203
+ return [...item.childNodes]
204
+ .filter((n) => n.nodeType === Node.TEXT_NODE || (n.nodeType === Node.ELEMENT_NODE && !n.slot))
205
+ .map((n) => n.textContent)
206
+ .join("")
207
+ .replace(/\s+/g, " ")
208
+ .trim();
209
+ }
210
+
211
+ const itemStyles = css`
212
+ :host {
213
+ display: flex;
214
+ align-items: center;
215
+ gap: var(--ray-space-2);
216
+ min-height: var(--ray-control-height-xs);
217
+ padding: 0 var(--ray-space-2);
218
+ font-size: var(--ray-font-size-sm);
219
+ color: var(--ray-popover-foreground);
220
+ border-radius: var(--ray-radius-sm);
221
+ cursor: default;
222
+ user-select: none;
223
+ outline: none;
224
+ }
225
+ :host(:hover), :host(:focus-visible), :host(:focus) { background: var(--ray-accent); color: var(--ray-accent-foreground); }
226
+ :host([color]) { color: var(--_light-color); }
227
+ :host([color]:hover), :host([color]:focus) { background: var(--_light); color: var(--_light-color); }
228
+ :host([disabled]) { color: var(--ray-muted-foreground); background: transparent; cursor: not-allowed; opacity: 0.6; }
229
+ .label { flex: 1; white-space: nowrap; }
230
+ .section { display: inline-flex; align-items: center; color: var(--ray-muted-foreground); }
231
+ :host([color]) .section { color: inherit; }
232
+ .section:not([data-filled]) { display: none; }
233
+ .right { margin-left: var(--ray-space-6); font-size: var(--ray-font-size-xs); }
234
+ ::slotted(svg) { width: 1rem; height: 1rem; }
235
+ `;
236
+
237
+ export class RayMenuItem extends RayElement {
238
+ static properties = {
239
+ value: { type: String, reflect: true },
240
+ disabled: { type: Boolean, reflect: true },
241
+ color: { type: String, reflect: true },
242
+ };
243
+ 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>`;
245
+
246
+ constructor() {
247
+ super();
248
+ this.internals = this.attachInternals();
249
+ this.internals.role = "menuitem";
250
+ for (const slot of this.shadowRoot.querySelectorAll("slot[name]")) {
251
+ slot.addEventListener("slotchange", () => slot.parentElement.toggleAttribute("data-filled", slot.assignedNodes().length > 0));
252
+ }
253
+ }
254
+
255
+ update() {
256
+ this.tabIndex = -1;
257
+ this.internals.ariaDisabled = this.disabled ? "true" : "false";
258
+ if (this.color) applyColor(this, this.color);
259
+ }
260
+ }
261
+
262
+ const labelStyles = css`
263
+ :host {
264
+ display: block;
265
+ padding: var(--ray-space-2) var(--ray-space-2) var(--ray-space-1);
266
+ font-size: var(--ray-font-size-xs);
267
+ font-weight: var(--ray-font-weight-medium);
268
+ color: var(--ray-muted-foreground);
269
+ }
270
+ `;
271
+
272
+ export class RayMenuLabel extends RayElement {
273
+ static styles = [labelStyles];
274
+ static template = `<slot></slot>`;
275
+ constructor() {
276
+ super();
277
+ this.attachInternals().role = "presentation";
278
+ }
279
+ }
280
+
281
+ const dividerStyles = css`
282
+ :host { display: block; height: 1px; margin: var(--ray-space-1) calc(var(--ray-space-1) * -1); background: var(--ray-border); }
283
+ `;
284
+
285
+ export class RayMenuDivider extends RayElement {
286
+ static styles = [dividerStyles];
287
+ constructor() {
288
+ super();
289
+ this.attachInternals().role = "separator";
290
+ }
291
+ }
292
+
293
+ define("ray-menu", RayMenu);
294
+ define("ray-menu-item", RayMenuItem);
295
+ define("ray-menu-label", RayMenuLabel);
296
+ define("ray-menu-divider", RayMenuDivider);