@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
@@ -1,17 +1,23 @@
1
1
  // <ray-sidebar>: the side navigation of an app shell (shadcn's Sidebar).
2
2
  // Header, content and footer slots; on wide screens a panel that can
3
- // collapse to an icon rail (`collapsed`); below `breakpoint` an off-canvas
3
+ // collapse (`collapsed`) to an icon rail, or out of view with
4
+ // collapse="offcanvas"; below `breakpoint` an off-canvas
4
5
  // drawer (`open`): a modal <dialog> sliding in from its side, with focus
5
6
  // trapped, Escape and backdrop click closing it.
6
7
  //
7
8
  // toggle() does the right thing for the screen: collapse/expand on wide
8
9
  // screens, open/close the drawer on narrow ones — wire it to a menu button.
9
10
  //
11
+ // With `resizable`, its inner edge is a separator (WAI-ARIA window splitter)
12
+ // that drags or arrow-keys the width between min-width and max-width;
13
+ // `autosave="key"` keeps it (localStorage).
14
+ //
10
15
  // <ray-sidebar-group label="Projects">: a titled section of nav links; the
11
16
  // title hides when the sidebar collapses.
12
17
 
13
18
  import { RayElement, css, define } from "../../core/core.js";
14
19
  import { overlayStyles } from "../../core/styles.js";
20
+ import { isRtl } from "../../core/direction.js";
15
21
  import { play, playAll } from "../../core/motion.js";
16
22
 
17
23
  const styles = css`
@@ -26,6 +32,10 @@ const styles = css`
26
32
  transition: width var(--ray-duration-base) var(--ray-easing-standard);
27
33
  }
28
34
  :host([collapsed]:not([data-mobile])) { width: var(--_rail); }
35
+ /* offcanvas: collapsed is out of view, and out of the tab order once the
36
+ width has animated to 0 (visibility waits for it; showing does not). */
37
+ :host([collapse="offcanvas"][collapsed]:not([data-mobile])) { width: 0; padding: 0; }
38
+ :host([collapse="offcanvas"][collapsed]:not([data-mobile])) .panel { visibility: hidden; transition: visibility 0s var(--ray-duration-base); }
29
39
  :host([data-mobile]) { width: 0; }
