@raylang/elements 0.0.0-stage → 0.3.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,239 @@
1
+ // <ray-stepper> + <ray-step>: a task in steps (Mantine's Stepper): a
2
+ // wizard, a checkout, an onboarding. The stepper draws the steps (number
3
+ // or icon, label, description, connector) and shows the content of the
4
+ // active one; past the last step, the `completed` slot.
5
+ //
6
+ // `active` is the index; next() and prev() move it. With
7
+ // allow-step-select the user can go back to a completed step (and forward
8
+ // to any with `linear` off): change fires. A step can be loading, in error
9
+ // or skippable (an optional label).
10
+
11
+ import { RayElement, css, define, uid } from "../../core/core.js";
12
+ import { applyColor } from "../../core/styles.js";
13
+
14
+ const stepperStyles = css`
15
+ :host { display: block; --_icon: 2.25rem; }
16
+ :host([hidden]) { display: none; }
17
+ :host([size="sm"]) { --_icon: 1.75rem; }
18
+ :host([size="lg"]) { --_icon: 2.75rem; }
19
+ .steps { display: flex; align-items: flex-start; gap: var(--ray-space-2); margin: 0; padding: 0; list-style: none; }
20
+ :host([orientation="vertical"]) { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--ray-space-6); }
21
+ :host([orientation="vertical"]) .steps { flex-direction: column; gap: 0; }
22
+ .step { display: flex; flex: 1 1 0; align-items: center; gap: var(--ray-space-2); min-width: 0; }
23
+ .step:last-child { flex: 0 0 auto; }
24
+ :host([orientation="vertical"]) .step { flex-direction: column; align-items: stretch; flex: none; }
25
+ .head {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: var(--ray-space-3);
29
+ min-width: 0;
30
+ padding: 0;
31
+ font: inherit;
32
+ color: inherit;
33
+ text-align: left;
34
+ background: none;
35
+ border: 0;
36
+ border-radius: var(--ray-radius-md);
37
+ }
38
+ button.head { cursor: pointer; }
39
+ button.head:focus-visible { outline: var(--ray-focus-ring-width) solid var(--ray-ring); outline-offset: 3px; }
40
+ .indicator {
41
+ flex: none;
42
+ box-sizing: border-box;
43
+ display: grid;
44
+ place-items: center;
45
+ width: var(--_icon);
46
+ height: var(--_icon);
47
+ font-size: calc(var(--_icon) * 0.4);
48
+ font-weight: 700;
49
+ font-variant-numeric: tabular-nums;
50
+ color: var(--ray-muted-foreground);
51
+ background: var(--ray-muted);
52
+ border: 2px solid var(--ray-muted);
53
+ border-radius: 50%;
54
+ transition: background-color var(--ray-duration-base), border-color var(--ray-duration-base), color var(--ray-duration-base);
55
+ --ray-icon-size: calc(var(--_icon) * 0.5);
56
+ --ray-icon-stroke: 2.5;
57
+ }
58
+ .step[data-state="active"] .indicator { color: var(--_light-color); background: var(--ray-background); border-color: var(--_filled); }
59
+ .step[data-state="done"] .indicator { color: var(--_contrast); background: var(--_filled); border-color: var(--_filled); }
60
+ .step[data-error] .indicator { color: #fff; background: var(--ray-red-filled); border-color: var(--ray-red-filled); }
61
+ .spinner { width: 50%; height: 50%; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: ray-step-spin 0.8s linear infinite; }
62
+ @keyframes ray-step-spin { to { rotate: 1turn; } }
63
+ .text { display: grid; min-width: 0; }
64
+ .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ray-font-size-sm); font-weight: var(--ray-font-weight-medium); }
65
+ .step[data-state="todo"] .label { color: var(--ray-muted-foreground); }
66
+ .description { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ray-font-size-xs); color: var(--ray-muted-foreground); }
67
+ .step[data-error] .description { color: var(--ray-destructive); }
68
+ .description:empty { display: none; }
69
+ .connector { flex: 1; min-width: var(--ray-space-4); height: 2px; border-radius: 1px; background: var(--ray-border); transition: background-color var(--ray-duration-base); }
70
+ .step[data-state="done"] .connector { background: var(--_filled); }
71
+ .step:last-child .connector { display: none; }
72
+ :host([orientation="vertical"]) .connector { flex: none; width: 2px; min-width: 0; height: auto; min-height: var(--ray-space-6); margin: var(--ray-space-1) 0 var(--ray-space-1) calc(var(--_icon) / 2 - 1px); }
73
+ /* Narrow (labels-below or a narrow stepper): the labels under the numbers. */
74
+ :host([icon-position="top"]) .step { align-items: flex-start; }
75
+ :host([icon-position="top"]) .head { flex-direction: column; align-items: center; text-align: center; gap: var(--ray-space-1); }
76
+ :host([icon-position="top"]) .connector { margin-top: calc(var(--_icon) / 2 - 1px); }
77
+ .content { padding-top: var(--ray-space-5); min-width: 0; }
78
+ :host([orientation="vertical"]) .content { padding-top: 0; }
79
+ ::slotted(ray-step:not([data-active])) { display: none; }
80
+ .completed:not([data-show]) { display: none; }
81
+ @media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2s; } }
82
+ `;
83
+
84
+ const glyph = (name) => {
85
+ const span = document.createElement("span");
86
+ span.setAttribute("data-ray-icon", name);
87
+ return span;
88
+ };
89
+
90
+ export class RayStepper extends RayElement {
91
+ static properties = {
92
+ active: { type: Number, reflect: true, default: 0 },
93
+ orientation: { type: String, reflect: true, default: "horizontal" },
94
+ iconPosition: { type: String, reflect: true, attribute: "icon-position", default: "left" },
95
+ allowStepSelect: { type: Boolean, reflect: true, attribute: "allow-step-select" },
96
+ linear: { type: Boolean, default: true },
97
+ size: { type: String, reflect: true, default: "md" },
98
+ color: { type: String, reflect: true },
99
+ label: { type: String, default: "Progress" },
100
+ };
101
+ static styles = [stepperStyles];
102
+ static template = `<ol class="steps" part="steps"></ol><div class="content" part="content"><slot></slot><div class="completed" part="completed"><slot name="completed"></slot></div></div>`;
103
+
104
+ #id = uid("stepper");
105
+
106
+ constructor() {
107
+ super();
108
+ this.$("slot:not([name])").addEventListener("slotchange", () => this.requestUpdate());
109
+ this.$(".steps").addEventListener("click", (e) => {
110
+ const head = e.target.closest?.("button.head");
111
+ if (head) this._select(Number(head.dataset.index));
112
+ });
113
+ }
114
+
115
+ /** The steps, in order. */
116
+ get steps() {
117
+ return [...this.children].filter((el) => el.localName === "ray-step");
118
+ }
119
+
120
+ /** Moves to the next step (past the last: the completed slot). */
121
+ next() {
122
+ this._go(Math.min((this.active ?? 0) + 1, this.steps.length));
123
+ }
124
+
125
+ /** Moves to the previous step. */
126
+ prev() {
127
+ this._go(Math.max((this.active ?? 0) - 1, 0));
128
+ }
129
+
130
+ _go(i) {
131
+ if (i === this.active) return;
132
+ this.active = i;
133
+ this.dispatchEvent(new Event("change", { bubbles: true }));
134
+ }
135
+
136
+ _selectable(i) {
137
+ const steps = this.steps;
138
+ const active = this.active ?? 0;
139
+ if (!this.allowStepSelect || i === active || i >= steps.length || steps[i].disabled) return false;
140
+ return i < active || this.linear === false;
141
+ }
142
+
143
+ _select(i) {
144
+ if (this._selectable(i)) this._go(i);
145
+ }
146
+
147
+ update() {
148
+ applyColor(this, this.color);
149
+ const steps = this.steps;
150
+ const active = Math.max(0, Math.min(this.active ?? 0, steps.length));
151
+ const list = this.$(".steps");
152
+ list.setAttribute("aria-label", this.label || "Progress");
153
+ // The steps are redrawn: keep the focus on the same step.
154
+ const focused = this.shadowRoot.activeElement?.closest?.(".head")?.dataset.index;
155
+ list.replaceChildren();
156
+ steps.forEach((step, i) => {
157
+ const state = i < active ? "done" : i === active ? "active" : "todo";
158
+ const li = document.createElement("li");
159
+ li.className = "step";
160
+ li.dataset.state = state;
161
+ li.toggleAttribute("data-error", Boolean(step.error));
162
+ li.setAttribute("part", `step step-${state}`);
163
+ const selectable = this._selectable(i);
164
+ const head = document.createElement(selectable ? "button" : "div");
165
+ head.className = "head";
166
+ if (selectable) head.type = "button";
167
+ head.dataset.index = String(i);
168
+ if (state === "active") head.setAttribute("aria-current", "step");
169
+ const indicator = document.createElement("span");
170
+ indicator.className = "indicator";
171
+ indicator.setAttribute("part", "indicator");
172
+ indicator.setAttribute("aria-hidden", "true");
173
+ if (step.loading) indicator.innerHTML = `<span class="spinner"></span>`;
174
+ else if (step.error) indicator.append(glyph("close"));
175
+ else if (state === "done") indicator.append(glyph(step.completedIcon || "check"));
176
+ else if (step.icon) indicator.append(glyph(step.icon));
177
+ else indicator.textContent = String(i + 1);
178
+ const text = document.createElement("span");
179
+ text.className = "text";
180
+ const label = document.createElement("span");
181
+ label.className = "label";
182
+ label.setAttribute("part", "label");
183
+ label.textContent = step.label ?? `Step ${i + 1}`;
184
+ const desc = document.createElement("span");
185
+ desc.className = "description";
186
+ desc.setAttribute("part", "description");
187
+ desc.textContent = step.error || step.description || "";
188
+ // Read as "Step 2 of 4: Shipping, completed".
189
+ const sr = document.createElement("span");
190
+ sr.className = "sr";
191
+ sr.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap";
192
+ sr.textContent = `Step ${i + 1} of ${steps.length}: `;
193
+ const status = document.createElement("span");
194
+ status.style.cssText = sr.style.cssText;
195
+ status.textContent = step.error ? ", error" : state === "done" ? ", completed" : state === "active" ? ", current" : "";
196
+ label.prepend(sr);
197
+ label.append(status);
198
+ text.append(label, desc);
199
+ head.append(indicator, text);
200
+ const connector = document.createElement("span");
201
+ connector.className = "connector";
202
+ connector.setAttribute("part", "connector");
203
+ connector.setAttribute("aria-hidden", "true");
204
+ li.append(head, connector);
205
+ list.append(li);
206
+ step.toggleAttribute("data-active", i === active);
207
+ step.id ||= `${this.#id}-${i}`;
208
+ });
209
+ this.paintIcons(list);
210
+ if (focused !== undefined) {
211
+ const again = list.querySelector(`button.head[data-index="${focused}"]`) ?? list.querySelector('[aria-current="step"]')?.closest("li")?.querySelector("button.head");
212
+ again?.focus();
213
+ }
214
+ this.$(".completed").toggleAttribute("data-show", active >= steps.length && steps.length > 0);
215
+ }
216
+ }
217
+
218
+ export class RayStep extends RayElement {
219
+ static properties = {
220
+ label: { type: String },
221
+ description: { type: String },
222
+ icon: { type: String },
223
+ completedIcon: { type: String, attribute: "completed-icon" },
224
+ loading: { type: Boolean, reflect: true },
225
+ error: { type: String },
226
+ disabled: { type: Boolean, reflect: true },
227
+ };
228
+ static styles = [css`:host { display: block; } :host([hidden]) { display: none; }`];
229
+ static template = `<slot></slot>`;
230
+
231
+ update() {
232
+ // The stepper draws the steps: tell it.
233
+ const stepper = this.parentElement;
234
+ if (stepper?.localName === "ray-stepper") stepper.requestUpdate();
235
+ }
236
+ }
237
+
238
+ define("ray-step", RayStep);
239
+ define("ray-stepper", RayStepper);
@@ -0,0 +1,45 @@
1
+ // <ray-switch>: an on/off toggle. Same API as <ray-checkbox> (checked,
2
+ // value, name, label…); announced as a switch.
3
+
4
+ import { css, define } from "../../core/core.js";
5
+ import { RayCheckbox, checkTemplate } from "../checkbox/checkbox.js";
6
+
7
+ const styles = css`
8
+ :host {
9
+ --_r: var(--ray-radius-full);
10
+ --_iw: calc(var(--_s) * 1.9);
11
+ }
12
+ .indicator {
13
+ position: relative;
14
+ justify-content: flex-start;
15
+ background: var(--ray-muted);
16
+ border-color: var(--ray-input);
17
+ }
18
+ .thumb {
19
+ position: absolute;
20
+ top: 50%;
21
+ left: 2px;
22
+ width: calc(var(--_s) - 6px);
23
+ height: calc(var(--_s) - 6px);
24
+ background: var(--ray-white);
25
+ border: 1px solid var(--ray-border);
26
+ border-radius: var(--_r);
27
+ box-shadow: var(--ray-shadow-xs);
28
+ transform: translateY(-50%);
29
+ transition: left var(--ray-duration-base) var(--ray-easing-standard);
30
+ }
31
+ .control:checked + .indicator { background: var(--_filled); border-color: var(--_filled); }
32
+ /* The thumb takes the text color of the filled shade: white on dark fills,
33
+ black on light ones (yellow, or "dark" in the dark scheme). */
34
+ .control:checked + .indicator .thumb { left: calc(100% - var(--_s) + 4px); border-color: transparent; background: var(--_contrast); }
35
+ /* Disabled: a muted track (gray when on, like a disabled checkbox) and a
36
+ plain thumb that stays visible on it, on its side. */
37
+ .control:disabled + .indicator .thumb { background: var(--ray-background); border-color: var(--ray-border); box-shadow: none; }
38
+ `;
39
+
40
+ export class RaySwitch extends RayCheckbox {
41
+ static styles = [...RayCheckbox.styles, styles];
42
+ static template = checkTemplate(`<span class="indicator" part="track" aria-hidden="true"><span class="thumb" part="thumb"></span></span>`, `role="switch"`);
43
+ }
44
+
45
+ define("ray-switch", RaySwitch);
@@ -0,0 +1,328 @@
1
+ // <ray-table>: a data table rendered from `columns` and `data`.
2
+ //
3
+ // table.columns = [
4
+ // { key: "name", label: "Name", sortable: true },
5
+ // { key: "size", label: "Size", align: "right", render: (row) => `${row.size} KB` },
6
+ // ];
7
+ // table.data = [{ id: 1, name: "main.ray", size: 4 }, …];
8
+ //
9
+ // Cells are text (render() may also return a DOM node; strings are never
10
+ // parsed as HTML). Sorting cycles asc -> desc -> none per column and is done
11
+ // here unless `manual-sort` (then listen to ray-sort and pass sorted data).
12
+ // With `selectable`, rows get checkboxes and `selection` holds their keys.
13
+ // For plain server-rendered tables there is the .ray-table class in base.css.
14
+
15
+ import { RayElement, css, define } from "../../core/core.js";
16
+
17
+ const styles = css`
18
+ :host {
19
+ display: block;
20
+ --_py: var(--ray-space-2);
21
+ --_px: var(--ray-space-3);
22
+ font-size: var(--ray-font-size-sm);
23
+ }
24
+ :host([density="xs"]) { --_py: 0.25rem; --_px: var(--ray-space-2); }
25
+ :host([density="md"]) { --_py: var(--ray-space-3); }
26
+ :host([density="lg"]) { --_py: var(--ray-space-4); --_px: var(--ray-space-4); }
27
+ .scroll { max-height: var(--ray-table-max-height, none); overflow: auto; }
28
+ :host([with-border]) .scroll { border: 1px solid var(--ray-border); border-radius: var(--ray-radius-default); }
29
+ table { width: 100%; border-collapse: separate; border-spacing: 0; font-variant-numeric: tabular-nums; }
30
+ caption { padding: var(--_py) var(--_px); text-align: left; font-weight: var(--ray-font-weight-semibold); }
31
+ th, td { padding: var(--_py) var(--_px); text-align: left; vertical-align: middle; border-bottom: 1px solid var(--ray-border); }
32
+ :host([with-column-borders]) th + th, :host([with-column-borders]) td + td { border-left: 1px solid var(--ray-border); }
33
+ tbody tr:last-child td { border-bottom: 0; }
34
+ :host(:not([with-border])) tbody tr:last-child td { border-bottom: 1px solid var(--ray-border); }
35
+ th { font-size: var(--ray-font-size-xs); font-weight: var(--ray-font-weight-semibold); color: var(--ray-muted-foreground); white-space: nowrap; background: var(--ray-background); }
36
+ :host([sticky-header]) thead th { position: sticky; top: 0; z-index: 1; box-shadow: 0 1px 0 var(--ray-border); }
37
+ [data-align="right"] { text-align: right; }
38
+ [data-align="center"] { text-align: center; }
39
+ :host([striped]) tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--ray-muted) 60%, transparent); }
40
+ tbody tr:hover td { background: var(--ray-default-hover); }
41
+ :host([no-hover]) tbody tr:hover td { background: inherit; }
42
+ tbody tr[aria-selected="true"] td { background: var(--ray-primary-light); }
43
+ tbody tr[data-clickable] { cursor: pointer; }
44
+ tbody tr:focus-visible { outline: var(--ray-focus-ring-width) solid var(--ray-ring); outline-offset: -2px; }
45
+ .sort {
46
+ display: inline-flex;
47
+ align-items: center;
48
+ gap: 0.25rem;
49
+ margin: -0.25rem -0.375rem;
50
+ padding: 0.25rem 0.375rem;
51
+ font: inherit;
52
+ color: inherit;
53
+ background: transparent;
54
+ border: 0;
55
+ border-radius: var(--ray-radius-sm);
56
+ cursor: pointer;
57
+ }
58
+ .sort:hover { color: var(--ray-foreground); background: var(--ray-accent); }
59
+ .sort { --ray-icon-stroke: 2.5; }
60
+ .sort svg { width: 0.75rem; height: 0.75rem; opacity: 0.35; }
61
+ th[aria-sort="ascending"] .sort, th[aria-sort="descending"] .sort { color: var(--ray-foreground); }
62
+ th[aria-sort="ascending"] .sort svg, th[aria-sort="descending"] .sort svg { opacity: 1; }
63
+ th[aria-sort="descending"] .sort svg { transform: rotate(180deg); }
64
+ .check { width: 1%; padding-right: 0; }
65
+ .check input { width: 1rem; height: 1rem; margin: 0; accent-color: var(--ray-primary); cursor: pointer; vertical-align: middle; }
66
+ .empty td { padding: var(--ray-space-8) var(--_px); text-align: center; color: var(--ray-muted-foreground); }
67
+ .empty:hover td { background: inherit; }
68
+ `;
69
+
70
+ const SORT_ICON = `<span data-ray-icon="chevron-up" aria-hidden="true"></span>`;
71
+
72
+ export class RayTable extends RayElement {
73
+ static properties = {
74
+ columns: { attribute: false },
75
+ data: { attribute: false },
76
+ rowKey: { type: String, default: "id" },
77
+ selectable: { type: Boolean, reflect: true },
78
+ selection: { attribute: false },
79
+ sort: { attribute: false },
80
+ manualSort: { type: Boolean },
81
+ clickableRows: { type: Boolean, reflect: true },
82
+ striped: { type: Boolean, reflect: true },
83
+ withBorder: { type: Boolean, reflect: true },
84
+ withColumnBorders: { type: Boolean, reflect: true },
85
+ stickyHeader: { type: Boolean, reflect: true },
86
+ noHover: { type: Boolean, reflect: true },
87
+ density: { type: String, reflect: true, default: "sm" },
88
+ label: { type: String },
89
+ emptyLabel: { type: String, default: "No data" },
90
+ };
91
+ static styles = [styles];
92
+ static template = `<div class="scroll" part="scroll"><table part="table"><caption hidden></caption><thead part="head"></thead><tbody part="body"></tbody></table></div><slot name="empty" hidden></slot>`;
93
+
94
+ constructor() {
95
+ super();
96
+ this.$("thead").addEventListener("click", (e) => this._headClick(e));
97
+ this.$("thead").addEventListener("change", (e) => this._headChange(e));
98
+ this.$("tbody").addEventListener("change", (e) => this._rowCheck(e));
99
+ this.$("tbody").addEventListener("click", (e) => this._rowClick(e));
100
+ this.$("tbody").addEventListener("keydown", (e) => this._rowKey(e));
101
+ }
102
+
103
+ /** The rows in display order (sorted unless manual-sort). */
104
+ get rows() {
105
+ const data = Array.isArray(this.data) ? this.data : [];
106
+ const s = this.sort;
107
+ if (!s || !s.key || !s.direction || this.manualSort) return data;
108
+ const col = (this.columns ?? []).find((c) => c.key === s.key);
109
+ const cmp = col?.sort ?? ((a, b) => compare(a[s.key], b[s.key]));
110
+ const sorted = [...data].sort(cmp);
111
+ return s.direction === "desc" ? sorted.reverse() : sorted;
112
+ }
113
+
114
+ _key(row, index) {
115
+ const k = row?.[this.rowKey ?? "id"];
116
+ return k === undefined ? index : k;
117
+ }
118
+
119
+ update() {
120
+ // The table is rebuilt: remember what had focus to give it back after.
121
+ const focus = this._focusKey();
122
+ const columns = Array.isArray(this.columns) ? this.columns : [];
123
+ const rows = this.rows;
124
+ const selection = new Set(Array.isArray(this.selection) ? this.selection : []);
125
+
126
+ const caption = this.$("caption");
127
+ caption.hidden = !this.label;
128
+ caption.textContent = this.label ?? "";
129
+ this.$("table").setAttribute("aria-label", this.label || this.getAttribute("aria-label") || "");
130
+ if (!this.label && !this.getAttribute("aria-label")) this.$("table").removeAttribute("aria-label");
131
+ // No aria-multiselectable: a plain table takes none (that is for
132
+ // role=grid); each row's checkbox says what is selected.
133
+
134
+ // Head
135
+ const head = document.createElement("tr");
136
+ if (this.selectable) {
137
+ const th = document.createElement("th");
138
+ th.className = "check";
139
+ const all = document.createElement("input");
140
+ all.type = "checkbox";
141
+ all.className = "focus-ring";
142
+ all.dataset.role = "all";
143
+ all.setAttribute("aria-label", "Select all rows");
144
+ const keys = rows.map((r, i) => this._key(r, i));
145
+ const picked = keys.filter((k) => selection.has(k)).length;
146
+ all.checked = keys.length > 0 && picked === keys.length;
147
+ all.indeterminate = picked > 0 && picked < keys.length;
148
+ th.append(all);
149
+ head.append(th);
150
+ }
151
+ for (const col of columns) {
152
+ const th = document.createElement("th");
153
+ th.scope = "col";
154
+ if (col.align) th.dataset.align = col.align;
155
+ if (col.width) th.style.width = typeof col.width === "number" ? `${col.width}px` : col.width;
156
+ const label = col.label ?? col.key;
157
+ if (col.sortable) {
158
+ const s = this.sort;
159
+ const dir = s && s.key === col.key ? s.direction : null;
160
+ th.setAttribute("aria-sort", dir === "asc" ? "ascending" : dir === "desc" ? "descending" : "none");
161
+ const b = document.createElement("button");
162
+ b.type = "button";
163
+ b.className = "sort focus-ring";
164
+ b.dataset.key = col.key;
165
+ b.append(label);
166
+ b.insertAdjacentHTML("beforeend", SORT_ICON);
167
+ th.append(b);
168
+ } else {
169
+ th.append(label);
170
+ }
171
+ head.append(th);
172
+ }
173
+ this.$("thead").replaceChildren(head);
174
+ this.paintIcons(this.$("thead"));
175
+
176
+ // Body
177
+ const body = this.$("tbody");
178
+ const out = [];
179
+ rows.forEach((row, index) => {
180
+ const key = this._key(row, index);
181
+ const tr = document.createElement("tr");
182
+ tr.dataset.index = String(index);
183
+ if (this.selectable) {
184
+ tr.setAttribute("aria-selected", String(selection.has(key)));
185
+ const td = document.createElement("td");
186
+ td.className = "check";
187
+ const box = document.createElement("input");
188
+ box.type = "checkbox";
189
+ box.className = "focus-ring";
190
+ box.checked = selection.has(key);
191
+ box.setAttribute("aria-label", `Select row ${index + 1}`);
192
+ td.append(box);
193
+ tr.append(td);
194
+ }
195
+ if (this.clickableRows) {
196
+ tr.tabIndex = 0;
197
+ tr.dataset.clickable = "";
198
+ }
199
+ for (const col of columns) {
200
+ const td = document.createElement("td");
201
+ if (col.align) td.dataset.align = col.align;
202
+ const v = typeof col.render === "function" ? col.render(row, index) : row?.[col.key];
203
+ if (v instanceof Node) td.append(v);
204
+ else td.textContent = v === undefined || v === null ? "" : String(v);
205
+ tr.append(td);
206
+ }
207
+ out.push(tr);
208
+ });
209
+ if (!rows.length) {
210
+ const tr = document.createElement("tr");
211
+ tr.className = "empty";
212
+ const td = document.createElement("td");
213
+ td.colSpan = Math.max(1, columns.length + (this.selectable ? 1 : 0));
214
+ // A slotted empty state wins over the text.
215
+ const slot = this.$('slot[name="empty"]');
216
+ if (slot.assignedElements().length) {
217
+ slot.hidden = false;
218
+ td.append(slot);
219
+ } else {
220
+ td.textContent = this.emptyLabel ?? "No data";
221
+ }
222
+ tr.append(td);
223
+ out.push(tr);
224
+ } else {
225
+ const slot = this.$('slot[name="empty"]');
226
+ if (slot.parentElement !== this.shadowRoot) {
227
+ slot.hidden = true;
228
+ this.shadowRoot.append(slot);
229
+ }
230
+ }
231
+ body.replaceChildren(...out);
232
+ this._restoreFocus(focus);
233
+ }
234
+
235
+ _focusKey() {
236
+ const a = this.shadowRoot.activeElement;
237
+ if (!a) return null;
238
+ if (a.dataset?.role === "all") return { kind: "all" };
239
+ if (a.classList?.contains("sort")) return { kind: "sort", key: a.dataset.key };
240
+ const tr = a.closest?.("tbody tr");
241
+ if (!tr) return null;
242
+ return { kind: a === tr ? "row" : "check", index: tr.dataset.index };
243
+ }
244
+
245
+ _restoreFocus(f) {
246
+ if (!f) return;
247
+ let el = null;
248
+ if (f.kind === "all") el = this.$('thead input[data-role="all"]');
249
+ else if (f.kind === "sort") el = [...this.shadowRoot.querySelectorAll("thead .sort")].find((b) => b.dataset.key === f.key);
250
+ else {
251
+ const tr = this.$(`tbody tr[data-index="${f.index}"]`);
252
+ el = f.kind === "row" ? tr : tr?.querySelector("input");
253
+ }
254
+ el?.focus({ preventScroll: true });
255
+ }
256
+
257
+ _rowOf(target) {
258
+ const tr = target.closest?.("tbody tr");
259
+ if (!tr || tr.classList.contains("empty")) return null;
260
+ const index = Number(tr.dataset.index);
261
+ return { tr, index, row: this.rows[index] };
262
+ }
263
+
264
+ _headClick(e) {
265
+ const b = e.target.closest?.(".sort");
266
+ if (!b) return;
267
+ const key = b.dataset.key;
268
+ const s = this.sort;
269
+ const dir = s && s.key === key ? s.direction : null;
270
+ const next = dir === null ? "asc" : dir === "asc" ? "desc" : null;
271
+ this.sort = next ? { key, direction: next } : null;
272
+ this.emit("ray-sort", { key, direction: next });
273
+ }
274
+
275
+ _headChange(e) {
276
+ if (e.target.dataset.role !== "all") return;
277
+ const keys = this.rows.map((r, i) => this._key(r, i));
278
+ this._select(e.target.checked ? keys : []);
279
+ }
280
+
281
+ _rowCheck(e) {
282
+ const hit = this._rowOf(e.target);
283
+ if (!hit || e.target.type !== "checkbox") return;
284
+ const key = this._key(hit.row, hit.index);
285
+ const set = new Set(Array.isArray(this.selection) ? this.selection : []);
286
+ if (e.target.checked) set.add(key);
287
+ else set.delete(key);
288
+ this._select([...set]);
289
+ }
290
+
291
+ _select(keys) {
292
+ this.selection = keys;
293
+ this.emit("ray-selection-change", { selection: keys });
294
+ }
295
+
296
+ _rowClick(e) {
297
+ if (!this.clickableRows) return;
298
+ // Clicks on controls inside the row are theirs.
299
+ if (e.target.closest?.("input, button, a, select, textarea, label")) return;
300
+ const hit = this._rowOf(e.target);
301
+ if (hit) this.emit("ray-row-click", { row: hit.row, index: hit.index });
302
+ }
303
+
304
+ _rowKey(e) {
305
+ if (!this.clickableRows || e.target.localName !== "tr") return;
306
+ if (e.key === "Enter" || e.key === " ") {
307
+ e.preventDefault();
308
+ const hit = this._rowOf(e.target);
309
+ if (hit) this.emit("ray-row-click", { row: hit.row, index: hit.index });
310
+ } else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
311
+ e.preventDefault();
312
+ const next = e.key === "ArrowDown" ? e.target.nextElementSibling : e.target.previousElementSibling;
313
+ next?.focus();
314
+ }
315
+ }
316
+ }
317
+
318
+ /** Numbers numerically, everything else as text with natural ordering; empty last. */
319
+ function compare(a, b) {
320
+ const ea = a === undefined || a === null || a === "";
321
+ const eb = b === undefined || b === null || b === "";
322
+ if (ea || eb) return ea === eb ? 0 : ea ? 1 : -1;
323
+ if (typeof a === "number" && typeof b === "number") return a - b;
324
+ if (a instanceof Date && b instanceof Date) return a - b;
325
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
326
+ }
327
+
328
+ define("ray-table", RayTable);