@raylang/elements 0.6.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 (61) hide show
  1. package/base.css +1 -1
  2. package/check/check.d.ts +2 -0
  3. package/check/check.js +18 -4
  4. package/check/cli.js +10 -6
  5. package/components/accordion/accordion.js +8 -5
  6. package/components/alert/alert.js +3 -2
  7. package/components/attachment/attachment.js +5 -6
  8. package/components/avatar/avatar.js +1 -1
  9. package/components/blockquote/blockquote.js +2 -2
  10. package/components/bottom-nav/bottom-nav.js +5 -3
  11. package/components/breadcrumbs/breadcrumbs.js +1 -1
  12. package/components/carousel/carousel.js +26 -14
  13. package/components/checkbox/checkbox.js +6 -3
  14. package/components/checkbox-group/checkbox-group.js +1 -1
  15. package/components/code/code.js +9 -7
  16. package/components/color-picker/color-picker.js +6 -6
  17. package/components/combobox/combobox.js +5 -4
  18. package/components/command-palette/command-palette.js +2 -2
  19. package/components/date-picker/date-picker.js +9 -7
  20. package/components/dialog/dialog.js +2 -2
  21. package/components/drawer/drawer.js +28 -10
  22. package/components/file-upload/file-upload.js +1 -1
  23. package/components/icon/icon.js +9 -1
  24. package/components/indicator/indicator.js +2 -1
  25. package/components/menu/menu.js +1 -1
  26. package/components/nav-link/nav-link.js +6 -3
  27. package/components/notification/notification.js +16 -13
  28. package/components/number-input/number-input.js +1 -1
  29. package/components/pagination/pagination.js +4 -4
  30. package/components/password-input/password-input.js +2 -2
  31. package/components/progress/progress.js +2 -2
  32. package/components/radio/radio.js +4 -2
  33. package/components/scroll-area/scroll-area.js +14 -11
  34. package/components/search/search.js +5 -5
  35. package/components/segmented-control/segmented-control.js +5 -3
  36. package/components/select/select.js +5 -5
  37. package/components/separator/separator.js +8 -3
  38. package/components/sidebar/sidebar.js +14 -6
  39. package/components/slider/slider.js +5 -2
  40. package/components/split/split.js +12 -6
  41. package/components/stat/stat.js +1 -1
  42. package/components/step-dots/step-dots.js +11 -2
  43. package/components/stepper/stepper.js +5 -5
  44. package/components/switch/switch.js +3 -3
  45. package/components/table/table.js +6 -6
  46. package/components/tabs/tabs.js +11 -8
  47. package/components/theme-toggle/theme-toggle.js +2 -2
  48. package/components/timeline/timeline.js +13 -8
  49. package/components/toggle-group/toggle-group.js +6 -4
  50. package/core/core.js +10 -4
  51. package/core/direction.js +19 -0
  52. package/core/icons.js +107 -8
  53. package/core/position.js +11 -3
  54. package/core/styles.js +1 -1
  55. package/core.js +1 -0
  56. package/custom-elements.json +151 -79
  57. package/llms-full.txt +75 -31
  58. package/llms.txt +32 -3
  59. package/package.json +1 -1
  60. package/ray-elements.d.ts +56 -39
  61. package/vscode.html-custom-data.json +1102 -39
package/base.css CHANGED
@@ -84,7 +84,7 @@
84
84
  }
85
85
  :where(.ray-table) :where(th, td) {
86
86
  padding: var(--ray-space-2) var(--ray-space-3);
87
- text-align: left;
87
+ text-align: start;
88
88
  vertical-align: middle;
89
89
  border-bottom: 1px solid var(--ray-border);
90
90
  }