30
40
  .panel {
31
41
  box-sizing: border-box;
@@ -38,7 +48,11 @@ const styles = css`
38
48
  background: var(--ray-sidebar, color-mix(in srgb, var(--ray-muted) 70%, var(--ray-background)));
39
49
  border-inline-end: 1px solid var(--ray-border);
40
50
  }
41
- :host([side="right"]) .panel { border-inline-end: 0; border-inline-start: 1px solid var(--ray-border); }
51
+ /* side: start/end follow the writing direction, left/right are sides of
52
+ the screen; the border faces the content. */
53
+ :host([side="end"]) .panel { border-inline-end: 0; border-inline-start: 1px solid var(--ray-border); }
54
+ :host([side="left"]) .panel { border: 0; border-right: 1px solid var(--ray-border); } /* physical */
55
+ :host([side="right"]) .panel { border: 0; border-left: 1px solid var(--ray-border); } /* physical */
42
56
  :host([variant="floating"]:not([data-mobile])) { padding: var(--ray-space-2); box-sizing: border-box; }
43
57
  :host([variant="floating"]) .panel { border: 1px solid var(--ray-border); border-radius: var(--ray-radius-lg); box-shadow: var(--ray-shadow-sm); }
44
58
  .header, .footer { flex: none; padding: var(--ray-space-2); }
@@ -49,7 +63,8 @@ const styles = css`
49
63
  dialog {
50
64
  --_from: -100%;
51
65
  position: fixed;
52
- inset: 0 auto 0 0;
66
+ inset-block: 0;
67
+ inset-inline: 0 auto;
53
68
  box-sizing: border-box;
54
69
  width: min(var(--_w), calc(100vw - 3rem));
55
70
  max-width: none;
@@ -61,25 +76,57 @@ const styles = css`
61
76
  background: transparent;
62
77
  overflow: visible;
63
78
  }
64
- :host([side="right"]) dialog { inset: 0 0 0 auto; }
79
+ dialog:dir(rtl) { --_from: 100%; }
80
+ :host([side="end"]) dialog { inset-inline: auto 0; --_from: 100%; }
81
+ :host([side="end"]) dialog:dir(rtl) { --_from: -100%; }
82
+ :host([side="left"]) dialog { inset: 0 auto 0 0; --_from: -100%; } /* physical */
83
+ :host([side="right"]) dialog { inset: 0 0 0 auto; --_from: 100%; } /* physical */
65
84
  dialog[open] { display: block; animation: ray-sidebar-in var(--ray-duration-slow) var(--ray-easing-out); }
66
85
  dialog .panel { box-shadow: var(--ray-shadow-xl); background: var(--ray-card); }
67
86
  @keyframes ray-sidebar-in { from { transform: translateX(var(--_from)); } }
68
- :host([side="right"]) dialog { --_from: 100%; }
69
87
  dialog::backdrop { animation: ray-fade-in var(--ray-duration-base) var(--ray-easing-out); }
70
88
  @keyframes ray-fade-in { from { opacity: 0; } }
89
+
90
+ /* resizable: a separator on the edge that faces the content. */
91
+ :host { position: relative; }
92
+ .resizer { display: none; position: absolute; z-index: 1; inset-block: 0; inset-inline-end: -0.25rem; width: 0.5rem; cursor: col-resize; touch-action: none; outline: none; }
93
+ :host([resizable]:not([collapsed]):not([data-mobile])) .resizer { display: block; }
94
+ :host([side="end"]) .resizer { inset-inline-end: auto; inset-inline-start: -0.25rem; }
95
+ :host([side="left"]) .resizer { inset-inline: auto; right: -0.25rem; } /* physical */
96
+ :host([side="right"]) .resizer { inset-inline: auto; left: -0.25rem; } /* physical */
97
+ .resizer::before {
98
+ content: "";
99
+ position: absolute;
100
+ inset-block: 0;
101
+ left: 50%; /* physical */
102
+ width: 3px;
103
+ background: transparent;
104
+ transform: translateX(-50%);
105
+ transition: background-color var(--ray-duration-fast) var(--ray-easing-standard);
106
+ }
107
+ .resizer:hover::before, .resizer:focus-visible::before, :host([data-dragging]) .resizer::before { background: var(--ray-primary); }
108
+ :host([data-dragging]) { transition: none; user-select: none; cursor: col-resize; }
71
109
  `;
72
110
 
111
+ const STEP = 10;
112
+ const BIG_STEP = 50;
113
+
73
114
  export class RaySidebar extends RayElement {
74
115
  static properties = {
75
116
  collapsed: { type: Boolean, reflect: true },
117
+ collapse: { type: String, reflect: true, default: "icon" },
76
118
  open: { type: Boolean, reflect: true },
77
- side: { type: String, reflect: true, default: "left" },
119
+ side: { type: String, reflect: true, default: "start" },
78
120
  variant: { type: String, reflect: true, default: "sidebar" },
79
121
  width: { type: String },
80
122
  collapsedWidth: { type: String },
81
123
  breakpoint: { type: String, default: "48em" },
82
124
  label: { type: String, default: "Sidebar" },
125
+ resizable: { type: Boolean, reflect: true },
126
+ minWidth: { type: Number, default: 192 },
127
+ maxWidth: { type: Number, default: 480 },
128
+ autosave: { type: String },
129
+ resizeLabel: { type: String, default: "Resize sidebar" },
83
130
  };
84
131
  static styles = [overlayStyles, styles];
85
132
  static template = `
@@ -88,7 +135,8 @@ export class RaySidebar extends RayElement {
88
135
  <nav class="content" part="content"><slot></slot></nav>
89
136
  <div class="footer" part="footer"><slot name="footer"></slot></div>
90
137
  </div>
91
- <dialog part="drawer"></dialog>`;
138
+ <dialog part="drawer"></dialog>
139
+ <div class="resizer" part="resizer" role="separator" tabindex="0" aria-orientation="vertical"></div>`;
92
140
 
93
141
  #mq = null;
94
142
  #exit = null;
@@ -120,11 +168,27 @@ export class RaySidebar extends RayElement {
120
168
  });
121
169
  // New nav links and groups pick up the collapsed state.
122
170
  this.$(".content slot").addEventListener("slotchange", () => this._syncCollapsed());
171
+ this._px = null;
172
+ this.resizer = this.$(".resizer");
173
+ this.resizer.addEventListener("pointerdown", (e) => this._down(e));
174
+ this.resizer.addEventListener("keydown", (e) => this._resizeKey(e));
123
175
  }
124
176
 
125
177
  connectedCallback() {
126
178
  super.connectedCallback();
127
179
  this._watch();
180
+ if (this.autosave && !this._restored) {
181
+ this._restored = true;
182
+ try {
183
+ const saved = Number(localStorage.getItem(`ray-sidebar:${this.autosave}`));
184
+ if (saved > 0) {
185
+ this._px = saved;
186
+ this.requestUpdate();
187
+ }
188
+ } catch {
189
+ // No storage: the default width is fine.
190
+ }
191
+ }
128
192
  }
129
193
 
130
194
  disconnectedCallback() {
@@ -168,13 +232,21 @@ export class RaySidebar extends RayElement {
168
232
  }
169
233
 
170
234
  update(changed) {
171
- if (this.width) this.style.setProperty("--_w", this.width);
235
+ // A later change of `width` wins over a dragged or restored one.
236
+ if (changed.has("width") && this._updated) this._px = null;
237
+ this._updated = true;
238
+ if (this._px !== null) this.style.setProperty("--_w", `${this._clamp(this._px)}px`);
239
+ else if (this.width) this.style.setProperty("--_w", this.width);
172
240
  else this.style.removeProperty("--_w");
241
+ this.resizer.setAttribute("aria-label", this.resizeLabel ?? "Resize sidebar");
242
+ this.resizer.setAttribute("aria-valuemin", String(this.minWidth ?? 0));
243
+ this.resizer.setAttribute("aria-valuemax", String(this.maxWidth ?? 0));
244
+ queueMicrotask(() => this.resizer.setAttribute("aria-valuenow", String(Math.round(this._width()))));
173
245
  if (this.collapsedWidth) this.style.setProperty("--_rail", this.collapsedWidth);
174
246
  else this.style.removeProperty("--_rail");
175
247
  this.$(".content").setAttribute("aria-label", this.label ?? "Sidebar");
176
248
  if (changed.has("breakpoint") && this.isConnected) this._watch();
177
- if (changed.has("collapsed")) this._syncCollapsed();
249
+ if (changed.has("collapsed") || changed.has("collapse")) this._syncCollapsed();
178
250
  if (changed.has("open") && this.mobile) {
179
251
  if (this.open && this.#exit) {
180
252
  this.#exit.cancel();
@@ -187,13 +259,84 @@ export class RaySidebar extends RayElement {
187
259
  }
188
260
  }
189
261
 
262
+ /** The expanded width in px (not mid-transition when it was set here). */
263
+ _width() {
264
+ return this._px !== null ? this._clamp(this._px) : this.getBoundingClientRect().width;
265
+ }
266
+
267
+ _clamp(px) {
268
+ return Math.round(Math.max(this.minWidth ?? 0, Math.min(px, this.maxWidth ?? Infinity)));
269
+ }
270
+
271
+ /** The screen side it sits on: start and end follow the writing direction. */
272
+ _onLeft() {
273
+ if (this.side === "left" || this.side === "right") return this.side === "left";
274
+ return (this.side === "end") === isRtl(this);
275
+ }
276
+
277
+ _resizeTo(px, commit) {
278
+ this._px = this._clamp(px);
279
+ this.requestUpdate();
280
+ if (!commit) return;
281
+ if (this.autosave) {
282
+ try {
283
+ localStorage.setItem(`ray-sidebar:${this.autosave}`, String(this._px));
284
+ } catch {
285
+ // Not persisted: fine.
286
+ }
287
+ }
288
+ this.emit("ray-resize", { width: this._px });
289
+ }
290
+
291
+ _down(e) {
292
+ if (e.button !== 0) return;
293
+ e.preventDefault();
294
+ this.resizer.focus();
295
+ try {
296
+ this.resizer.setPointerCapture(e.pointerId);
297
+ } catch {
298
+ // The pointer is already gone: drag without capture.
299
+ }
300
+ this.setAttribute("data-dragging", "");
301
+ const rect = this.getBoundingClientRect();
302
+ const left = this._onLeft();
303
+ const at = (ev) => (left ? ev.clientX - rect.left : rect.right - ev.clientX);
304
+ const move = (ev) => this._resizeTo(at(ev), false);
305
+ const up = (ev) => {
306
+ this.resizer.removeEventListener("pointermove", move);
307
+ this.resizer.removeEventListener("pointerup", up);
308
+ this.resizer.removeEventListener("pointercancel", up);
309
+ this.removeAttribute("data-dragging");
310
+ this._resizeTo(at(ev), true);
311
+ };
312
+ this.resizer.addEventListener("pointermove", move);
313
+ this.resizer.addEventListener("pointerup", up);
314
+ this.resizer.addEventListener("pointercancel", up);
315
+ }
316
+
317
+ // Arrows move the edge on screen: toward the content widens it.
318
+ _resizeKey(e) {
319
+ const step = e.shiftKey ? BIG_STEP : STEP;
320
+ const current = this._width();
321
+ const wider = this._onLeft() ? "ArrowRight" : "ArrowLeft";
322
+ const narrower = this._onLeft() ? "ArrowLeft" : "ArrowRight";
323
+ let next = null;
324
+ if (e.key === wider) next = current + step;
325
+ else if (e.key === narrower) next = current - step;
326
+ else if (e.key === "Home") next = this.minWidth ?? 0;
327
+ else if (e.key === "End") next = this.maxWidth ?? current;
328
+ if (next === null) return;
329
+ e.preventDefault();
330
+ this._resizeTo(next, true);
331
+ }
332
+
190
333
  _show() {
191
334
  this.dialog.showModal();
192
335
  this.emit("ray-open");
193
336
  }
194
337
 
195
338
  async _leave() {
196
- const to = this.side === "right" ? "translateX(100%)" : "translateX(-100%)";
339
+ const to = `translateX(${getComputedStyle(this.dialog).getPropertyValue("--_from").trim() || "-100%"})`;
197
340
  const exit = playAll([
198
341
  play(this.dialog, [{ transform: "none" }, { transform: to }]),
199
342
  play(this.dialog, [{ opacity: 1 }, { opacity: 0 }], { pseudoElement: "::backdrop" }),
@@ -209,7 +352,7 @@ export class RaySidebar extends RayElement {
209
352
 
210
353
  // The rail shows icons only: nav links and group titles follow.
211
354
  _syncCollapsed() {
212
- const rail = Boolean(this.collapsed) && !this.mobile;
355
+ const rail = Boolean(this.collapsed) && !this.mobile && this.collapse !== "offcanvas";
213
356
  for (const el of this.querySelectorAll("ray-nav-link")) el.iconOnly = rail;
214
357
  for (const el of this.querySelectorAll("ray-sidebar-group")) el.toggleAttribute("collapsed", rail);
215
358
  }
@@ -33,9 +33,11 @@ const styles = css`
33
33
  input::-webkit-slider-runnable-track {
34
34
  height: var(--_track);
35
35
  border-radius: var(--ray-radius-full);
36
- background: linear-gradient(to right, var(--_filled) var(--_pct), var(--ray-muted) var(--_pct));
36
+ background: linear-gradient(to right, var(--_filled) var(--_pct), var(--ray-muted) var(--_pct)); /* physical */
37
37
  box-shadow: inset 0 0 0 1px var(--ray-border);
38
38
  }
39
+ /* Right to left the native range runs from the right: so does its fill. */
40
+ input:dir(rtl)::-webkit-slider-runnable-track { background: linear-gradient(to left, var(--_filled) var(--_pct), var(--ray-muted) var(--_pct)); } /* physical */
39
41
  input::-moz-range-track { height: var(--_track); border-radius: var(--ray-radius-full); background: var(--ray-muted); }
40
42
  input::-moz-range-progress { height: var(--_track); border-radius: var(--ray-radius-full); background: var(--_filled); }
41
43
  input::-webkit-slider-thumb {
@@ -62,6 +64,7 @@ const styles = css`
62
64
  .marks { position: relative; height: 1.25rem; margin: var(--ray-space-1) calc(var(--_thumb) / 2) 0; font-size: var(--ray-font-size-xs); color: var(--ray-muted-foreground); }
63
65
  .marks:empty { display: none; }
64
66
  .mark { position: absolute; transform: translateX(-50%); white-space: nowrap; }
67
+ .mark:dir(rtl) { transform: translateX(50%); }
65
68
  .mark::before { content: ""; display: block; width: 1px; height: 0.25rem; margin: 0 auto 0.125rem; background: var(--ray-border); }
66
69
  `;
67
70
 
@@ -137,7 +140,7 @@ export class RaySlider extends FieldControl {
137
140
  const v = typeof m === "number" ? m : m.value;
138
141
  const span = document.createElement("span");
139
142
  span.className = "mark";
140
- span.style.left = `${((v - min) / (max - min || 1)) * 100}%`;
143
+ span.style.insetInlineStart = `${((v - min) / (max - min || 1)) * 100}%`;
141
144
  span.textContent = typeof m === "number" ? String(m) : (m.label ?? String(m.value));
142
145
  box.append(span);
143
146
  }
@@ -4,9 +4,11 @@
4
4
  //
5
5
  // The divider is a focusable separator (WAI-ARIA window splitter): arrows
6
6
  // move it (Shift: faster), Home/End go to min/max, Enter collapses the start
7
- // pane. With `autosave="key"` the size survives reloads (localStorage).
7
+ // pane. With `autosave="key"` the size and the collapsed state survive
8
+ // reloads (localStorage).
8
9
 
9
10
  import { RayElement, css, define } from "../../core/core.js";
11
+ import { isRtl, inlineKey } from "../../core/direction.js";
10
12
 
11
13
  const styles = css`
12
14
  :host {
@@ -34,13 +36,13 @@ const styles = css`
34
36
  position: absolute;
35
37
  top: 0;
36
38
  bottom: 0;
37
- left: 50%;
39
+ left: 50%; /* physical */
38
40
  width: 1px;
39
41
  background: var(--ray-border);
40
42
  transform: translateX(-50%);
41
43
  transition: background-color var(--ray-duration-fast) var(--ray-easing-standard), width var(--ray-duration-fast) var(--ray-easing-standard);
42
44
  }
43
- :host([orientation="vertical"]) .divider::before { top: 50%; bottom: auto; left: 0; right: 0; width: auto; height: 1px; transform: translateY(-50%); }
45
+ :host([orientation="vertical"]) .divider::before { top: 50%; bottom: auto; inset-inline: 0; width: auto; height: 1px; transform: translateY(-50%); }
44
46
  :host(:not([disabled])) .divider:hover::before,
45
47
  .divider:focus-visible::before,
46
48
  :host([data-dragging]) .divider::before { width: 3px; background: var(--ray-primary); }
@@ -87,6 +89,9 @@ export class RaySplit extends RayElement {
87
89
  try {
88
90
  const saved = Number(localStorage.getItem(`ray-split:${this.autosave}`));
89
91
  if (saved > 0) this._px = saved;
92
+ // "1" or "0"; nothing saved keeps the markup's collapsed.
93
+ const collapsed = localStorage.getItem(`ray-split:${this.autosave}:collapsed`);
94
+ if (collapsed !== null) this.collapsed = collapsed === "1";
90
95
  } catch {
91
96
  // No storage: the default position is fine.
92
97
  }
@@ -108,6 +113,14 @@ export class RaySplit extends RayElement {
108
113
  // A later change of `position` wins over a dragged/restored size; the
109
114
  // first update (position from the markup) does not.
110
115
  if (changed.has("position") && this._updated) this._px = null;
116
+ // Collapsed by the divider or by script after the first update: saved.
117
+ if (changed.has("collapsed") && this._updated && this.autosave) {
118
+ try {
119
+ localStorage.setItem(`ray-split:${this.autosave}:collapsed`, this.collapsed ? "1" : "0");
120
+ } catch {
121
+ // Not persisted: fine.
122
+ }
123
+ }
111
124
  this._updated = true;
112
125
  const min = Math.max(0, this.min ?? 0);
113
126
  const max = this.max ?? null;
@@ -182,13 +195,16 @@ export class RaySplit extends RayElement {
182
195
  this.setAttribute("data-dragging", "");
183
196
  const rect = this.getBoundingClientRect();
184
197
  const vertical = this._vertical();
185
- const move = (ev) => this._resizeTo(vertical ? ev.clientY - rect.top : ev.clientX - rect.left, false);
198
+ // The start pane's size: right to left it is on the right.
199
+ const rtl = !vertical && isRtl(this);
200
+ const at = (ev) => (vertical ? ev.clientY - rect.top : rtl ? rect.right - ev.clientX : ev.clientX - rect.left);
201
+ const move = (ev) => this._resizeTo(at(ev), false);
186
202
  const up = (ev) => {
187
203
  this.divider.removeEventListener("pointermove", move);
188
204
  this.divider.removeEventListener("pointerup", up);
189
205
  this.divider.removeEventListener("pointercancel", up);
190
206
  this.removeAttribute("data-dragging");
191
- this._resizeTo(vertical ? ev.clientY - rect.top : ev.clientX - rect.left, true);
207
+ this._resizeTo(at(ev), true);
192
208
  };
193
209
  this.divider.addEventListener("pointermove", move);
194
210
  this.divider.addEventListener("pointerup", up);
@@ -202,9 +218,11 @@ export class RaySplit extends RayElement {
202
218
  const current = this.collapsed ? 0 : this.size;
203
219
  const less = vertical ? "ArrowUp" : "ArrowLeft";
204
220
  const more = vertical ? "ArrowDown" : "ArrowRight";
221
+ // Right to left, ArrowLeft moves the divider away from the start pane.
222
+ const key = vertical ? e.key : inlineKey(e.key, this);
205
223
  let next = null;
206
- if (e.key === less) next = current - step;
207
- else if (e.key === more) next = current + step;
224
+ if (key === less) next = current - step;
225
+ else if (key === more) next = current + step;
208
226
  else if (e.key === "Home") next = this.min ?? 0;
209
227
  else if (e.key === "End") next = this.max ?? this._total();
210
228
  else if (e.key === "Enter") {
@@ -30,7 +30,7 @@ const stepperStyles = css`
30
30
  padding: 0;
31
31
  font: inherit;
32
32
  color: inherit;
33
- text-align: left;
33
+ text-align: start;
34
34
  background: none;
35
35
  border: 0;
36
36
  border-radius: var(--ray-radius-md);
@@ -58,7 +58,7 @@ const stepperStyles = css`
58
58
  .step[data-state="active"] .indicator { color: var(--_light-color); background: var(--ray-background); border-color: var(--_filled); }
59
59
  .step[data-state="done"] .indicator { color: var(--_contrast); background: var(--_filled); border-color: var(--_filled); }
60
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; }
61
+ .spinner { width: 50%; height: 50%; border: 2px solid currentColor; border-inline-end-color: transparent; border-radius: 50%; animation: ray-step-spin 0.8s linear infinite; }
62
62
  @keyframes ray-step-spin { to { rotate: 1turn; } }
63
63
  .text { display: grid; min-width: 0; }
64
64
  .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--ray-font-size-sm); font-weight: var(--ray-font-weight-medium); }
@@ -69,7 +69,7 @@ const stepperStyles = css`
69
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
70
  .step[data-state="done"] .connector { background: var(--_filled); }
71
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); }
72
+ :host([orientation="vertical"]) .connector { flex: none; width: 2px; min-width: 0; height: auto; min-height: var(--ray-space-6); margin-block: var(--ray-space-1); margin-inline-start: calc(var(--_icon) / 2 - 1px); }
73
73
  /* Narrow (labels-below or a narrow stepper): the labels under the numbers. */
74
74
  :host([icon-position="top"]) .step { align-items: flex-start; }
75
75
  :host([icon-position="top"]) .head { flex-direction: column; align-items: center; text-align: center; gap: var(--ray-space-1); }
@@ -91,7 +91,7 @@ export class RayStepper extends RayElement {
91
91
  static properties = {
92
92
  active: { type: Number, reflect: true, default: 0 },
93
93
  orientation: { type: String, reflect: true, default: "horizontal" },
94
- iconPosition: { type: String, reflect: true, attribute: "icon-position", default: "left" },
94
+ iconPosition: { type: String, reflect: true, attribute: "icon-position", default: "start" },
95
95
  allowStepSelect: { type: Boolean, reflect: true, attribute: "allow-step-select" },
96
96
  linear: { type: Boolean, default: true },
97
97
  size: { type: String, reflect: true, default: "md" },
@@ -18,7 +18,7 @@ const styles = css`
18
18
  .thumb {
19
19
  position: absolute;
20
20
  top: 50%;
21
- left: 2px;
21
+ inset-inline-start: 2px;
22
22
  width: calc(var(--_s) - 6px);
23
23
  height: calc(var(--_s) - 6px);
24
24
  background: var(--ray-white);
@@ -26,12 +26,12 @@ const styles = css`
26
26
  border-radius: var(--_r);
27
27
  box-shadow: var(--ray-shadow-xs);
28
28
  transform: translateY(-50%);
29
- transition: left var(--ray-duration-base) var(--ray-easing-standard);
29
+ transition: inset-inline-start var(--ray-duration-base) var(--ray-easing-standard);
30
30
  }
31
31
  .control:checked + .indicator { background: var(--_filled); border-color: var(--_filled); }
32
32
  /* The thumb takes the text color of the filled shade: white on dark fills,
33
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); }
34
+ .control:checked + .indicator .thumb { inset-inline-start: calc(100% - var(--_s) + 4px); border-color: transparent; background: var(--_contrast); }
35
35
  /* Disabled: a muted track (gray when on, like a disabled checkbox) and a
36
36
  plain thumb that stays visible on it, on its side. */
37
37
  .control:disabled + .indicator .thumb { background: var(--ray-background); border-color: var(--ray-border); box-shadow: none; }
@@ -27,14 +27,14 @@ const styles = css`
27
27
  .scroll { max-height: var(--ray-table-max-height, none); overflow: auto; }
28
28
  :host([with-border]) .scroll { border: 1px solid var(--ray-border); border-radius: var(--ray-radius-default); }
29
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); }
30
+ caption { padding: var(--_py) var(--_px); text-align: start; font-weight: var(--ray-font-weight-semibold); }
31
+ th, td { padding: var(--_py) var(--_px); text-align: start; vertical-align: middle; border-bottom: 1px solid var(--ray-border); }
32
+ :host([with-column-borders]) th + th, :host([with-column-borders]) td + td { border-inline-start: 1px solid var(--ray-border); }
33
33
  tbody tr:last-child td { border-bottom: 0; }
34
34
  :host(:not([with-border])) tbody tr:last-child td { border-bottom: 1px solid var(--ray-border); }
35
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
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; }
37
+ [data-align="right"] { text-align: right; } /* physical */
38
38
  [data-align="center"] { text-align: center; }
39
39
  :host([striped]) tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--ray-muted) 60%, transparent); }
