@raylang/elements 0.7.0 → 0.8.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 (49) 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 +8 -6
  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 +1 -1
  14. package/components/date-picker/date-picker.js +5 -3
  15. package/components/dialog/dialog.js +1 -1
  16. package/components/drawer/drawer.js +27 -9
  17. package/components/icon/icon.js +4 -1
  18. package/components/indicator/indicator.js +2 -1
  19. package/components/menu/menu.js +1 -1
  20. package/components/nav-link/nav-link.js +5 -2
  21. package/components/notification/notification.js +15 -12
  22. package/components/progress/progress.js +2 -2
  23. package/components/radio/radio.js +4 -2
  24. package/components/scroll-area/scroll-area.js +14 -11
  25. package/components/search/search.js +3 -3
  26. package/components/segmented-control/segmented-control.js +5 -3
  27. package/components/select/select.js +3 -3
  28. package/components/separator/separator.js +8 -3
  29. package/components/sidebar/sidebar.js +14 -6
  30. package/components/slider/slider.js +5 -2
  31. package/components/split/split.js +12 -6
  32. package/components/stepper/stepper.js +4 -4
  33. package/components/switch/switch.js +3 -3
  34. package/components/table/table.js +5 -5
  35. package/components/tabs/tabs.js +11 -8
  36. package/components/timeline/timeline.js +13 -8
  37. package/components/toggle-group/toggle-group.js +6 -4
  38. package/core/core.js +6 -1
  39. package/core/direction.js +19 -0
  40. package/core/icons.js +12 -0
  41. package/core/position.js +11 -3
  42. package/core/styles.js +1 -1
  43. package/core.js +1 -0
  44. package/custom-elements.json +80 -62
  45. package/llms-full.txt +41 -17
  46. package/llms.txt +20 -2
  47. package/package.json +1 -1
  48. package/ray-elements.d.ts +32 -25
  49. package/vscode.html-custom-data.json +80 -18
@@ -42,7 +42,7 @@ const styles = css`
42
42
  .value {
43
43
  flex: none;
44
44
  min-width: 2.75em;
45
- text-align: right;
45
+ text-align: end;
46
46
  font-size: var(--ray-font-size-xs);
47
47
  font-variant-numeric: tabular-nums;
48
48
  color: var(--ray-muted-foreground);
@@ -59,7 +59,7 @@ const styles = css`
59
59
  width: 40%;
60
60
  animation: ray-indeterminate 1.4s var(--ray-easing-standard) infinite;
61
61
  }
62
- @keyframes ray-indeterminate { from { left: -40%; } to { left: 100%; } }
62
+ @keyframes ray-indeterminate { from { inset-inline-start: -40%; } to { inset-inline-start: 100%; } }
63
63
  `;
64
64
 
65
65
  export class RayProgress extends RayElement {
@@ -8,6 +8,7 @@
8
8
  // plans, presets, shipping methods. Both can share a group.
9
9
 
10
10
  import { RayElement, css, define } from "../../core/core.js";
11
+ import { inlineKey } from "../../core/direction.js";
11
12
  import { applyColor, sizeStyles } from "../../core/styles.js";
12
13
  import { FormControl, fieldStyles } from "../../core/form.js";
13
14
 
@@ -136,9 +137,10 @@ export class RayRadioGroup extends FormControl {
136
137
  const radios = this.radios.filter((r) => !r.disabled);
137
138
  const i = radios.indexOf(e.target.closest?.(RADIOS));
138
139
  if (i < 0) return;
140
+ const key = inlineKey(e.key, this);
139
141
  let next = null;
140
- if (e.key === "ArrowDown" || e.key === "ArrowRight") next = radios[(i + 1) % radios.length];
141
- else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = radios[(i - 1 + radios.length) % radios.length];
142
+ if (key === "ArrowDown" || key === "ArrowRight") next = radios[(i + 1) % radios.length];
143
+ else if (key === "ArrowUp" || key === "ArrowLeft") next = radios[(i - 1 + radios.length) % radios.length];
142
144
  else if (e.key === " ") next = radios[i];
143
145
  if (!next) return;
144
146
  e.preventDefault();
@@ -7,6 +7,7 @@
7
7
  // Give it a size: height / max-height (or CSS, or a flex/grid parent).
8
8
 
9
9
  import { RayElement, css, define } from "../../core/core.js";
10
+ import { isRtl } from "../../core/direction.js";
10
11
 
11
12
  const styles = css`