package/check/check.d.ts CHANGED
@@ -14,6 +14,8 @@ export interface CheckOptions {
14
14
  manifest: object | object[];
15
15
  /** The registered icon names; without them icons are not checked. */
16
16
  icons?: string[];
17
+ /** The icon names that have a filled version (iconNames({ filled: true })); without them filled is not checked. */
18
+ filledIcons?: string[];
17
19
  /** More values of an attribute, for every element: an app's own variant, { variant: ["brand"] }. */
18
20
  allow?: Record<string, string[]>;
19
21
  /** Reported in the diagnostics; its extension picks the language. */
package/check/check.js CHANGED
@@ -20,6 +20,7 @@ export const RULES = {
20
20
  "unknown-event": "a ray-* event the element does not fire",
21
21
  "unknown-icon": "an icon name that is not registered",
22
22
  "missing-label": "an icon-only button without its accessible name",
23
+ "no-filled-icon": "filled on an icon that has no filled version (the outline one is drawn)",
23
24
  "literal-color": "a literal color in CSS instead of a token",
24
25
  };
25
26
 
@@ -83,7 +84,9 @@ function attribute(a) {
83
84
  const literals = parts.filter((s) => /^"[^"]*"$/.test(s)).map((s) => s.slice(1, -1));
84
85
  const others = parts.filter((s) => !/^"[^"]*"$/.test(s) && s !== "undefined" && s !== "null");
85
86
  const length = others.length === 1 && others[0] === "(string & {})";
86
- return { name: a.name, boolean: text === "boolean", values: literals.length > 0 && (others.length === 0 || length) ? literals : null, length };
87
+ // IconName (ray-icon's name, the icon of an alert, a nav item…): checked against the icons.
88
+ const icon = others.includes("IconName");
89
+ return { name: a.name, boolean: text === "boolean", values: literals.length > 0 && (others.length === 0 || length) ? literals : null, length, icon };
87
90
  }
88
91
 
89
92
  const LENGTH = /^(?:-?(?:\d+\.?\d*|\.\d+)(?:px|r?em|ex|ch|lh|vw|vh|vmin|vmax|dvh|svh|lvh|cm|mm|in|pt|pc|q|%)|0|(?:calc|min|max|clamp|var)\(.*\))$/i;
@@ -99,14 +102,16 @@ export function languageOf(file) {
99
102
  /**
100
103
  * Checks one file's markup.
101
104
  * @param {string} source
102
- * @param {{ manifest: object | object[], icons?: string[], allow?: Record<string, string[]>, file?: string, lang?: "html" | "vue" | "jsx" }} options
105
+ * @param {{ manifest: object | object[], icons?: string[], filledIcons?: string[], allow?: Record<string, string[]>, file?: string, lang?: "html" | "vue" | "jsx" }} options
106
+ * filledIcons: the names with a filled version (iconNames({ filled: true }))
103
107
  * allow: more values of an attribute, for every element (an app's own variant: { variant: ["brand"] })
104
108
  * @returns {Diagnostic[]}
105
109
  */
106
- export function checkMarkup(source, { manifest, icons, allow = {}, file = "<input>", lang } = {}) {
110
+ export function checkMarkup(source, { manifest, icons, filledIcons, allow = {}, file = "<input>", lang } = {}) {
107
111
  const index = indexManifest(manifest);
108
112
  const language = lang ?? languageOf(file);
109
113
  const iconSet = icons ? new Set(icons) : null;
114
+ const filledSet = filledIcons ? new Set(filledIcons) : null;
110
115
  const lines = lineStarts(source);
111
116
  const out = [];
112
117
  const report = (pos, severity, rule, message) => {
@@ -138,6 +143,15 @@ export function checkMarkup(source, { manifest, icons, allow = {}, file = "<inpu
138
143
  const named = tok.attrs.some((a) => ["label", "aria-label", "aria-labelledby"].includes(normalize(a.name, language).name));
139
144
  if (!named) report(tok.pos, "error", "missing-label", `<${tok.name}> needs a label: it is the button's accessible name (and its tooltip)`);
140
145
  }
146
+ if (el?.tag === "ray-icon" && filledSet) {
147
+ const attr = (n) => tok.attrs.find((a) => normalize(a.name, language).name === n);
148
+ const filled = attr("filled");
149
+ const name = attr("name");
150
+ // Bound or not: an icon that is ever filled needs the filled version.
151
+ if (filled && name && name.value && !name.dynamic && !normalize(name.name, language).bound && name.value !== "none" && !filledSet.has(name.value)) {
152
+ report(filled.pos, "warning", "no-filled-icon", `"${name.value}" has no filled version: the outline icon is drawn (add one with setIcons({ "${name.value}:filled": … }))`);
153
+ }
154
+ }
141
155
  if (!tok.selfClosing && !(language !== "jsx" && VOID.has(tok.name.toLowerCase()))) {
142
156
  stack.push({ name: tok.name, el });
143
157
  }
@@ -231,7 +245,7 @@ function attributeChecks(a, tok, el, parent, language, icons, allow, report) {
231
245
  report(a.pos, "error", "invalid-value", `${name}="${a.value}" is not valid on <${tok.name}>: one of ${attr.values.map((v) => `"${v}"`).join(", ")}${or}`);
232
246
  }
233
247
  // "none" (or nothing) draws no icon: a blank that keeps the alignment.
234
- if (el.tag === "ray-icon" && name === "name" && icons && a.value !== "" && a.value !== "none" && !icons.has(a.value)) {
248
+ if (attr.icon && icons && a.value !== "" && a.value !== "none" && !icons.has(a.value)) {
235
249
  report(a.pos, "warning", "unknown-icon", `no icon named "${a.value}"${hint(a.value, [...icons], "icons")} (register it with setIcons)`);
236
250
  }
237
251
  }
package/check/cli.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import { readFileSync, readdirSync, statSync } from "node:fs";
9
9
  import { join, relative } from "node:path";
10
10
  import { checkMarkup, customValues, RULES } from "./check.js";
11
- import { iconNames } from "../core/icons.js";
11
+ import { iconNames, iconAliases } from "../core/icons.js";
12
12
 
13
13
  const EXTENSIONS = new Set(["html", "htm", "vue", "svelte", "astro", "jsx", "tsx"]);
14
14
  // Read for what an app adds: setIcons({ … }) and its own variants in CSS.
@@ -88,10 +88,14 @@ function main(argv) {
88
88
  const files = [];
89
89
  const scripts = [];
90
90
  for (const p of paths.length > 0 ? paths : ["."]) collect(p, files, scripts, true);
91
- const icons = new Set([...iconNames(), ...extraIcons]);
91
+ const icons = new Set([...iconNames(), ...Object.keys(iconAliases()), ...extraIcons]);
92
+ const filledIcons = new Set(iconNames({ filled: true }));
92
93
  for (const f of scripts) {
93
94
  const src = readFileSync(f, "utf8");
94
- for (const name of registeredIcons(src)) icons.add(name);
95
+ for (const name of registeredIcons(src)) {
96
+ if (name.endsWith(":filled")) filledIcons.add(name.slice(0, -":filled".length));
97
+ else icons.add(name);
98
+ }
95
99
  for (const [attr, values] of Object.entries(customValues(src))) values.forEach((v) => allowValue(attr, v));
96
100
  }
97
101
 
@@ -99,7 +103,7 @@ function main(argv) {
99
103
  for (const f of files) {
100
104
  const source = readFileSync(f, "utf8");
101
105
  const file = relative(process.cwd(), f) || f;
102
- for (const d of checkMarkup(source, { manifest: manifests, icons: [...icons], allow, file })) {
106
+ for (const d of checkMarkup(source, { manifest: manifests, icons: [...icons], filledIcons: [...filledIcons], allow, file })) {
103
107
  if (!ignore.has(d.rule)) diagnostics.push(d);
104
108
  }
105
109
  }
@@ -128,11 +132,11 @@ function collect(path, files, scripts, explicit) {
128
132
  if (SCRIPTS.has(ext)) scripts.push(path);
129
133
  }
130
134
 
131
- // The keys of setIcons({ inbox: '<svg…>', "file-text": … }) calls.
135
+ // The keys of setIcons({ inbox: '<svg…>', "file-text": …, "star:filled": …, "ui.remove": … }) calls.
132
136
  function registeredIcons(src) {
133
137
  const out = [];
134
138
  for (const call of src.matchAll(/setIcons\(\s*\{([\s\S]*?)\}\s*\)/g)) {
135
- for (const key of call[1].matchAll(/(?:^|[,{\s])(["']?)([A-Za-z][\w-]*)\1\s*:/g)) out.push(key[2]);
139
+ for (const key of call[1].matchAll(/(?:^|[,{\s])(?:(["'])([A-Za-z][\w.-]*(?::filled)?)\1|([A-Za-z][\w-]*))\s*:/g)) out.push(key[2] ?? key[3]);
136
140
  }
137
141
  return out;
138
142
  }
@@ -17,7 +17,7 @@ export class RayAccordion extends RayElement {
17
17
  value: { type: String },
18
18
  multiple: { type: Boolean, reflect: true },
19
19
  variant: { type: String, reflect: true, default: "default" },
20
- chevronPosition: { type: String, reflect: true, default: "right" },
20
+ chevronPosition: { type: String, reflect: true, default: "end" },
21
21
  };
22
22
  static styles = [accordionStyles];
23
23
  static template = `<div class="items" part="items"><slot></slot></div>`;
@@ -52,7 +52,7 @@ export class RayAccordion extends RayElement {
52
52
  items.forEach((item, i) => {
53
53
  item._setOpen(open.includes(item.value));
54
54
  item.dataset.variant = this.variant || "default";
55
- item.dataset.chevron = this.chevronPosition || "right";
55
+ item.dataset.chevron = this.chevronPosition || "end";
56
56
  item.toggleAttribute("data-first", i === 0);
57
57
  item.toggleAttribute("data-last", i === items.length - 1);
58
58
  });
@@ -104,7 +104,7 @@ const itemStyles = css`
104
104
  font-size: var(--ray-font-size-md);
105
105
  font-weight: var(--ray-font-weight-medium);
106
106
  color: var(--ray-foreground);
107
- text-align: left;
107
+ text-align: start;
108
108
  background: transparent;
109
109
  border: 0;
110
110
  border-radius: inherit;
@@ -116,7 +116,10 @@ const itemStyles = css`
116
116
  .label { display: grid; flex: 1; gap: 0.125rem; min-width: 0; }
117
117
  .description { font-size: var(--ray-font-size-sm); font-weight: var(--ray-font-weight-normal, 400); color: var(--ray-muted-foreground); }
118
118
  .description:empty { display: none; }
119
- :host([data-chevron="left"]) .chevron { order: -1; }
119
+ /* The chevron before the label: start, or left/right as sides of the screen. */
120
+ :host([data-chevron="start"]) .chevron,
121
+ :host([data-chevron="left"]) .chevron:dir(ltr),
122
+ :host([data-chevron="right"]) .chevron:dir(rtl) { order: -1; }
120
123
  .chevron { display: inline-flex; color: var(--ray-muted-foreground); transition: transform var(--ray-duration-base) var(--ray-easing-standard); }
121
124
  .chevron svg { width: 1rem; height: 1rem; }
122
125
  :host([data-open]) .chevron { transform: rotate(180deg); }
@@ -149,7 +152,7 @@ export class RayAccordionItem extends RayElement {
149
152
  <button class="control focus-ring" id="control" part="control" type="button" aria-expanded="false" aria-controls="panel">
150
153
  <span class="section" part="left-section"><slot name="left"></slot></span>
151
154
  <span class="label" part="label"><span class="label-text"><slot name="label"></slot></span><span class="description" part="description"></span></span>
152
- <span class="chevron" part="chevron" aria-hidden="true" data-ray-icon="chevron-down"></span>
155
+ <span class="chevron" part="chevron" aria-hidden="true" data-ray-icon="ui.expand"></span>
153
156
  </button>
154
157
  </h3>
155
158
  <div class="panel" id="panel" part="panel" role="region" aria-labelledby="control" inert>
@@ -34,7 +34,8 @@ const styles = css`
34
34
  justify-content: center;
35
35
  width: 1.5rem;
36
36
  height: 1.5rem;
37
- margin: -0.125rem -0.375rem 0 0;
37
+ margin: -0.125rem 0 0;
38
+ margin-inline-end: -0.375rem;
38
39
  padding: 0;
39
40
  color: inherit;
40
41
  background: transparent;
@@ -66,7 +67,7 @@ export class RayAlert extends RayElement {
66
67
  <p class="title" part="title"></p>
67
68
  <div class="text" part="text"><slot></slot></div>
68
69
  </div>
69
- <button class="close focus-ring" part="close" type="button" hidden><span data-ray-icon="close" aria-hidden="true"></span></button>
70
+ <button class="close focus-ring" part="close" type="button" hidden><span data-ray-icon="ui.close" aria-hidden="true"></span></button>
70
71
  </div>`;
71
72
 
72
73
  constructor() {
@@ -25,7 +25,7 @@ const styles = css`
25
25
  width: 16rem;
26
26
  max-width: 100%;
27
27
  padding: var(--ray-space-2);
28
- padding-right: var(--ray-space-3);
28
+ padding-inline-end: var(--ray-space-3);
29
29
  color: var(--ray-foreground);
30
30
  background: var(--ray-card);
31
31
  border: 1px solid var(--ray-border);
@@ -53,7 +53,7 @@ const styles = css`
53
53
  .badge {
54
54
  position: absolute;
55
55
  bottom: 3px;
56
- left: 50%;
56
+ left: 50%; /* physical */
57
57
  transform: translateX(-50%);
58
58
  padding: 0 0.25rem;
59
59
  font-size: 0.5625rem;
@@ -82,8 +82,7 @@ const styles = css`
82
82
  :host([status="error"]) .meta { color: var(--ray-destructive); }
83
83
  .bar {
84
84
  position: absolute;
85
- left: 0;
86
- right: 0;
85
+ inset-inline: 0;
87
86
  bottom: 0;
88
87
  height: 2px;
89
88
  background: var(--ray-muted);
@@ -131,7 +130,7 @@ const styles = css`
131
130
  :host([variant="card"]) .remove {
132
131
  position: absolute;
133
132
  top: calc(var(--ray-space-2) + 4px);
134
- right: calc(var(--ray-space-2) + 4px);
133
+ inset-inline-end: calc(var(--ray-space-2) + 4px);
135
134
  background: var(--ray-card);
136
135
  box-shadow: var(--ray-shadow-xs);
137
136
  opacity: 1;
@@ -191,7 +190,7 @@ export class RayAttachment extends RayElement {
191
190
  <div class="item" part="item" role="group">
192
191
  <span class="thumb" part="thumbnail"><img alt="" hidden><span class="icon" aria-hidden="true"></span><span class="badge" part="badge" aria-hidden="true"></span></span>
193
192
  <span class="text"><span class="name-box"></span><span class="meta" part="meta"></span></span>
194
- <button class="remove focus-ring" part="remove" type="button" hidden><span data-ray-icon="close" aria-hidden="true"></span></button>
193
+ <button class="remove focus-ring" part="remove" type="button" hidden><span data-ray-icon="ui.remove" aria-hidden="true"></span></button>
195
194
  <span class="bar" part="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" hidden><span></span></span>
196
195
  </div>`;
197
196
 
@@ -32,7 +32,7 @@ const styles = css`
32
32
  [hidden] { display: none !important; }
33
33
  `;
34
34
 
35
- const PLACEHOLDER = `<span data-ray-icon="user" aria-hidden="true"></span>`;
35
+ const PLACEHOLDER = `<span data-ray-icon="ui.avatar" aria-hidden="true"></span>`;
36
36
 
37
37
  /** Up to two initials: "Ada Lovelace" -> "AL", "ray" -> "R". */
38
38
  export function initials(name) {
@@ -22,7 +22,7 @@ const styles = css`
22
22
  :host([hide-edge]) { --_edge: 0px; }
23
23
  figure { position: relative; margin: 0; }
24
24
  /* Room for the mark, so it does not cover what is above or beside. */
25
- :host([data-icon]) figure { padding: calc(var(--_icon) / 2) 0 0 calc(var(--_icon) / 2); }
25
+ :host([data-icon]) figure { padding-block-start: calc(var(--_icon) / 2); padding-inline-start: calc(var(--_icon) / 2); }
26
26
  blockquote {
27
27
  margin: 0;
28
28
  padding: var(--_p) calc(var(--_p) * 1.25);
@@ -43,7 +43,7 @@ const styles = css`
43
43
  .icon {
44
44
  position: absolute;
45
45
  top: 0;
46
- left: 0;
46
+ inset-inline-start: 0;
47
47
  display: grid;
48
48
  place-items: center;
49
49
  width: var(--_icon);
@@ -40,7 +40,7 @@ const navStyles = css`
40
40
  grid-auto-columns: minmax(0, 1fr);
41
41
  grid-auto-flow: column;
42
42
  align-items: stretch;
43
- padding: var(--ray-space-1) max(var(--ray-space-1), env(safe-area-inset-right, 0px)) max(var(--ray-space-1), env(safe-area-inset-bottom, 0px)) max(var(--ray-space-1), env(safe-area-inset-left, 0px));
43
+ padding: var(--ray-space-1) max(var(--ray-space-1), env(safe-area-inset-right, 0px)) max(var(--ray-space-1), env(safe-area-inset-bottom, 0px)) max(var(--ray-space-1), env(safe-area-inset-left, 0px)); /* physical */
44
44
  background: var(--ray-bottom-nav, var(--ray-background));
45
45
  border-top: 1px solid var(--ray-border);
46
46
  }
@@ -109,7 +109,7 @@ const itemStyles = css`
109
109
  .badge {
110
110
  position: absolute;
111
111
  top: 0;
112
- left: calc(50% + 0.375rem);
112
+ inset-inline-start: calc(50% + 0.375rem);
113
113
  min-width: 1rem;
114
114
  height: 1rem;
115
115
  padding: 0 0.25rem;
@@ -125,7 +125,7 @@ const itemStyles = css`
125
125
  box-sizing: content-box;
126
126
  }
127
127
  .badge:empty:not([data-dot]) { display: none; }
128
- .badge[data-dot] { min-width: 0; width: 0.5rem; height: 0.5rem; padding: 0; top: 0.25rem; left: calc(50% + 0.5rem); }
128
+ .badge[data-dot] { min-width: 0; width: 0.5rem; height: 0.5rem; padding: 0; top: 0.25rem; inset-inline-start: calc(50% + 0.5rem); }
129
129
  .label {
130
130
  max-width: 100%;
131
131
  overflow: hidden;
@@ -193,8 +193,10 @@ export class RayBottomNavItem extends RayElement {
193
193
  }
194
194
  if (this.active && !this.action) item.setAttribute("aria-current", "page");
195
195
  else item.removeAttribute("aria-current");
196
+ // The current destination draws its icon filled (when it has a filled version).
196
197
  const icon = this.$(".icon");
197
198
  icon.setAttribute("data-ray-icon", this.icon ?? "");
199
+ icon.toggleAttribute("data-ray-icon-filled", Boolean(this.active && !this.action));
198
200
  this.paintIcons();
199
201
  this.$(".label").textContent = this.label ?? "";
200
202
  const badge = this.$(".badge");
@@ -150,7 +150,7 @@ export class RayBreadcrumb extends RayElement {
150
150
  if (sep === this.#sep) return;
151
151
  this.#sep = sep;
152
152
  for (const el of this.shadowRoot.querySelectorAll(".sep")) {
153
- if (sep === "chevron") el.innerHTML = `<span data-ray-icon="chevron-right"></span>`;
153
+ if (sep === "chevron") el.innerHTML = `<span data-ray-icon="ui.separator"></span>`;
154
154
  else el.textContent = sep;
155
155
  }
156
156
  this.paintIcons();
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { RayElement, css, define } from "../../core/core.js";
11
11
  import { motionAllowed } from "../../core/motion.js";
12
+ import { isRtl, inlineKey } from "../../core/direction.js";
12
13
 
13
14
  const styles = css`
14
15
  :host {
@@ -74,9 +75,11 @@ const styles = css`
74
75
  .control:hover:not(:disabled) { background: var(--ray-accent); }
75
76
  .control:disabled { opacity: 0; pointer-events: none; }
76
77
  .control svg { width: 1rem; height: 1rem; }
77
- .prev { left: var(--ray-space-2); }
78
- .next { right: var(--ray-space-2); }
79
- :host([orientation="vertical"]) .control { top: auto; left: 50%; transform: translateX(-50%) rotate(90deg); }
78
+ .prev { inset-inline-start: var(--ray-space-2); }
79
+ .next { inset-inline-end: var(--ray-space-2); }
80
+ :host([orientation="vertical"]) .control { top: auto; left: 50%; transform: translateX(-50%) rotate(90deg); } /* physical */
81
+ /* Up and down do not depend on the writing direction: the turned arrows are not mirrored. */
82
+ :host([orientation="vertical"]) .control [data-ray-icon] > svg { transform: none; }
80
83
  :host([orientation="vertical"]) .prev { top: var(--ray-space-2); }
81
84
  :host([orientation="vertical"]) .next { bottom: var(--ray-space-2); }
82
85
  :host([hide-controls]) .control { display: none; }
@@ -128,7 +131,7 @@ const styles = css`
128
131
  .play svg { width: 0.875rem; height: 0.875rem; }
129
132
  `;
130
133
 
131
- const PAUSE_ICON = `<span data-ray-icon="pause" aria-hidden="true"></span>`;
134
+ const PAUSE_ICON = `<span data-ray-icon="ui.pause" aria-hidden="true"></span>`;
132
135
 
133
136
  const reduced = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : null;
134
137
 
@@ -150,8 +153,8 @@ export class RayCarousel extends RayElement {
150
153
  <div class="root" part="root" role="region" aria-roledescription="carousel">
151
154
  <div class="stage">
152
155
  <div class="viewport" part="viewport" tabindex="0" aria-live="polite"><slot></slot></div>
153
- <button class="control prev" part="previous" type="button" aria-label="Previous slide"><span data-ray-icon="chevron-left" aria-hidden="true"></span></button>
154
- <button class="control next" part="next" type="button" aria-label="Next slide"><span data-ray-icon="chevron-right" aria-hidden="true"></span></button>
156
+ <button class="control prev" part="previous" type="button" aria-label="Previous slide"><span data-ray-icon="ui.previous" aria-hidden="true"></span></button>
157
+ <button class="control next" part="next" type="button" aria-label="Next slide"><span data-ray-icon="ui.next" aria-hidden="true"></span></button>
155
158
  </div>
156
159
  <div class="footer" part="footer">
157
160
  <button class="play" part="play" type="button" hidden></button>
@@ -272,6 +275,14 @@ export class RayCarousel extends RayElement {
272
275
  return this.orientation === "vertical";
273
276
  }
274
277
 
278
+ // The scroll offset from the start. Right to left, scrollLeft runs from 0
279
+ // (the start, on the right) down to negative values.
280
+ _scroll() {
281
+ const vp = this.viewport;
282
+ if (this._vertical()) return vp.scrollTop;
283
+ return isRtl(this) ? -vp.scrollLeft : vp.scrollLeft;
284
+ }
285
+
275
286
  _current() {
276
287
  return Math.min(Math.max(0, this.index ?? 0), this.count - 1);
277
288
  }
@@ -294,11 +305,12 @@ export class RayCarousel extends RayElement {
294
305
  const vr = vp.getBoundingClientRect();
295
306
  const size = v ? vp.clientHeight : vp.clientWidth;
296
307
  const max = Math.max(0, v ? vp.scrollHeight - vp.clientHeight : vp.scrollWidth - vp.clientWidth);
297
- const scroll = v ? vp.scrollTop : vp.scrollLeft;
308
+ const scroll = this._scroll();
309
+ const rtl = !v && isRtl(this);
298
310
  const snaps = [];
299
311
  for (const s of slides) {
300
312
  const r = s.getBoundingClientRect();
301
- const start = (v ? r.top - vr.top : r.left - vr.left) + scroll;
313
+ const start = (v ? r.top - vr.top : rtl ? vr.right - r.right : r.left - vr.left) + scroll;
302
314
  const length = v ? r.height : r.width;
303
315
  const pos = this.align === "center" ? start - (size - length) / 2 : start;
304
316
  const clamped = Math.round(Math.min(max, Math.max(0, pos)));
@@ -333,8 +345,7 @@ export class RayCarousel extends RayElement {
333
345
 
334
346
  // The position closest to the current scroll offset.
335
347
  _track() {
336
- const vp = this.viewport;
337
- const scroll = this._vertical() ? vp.scrollTop : vp.scrollLeft;
348
+ const scroll = this._scroll();
338
349
  let best = 0;
339
350
  for (let i = 1; i < this.#snaps.length; i++) {
340
351
  if (Math.abs(this.#snaps[i] - scroll) < Math.abs(this.#snaps[best] - scroll)) best = i;
@@ -358,7 +369,7 @@ export class RayCarousel extends RayElement {
358
369
  const smooth = user && !instant && !reduced?.matches && motionAllowed(this);
359
370
  const opts = { behavior: smooth ? "smooth" : "instant" };
360
371
  if (this._vertical()) this.viewport.scrollTo({ top: pos, ...opts });
361
- else this.viewport.scrollTo({ left: pos, ...opts });
372
+ else this.viewport.scrollTo({ left: isRtl(this) ? -pos : pos, ...opts });
362
373
  this._setIndex(i);
363
374
  this._paint();
364
375
  this._schedule();
@@ -403,7 +414,7 @@ export class RayCarousel extends RayElement {
403
414
  const paused = this.#userPaused;
404
415
  if (auto && play.dataset.state !== (paused ? "paused" : "playing")) {
405
416
  play.dataset.state = paused ? "paused" : "playing";
406
- play.innerHTML = paused ? `<span data-ray-icon="play" aria-hidden="true"></span>` : PAUSE_ICON;
417
+ play.innerHTML = paused ? `<span data-ray-icon="ui.play" aria-hidden="true"></span>` : PAUSE_ICON;
407
418
  this.paintIcons(play);
408
419
  play.setAttribute("aria-label", paused ? "Start autoplay" : "Pause autoplay");
409
420
  }
@@ -416,10 +427,11 @@ export class RayCarousel extends RayElement {
416
427
  const v = this._vertical();
417
428
  const back = v ? "ArrowUp" : "ArrowLeft";
418
429
  const fwd = v ? "ArrowDown" : "ArrowRight";
419
- if (e.key === fwd) {
430
+ const key = v ? e.key : inlineKey(e.key, this);
431
+ if (key === fwd) {
420
432
  e.preventDefault();
421
433
  this.next();
422
- } else if (e.key === back) {
434
+ } else if (key === back) {
423
435
  e.preventDefault();
424
436
  this.prev();
425
437
  } else if (e.key === "Home") {
@@ -19,7 +19,10 @@ export const checkStyles = css`
19
19
  align-items: flex-start;
20
20
  gap: calc(var(--_s) * 0.6);
21
21
  }
22
- :host([label-position="left"]) .root { flex-direction: row-reverse; justify-content: flex-end; }
22
+ /* The label before the box: start, or left/right as sides of the screen. */
23
+ :host([label-position="start"]) .root,
24
+ :host([label-position="left"]) .root:dir(ltr),
25
+ :host([label-position="right"]) .root:dir(rtl) { flex-direction: row-reverse; justify-content: flex-end; }
23
26
  .inner {
24
27
  position: relative;
25
28
  flex: none;
@@ -100,10 +103,10 @@ export class RayCheckbox extends FieldControl {
100
103
  value: { type: String, reflect: true, default: "on" },
101
104
  color: { type: String, reflect: true },
102
105
  variant: { type: String, reflect: true, default: "filled" },
103
- labelPosition: { type: String, reflect: true, default: "right" },
106
+ labelPosition: { type: String, reflect: true, default: "end" },
104
107
  };
105
108
  static styles = [sizeStyles, radiusStyles, fieldStyles, checkStyles, styles];
106
- static template = checkTemplate(`<span class="indicator" part="indicator" aria-hidden="true"><span class="check" data-ray-icon="check"></span><span class="dash" data-ray-icon="minus"></span></span>`);
109
+ static template = checkTemplate(`<span class="indicator" part="indicator" aria-hidden="true"><span class="check" data-ray-icon="ui.checked"></span><span class="dash" data-ray-icon="ui.indeterminate"></span></span>`);
107
110
 
108
111
  wire() {
109
112
  // The `checked` attribute is the default state, like on <input>.
@@ -15,7 +15,7 @@ const styles = css`
15
15
  .items { display: flex; flex-direction: column; gap: var(--ray-space-3); margin-top: var(--ray-space-2); }
16
16
  :host([orientation="horizontal"]) .items { flex-direction: row; flex-wrap: wrap; gap: var(--ray-space-3) var(--ray-space-5); }
17
17
  .label { margin-bottom: 0; }
18
- .count { margin-left: var(--ray-space-1); font-weight: 400; color: var(--ray-muted-foreground); font-variant-numeric: tabular-nums; }
18
+ .count { margin-inline-start: var(--ray-space-1); font-weight: 400; color: var(--ray-muted-foreground); font-variant-numeric: tabular-nums; }
19
19
  `;
20
20
 
21
21
  /** "a, b" (an attribute) or an array, as an array of values. */
@@ -293,7 +293,8 @@ const styles = css`
293
293
  align-items: center;
294
294
  gap: var(--ray-space-2);
295
295
  min-height: 2.5rem;
296
- padding: 0 var(--ray-space-2) 0 var(--ray-space-4);
296
+ padding-block: 0;
297
+ padding-inline: var(--ray-space-4) var(--ray-space-2);
297
298
  font-size: var(--ray-font-size-xs);
298
299
  color: var(--ray-muted-foreground);
299
300
  border-bottom: 1px solid var(--ray-border);
@@ -325,12 +326,13 @@ const styles = css`
325
326
  .copy-label { display: none; }
326
327
  .copy[data-copied] .copy-label { display: inline; }
327
328
  /* Without a header, the button floats in the top-end corner. */
328
- .copy.floating { position: absolute; z-index: 1; top: var(--ray-space-2); right: var(--ray-space-2); background: var(--ray-code-background); opacity: 0; transition: opacity var(--ray-duration-fast); }
329
+ .copy.floating { position: absolute; z-index: 1; top: var(--ray-space-2); inset-inline-end: var(--ray-space-2); background: var(--ray-code-background); opacity: 0; transition: opacity var(--ray-duration-fast); }
329
330
  .frame:hover .copy.floating, .copy.floating:focus-visible, .copy.floating[data-copied] { opacity: 1; }
330
331
  @media (hover: none) { .copy.floating { opacity: 1; } }
331
332
  .copy[hidden] { display: none; }
332
333
 
333
- .scroller { overflow: auto; max-height: var(--_max, none); outline: none; }
334
+ /* Code reads left to right, in a right-to-left page too. */
335
+ .scroller { direction: ltr; overflow: auto; max-height: var(--_max, none); outline: none; }
334
336
  .scroller:focus-visible { box-shadow: inset 0 0 0 2px var(--ray-ring); }
335
337
  :host([expanded]) .scroller { max-height: none; }
336
338
  pre { margin: 0; padding: var(--ray-space-3) 0; font-family: var(--ray-font-mono); font-size: var(--_fs); line-height: var(--_lh); tab-size: 2; }
@@ -339,19 +341,19 @@ const styles = css`
339
341
  .line { display: block; padding: 0 var(--ray-space-4); white-space: pre; border-inline-start: 2px solid transparent; }
340
342
  :host([wrap]) .line { white-space: pre-wrap; overflow-wrap: anywhere; }
341
343
  .line:empty::after { content: " "; }
342
- :host([line-numbers]) .line { counter-increment: line; padding-left: 0; }
344
+ :host([line-numbers]) .line { counter-increment: line; padding-inline-start: 0; }
343
345
  :host([line-numbers]) .line::before {
344
346
  content: counter(line);
345
347
  display: inline-block;
346
348
  width: calc(var(--_digits, 2) * 1ch + var(--ray-space-6));
347
349
  padding-inline-end: var(--ray-space-4);
348
350
  box-sizing: border-box;
349
- text-align: right;
351
+ text-align: end;
350
352
  color: var(--ray-code-comment);
351
353
  opacity: 0.7;
352
354
  user-select: none;
353
355
  }
354
- :host([line-numbers][wrap]) .line { padding-left: calc(var(--_digits, 2) * 1ch + var(--ray-space-6)); text-indent: calc((var(--_digits, 2) * 1ch + var(--ray-space-6)) * -1); }
356
+ :host([line-numbers][wrap]) .line { padding-inline-start: calc(var(--_digits, 2) * 1ch + var(--ray-space-6)); text-indent: calc((var(--_digits, 2) * 1ch + var(--ray-space-6)) * -1); }
355
357
  .line[data-highlight] { background: color-mix(in srgb, var(--ray-primary) 12%, transparent); border-inline-start-color: var(--ray-primary); }
356
358
  .line[data-diff="add"] { color: var(--ray-code-added); background: color-mix(in srgb, var(--ray-code-added) 12%, transparent); }
357
359
  .line[data-diff="remove"] { color: var(--ray-code-removed); background: color-mix(in srgb, var(--ray-code-removed) 12%, transparent); }
@@ -427,7 +429,7 @@ export class RayCode extends RayElement {
427
429
  b.type = "button";
428
430
  b.className = "copy focus-ring";
429
431
  b.setAttribute("part", "copy");
430
- b.innerHTML = `<span class="idle" data-ray-icon="copy" aria-hidden="true"></span><span class="done" data-ray-icon="check" aria-hidden="true"></span><span class="copy-label"></span>`;
432
+ b.innerHTML = `<span class="idle" data-ray-icon="ui.copy" aria-hidden="true"></span><span class="done" data-ray-icon="ui.copied" aria-hidden="true"></span><span class="copy-label"></span>`;
431
433
  b.addEventListener("click", () => this.copy());
432
434
  this.$(".more button").addEventListener("click", () => { this.expanded = !this.expanded; });
433
435
  this.$("slot").addEventListener("slotchange", () => this.requestUpdate());
@@ -172,7 +172,7 @@ const pickerStyles = css`
172
172
  height: calc(var(--_w) * 0.62);
173
173
  background:
174
174
  linear-gradient(to top, #000, transparent),
175
- linear-gradient(to right, #fff, hsl(var(--_hue) 100% 50%));
175
+ linear-gradient(to right, #fff, hsl(var(--_hue) 100% 50%)); /* physical */
176
176
  border-radius: var(--ray-radius-sm);
177
177
  cursor: crosshair;
178
178
  touch-action: none;
@@ -180,7 +180,7 @@ const pickerStyles = css`
180
180
  :host([full-width]) .area { height: auto; aspect-ratio: 5 / 3; }
181
181
  .area-thumb {
182
182
  position: absolute;
183
- left: var(--_s);
183
+ left: var(--_s); /* physical */
184
184
  top: calc(100% - var(--_v));
185
185
  width: var(--_thumb);
186
186
  height: var(--_thumb);
@@ -209,13 +209,13 @@ const pickerStyles = css`
209
209
  inset: 0 calc(var(--_thumb) / -2);
210
210
  border-radius: inherit;
211
211
  }
212
- .hue::before { background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); }
213
- .alpha::before { background: linear-gradient(to right, transparent, var(--_solid)), ${CHECKER}; }
212
+ .hue::before { background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); } /* physical */
213
+ .alpha::before { background: linear-gradient(to right, transparent, var(--_solid)), ${CHECKER}; } /* physical */
214
214
  :host(:not([data-alpha])) .alpha { display: none; }
215
215
  .slider-thumb {
216
216
  position: absolute;
217
217
  top: 50%;
218
- left: var(--_pos);
218
+ left: var(--_pos); /* physical */
219
219
  width: var(--_thumb);
220
220
  height: var(--_thumb);
221
221
  box-sizing: border-box;
@@ -540,7 +540,7 @@ export class RayColorInput extends FieldControl {
540
540
  static template = fieldTemplate(
541
541
  `<button type="button" class="preview" part="preview" tabindex="-1" aria-hidden="true"></button>` +
542
542
  `<input id="control" class="control" part="input" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="none" aria-haspopup="dialog" aria-expanded="false" aria-controls="panel">` +
543
- `<button type="button" class="dropper focus-ring" part="eyedropper" aria-label="Pick a color from the screen" hidden><span data-ray-icon="pipette" aria-hidden="true"></span></button>`,
543
+ `<button type="button" class="dropper focus-ring" part="eyedropper" aria-label="Pick a color from the screen" hidden><span data-ray-icon="ui.eyedropper" aria-hidden="true"></span></button>`,
544
544
  { sections: false },
545
545
  ) + `<div class="panel" id="panel" part="dropdown" popover="manual" role="dialog" aria-label="Choose a color"><ray-color-picker part="picker"></ray-color-picker></div>`;
546
546
 
@@ -30,7 +30,8 @@ const styles = css`
30
30
  max-width: 100%;
31
31
  height: calc(var(--_h) - 0.75rem);
32
32
  min-height: 1.375rem;
33
- padding: 0 0.125rem 0 0.5rem;
33
+ padding-block: 0;
34
+ padding-inline: 0.5rem 0.125rem;
34
35
  font-size: calc(var(--_fs) - 0.0625rem);
35
36
  color: var(--ray-foreground);
36
37
  background: var(--ray-muted);
@@ -120,7 +121,7 @@ export class RayCombobox extends FieldControl {
120
121
  static styles = [sizeStyles, radiusStyles, fieldStyles, boxStyles, styles];
121
122
  static template = fieldTemplate(
122
123
  `<span class="chips" part="chips"></span><input id="control" class="control" part="input" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="listbox" autocomplete="off" spellcheck="false">` +
123
- `<span class="tail"><button type="button" class="clear" part="clear" aria-label="Clear" hidden tabindex="-1"><span data-ray-icon="close"></span></button><span class="chevron" aria-hidden="true" data-ray-icon="chevron-down"></span></span>`,
124
+ `<span class="tail"><button type="button" class="clear" part="clear" aria-label="Clear" hidden tabindex="-1"><span data-ray-icon="ui.clear"></span></button><span class="chevron" aria-hidden="true" data-ray-icon="ui.expand"></span></span>`,
124
125
  { sections: false },
125
126
  ) + `<div class="listbox" id="listbox" part="dropdown" role="listbox" popover="manual" tabindex="-1"></div><div class="options"><slot></slot></div>`;
126
127
 
@@ -299,7 +300,7 @@ export class RayCombobox extends FieldControl {
299
300
  b.tabIndex = -1;
300
301
  b.dataset.value = v;
301
302
  b.setAttribute("aria-label", `Remove ${this._labelOf(v)}`);
302
- b.innerHTML = `<span data-ray-icon="close"></span>`;
303
+ b.innerHTML = `<span data-ray-icon="ui.remove"></span>`;
303
304
  chip.append(t, b);
304
305
  box.append(chip);
305
306
  }
@@ -364,7 +365,7 @@ export class RayCombobox extends FieldControl {
364
365
  }
365
366
  const check = document.createElement("span");
366
367
  check.className = "mark";
367
- check.setAttribute("data-ray-icon", "check");
368
+ check.setAttribute("data-ray-icon", "ui.checked");
368
369
  el.append(text, check);
369
370
  (groupEl ?? lb).append(el);
370
371
  this.#items.push({ el, value: o.value, disabled, create: false });
@@ -110,7 +110,7 @@ const styles = css`
110
110
  .hint b {
111
111
  display: inline-block;
112
112
  min-width: 1.25rem;
113
- margin-right: var(--ray-space-1);
113
+ margin-inline-end: var(--ray-space-1);
114
114
  padding: 0 0.3rem;
115
115
  font-family: var(--ray-font-mono);
116
116
  font-size: 0.6875rem;
@@ -124,7 +124,7 @@ const styles = css`
124
124
  }
125
125
  `;
126
126
 
127
- const SEARCH_ICON = `<span data-ray-icon="search" aria-hidden="true"></span>`;
127
+ const SEARCH_ICON = `<span data-ray-icon="ui.search" aria-hidden="true"></span>`;
128
128
 
129
129
  /**
130
130
  * How well `action` matches `query` (0 = not at all). Every word of the query