@raylang/elements 0.5.2 → 0.7.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 (44) hide show
  1. package/check/check.d.ts +2 -0
  2. package/check/check.js +27 -6
  3. package/check/cli.js +10 -6
  4. package/components/accordion/accordion.js +1 -1
  5. package/components/alert/alert.js +1 -1
  6. package/components/attachment/attachment.js +1 -1
  7. package/components/avatar/avatar.js +1 -1
  8. package/components/bottom-nav/bottom-nav.js +2 -0
  9. package/components/breadcrumbs/breadcrumbs.js +1 -1
  10. package/components/button/button.js +17 -0
  11. package/components/carousel/carousel.js +4 -4
  12. package/components/checkbox/checkbox.js +1 -1
  13. package/components/code/code.js +1 -1
  14. package/components/color-picker/color-picker.js +1 -1
  15. package/components/combobox/combobox.js +3 -3
  16. package/components/command-palette/command-palette.js +1 -1
  17. package/components/date-picker/date-picker.js +4 -4
  18. package/components/dialog/dialog.js +1 -1
  19. package/components/drawer/drawer.js +1 -1
  20. package/components/file-upload/file-upload.js +1 -1
  21. package/components/icon/icon.js +6 -1
  22. package/components/nav-link/nav-link.js +1 -1
  23. package/components/notification/notification.js +1 -1
  24. package/components/number-input/number-input.js +55 -9
  25. package/components/pagination/pagination.js +4 -4
  26. package/components/password-input/password-input.js +2 -2
  27. package/components/radio/radio.js +68 -5
  28. package/components/ring-progress/ring-progress.js +127 -0
  29. package/components/search/search.js +2 -2
  30. package/components/select/select.js +2 -2
  31. package/components/stat/stat.js +1 -1
  32. package/components/step-dots/step-dots.js +95 -0
  33. package/components/stepper/stepper.js +1 -1
  34. package/components/table/table.js +1 -1
  35. package/components/theme-toggle/theme-toggle.js +2 -2
  36. package/core/core.js +4 -3
  37. package/core/icons.js +95 -8
  38. package/custom-elements.json +700 -43
  39. package/index.js +3 -1
  40. package/llms-full.txt +117 -21
  41. package/llms.txt +18 -5
  42. package/package.json +9 -1
  43. package/ray-elements.d.ts +77 -19
  44. package/vscode.html-custom-data.json +1203 -73
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
 
@@ -74,14 +75,22 @@ function indexManifest(manifests) {
74
75
  return index;
75
76
  }
76
77
 
78
+ // The literals of a union are its values. `(string & {})` beside them (keeps
79
+ // the literals in autocompletion) is the system's "or any CSS length" (size
80
+ // of buttons, dialogs, drawers).
77
81
  function attribute(a) {
78
82
  const text = a.type?.text ?? "string";
79
83
  const parts = text.split("|").map((s) => s.trim());
80
84
  const literals = parts.filter((s) => /^"[^"]*"$/.test(s)).map((s) => s.slice(1, -1));
81
85
  const others = parts.filter((s) => !/^"[^"]*"$/.test(s) && s !== "undefined" && s !== "null");
82
- return { name: a.name, boolean: text === "boolean", values: literals.length > 0 && others.length === 0 ? literals : null };
86
+ const length = others.length === 1 && others[0] === "(string & {})";
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 };
83
90
  }
84
91
 
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;
93
+
85
94
  /** The language of a file by its extension: "html" (also Vue, Svelte, Astro) or "jsx". */