40
40
  tbody tr:hover td { background: var(--ray-default-hover); }
@@ -61,7 +61,7 @@ const styles = css`
61
61
  th[aria-sort="ascending"] .sort, th[aria-sort="descending"] .sort { color: var(--ray-foreground); }
62
62
  th[aria-sort="ascending"] .sort svg, th[aria-sort="descending"] .sort svg { opacity: 1; }
63
63
  th[aria-sort="descending"] .sort svg { transform: rotate(180deg); }
64
- .check { width: 1%; padding-right: 0; }
64
+ .check { width: 1%; padding-inline-end: 0; }
65
65
  .check input { width: 1rem; height: 1rem; margin: 0; accent-color: var(--ray-primary); cursor: pointer; vertical-align: middle; }
66
66
  .empty td { padding: var(--ray-space-8) var(--_px); text-align: center; color: var(--ray-muted-foreground); }
67
67
  .empty:hover td { background: inherit; }
@@ -3,12 +3,25 @@
3
3
  // Tabs and panels are light-DOM children, so their roles, ids,
4
4
  // aria-controls and aria-labelledby work as plain attributes. A tab and its
5
5
  // panel share a `value`.
6
+ //
7
+ // Too many tabs for the width: the list scrolls sideways, fades the edge that
8
+ // hides tabs (the host carries data-overflow-start / data-overflow-end), turns
9
+ // a vertical wheel into a sideways scroll and keeps the selected tab in view.
6
10
 
7
11
  import { RayElement, css, define, uid } from "../../core/core.js";
12
+ import { inlineKey } from "../../core/direction.js";
13
+ import { SideScroller, sideScrollerStyles } from "../../core/scroll.js";
8
14
  import { applyColor } from "../../core/styles.js";
9
15
 
10
16
  const tabsStyles = css`