12
13
  :host {
@@ -47,12 +48,14 @@ const styles = css`
47
48
  transition: opacity var(--ray-duration-fast);
48
49
  }
49
50
  .edge[data-show] { opacity: 1; }
50
- .top, .bottom { left: 0; right: 0; height: 0.75rem; }
51
- .left, .right { top: 0; bottom: 0; width: 0.75rem; }
51
+ .top, .bottom { inset-inline: 0; height: 0.75rem; }
52
+ .start, .end { top: 0; bottom: 0; width: 0.75rem; }
52
53
  .top { top: 0; background: linear-gradient(var(--_shadow), transparent); }
53
54
  .bottom { bottom: 0; background: linear-gradient(transparent, var(--_shadow)); }
54
- .left { left: 0; background: linear-gradient(to right, var(--_shadow), transparent); }
55
- .right { right: 0; background: linear-gradient(to left, var(--_shadow), transparent); }
55
+ .start { inset-inline-start: 0; background: linear-gradient(to right, var(--_shadow), transparent); } /* physical */
56
+ .end { inset-inline-end: 0; background: linear-gradient(to left, var(--_shadow), transparent); } /* physical */
57
+ .start:dir(rtl) { background: linear-gradient(to left, var(--_shadow), transparent); } /* physical */
58
+ .end:dir(rtl) { background: linear-gradient(to right, var(--_shadow), transparent); } /* physical */
56
59
  :host([no-shadows]) .edge { display: none; }
57
60
  `;
58
61
 
@@ -70,7 +73,7 @@ export class RayScrollArea extends RayElement {
70
73
  static template = `
71
74
  <div class="viewport" part="viewport" tabindex="0"><slot></slot></div>
72
75
  <span class="edge top" part="shadow"></span><span class="edge bottom" part="shadow"></span>
73
- <span class="edge left" part="shadow"></span><span class="edge right" part="shadow"></span>`;
76
+ <span class="edge start" part="shadow"></span><span class="edge end" part="shadow"></span>`;
74
77
 
75
78
  #ro = null;
76
79
  #atEnd = false;
@@ -115,15 +118,15 @@ export class RayScrollArea extends RayElement {
115
118
  this._edges();
116
119
  }
117
120
 
118
- /** Scrolls to the start (top/left). */
121
+ /** Scrolls to the start (the top, or the left; the right right to left). */
119
122
  scrollToStart({ behavior = "smooth" } = {}) {
120
123
  this.viewport.scrollTo({ top: 0, left: 0, behavior });
121
124
  }
122
125
 
123
- /** Scrolls to the end (bottom, or right when horizontal). */
126
+ /** Scrolls to the end (the bottom, or the right when horizontal; the left right to left). */
124
127
  scrollToEnd({ behavior = "smooth" } = {}) {
125
128
  const v = this.viewport;
126
- if (this.orientation === "horizontal") v.scrollTo({ left: v.scrollWidth, behavior });
129
+ if (this.orientation === "horizontal") v.scrollTo({ left: isRtl(this) ? -v.scrollWidth : v.scrollWidth, behavior });
127
130
  else v.scrollTo({ top: v.scrollHeight, behavior });
128
131
  }
129
132
 
@@ -144,13 +147,13 @@ export class RayScrollArea extends RayElement {
144
147
  const x = Math.abs(v.scrollLeft);
145
148
  show("top", v.scrollTop > 1);
146
149
  show("bottom", maxY > 1 && v.scrollTop < maxY - 1);
147
- show("left", x > 1);
148
- show("right", maxX > 1 && x < maxX - 1);
150
+ show("start", x > 1);
151
+ show("end", maxX > 1 && x < maxX - 1);
149
152
  const horizontal = this.orientation === "horizontal";
150
153
  const left = horizontal ? maxX - x : maxY - v.scrollTop;
151
154
  const scrollable = horizontal ? maxX > 1 : maxY > 1;
152
155
  const atEnd = scrollable && left <= (this.threshold ?? 48);
153
- if (atEnd && !this.#atEnd) this.emit("ray-reach-end", { edge: horizontal ? "right" : "bottom" });
156
+ if (atEnd && !this.#atEnd) this.emit("ray-reach-end", { edge: !horizontal ? "bottom" : isRtl(this) ? "left" : "right" });
154
157
  this.#atEnd = atEnd;
155
158
  }
156
159
  }
@@ -14,14 +14,14 @@ const styles = css`
14
14
  :host(:not([variant])) .box { background: var(--ray-muted); border-color: transparent; }
15
15
  .lead {
16
16
  display: inline-flex;
17
- padding-left: calc(var(--_px) * 0.66);
17
+ padding-inline-start: calc(var(--_px) * 0.66);
18
18
  color: var(--ray-muted-foreground);
19
19
  pointer-events: none;
20
20
  }
21
21
  .lead svg { width: 1.1em; height: 1.1em; }
22
- .control { padding-left: calc(var(--_px) * 0.5); }
22
+ .control { padding-inline-start: calc(var(--_px) * 0.5); }
23
23
  .control::-webkit-search-cancel-button, .control::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
24
- .tail { display: inline-flex; align-items: center; gap: var(--ray-space-1); padding-right: calc(var(--_px) * 0.4); }
24
+ .tail { display: inline-flex; align-items: center; gap: var(--ray-space-1); padding-inline-end: calc(var(--_px) * 0.4); }
25
25
  .clear {
26
26
  display: inline-flex;
27
27
  align-items: center;
@@ -3,6 +3,7 @@
3
3
  // <option> elements or the `data` property. Form-associated.
4
4
 
5
5
  import { css, define } from "../../core/core.js";
6
+ import { inlineKey } from "../../core/direction.js";
6
7
  import { applyColor, sizeStyles, radiusStyles } from "../../core/styles.js";
7
8
  import { FormControl } from "../../core/form.js";
8
9
 
@@ -22,7 +23,7 @@ const styles = css`
22
23
  .indicator {
23
24
  position: absolute;
24
25
  top: 0;
25
- left: 0;
26
+ left: 0; /* physical */
26
27
  z-index: 0;
27
28
  box-sizing: border-box;
28
29
  background: var(--ray-raised);
@@ -170,9 +171,10 @@ export class RaySegmentedControl extends FormControl {
170
171
  _key(e) {
171
172
  const enabled = this._buttons.filter((b) => !b.disabled);
172
173
  const i = enabled.findIndex((b) => b.dataset.value === this.value);
174
+ const key = inlineKey(e.key, this);
173
175
  let next = null;
174
- if (e.key === "ArrowRight" || e.key === "ArrowDown") next = enabled[(i + 1) % enabled.length];
175
- else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = enabled[(i - 1 + enabled.length) % enabled.length];
176
+ if (key === "ArrowRight" || key === "ArrowDown") next = enabled[(i + 1) % enabled.length];
177
+ else if (key === "ArrowLeft" || key === "ArrowUp") next = enabled[(i - 1 + enabled.length) % enabled.length];
176
178
  if (!next) return;
177
179
  e.preventDefault();
178
180
  this._choose(next.dataset.value, true);
@@ -30,8 +30,8 @@ const styles = css`
30
30
  align-items: center;
31
31
  width: 100%;
32
32
  min-width: 0;
33
- padding-right: calc(var(--_h) * 0.8);
34
- text-align: left;
33
+ padding-inline-end: calc(var(--_h) * 0.8);
34
+ text-align: start;
35
35
  color: inherit;
36
36
  background: transparent;
37
37
  border: 0;
@@ -43,7 +43,7 @@ const styles = css`
43
43
  .trigger[data-placeholder] .value { color: var(--ray-muted-foreground); }
44
44
  .chevron {
45
45
  position: absolute;
46
- right: 0;
46
+ inset-inline-end: 0;
47
47
  display: inline-flex;
48
48
  align-items: center;
49
49
  justify-content: center;
@@ -18,7 +18,7 @@ const styles = css`
18
18
  :host([variant="dotted"]) { --_style: dotted; }
19
19
  :host([orientation="vertical"]) { display: inline-block; align-self: stretch; height: auto; min-height: 1em; vertical-align: middle; }
20
20
  .line { border-top: var(--_w) var(--_style) var(--_color); }
21
- :host([orientation="vertical"]) .line { height: 100%; border-top: 0; border-left: var(--_w) var(--_style) var(--_color); }
21
+ :host([orientation="vertical"]) .line { height: 100%; border-top: 0; border-inline-start: var(--_w) var(--_style) var(--_color); }
22
22
  .labeled {
23
23
  display: flex;
24
24
  align-items: center;
@@ -27,8 +27,13 @@ const styles = css`
27
27
  color: var(--ray-muted-foreground);
28
28
  }
29
29
  .labeled::before, .labeled::after { content: ""; flex: 1; border-top: var(--_w) var(--_style) var(--_color); }
30
- :host([label-position="left"]) .labeled::before { flex: 0 0 var(--ray-space-3); }
31
- :host([label-position="right"]) .labeled::after { flex: 0 0 var(--ray-space-3); }
30
+ /* start/end follow the writing direction; left/right are sides of the screen. */
31
+ :host([label-position="start"]) .labeled::before,
32
+ :host([label-position="left"]) .labeled:dir(ltr)::before,
33
+ :host([label-position="right"]) .labeled:dir(rtl)::before,
34
+ :host([label-position="end"]) .labeled::after,
35
+ :host([label-position="right"]) .labeled:dir(ltr)::after,
36
+ :host([label-position="left"]) .labeled:dir(rtl)::after { flex: 0 0 var(--ray-space-3); }
32
37
  [hidden] { display: none !important; }
33
38
  `;
34
39
 
@@ -38,7 +38,11 @@ const styles = css`
38
38
  background: var(--ray-sidebar, color-mix(in srgb, var(--ray-muted) 70%, var(--ray-background)));
39
39
  border-inline-end: 1px solid var(--ray-border);
40
40
  }
41
- :host([side="right"]) .panel { border-inline-end: 0; border-inline-start: 1px solid var(--ray-border); }
41
+ /* side: start/end follow the writing direction, left/right are sides of
42
+ the screen; the border faces the content. */
43
+ :host([side="end"]) .panel { border-inline-end: 0; border-inline-start: 1px solid var(--ray-border); }
44
+ :host([side="left"]) .panel { border: 0; border-right: 1px solid var(--ray-border); } /* physical */
45
+ :host([side="right"]) .panel { border: 0; border-left: 1px solid var(--ray-border); } /* physical */
42
46
  :host([variant="floating"]:not([data-mobile])) { padding: var(--ray-space-2); box-sizing: border-box; }
43
47
  :host([variant="floating"]) .panel { border: 1px solid var(--ray-border); border-radius: var(--ray-radius-lg); box-shadow: var(--ray-shadow-sm); }
44
48
  .header, .footer { flex: none; padding: var(--ray-space-2); }
@@ -49,7 +53,8 @@ const styles = css`
49
53
  dialog {
50
54
  --_from: -100%;
51
55
  position: fixed;
52
- inset: 0 auto 0 0;
56
+ inset-block: 0;
57
+ inset-inline: 0 auto;
53
58
  box-sizing: border-box;
54
59
  width: min(var(--_w), calc(100vw - 3rem));
55
60
  max-width: none;
@@ -61,11 +66,14 @@ const styles = css`
61
66
  background: transparent;
62
67
  overflow: visible;
63
68
  }
64
- :host([side="right"]) dialog { inset: 0 0 0 auto; }
69
+ dialog:dir(rtl) { --_from: 100%; }
70
+ :host([side="end"]) dialog { inset-inline: auto 0; --_from: 100%; }
71
+ :host([side="end"]) dialog:dir(rtl) { --_from: -100%; }
72
+ :host([side="left"]) dialog { inset: 0 auto 0 0; --_from: -100%; } /* physical */
73
+ :host([side="right"]) dialog { inset: 0 0 0 auto; --_from: 100%; } /* physical */
65
74
  dialog[open] { display: block; animation: ray-sidebar-in var(--ray-duration-slow) var(--ray-easing-out); }
66
75
  dialog .panel { box-shadow: var(--ray-shadow-xl); background: var(--ray-card); }
67
76
  @keyframes ray-sidebar-in { from { transform: translateX(var(--_from)); } }
68
- :host([side="right"]) dialog { --_from: 100%; }
69
77
  dialog::backdrop { animation: ray-fade-in var(--ray-duration-base) var(--ray-easing-out); }
70
78
  @keyframes ray-fade-in { from { opacity: 0; } }
71
79
  `;
@@ -74,7 +82,7 @@ export class RaySidebar extends RayElement {
74
82
  static properties = {
75
83
  collapsed: { type: Boolean, reflect: true },
76
84
  open: { type: Boolean, reflect: true },
77
- side: { type: String, reflect: true, default: "left" },
85
+ side: { type: String, reflect: true, default: "start" },
78
86
  variant: { type: String, reflect: true, default: "sidebar" },
79
87
  width: { type: String },
80
88
  collapsedWidth: { type: String },
@@ -193,7 +201,7 @@ export class RaySidebar extends RayElement {
193
201
  }
194
202
 
195
203
  async _leave() {
196
- const to = this.side === "right" ? "translateX(100%)" : "translateX(-100%)";
204
+ const to = `translateX(${getComputedStyle(this.dialog).getPropertyValue("--_from").trim() || "-100%"})`;
197
205
  const exit = playAll([
198
206
  play(this.dialog, [{ transform: "none" }, { transform: to }]),
199
207
  play(this.dialog, [{ opacity: 1 }, { opacity: 0 }], { pseudoElement: "::backdrop" }),
@@ -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
  }
@@ -7,6 +7,7 @@
7
7
  // pane. With `autosave="key"` the size survives reloads (localStorage).
8
8
 
9
9
  import { RayElement, css, define } from "../../core/core.js";
10
+ import { isRtl, inlineKey } from "../../core/direction.js";
10
11
 
11
12
  const styles = css`
12
13
  :host {
@@ -34,13 +35,13 @@ const styles = css`
34
35
  position: absolute;
35
36
  top: 0;
36
37
  bottom: 0;
37
- left: 50%;
38
+ left: 50%; /* physical */
38
39
  width: 1px;
39
40
  background: var(--ray-border);
40
41
  transform: translateX(-50%);
41
42
  transition: background-color var(--ray-duration-fast) var(--ray-easing-standard), width var(--ray-duration-fast) var(--ray-easing-standard);
42
43
  }
43
- :host([orientation="vertical"]) .divider::before { top: 50%; bottom: auto; left: 0; right: 0; width: auto; height: 1px; transform: translateY(-50%); }
44
+ :host([orientation="vertical"]) .divider::before { top: 50%; bottom: auto; inset-inline: 0; width: auto; height: 1px; transform: translateY(-50%); }
44
45
  :host(:not([disabled])) .divider:hover::before,
45
46
  .divider:focus-visible::before,
46
47
  :host([data-dragging]) .divider::before { width: 3px; background: var(--ray-primary); }
@@ -182,13 +183,16 @@ export class RaySplit extends RayElement {
182
183
  this.setAttribute("data-dragging", "");
183
184
  const rect = this.getBoundingClientRect();
184
185
  const vertical = this._vertical();
185
- const move = (ev) => this._resizeTo(vertical ? ev.clientY - rect.top : ev.clientX - rect.left, false);
186
+ // The start pane's size: right to left it is on the right.
187
+ const rtl = !vertical && isRtl(this);
188
+ const at = (ev) => (vertical ? ev.clientY - rect.top : rtl ? rect.right - ev.clientX : ev.clientX - rect.left);
189
+ const move = (ev) => this._resizeTo(at(ev), false);
186
190
  const up = (ev) => {
187
191
  this.divider.removeEventListener("pointermove", move);
188
192
  this.divider.removeEventListener("pointerup", up);
189
193
  this.divider.removeEventListener("pointercancel", up);
190
194
  this.removeAttribute("data-dragging");
191
- this._resizeTo(vertical ? ev.clientY - rect.top : ev.clientX - rect.left, true);
195
+ this._resizeTo(at(ev), true);
192
196
  };
193
197
  this.divider.addEventListener("pointermove", move);
194
198
  this.divider.addEventListener("pointerup", up);
@@ -202,9 +206,11 @@ export class RaySplit extends RayElement {
202
206
  const current = this.collapsed ? 0 : this.size;
203
207
  const less = vertical ? "ArrowUp" : "ArrowLeft";
204
208
  const more = vertical ? "ArrowDown" : "ArrowRight";
209
+ // Right to left, ArrowLeft moves the divider away from the start pane.
210
+ const key = vertical ? e.key : inlineKey(e.key, this);
205
211
  let next = null;
206
- if (e.key === less) next = current - step;
207
- else if (e.key === more) next = current + step;
212
+ if (key === less) next = current - step;
213
+ else if (key === more) next = current + step;
208
214
  else if (e.key === "Home") next = this.min ?? 0;
209
215
  else if (e.key === "End") next = this.max ?? this._total();
210
216
  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; }
@@ -5,6 +5,7 @@
5
5
  // panel share a `value`.
6
6
 
7
7
  import { RayElement, css, define, uid } from "../../core/core.js";
8
+ import { inlineKey } from "../../core/direction.js";
8
9
  import { applyColor } from "../../core/styles.js";
9
10
 
10
11
  const tabsStyles = css`
@@ -24,9 +25,9 @@ const tabsStyles = css`
24
25
  }
25
26
  .list::-webkit-scrollbar { display: none; }
26
27
  .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); }
28
+ :host([orientation="vertical"]) .list { flex-direction: column; overflow-x: visible; box-shadow: none; border-inline-end: 2px solid var(--ray-border); }
28
29
  :host([variant="outline"]) .list { --_line: 1px; }
29
- :host([variant="outline"][orientation="vertical"]) .list { border-right-width: 1px; }
30
+ :host([variant="outline"][orientation="vertical"]) .list { border-inline-end-width: 1px; }
30
31
  :host([variant="pills"]) .list { gap: var(--ray-space-1); border: 0; box-shadow: none; }
31
32
  :host([grow]) .list ::slotted(ray-tab) { flex: 1 0 auto; }
32
33
  .panels { flex: 1; min-width: 0; padding-top: var(--ray-space-3); }
@@ -114,9 +115,11 @@ export class RayTabs extends RayElement {
114
115
  const vertical = this.orientation === "vertical";
115
116
  const next = vertical ? "ArrowDown" : "ArrowRight";
116
117
  const prev = vertical ? "ArrowUp" : "ArrowLeft";
118
+ // Right to left, ArrowLeft is the next tab.
119
+ const key = vertical ? e.key : inlineKey(e.key, this);
117
120
  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];
121
+ if (key === next) target = tabs[(i + 1) % tabs.length];
122
+ else if (key === prev) target = tabs[(i - 1 + tabs.length) % tabs.length];
120
123
  else if (e.key === "Home") target = tabs[0];
121
124
  else if (e.key === "End") target = tabs[tabs.length - 1];
122
125
  else if ((e.key === "Enter" || e.key === " ") && this.activation === "manual") {
@@ -157,14 +160,14 @@ const tabStyles = css`
157
160
  :host([data-variant="default"]) { border-bottom: 2px solid transparent; }
158
161
  :host([data-variant="default"]:hover:not([data-active])) { border-bottom-color: var(--ray-border); background: var(--ray-muted); }
159
162
  :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)); }
163
+ :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; }
164
+ :host([data-variant="default"][data-vertical][data-active]) { border-inline-end-color: var(--_filled, var(--ray-primary)); }
162
165
 
163
166
  /* outline */
164
167
  :host([data-variant="outline"]) { border: 1px solid transparent; border-radius: var(--ray-radius-default) var(--ray-radius-default) 0 0; }
165
168
  :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); }
169
+ :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); }
170
+ :host([data-variant="outline"][data-vertical][data-active]) { border-bottom-color: var(--ray-border); border-inline-end-color: var(--ray-background); }
168
171
 
169
172
  /* pills */
170
173
  :host([data-variant="pills"]) { border-radius: var(--ray-radius-default); }
@@ -22,9 +22,14 @@ const itemStyles = css`
22
22
  }
23
23
  :host([hidden]) { display: none; }
24
24
  :host([data-last]) { padding-bottom: 0; }
25
- :host([data-align="right"]) { grid-template-columns: minmax(0, 1fr) var(--_bullet); text-align: right; }
26
- :host([data-align="right"]) .bullet-col { grid-column: 2; grid-row: 1; }
27
- :host([data-align="right"]) .body { grid-column: 1; grid-row: 1; }
25
+ /* The line at the end: end, or left/right as sides of the screen (the
26
+ grid's columns follow the writing direction). */
27
+ :host([data-align="end"]), :host([data-align="right"]:dir(ltr)), :host([data-align="left"]:dir(rtl)) {
28
+ grid-template-columns: minmax(0, 1fr) var(--_bullet);
29
+ text-align: end;
30
+ }
31
+ :host([data-align="end"]) .bullet-col, :host([data-align="right"]:dir(ltr)) .bullet-col, :host([data-align="left"]:dir(rtl)) .bullet-col { grid-column: 2; grid-row: 1; }
32
+ :host([data-align="end"]) .body, :host([data-align="right"]:dir(ltr)) .body, :host([data-align="left"]:dir(rtl)) .body { grid-column: 1; grid-row: 1; }
28
33
  .bullet-col { position: relative; display: flex; justify-content: center; }
29
34
  .bullet {
30
35
  position: relative;
@@ -47,12 +52,12 @@ const itemStyles = css`
47
52
  position: absolute;
48
53
  top: var(--_bullet);
49
54
  bottom: calc(-1 * var(--ray-space-6));
50
- left: calc(50% - var(--_line) / 2);
55
+ left: calc(50% - var(--_line) / 2); /* physical */
51
56
  width: 0;
52
- border-left: var(--_line) var(--_line-style, solid) var(--ray-border);
57
+ border-left: var(--_line) var(--_line-style, solid) var(--ray-border); /* physical */
53
58
  }
54
59
  :host([data-last]) .line { display: none; }
55
- :host([data-line-reached]) .line { border-left-color: var(--_filled); }
60
+ :host([data-line-reached]) .line { border-left-color: var(--_filled); } /* physical */
56
61
  :host([line-variant="dashed"]) .line { --_line-style: dashed; }
57
62
  :host([line-variant="dotted"]) .line { --_line-style: dotted; }
58
63
  .body { min-width: 0; padding-top: calc((var(--_bullet) - 1.4em) / 2); }
@@ -67,7 +72,7 @@ const itemStyles = css`
67
72
  export class RayTimeline extends RayElement {
68
73
  static properties = {
69
74
  active: { type: Number, reflect: true, default: -1 },
70
- align: { type: String, reflect: true, default: "left" },
75
+ align: { type: String, reflect: true, default: "start" },
71
76
  bulletSize: { type: String, attribute: "bullet-size" },
72
77
  lineWidth: { type: Number, attribute: "line-width" },
73
78
  color: { type: String, reflect: true },
@@ -106,7 +111,7 @@ export class RayTimeline extends RayElement {
106
111
  item.toggleAttribute("data-reached", active >= 0 && k <= active);
107
112
  item.toggleAttribute("data-line-reached", active >= 0 && (this.reverseActive ? k <= active && k > 0 : k < active));
108
113
  item.toggleAttribute("data-last", i === n - 1);
109
- item.setAttribute("data-align", this.align === "right" ? "right" : "left");
114
+ item.setAttribute("data-align", ["end", "left", "right"].includes(this.align) ? this.align : "start");
110
115
  });
111
116
  }
112
117
  }
@@ -13,6 +13,7 @@
13
13
  // </ray-toggle-group>
14
14
 
15
15
  import { css, define } from "../../core/core.js";
16
+ import { inlineKey } from "../../core/direction.js";
16
17
  import { RayButtonGroup } from "../button-group/button-group.js";
17
18
  import "../toggle/toggle.js";
18
19
 
@@ -113,14 +114,15 @@ export class RayToggleGroup extends RayButtonGroup {
113
114
  const vertical = this.orientation === "vertical";
114
115
  const next = vertical ? "ArrowDown" : "ArrowRight";
115
116
  const prev = vertical ? "ArrowUp" : "ArrowLeft";
116
- if (![next, prev, "Home", "End"].includes(e.key)) return;
117
+ const key = vertical ? e.key : inlineKey(e.key, this);
118
+ if (![next, prev, "Home", "End"].includes(key)) return;
117
119
  const items = this.toggles.filter((t) => !t.disabled);
118
120
  if (items.length === 0) return;
119
121
  const current = items.findIndex((t) => t.matches(":focus-within"));
120
122
  let i = current;
121
- if (e.key === "Home") i = 0;
122
- else if (e.key === "End") i = items.length - 1;
123
- else if (e.key === next) i = (current + 1) % items.length;
123
+ if (key === "Home") i = 0;
124
+ else if (key === "End") i = items.length - 1;
125
+ else if (key === next) i = (current + 1) % items.length;
124
126
  else i = (current - 1 + items.length) % items.length;
125
127
  e.preventDefault();
126
128
  this.#rove(items[i]);
package/core/core.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // the theme API. No dependencies; loads as a plain ES module (also from
3
3
  // ray://app in a std/ui window).
4
4
 
5
- import { getIcon, onIconsChange } from "./icons.js";
5
+ import { getIcon, mirrorsInRtl, onIconsChange } from "./icons.js";
6
6
  import { parseColor, shades, contrastRatio, toHex, toOklch } from "./color.js";
7
7
 
8
8
  export { setIcons, getIcon, iconNames, iconAliases } from "./icons.js";
@@ -59,6 +59,8 @@ export const hostStyles = css`
59
59
  height: var(--ray-icon-size, 1em);
60
60
  stroke-width: var(--ray-icon-stroke, 2);
61
61
  }
62
+ /* Right to left, the roles that point along the text. */
63
+ :where([data-ray-icon-mirror]):dir(rtl) > svg { transform: scaleX(-1); }
62
64
  .focus-ring:focus-visible {
63
65
  outline: var(--ray-focus-ring-width) solid var(--ray-ring);
64
66
  outline-offset: var(--ray-focus-ring-offset);
@@ -315,11 +317,14 @@ export class RayElement extends HTMLElement {
315
317
  * it for markup they create after the template. An empty name or "none"
316
318
  * (the value components use to hide an icon) leaves the slot empty;
317
319
  * data-ray-icon-filled asks for the filled version (the outline without one).
320
+ * A role that points along the text (ui.next…) gets data-ray-icon-mirror,
321
+ * which mirrors it under dir="rtl".
318
322
  */
319
323
  paintIcons(root = this.shadowRoot) {
320
324
  for (const slot of root.querySelectorAll("[data-ray-icon]")) {
321
325
  const name = slot.getAttribute("data-ray-icon");
322
326
  slot.innerHTML = name && name !== "none" ? getIcon(name, { filled: slot.hasAttribute("data-ray-icon-filled") }) : "";
327
+ slot.toggleAttribute("data-ray-icon-mirror", mirrorsInRtl(name));
323
328
  }
324
329
  }
325
330
  }
@@ -0,0 +1,19 @@
1
+ // The writing direction, for what CSS does not mirror by itself: arrow keys
2
+ // and pointer or scroll coordinates. Read when used (not cached), so a
3
+ // change of dir takes effect at once.
4
+
5
+ /** Whether `el` lays out right to left (dir="rtl" on it or an ancestor). */
6
+ export function isRtl(el) {
7
+ return getComputedStyle(el).direction === "rtl";
8
+ }
9
+
10
+ /**
11
+ * An arrow key as the reading direction sees it. Right to left, ArrowLeft
12
+ * moves forward, so it comes back as "ArrowRight", and ArrowRight as
13
+ * "ArrowLeft"; any other key as it is.
14
+ * if (inlineKey(e.key, this) === "ArrowRight") this.next();
15
+ */
16
+ export function inlineKey(key, el) {
17
+ if ((key === "ArrowLeft" || key === "ArrowRight") && isRtl(el)) return key === "ArrowLeft" ? "ArrowRight" : "ArrowLeft";
18
+ return key;
19
+ }