86
95
  export function languageOf(file) {
87
96
  const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
@@ -93,14 +102,16 @@ export function languageOf(file) {
93
102
  /**
94
103
  * Checks one file's markup.
95
104
  * @param {string} source
96
- * @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 }))
97
107
  * allow: more values of an attribute, for every element (an app's own variant: { variant: ["brand"] })
98
108
  * @returns {Diagnostic[]}
99
109
  */
100
- export function checkMarkup(source, { manifest, icons, allow = {}, file = "<input>", lang } = {}) {
110
+ export function checkMarkup(source, { manifest, icons, filledIcons, allow = {}, file = "<input>", lang } = {}) {
101
111
  const index = indexManifest(manifest);
102
112
  const language = lang ?? languageOf(file);
103
113
  const iconSet = icons ? new Set(icons) : null;
114
+ const filledSet = filledIcons ? new Set(filledIcons) : null;
104
115
  const lines = lineStarts(source);
105
116
  const out = [];
106
117
  const report = (pos, severity, rule, message) => {
@@ -132,6 +143,15 @@ export function checkMarkup(source, { manifest, icons, allow = {}, file = "<inpu
132
143
  const named = tok.attrs.some((a) => ["label", "aria-label", "aria-labelledby"].includes(normalize(a.name, language).name));
133
144
  if (!named) report(tok.pos, "error", "missing-label", `<${tok.name}> needs a label: it is the button's accessible name (and its tooltip)`);
134
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
+ }
135
155
  if (!tok.selfClosing && !(language !== "jsx" && VOID.has(tok.name.toLowerCase()))) {
136
156
  stack.push({ name: tok.name, el });
137
157
  }
@@ -220,11 +240,12 @@ function attributeChecks(a, tok, el, parent, language, icons, allow, report) {
220
240
  if (a.value === null || a.dynamic || n.bound) return;
221
241
  if (attr.boolean && a.value === "false") {
222
242
  report(a.pos, "error", "boolean-value", `${name}="false" still means true on <${tok.name}>: a boolean attribute is present or absent`);
223
- } else if (attr.values && !attr.values.includes(a.value) && !allow[name]?.includes(a.value)) {
224
- report(a.pos, "error", "invalid-value", `${name}="${a.value}" is not valid on <${tok.name}>: one of ${attr.values.map((v) => `"${v}"`).join(", ")}`);
243
+ } else if (attr.values && !attr.values.includes(a.value) && !allow[name]?.includes(a.value) && !(attr.length && LENGTH.test(a.value.trim()))) {
244
+ const or = attr.length ? " or a CSS length" : "";
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}`);
225
246
  }
226
247
  // "none" (or nothing) draws no icon: a blank that keeps the alignment.
227
- 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)) {
228
249
  report(a.pos, "warning", "unknown-icon", `no icon named "${a.value}"${hint(a.value, [...icons], "icons")} (register it with setIcons)`);
229
250
  }
230
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
  }
@@ -149,7 +149,7 @@ export class RayAccordionItem extends RayElement {
149
149
  <button class="control focus-ring" id="control" part="control" type="button" aria-expanded="false" aria-controls="panel">
150
150
  <span class="section" part="left-section"><slot name="left"></slot></span>
151
151
  <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>
152
+ <span class="chevron" part="chevron" aria-hidden="true" data-ray-icon="ui.expand"></span>
153
153
  </button>
154
154
  </h3>
155
155
  <div class="panel" id="panel" part="panel" role="region" aria-labelledby="control" inert>
@@ -66,7 +66,7 @@ export class RayAlert extends RayElement {
66
66
  <p class="title" part="title"></p>
67
67
  <div class="text" part="text"><slot></slot></div>
68
68
  </div>
69
- <button class="close focus-ring" part="close" type="button" hidden><span data-ray-icon="close" aria-hidden="true"></span></button>
69
+ <button class="close focus-ring" part="close" type="button" hidden><span data-ray-icon="ui.close" aria-hidden="true"></span></button>
70
70
  </div>`;
71
71
 
72
72
  constructor() {
@@ -191,7 +191,7 @@ export class RayAttachment extends RayElement {
191
191
  <div class="item" part="item" role="group">
192
192
  <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
193
  <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>
194
+ <button class="remove focus-ring" part="remove" type="button" hidden><span data-ray-icon="ui.remove" aria-hidden="true"></span></button>
195
195
  <span class="bar" part="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" hidden><span></span></span>
196
196
  </div>`;
197
197
 
@@ -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) {
@@ -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();
@@ -1,8 +1,13 @@
1
1
  // <ray-button>: a button (or a link, with href) in Mantine's variants.
2
+ // `size` is xs…xl or any CSS length: the height, with the font, the padding
3
+ // and the gap in the proportions of the scale (a big round main action:
4
+ // <ray-icon-button size="5.5rem" radius="full">).
2
5
 
3
6
  import { RayElement, css, define } from "../../core/core.js";
4
7
  import { applyColor, applyGradient, sizeStyles, radiusStyles, variantStyles, loaderStyles } from "../../core/styles.js";
5
8
 
9
+ const SIZES = ["xs", "sm", "md", "lg", "xl"];
10
+
6
11
  const styles = css`
7
12
  :host {
8
13
  display: inline-flex;
@@ -190,6 +195,7 @@ export class RayButton extends RayElement {
190
195
  }
191
196
  }
192
197
  el.toggleAttribute("aria-busy", Boolean(this.loading));
198
+ this._applySize();
193
199
  // ARIA put on the host (by the app, a popover, a menu…) belongs to the inner button.
194
200
  for (const attr of MIRRORED_ARIA) {
195
201
  const v = this.getAttribute(attr);
@@ -204,6 +210,17 @@ export class RayButton extends RayElement {
204
210
  }
205
211
  }
206
212
 
213
+ // A length sets sizeStyles' variables inline (over its :host rules).
214
+ _applySize() {
215
+ const length = this.size && !SIZES.includes(this.size) ? this.size : "";
216
+ if (length === (this._length ?? "")) return;
217
+ this._length = length;
218
+ for (const [prop, ratio] of [["--_h", 1], ["--_fs", 0.4], ["--_px", 0.5], ["--_gap", 0.22]]) {
219
+ if (length) this.style.setProperty(prop, ratio === 1 ? length : `calc(${length} * ${ratio})`);
220
+ else this.style.removeProperty(prop);
221
+ }
222
+ }
223
+
207
224
  _render(tag) {
208
225
  this._el?.remove();
209
226
  const el = document.createElement(tag);
@@ -128,7 +128,7 @@ const styles = css`
128
128
  .play svg { width: 0.875rem; height: 0.875rem; }
129
129
  `;
130
130
 
131
- const PAUSE_ICON = `<span data-ray-icon="pause" aria-hidden="true"></span>`;
131
+ const PAUSE_ICON = `<span data-ray-icon="ui.pause" aria-hidden="true"></span>`;
132
132
 
133
133
  const reduced = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)") : null;
134
134
 
@@ -150,8 +150,8 @@ export class RayCarousel extends RayElement {
150
150
  <div class="root" part="root" role="region" aria-roledescription="carousel">
151
151
  <div class="stage">
152
152
  <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>
153
+ <button class="control prev" part="previous" type="button" aria-label="Previous slide"><span data-ray-icon="ui.previous" aria-hidden="true"></span></button>
154
+ <button class="control next" part="next" type="button" aria-label="Next slide"><span data-ray-icon="ui.next" aria-hidden="true"></span></button>
155
155
  </div>
156
156
  <div class="footer" part="footer">
157
157
  <button class="play" part="play" type="button" hidden></button>
@@ -403,7 +403,7 @@ export class RayCarousel extends RayElement {
403
403
  const paused = this.#userPaused;
404
404
  if (auto && play.dataset.state !== (paused ? "paused" : "playing")) {
405
405
  play.dataset.state = paused ? "paused" : "playing";
406
- play.innerHTML = paused ? `<span data-ray-icon="play" aria-hidden="true"></span>` : PAUSE_ICON;
406
+ play.innerHTML = paused ? `<span data-ray-icon="ui.play" aria-hidden="true"></span>` : PAUSE_ICON;
407
407
  this.paintIcons(play);
408
408
  play.setAttribute("aria-label", paused ? "Start autoplay" : "Pause autoplay");
409
409
  }
@@ -103,7 +103,7 @@ export class RayCheckbox extends FieldControl {
103
103
  labelPosition: { type: String, reflect: true, default: "right" },
104
104
  };
105
105
  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>`);
106
+ 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
107
 
108
108
  wire() {
109
109
  // The `checked` attribute is the default state, like on <input>.
@@ -427,7 +427,7 @@ export class RayCode extends RayElement {
427
427
  b.type = "button";
428
428
  b.className = "copy focus-ring";
429
429
  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>`;
430
+ 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
431
  b.addEventListener("click", () => this.copy());
432
432
  this.$(".more button").addEventListener("click", () => { this.expanded = !this.expanded; });
433
433
  this.$("slot").addEventListener("slotchange", () => this.requestUpdate());
@@ -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
 
@@ -120,7 +120,7 @@ export class RayCombobox extends FieldControl {
120
120
  static styles = [sizeStyles, radiusStyles, fieldStyles, boxStyles, styles];
121
121
  static template = fieldTemplate(
122
122
  `<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>`,
123
+ `<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
124
  { sections: false },
125
125
  ) + `<div class="listbox" id="listbox" part="dropdown" role="listbox" popover="manual" tabindex="-1"></div><div class="options"><slot></slot></div>`;
126
126
 
@@ -299,7 +299,7 @@ export class RayCombobox extends FieldControl {
299
299
  b.tabIndex = -1;
300
300
  b.dataset.value = v;
301
301
  b.setAttribute("aria-label", `Remove ${this._labelOf(v)}`);
302
- b.innerHTML = `<span data-ray-icon="close"></span>`;
302
+ b.innerHTML = `<span data-ray-icon="ui.remove"></span>`;
303
303
  chip.append(t, b);
304
304
  box.append(chip);
305
305
  }
@@ -364,7 +364,7 @@ export class RayCombobox extends FieldControl {
364
364
  }
365
365
  const check = document.createElement("span");
366
366
  check.className = "mark";
367
- check.setAttribute("data-ray-icon", "check");
367
+ check.setAttribute("data-ray-icon", "ui.checked");
368
368
  el.append(text, check);
369
369
  (groupEl ?? lb).append(el);
370
370
  this.#items.push({ el, value: o.value, disabled, create: false });
@@ -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
@@ -144,9 +144,9 @@ export class RayCalendar extends RayElement {
144
144
  static styles = [sizeStyles, calendarStyles];
145
145
  static template = `
146
146
  <div class="header" part="header">
147
- <button class="nav prev focus-ring" part="nav" type="button" data-ray-icon="chevron-left"></button>
147
+ <button class="nav prev focus-ring" part="nav" type="button" data-ray-icon="ui.previous"></button>
148
148
  <div class="caption" part="caption" id="caption" aria-live="polite"></div>
149
- <button class="nav next focus-ring" part="nav" type="button" data-ray-icon="chevron-right"></button>
149
+ <button class="nav next focus-ring" part="nav" type="button" data-ray-icon="ui.next"></button>
150
150
  </div>
151
151
  <table role="grid" aria-labelledby="caption" part="grid"><thead><tr></tr></thead><tbody></tbody></table>`;
152
152
 
@@ -382,8 +382,8 @@ export class RayDatePicker extends FieldControl {
382
382
  static template = fieldTemplate(
383
383
  `<input id="control" class="native" part="input" type="date" tabindex="-1" aria-hidden="true">` +
384
384
  `<button type="button" id="trigger" class="control trigger" part="trigger" aria-haspopup="dialog" aria-expanded="false" aria-controls="panel">` +
385
- `<span class="icon" aria-hidden="true" data-ray-icon="calendar"></span><span class="value" id="value"></span></button>` +
386
- `<button type="button" class="clear focus-ring" part="clear" aria-label="Clear date" hidden><span data-ray-icon="close" aria-hidden="true"></span></button>`,
385
+ `<span class="icon" aria-hidden="true" data-ray-icon="ui.calendar"></span><span class="value" id="value"></span></button>` +
386
+ `<button type="button" class="clear focus-ring" part="clear" aria-label="Clear date" hidden><span data-ray-icon="ui.clear" aria-hidden="true"></span></button>`,
387
387
  { sections: false },
388
388
  ) + `<div class="panel" id="panel" part="dropdown" popover="manual" role="dialog" aria-label="Choose a date"><ray-calendar part="calendar" exportparts="day, caption, nav, grid"></ray-calendar></div>`;
389
389
 
@@ -106,7 +106,7 @@ export class RayDialog extends RayElement {
106
106
  <dialog part="dialog" aria-labelledby="title">
107
107
  <header class="header" part="header">
108
108
  <div class="heading"><h2 class="title" id="title" part="title"></h2><p class="description" id="description" part="description" hidden></p></div>
109
- <div class="actions"><slot name="header"></slot><button class="close focus-ring" part="close" type="button" aria-label="Close"><span data-ray-icon="close" aria-hidden="true"></span></button></div>
109
+ <div class="actions"><slot name="header"></slot><button class="close focus-ring" part="close" type="button" aria-label="Close"><span data-ray-icon="ui.close" aria-hidden="true"></span></button></div>
110
110
  </header>
111
111
  <div class="body" part="body"><slot></slot></div>
112
112
  <footer class="footer" part="footer"><slot name="footer"></slot></footer>
@@ -111,7 +111,7 @@ export class RayDrawer extends RayDialog {
111
111
  <dialog part="drawer" aria-labelledby="title">
112
112
  <header class="header" part="header">
113
113
  <div class="heading"><h2 class="title" id="title" part="title"></h2><p class="description" id="description" part="description" hidden></p></div>
114
- <div class="actions"><slot name="header"></slot><button class="close focus-ring" part="close" type="button" aria-label="Close"><span data-ray-icon="close" aria-hidden="true"></span></button></div>
114
+ <div class="actions"><slot name="header"></slot><button class="close focus-ring" part="close" type="button" aria-label="Close"><span data-ray-icon="ui.close" aria-hidden="true"></span></button></div>
115
115
  </header>
116
116
  <div class="body" part="body"><slot></slot></div>
117
117
  <footer class="footer" part="footer"><slot name="footer"></slot></footer>
@@ -233,7 +233,7 @@ export class RayFileUpload extends FormControl {
233
233
  <span class="label" part="label" id="label" hidden><span class="label-text"></span><span class="required" aria-hidden="true" hidden> *</span></span>
234
234
  <div class="description" part="description" id="description" hidden></div>
235
235
  <button type="button" class="zone" part="dropzone" aria-labelledby="label title hint pill" aria-describedby="description error">
236
- <span class="glyph" part="icon"><slot name="icon"><span data-ray-icon="upload"></span></slot></span>
236
+ <span class="glyph" part="icon"><slot name="icon"><span data-ray-icon="ui.upload"></span></slot></span>
237
237
  <span class="copy">
238
238
  <span class="title" id="title"></span>
239
239
  <span class="hint" id="hint" part="hint"></span>
@@ -1,5 +1,7 @@
1
1
  // <ray-icon name="…">: an icon of the registry (setIcons), so the app's own
2
2
  // markup uses the same set as the components. Decorative unless `label`.
3
+ // `filled` draws the icon's filled version (a state: active, selected,
4
+ // favorite), or the outline one when it has none.
3
5
 
4
6
  import { RayElement, css, define } from "../../core/core.js";
5
7
 
@@ -20,6 +22,7 @@ export class RayIcon extends RayElement {
20
22
  label: { type: String },
21
23
  color: { type: String, reflect: true },
22
24
  stroke: { type: Number },
25
+ filled: { type: Boolean, reflect: true },
23
26
  };
24
27
  static styles = [styles];
25
28
  static template = `<span class="icon" part="icon" data-ray-icon=""></span>`;
@@ -31,8 +34,10 @@ export class RayIcon extends RayElement {
31
34
 
32
35
  update() {
33
36
  const icon = this.$(".icon");
34
- if (icon.getAttribute("data-ray-icon") !== (this.name ?? "")) {
37
+ const filled = Boolean(this.filled);
38
+ if (icon.getAttribute("data-ray-icon") !== (this.name ?? "") || icon.hasAttribute("data-ray-icon-filled") !== filled) {
35
39
  icon.setAttribute("data-ray-icon", this.name ?? "");
40
+ icon.toggleAttribute("data-ray-icon-filled", filled);
36
41
  this.paintIcons();
37
42
  }
38
43
  // xs…xl from the scale, or any CSS length.
@@ -65,7 +65,7 @@ const INNER = `
65
65
  <span class="section" part="left-section"><slot name="left"></slot></span>
66
66
  <span class="body"><span class="label" part="label"></span><span class="description" part="description"></span></span>
67
67
  <span class="section right" part="right-section"><slot name="right"></slot></span>
68
- <span class="chevron" aria-hidden="true" data-ray-icon="chevron-right"></span>`;
68
+ <span class="chevron" aria-hidden="true" data-ray-icon="ui.disclosure"></span>`;
69
69
 
70
70
  export class RayNavLink extends RayElement {
71
71
  static delegatesFocus = true;
@@ -150,7 +150,7 @@ export class RayNotification extends RayElement {
150
150
  <div class="message" part="message"><slot></slot></div>
151
151
  </div>
152
152
  <button class="action focus-ring" part="action" type="button" hidden></button>
153
- <button class="close focus-ring" part="close" type="button" aria-label="Close notification"><span data-ray-icon="close" aria-hidden="true"></span></button>
153
+ <button class="close focus-ring" part="close" type="button" aria-label="Close notification"><span data-ray-icon="ui.close" aria-hidden="true"></span></button>
154
154
  </div>`;
155
155
 
156
156
  constructor() {
@@ -2,7 +2,8 @@
2
2
  // buttons (hold to repeat), the arrow keys (Shift: ×10), min/max/step,
3
3
  // a fixed number of decimals, a prefix and a suffix ("$", " kg") and a
4
4
  // thousands separator in the shown text. The value is a number (or empty);
5
- // the form gets its plain digits.
5
+ // the form gets its plain digits. format="duration": the value is seconds,
6
+ // shown and typed as m:ss (h:mm:ss from an hour).
6
7
  //
7
8
  // An ARIA spinbutton: aria-valuenow/min/max/valuetext on the text field.
8
9
 
@@ -60,6 +61,36 @@ export function formatNumber(n, { decimals, thousands = "", decimal = "." } = {}
60
61
  return frac !== undefined ? `${int}${decimal}${frac}` : int;
61
62
  }
62
63
 
64
+ /** Seconds as m:ss, or h:mm:ss with `hours` (or from an hour); "" if not a number. */
65
+ export function formatDuration(seconds, { hours = false } = {}) {
66
+ if (!Number.isFinite(seconds)) return "";
67
+ const sign = seconds < 0 ? "-" : "";
68
+ const t = Math.round(Math.abs(seconds));
69
+ const h = Math.floor(t / 3600);
70
+ const m = Math.floor((t % 3600) / 60);
71
+ const ss = String(t % 60).padStart(2, "0");
72
+ return hours || h > 0 ? `${sign}${h}:${String(m).padStart(2, "0")}:${ss}` : `${sign}${m}:${ss}`;
73
+ }
74
+
75
+ /** Seconds from "m:ss", "h:mm:ss" or a plain number of seconds ("3:", "3:0" while typing); NaN if none. */
76
+ export function parseDuration(text) {
77
+ const m = String(text ?? "").replace(/\s+/g, "").match(/^(-?)(\d*)(?::(\d*))?(?::(\d*))?$/);
78
+ if (!m || (m[2] === "" && !m[3])) return NaN;
79
+ const parts = [m[2], m[3], m[4]].filter((p) => p !== undefined).map((p) => Number(p || 0));
80
+ const seconds = parts.reduce((total, p) => total * 60 + p, 0);
81
+ return m[1] ? -seconds : seconds;
82
+ }
83
+
84
+ /** "3 minutes, 15 seconds" in `lang` (Intl), for aria-valuetext. */
85
+ export function durationText(seconds, lang) {
86
+ const t = Math.round(Math.abs(seconds));
87
+ const units = [["hour", Math.floor(t / 3600)], ["minute", Math.floor((t % 3600) / 60)], ["second", t % 60]];
88
+ const said = units.filter(([, n], i) => n > 0 || (t === 0 && i === 2));
89
+ const words = said.map(([unit, n]) => new Intl.NumberFormat(lang, { style: "unit", unit, unitDisplay: "long" }).format(n));
90
+ const text = new Intl.ListFormat(lang, { style: "long", type: "unit" }).format(words);
91
+ return seconds < 0 ? `-${text}` : text;
92
+ }
93
+
63
94
  export class RayNumberInput extends FieldControl {
64
95
  /** Enter submits the form (core/form.js). */
65
96
  static implicitSubmission = true;
@@ -78,12 +109,13 @@ export class RayNumberInput extends FieldControl {
78
109
  placeholder: { type: String },
79
110
  hideControls: { type: Boolean, reflect: true, attribute: "hide-controls" },
80
111
  clamp: { type: String, default: "blur" },
112
+ format: { type: String, reflect: true, default: "number" },
81
113
  readonly: { type: Boolean, reflect: true },
82
114
  };
83
115
  static styles = [sizeStyles, radiusStyles, fieldStyles, boxStyles, styles];
84
116
  static template = fieldTemplate(
85
117
  `<input id="control" class="control" part="input" role="spinbutton" inputmode="decimal" autocomplete="off" spellcheck="false">` +
86
- `<span class="steppers" part="controls"><button type="button" class="step up" part="increment" tabindex="-1" aria-label="Increment"><span data-ray-icon="chevron-up"></span></button><button type="button" class="step down" part="decrement" tabindex="-1" aria-label="Decrement"><span data-ray-icon="chevron-down"></span></button></span>`,
118
+ `<span class="steppers" part="controls"><button type="button" class="step up" part="increment" tabindex="-1" aria-label="Increment"><span data-ray-icon="ui.increment"></span></button><button type="button" class="step down" part="decrement" tabindex="-1" aria-label="Decrement"><span data-ray-icon="ui.decrement"></span></button></span>`,
87
119
  );
88
120
 
89
121
  #typing = false;
@@ -135,6 +167,8 @@ export class RayNumberInput extends FieldControl {
135
167
  update() {
136
168
  const c = this.control;
137
169
  c.readOnly = Boolean(this.readonly);
170
+ // The number pad has no ":".
171
+ c.inputMode = this._duration ? "text" : "decimal";
138
172
  if (this.placeholder !== undefined) c.placeholder = this.placeholder;
139
173
  if (!this.#typing) this._paint();
140
174
  this.syncField();
@@ -162,10 +196,15 @@ export class RayNumberInput extends FieldControl {
162
196
  let v = n;
163
197
  if (this.min !== undefined && this.min !== null && v < this.min) v = this.min;
164
198
  if (this.max !== undefined && this.max !== null && v > this.max) v = this.max;
165
- return this.decimals !== undefined && this.decimals !== null ? Number(v.toFixed(this.decimals)) : v;
199
+ return this.decimals !== undefined && this.decimals !== null && !this._duration ? Number(v.toFixed(this.decimals)) : v;
200
+ }
201
+
202
+ get _duration() {
203
+ return this.format === "duration";
166
204
  }
167
205
 
168
206
  _parse(text) {
207
+ if (this._duration) return parseDuration(text);
169
208
  let s = String(text ?? "");
170
209
  if (this.prefix) s = s.split(this.prefix).join("");
171
210
  if (this.suffix) s = s.split(this.suffix).join("");
@@ -184,10 +223,16 @@ export class RayNumberInput extends FieldControl {
184
223
  }
185
224
  }
186
225
 
187
- // The shown text: prefix, separators, decimals, suffix.
226
+ // The shown text: prefix, separators, decimals, suffix; or m:ss.
188
227
  _text() {
189
- if (this.value === undefined || this.value === null || !Number.isFinite(this.value)) return "";
190
- const num = formatNumber(this.value, { decimals: this.decimals, thousands: this.thousandsSeparator || "", decimal: this.decimalSeparator || "." });
228
+ return this._show(this.value);
229
+ }
230
+
231
+ _show(n) {
232
+ if (n === undefined || n === null || !Number.isFinite(n)) return "";
233
+ // h:mm:ss when the range reaches an hour, so the width does not jump.
234
+ if (this._duration) return formatDuration(n, { hours: Math.abs(this.max ?? 0) >= 3600 || Math.abs(this.min ?? 0) >= 3600 });
235
+ const num = formatNumber(n, { decimals: this.decimals, thousands: this.thousandsSeparator || "", decimal: this.decimalSeparator || "." });
191
236
  return `${this.prefix ?? ""}${num}${this.suffix ?? ""}`;
192
237
  }
193
238
 
@@ -206,7 +251,8 @@ export class RayNumberInput extends FieldControl {
206
251
  set("aria-valuenow", this.value);
207
252
  set("aria-valuemin", this.min);
208
253
  set("aria-valuemax", this.max);
209
- if (this.prefix || this.suffix || this.thousandsSeparator) set("aria-valuetext", this._text() || undefined);
254
+ if (this._duration) set("aria-valuetext", Number.isFinite(this.value) ? durationText(this.value, this.closest("[lang]")?.lang || undefined) : undefined);
255
+ else if (this.prefix || this.suffix || this.thousandsSeparator) set("aria-valuetext", this._text() || undefined);
210
256
  else c.removeAttribute("aria-valuetext");
211
257
  }
212
258
 
@@ -225,8 +271,8 @@ export class RayNumberInput extends FieldControl {
225
271
  const c = this.control;
226
272
  let msg = "";
227
273
  if (this.required && (this.value === undefined || this.value === null)) msg = "Please enter a number.";
228
- else if (this.value !== undefined && this.min !== undefined && this.value < this.min) msg = `The minimum is ${this.min}.`;
229
- else if (this.value !== undefined && this.max !== undefined && this.value > this.max) msg = `The maximum is ${this.max}.`;
274
+ else if (this.value !== undefined && this.min !== undefined && this.value < this.min) msg = `The minimum is ${this._show(this.min)}.`;
275
+ else if (this.value !== undefined && this.max !== undefined && this.value > this.max) msg = `The maximum is ${this._show(this.max)}.`;
230
276
  if (msg) this.internals.setValidity(this.value === undefined ? { valueMissing: true } : this.value < (this.min ?? -Infinity) ? { rangeUnderflow: true } : { rangeOverflow: true }, msg, c);
231
277
  else this.internals.setValidity({});
232
278
  }
@@ -139,15 +139,15 @@ export class RayPagination extends RayElement {
139
139
  const items = [];
140
140
  const control = (role, target, icon, label, off) => ({ kind: "control", role, target, icon, label, off });
141
141
  if (!this.hideControls) {
142
- if (this.withEdges) items.push(control("first", 1, "chevrons-left", "First page", page <= 1));
143
- items.push(control("previous", page - 1, "chevron-left", "Previous page", page <= 1));
142
+ if (this.withEdges) items.push(control("first", 1, "ui.first", "First page", page <= 1));
143
+ items.push(control("previous", page - 1, "ui.previous", "Previous page", page <= 1));
144
144
  }
145
145
  for (const it of paginationRange(total, page, this.siblings ?? 1, this.boundaries ?? 1)) {
146
146
  items.push(it === "dots" ? { kind: "dots" } : { kind: "page", target: it });
147
147
  }
148
148
  if (!this.hideControls) {
149
- items.push(control("next", page + 1, "chevron-right", "Next page", page >= total));
150
- if (this.withEdges) items.push(control("last", total, "chevrons-right", "Last page", page >= total));
149
+ items.push(control("next", page + 1, "ui.next", "Next page", page >= total));
150
+ if (this.withEdges) items.push(control("last", total, "ui.last", "Last page", page >= total));
151
151
  }
152
152
  list.replaceChildren(...items.map((it) => this._item(it, page)));
153
153
  this.paintIcons(list);