11
17
  :host { display: block; }
18
+ /* The size scale, inherited by the ray-tab children (own names: they reach
19
+ the panels' content too). */
20
+ :host { --_tab-h: var(--ray-control-height-sm); --_tab-fs: var(--ray-font-size-sm); --_tab-px: var(--ray-space-4); --_tab-gap: var(--ray-space-2); }
21
+ :host([size="xs"]) { --_tab-h: var(--ray-control-height-xs); --_tab-fs: var(--ray-font-size-xs); --_tab-px: var(--ray-space-3); --_tab-gap: var(--ray-space-1); }
22
+ :host([size="md"]) { --_tab-h: var(--ray-control-height-md); --_tab-fs: var(--ray-font-size-md); --_tab-px: var(--ray-space-5); }
23
+ :host([size="lg"]) { --_tab-h: var(--ray-control-height-lg); --_tab-fs: var(--ray-font-size-lg); --_tab-px: var(--ray-space-6); --_tab-gap: var(--ray-space-3); }
24
+ :host([size="xl"]) { --_tab-h: var(--ray-control-height-xl); --_tab-fs: var(--ray-font-size-xl); --_tab-px: var(--ray-space-8); --_tab-gap: var(--ray-space-3); }
12
25
  :host([orientation="vertical"]) { display: flex; gap: var(--ray-space-4); }
13
26
  /* Too many tabs for the width: the list scrolls sideways instead of
14
27
  wrapping (a wrapped underline reads as two separate tab bars). The
@@ -24,9 +37,9 @@ const tabsStyles = css`
24
37
  }
25
38
  .list::-webkit-scrollbar { display: none; }
26
39
  .list ::slotted(ray-tab) { flex: none; }
27
- :host([orientation="vertical"]) .list { flex-direction: column; overflow-x: visible; box-shadow: none; border-right: 2px solid var(--ray-border); }
40
+ :host([orientation="vertical"]) .list { flex-direction: column; overflow-x: visible; box-shadow: none; border-inline-end: 2px solid var(--ray-border); }
28
41
  :host([variant="outline"]) .list { --_line: 1px; }
29
- :host([variant="outline"][orientation="vertical"]) .list { border-right-width: 1px; }
42
+ :host([variant="outline"][orientation="vertical"]) .list { border-inline-end-width: 1px; }
30
43
  :host([variant="pills"]) .list { gap: var(--ray-space-1); border: 0; box-shadow: none; }
31
44
  :host([grow]) .list ::slotted(ray-tab) { flex: 1 0 auto; }
32
45
  .panels { flex: 1; min-width: 0; padding-top: var(--ray-space-3); }
@@ -41,8 +54,9 @@ export class RayTabs extends RayElement {
41
54
  color: { type: String, reflect: true },
42
55
  activation: { type: String, default: "auto" },
43
56
  grow: { type: Boolean, reflect: true },
57
+ size: { type: String, reflect: true, default: "sm" },
44
58
  };
45
- static styles = [tabsStyles];
59
+ static styles = [sideScrollerStyles, tabsStyles];
46
60
  static template = `<div class="list" part="list" role="tablist"><slot name="tab"></slot></div><div class="panels" part="panels"><slot></slot></div>`;
47
61
 
48
62
  constructor() {
@@ -55,6 +69,20 @@ export class RayTabs extends RayElement {
55
69
  for (const slot of this.shadowRoot.querySelectorAll("slot")) {
56
70
  slot.addEventListener("slotchange", () => this.requestUpdate());
57
71
  }
72
+ this._scroller = new SideScroller(this, this.$(".list"), {
73
+ active: () => this.orientation !== "vertical",
74
+ onResize: () => this.requestUpdate(),
75
+ });
76
+ }
77
+
78
+ connectedCallback() {
79
+ super.connectedCallback();
80
+ this._scroller.connect();
81
+ }
82
+
83
+ disconnectedCallback() {
84
+ super.disconnectedCallback();
85
+ this._scroller.disconnect();
58
86
  }
59
87
 
60
88
  get tabs() {
@@ -98,6 +126,11 @@ export class RayTabs extends RayElement {
98
126
  panel.tabIndex = 0;
99
127
  panel.hidden = panel.value !== this.value;
100
128
  }
129
+ // Once laid out: a list with no width yet tries again on the next update.
130
+ if (this.value !== this._revealed && this._scroller.reveal(tabs.find((t) => t.value === this.value))) {
131
+ this._revealed = this.value;
132
+ }
133
+ this._scroller.update();
101
134
  }
102
135
 
103
136
  _select(tab, user) {
@@ -114,9 +147,11 @@ export class RayTabs extends RayElement {
114
147
  const vertical = this.orientation === "vertical";
115
148
  const next = vertical ? "ArrowDown" : "ArrowRight";
116
149
  const prev = vertical ? "ArrowUp" : "ArrowLeft";
150
+ // Right to left, ArrowLeft is the next tab.
151
+ const key = vertical ? e.key : inlineKey(e.key, this);
117
152
  let target = null;
118
- if (e.key === next) target = tabs[(i + 1) % tabs.length];
119
- else if (e.key === prev) target = tabs[(i - 1 + tabs.length) % tabs.length];
153
+ if (key === next) target = tabs[(i + 1) % tabs.length];
154
+ else if (key === prev) target = tabs[(i - 1 + tabs.length) % tabs.length];
120
155
  else if (e.key === "Home") target = tabs[0];
121
156
  else if (e.key === "End") target = tabs[tabs.length - 1];
122
157
  else if ((e.key === "Enter" || e.key === " ") && this.activation === "manual") {
@@ -136,10 +171,10 @@ const tabStyles = css`
136
171
  display: inline-flex;
137
172
  align-items: center;
138
173
  justify-content: center;
139
- gap: var(--ray-space-2);
140
- min-height: var(--ray-control-height-sm);
141
- padding: 0 var(--ray-space-4);
142
- font-size: var(--ray-font-size-sm);
174
+ gap: var(--_tab-gap, var(--ray-space-2));
175
+ min-height: var(--_tab-h, var(--ray-control-height-sm));
176
+ padding: 0 var(--_tab-px, var(--ray-space-4));
177
+ font-size: var(--_tab-fs, var(--ray-font-size-sm));
143
178
  font-weight: var(--ray-font-weight-medium);
144
179
  color: var(--ray-muted-foreground);
145
180
  white-space: nowrap;
@@ -157,14 +192,14 @@ const tabStyles = css`
157
192
  :host([data-variant="default"]) { border-bottom: 2px solid transparent; }
158
193
  :host([data-variant="default"]:hover:not([data-active])) { border-bottom-color: var(--ray-border); background: var(--ray-muted); }
159
194
  :host([data-variant="default"][data-active]) { border-bottom-color: var(--_filled, var(--ray-primary)); }
160
- :host([data-variant="default"][data-vertical]) { margin: 0 -2px 0 0; justify-content: flex-start; border-bottom: 0; border-right: 2px solid transparent; }
161
- :host([data-variant="default"][data-vertical][data-active]) { border-right-color: var(--_filled, var(--ray-primary)); }
195
+ :host([data-variant="default"][data-vertical]) { margin: 0; margin-inline-end: -2px; justify-content: flex-start; border-bottom: 0; border-inline-end: 2px solid transparent; }
196
+ :host([data-variant="default"][data-vertical][data-active]) { border-inline-end-color: var(--_filled, var(--ray-primary)); }
162
197
 
163
198
  /* outline */
164
199
  :host([data-variant="outline"]) { border: 1px solid transparent; border-radius: var(--ray-radius-default) var(--ray-radius-default) 0 0; }
165
200
  :host([data-variant="outline"][data-active]) { border-color: var(--ray-border); border-bottom-color: var(--ray-background); background: var(--ray-background); }
166
- :host([data-variant="outline"][data-vertical]) { margin: 0 -1px 0 0; justify-content: flex-start; border-radius: var(--ray-radius-default) 0 0 var(--ray-radius-default); }
167
- :host([data-variant="outline"][data-vertical][data-active]) { border-bottom-color: var(--ray-border); border-right-color: var(--ray-background); }
201
+ :host([data-variant="outline"][data-vertical]) { margin: 0; margin-inline-end: -1px; justify-content: flex-start; border-radius: 0; border-start-start-radius: var(--ray-radius-default); border-end-start-radius: var(--ray-radius-default); }
202
+ :host([data-variant="outline"][data-vertical][data-active]) { border-bottom-color: var(--ray-border); border-inline-end-color: var(--ray-background); }
168
203
 
169
204
  /* pills */
170
205
  :host([data-variant="pills"]) { border-radius: var(--ray-radius-default); }
@@ -174,15 +209,20 @@ const tabStyles = css`
174
209
  .section { display: inline-flex; }
175
210
  .section:not([data-filled]) { display: none; }
176
211
  ::slotted(svg) { width: 1rem; height: 1rem; }
212
+ /* count: a small number after the label, lighter than a badge. */
213
+ .count { font-size: 0.85em; font-variant-numeric: tabular-nums; color: var(--ray-muted-foreground); }
214
+ .count:empty { display: none; }
215
+ :host([data-active]) .count, :host(:hover) .count { color: inherit; opacity: 0.75; }
177
216
  `;
178
217
 
179
218
  export class RayTab extends RayElement {
180
219
  static properties = {
181
220
  value: { type: String, reflect: true },
182
221
  disabled: { type: Boolean, reflect: true },
222
+ count: { type: String, reflect: true },
183
223
  };
184
224
  static styles = [tabStyles];
185
- static template = `<span class="section" part="left-section"><slot name="left"></slot></span><slot></slot><span class="section" part="right-section"><slot name="right"></slot></span>`;
225
+ static template = `<span class="section" part="left-section"><slot name="left"></slot></span><slot></slot><span class="count" part="count"></span><span class="section" part="right-section"><slot name="right"></slot></span>`;
186
226
 
187
227
  constructor() {
188
228
  super();
@@ -198,6 +238,7 @@ export class RayTab extends RayElement {
198
238
  }
199
239
 
200
240
  update(changed) {
241
+ this.$(".count").textContent = this.count ?? "";
201
242
  if (changed.has("value") || changed.has("disabled")) this.closest("ray-tabs")?.requestUpdate();
202
243
  }
203
244
  }