@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
@@ -43,7 +43,7 @@ export class RayPasswordInput extends RayInput {
43
43
  static styles = [...RayInput.styles, styles];
44
44
  static template = fieldTemplate(
45
45
  `<input id="control" class="control" part="input" type="password" autocomplete="current-password" spellcheck="false" autocapitalize="off">` +
46
- `<button type="button" class="toggle focus-ring" part="toggle" aria-pressed="false"><span data-ray-icon="eye" aria-hidden="true"></span></button>`,
46
+ `<button type="button" class="toggle focus-ring" part="toggle" aria-pressed="false"><span data-ray-icon="ui.show-password" aria-hidden="true"></span></button>`,
47
47
  );
48
48
 
49
49
  wire() {
@@ -69,7 +69,7 @@ export class RayPasswordInput extends RayInput {
69
69
  toggle.title = toggle.getAttribute("aria-label");
70
70
  toggle.disabled = this.effectivelyDisabled;
71
71
  const icon = toggle.firstElementChild;
72
- const name = this.visible ? "eye-off" : "eye";
72
+ const name = this.visible ? "ui.hide-password" : "ui.show-password";
73
73
  if (icon.dataset.rayIcon !== name) {
74
74
  icon.dataset.rayIcon = name;
75
75
  this.paintIcons(toggle);
@@ -3,12 +3,17 @@
3
3
  // Native radios cannot group across shadow roots, so the group owns the
4
4
  // state: it is the form control (value submitted under `name`), keeps one
5
5
  // radio checked, and moves focus and selection with the arrow keys (roving
6
- // tabindex), like native radios.
6
+ // tabindex), like native radios. <ray-radio-card> is a radio drawn as a
7
+ // card (a title, a detail, an icon), for choices that need more than a line:
8
+ // plans, presets, shipping methods. Both can share a group.
7
9
 
8
10
  import { RayElement, css, define } from "../../core/core.js";
9
11
  import { applyColor, sizeStyles } from "../../core/styles.js";
10
12
  import { FormControl, fieldStyles } from "../../core/form.js";
11
13
 
14
+ /** The elements a group owns: radios and radio cards. */
15
+ const RADIOS = "ray-radio, ray-radio-card";
16
+
12
17
  const groupStyles = css`
13
18
  :host { --_rs: 1.25rem; }
14
19
  :host([size="xs"]) { --_rs: 1rem; }
@@ -53,7 +58,7 @@ export class RayRadioGroup extends FormControl {
53
58
  this.internals.role = "radiogroup";
54
59
  this.addEventListener("keydown", (e) => this._keydown(e));
55
60
  this.addEventListener("click", (e) => {
56
- const radio = e.target.closest?.("ray-radio");
61
+ const radio = e.target.closest?.(RADIOS);
57
62
  if (radio && this.radios.includes(radio)) this._choose(radio, true);
58
63
  });
59
64
  }
@@ -67,9 +72,9 @@ export class RayRadioGroup extends FormControl {
67
72
  }
68
73
  }
69
74
 
70
- /** The radios of this group (direct or nested, not in a nested group). */
75
+ /** The radios (and radio cards) of this group (direct or nested, not in a nested group). */
71
76
  get radios() {
72
- return [...this.querySelectorAll("ray-radio")].filter((r) => r.closest("ray-radio-group") === this);
77
+ return [...this.querySelectorAll(RADIOS)].filter((r) => r.closest("ray-radio-group") === this);
73
78
  }
74
79
 
75
80
  update() {
@@ -129,7 +134,7 @@ export class RayRadioGroup extends FormControl {
129
134
 
130
135
  _keydown(e) {
131
136
  const radios = this.radios.filter((r) => !r.disabled);
132
- const i = radios.indexOf(e.target.closest?.("ray-radio"));
137
+ const i = radios.indexOf(e.target.closest?.(RADIOS));
133
138
  if (i < 0) return;
134
139
  let next = null;
135
140
  if (e.key === "ArrowDown" || e.key === "ArrowRight") next = radios[(i + 1) % radios.length];
@@ -266,5 +271,63 @@ export class RayRadio extends RayElement {
266
271
  }
267
272
  }
268
273
 
274
+ const cardStyles = css`
275
+ :host { --_s: var(--_rs, 1.125rem); }
276
+ .root {
277
+ align-items: flex-start;
278
+ gap: var(--ray-space-3);
279
+ height: 100%;
280
+ box-sizing: border-box;
281
+ padding: var(--ray-space-3) var(--ray-space-4);
282
+ background: var(--ray-background);
283
+ border: 1px solid var(--ray-border);
284
+ border-radius: var(--ray-radius-default);
285
+ transition: background-color var(--ray-duration-fast) var(--ray-easing-standard), border-color var(--ray-duration-fast) var(--ray-easing-standard);
286
+ }
287
+ :host(:not([data-disabled])) .root:hover { background: var(--ray-default-hover); }
288
+ :host([data-checked]) .root, :host([data-checked]) .root:hover {
289
+ background: var(--_light, var(--ray-primary-light));
290
+ border-color: var(--_filled, var(--ray-primary-filled));
291
+ }
292
+ :host(:focus-visible) .root { outline: var(--ray-focus-ring-width) solid var(--ray-ring); outline-offset: var(--ray-focus-ring-offset); }
293
+ :host(:focus-visible) .indicator { outline: none; }
294
+ :host([data-disabled]) .root { background: var(--ray-muted); opacity: 0.6; }
295
+ .body { flex: 1; min-width: 0; min-height: 0; }
296
+ .label { font-weight: var(--ray-font-weight-semibold); }
297
+ .icon { display: inline-flex; flex: none; color: var(--_filled, var(--ray-primary-filled)); font-size: 1.25rem; line-height: 1; }
298
+ .extra { margin-top: var(--ray-space-2); font-size: var(--ray-font-size-sm); }
299
+ :host([hide-indicator]) .indicator { display: none; }
300
+ `;
301
+
302
+ export class RayRadioCard extends RayRadio {
303
+ static properties = {
304
+ ...RayRadio.properties,
305
+ hideIndicator: { type: Boolean, reflect: true, attribute: "hide-indicator" },
306
+ };
307
+ static styles = [radioStyles, cardStyles];
308
+ static template = `
309
+ <div class="root" part="root">
310
+ <span class="icon" part="icon" hidden><slot name="icon"></slot></span>
311
+ <span class="body">
312
+ <span class="label" part="label"><span class="label-text"></span></span>
313
+ <span class="description" part="description" hidden></span>
314
+ <span class="extra" hidden><slot></slot></span>
315
+ </span>
316
+ <span class="indicator" part="indicator"><span class="dot"></span></span>
317
+ </div>`;
318
+
319
+ constructor() {
320
+ super();
321
+ // The icon and the extra content take room only when given.
322
+ for (const slot of this.shadowRoot.querySelectorAll("slot")) {
323
+ slot.addEventListener("slotchange", () => {
324
+ const given = slot.assignedNodes().some((n) => n.nodeType === Node.ELEMENT_NODE || n.textContent.trim());
325
+ slot.parentElement.hidden = !given;
326
+ });
327
+ }
328
+ }
329
+ }
330
+
269
331
  define("ray-radio-group", RayRadioGroup);
270
332
  define("ray-radio", RayRadio);
333
+ define("ray-radio-card", RayRadioCard);
@@ -0,0 +1,127 @@
1
+ // <ray-ring-progress>: progress as a ring (Mantine's RingProgress), with
2
+ // room in the middle for content (a countdown's clock, a percentage).
3
+ //
4
+ // The arc is a stroke-dasharray on a circle with pathLength=100, drawn from
5
+ // 12 o'clock clockwise; r is a CSS length (half the box minus half the
6
+ // stroke), so any size and thickness need no viewBox arithmetic.
7
+
8
+ import { RayElement, css, define } from "../../core/core.js";
9
+ import { applyColor } from "../../core/styles.js";
10
+
11
+ const SIZES = { xs: "3rem", sm: "5rem", md: "7.5rem", lg: "10rem", xl: "12.5rem" };
12
+
13
+ const styles = css`
14
+ :host {
15
+ --_d: 7.5rem;
16
+ --_t: calc(var(--_d) / 10);
17
+ position: relative;
18
+ display: inline-grid;
19
+ /* One cell of exactly the diameter: the svg's intrinsic 300×150 must not size it. */
20
+ grid-template: minmax(0, 1fr) / minmax(0, 1fr);
21
+ vertical-align: middle;
22
+ width: var(--_d);
23
+ height: var(--_d);
24
+ flex: none;
25
+ }
26
+ svg {
27
+ grid-area: 1 / 1;
28
+ width: 100%;
29
+ height: 100%;
30
+ overflow: visible;
31
+ transform: rotate(-90deg);
32
+ }
33
+ circle {
34
+ cx: 50%;
35
+ cy: 50%;
36
+ r: calc(50% - var(--_t) / 2);
37
+ fill: none;
38
+ stroke-width: var(--_t);
39
+ }
40
+ .track { stroke: var(--ray-muted); }
41
+ .bar {
42
+ stroke: var(--_filled);
43
+ stroke-dasharray: var(--_pct, 0) 100;
44
+ transition: stroke-dasharray var(--ray-duration-slow) var(--ray-easing-standard);
45
+ }
46
+ :host([round-caps]) .bar { stroke-linecap: round; }
47
+ /* A round cap draws a dot at 0. */
48
+ .bar[data-empty] { visibility: hidden; }
49
+ svg[data-indeterminate] { animation: ray-ring-spin 1.2s linear infinite; }
50
+ svg[data-indeterminate] .bar { transition: none; }
51
+ @keyframes ray-ring-spin { from { transform: rotate(-90deg); } to { transform: rotate(270deg); } }
52
+ .center {
53
+ grid-area: 1 / 1;
54
+ display: grid;
55
+ place-items: center;
56
+ padding: calc(var(--_t) * 1.5);
57
+ min-width: 0;
58
+ text-align: center;
59
+ font-size: max(var(--ray-font-size-xs), calc(var(--_d) * 0.16));
60
+ font-variant-numeric: tabular-nums;
61
+ line-height: 1.1;
62
+ }
63
+ .value { font-weight: var(--ray-font-weight-semibold); }
64
+ .value:empty { display: none; }
65
+ `;
66
+
67
+ export class RayRingProgress extends RayElement {
68
+ static properties = {
69
+ value: { type: Number },
70
+ max: { type: Number, default: 100 },
71
+ color: { type: String, reflect: true },
72
+ size: { type: String, reflect: true, default: "md" },
73
+ thickness: { type: String, reflect: true },
74
+ roundCaps: { type: Boolean, reflect: true, attribute: "round-caps" },
75
+ showValue: { type: Boolean, reflect: true, attribute: "show-value" },
76
+ label: { type: String },
77
+ };
78
+ static styles = [styles];
79
+ static template = `<svg aria-hidden="true"><circle class="track" part="track" pathLength="100"></circle><circle class="bar" part="bar" pathLength="100"></circle></svg><div class="center" part="center"><slot></slot><span class="value" aria-hidden="true"></span></div>`;
80
+
81
+ constructor() {
82
+ super();
83
+ this.internals = this.attachInternals();
84
+ this.internals.role = "progressbar";
85
+ }
86
+
87
+ update() {
88
+ const svg = this.$("svg");
89
+ const bar = this.$(".bar");
90
+ applyColor(svg, this.color);
91
+ // size: xs…xl or a length; thickness: a length (a bare number is px).
92
+ const d = SIZES[this.size] ?? this.size ?? "";
93
+ if (d) this.style.setProperty("--_d", d);
94
+ else this.style.removeProperty("--_d");
95
+ const t = this.thickness ? (/^\d*\.?\d+$/.test(this.thickness) ? `${this.thickness}px` : this.thickness) : "";
96
+ if (t) this.style.setProperty("--_t", t);
97
+ else this.style.removeProperty("--_t");
98
+
99
+ const max = this.max > 0 ? this.max : 100;
100
+ const indeterminate = this.value === undefined || this.value === null || Number.isNaN(this.value);
101
+ svg.toggleAttribute("data-indeterminate", indeterminate);
102
+ const pct = indeterminate ? 0 : Math.min(100, Math.max(0, (this.value / max) * 100));
103
+ // Indeterminate: a quarter arc that spins.
104
+ bar.style.setProperty("--_pct", String(indeterminate ? 25 : pct));
105
+ bar.toggleAttribute("data-empty", !indeterminate && pct === 0);
106
+ // The percentage in the middle only when the app puts nothing there.
107
+ const empty = this.$("slot").assignedNodes().every((n) => n.nodeType === Node.TEXT_NODE && !n.textContent.trim());
108
+ this.$(".value").textContent = this.showValue && !indeterminate && empty ? `${Math.round(pct)}%` : "";
109
+
110
+ const i = this.internals;
111
+ i.ariaValueMin = "0";
112
+ i.ariaValueMax = String(max);
113
+ i.ariaValueNow = indeterminate ? null : String(this.value);
114
+ i.ariaValueText = indeterminate ? null : `${Math.round(pct)}%`;
115
+ i.ariaLabel = this.label || this.getAttribute("aria-label") || "Progress";
116
+ }
117
+
118
+ connectedCallback() {
119
+ super.connectedCallback();
120
+ if (!this._wired) {
121
+ this._wired = true;
122
+ this.$("slot").addEventListener("slotchange", () => this.requestUpdate());
123
+ }
124
+ }
125
+ }
126
+
127
+ define("ray-ring-progress", RayRingProgress);
@@ -62,9 +62,9 @@ export class RaySearch extends RayInput {
62
62
  };
63
63
  static styles = [...RayInput.styles, styles];
64
64
  static template = fieldTemplate(
65
- `<span class="lead" part="icon" aria-hidden="true" data-ray-icon="search"></span>` +
65
+ `<span class="lead" part="icon" aria-hidden="true" data-ray-icon="ui.search"></span>` +
66
66
  `<input id="control" class="control" part="input" type="search" enterkeyhint="search" autocomplete="off" spellcheck="false">` +
67
- `<span class="tail"><button class="clear focus-ring" part="clear" type="button" aria-label="Clear search" tabindex="-1" hidden><span data-ray-icon="close" aria-hidden="true"></span></button>` +
67
+ `<span class="tail"><button class="clear focus-ring" part="clear" type="button" aria-label="Clear search" tabindex="-1" hidden><span data-ray-icon="ui.clear" aria-hidden="true"></span></button>` +
68
68
  `<kbd class="shortcut" part="shortcut" aria-hidden="true" hidden></kbd></span>`,
69
69
  );
70
70
 
@@ -101,7 +101,7 @@ const styles = css`
101
101
  .empty { padding: var(--ray-space-2); color: var(--ray-muted-foreground); }
102
102
  `;
103
103
 
104
- const CHEVRON = `<span class="chevron" aria-hidden="true" data-ray-icon="chevron-down"></span>`;
104
+ const CHEVRON = `<span class="chevron" aria-hidden="true" data-ray-icon="ui.expand"></span>`;
105
105
 
106
106
  export class RaySelect extends FieldControl {
107
107
  static properties = {
@@ -261,7 +261,7 @@ export class RaySelect extends FieldControl {
261
261
  const group = o.parentElement?.localName === "optgroup" ? o.parentElement : null;
262
262
  const disabled = o.disabled || Boolean(group?.disabled);
263
263
  el.setAttribute("aria-disabled", String(disabled));
264
- el.innerHTML = `<span class="text"></span><span class="mark" aria-hidden="true" data-ray-icon="check"></span>`;
264
+ el.innerHTML = `<span class="text"></span><span class="mark" aria-hidden="true" data-ray-icon="ui.checked"></span>`;
265
265
  el.firstChild.textContent = o.label || o.text;
266
266
  this.#items.push({ el, value: o.value, disabled, text: (o.label || o.text).trim().toLowerCase() });
267
267
  return el;
@@ -34,7 +34,7 @@ const styles = css`
34
34
  `;
35
35
 
36
36
  // Icon of each trend (from the icon registry).
37
- const ARROWS = { up: "arrow-up-right", down: "arrow-down-right", flat: "minus" };
37
+ const ARROWS = { up: "ui.trend-up", down: "ui.trend-down", flat: "ui.trend-flat" };
38
38
 
39
39
  export class RayStat extends RayElement {
40
40
  static properties = {
@@ -0,0 +1,95 @@
1
+ // <ray-step-dots>: a row of dots, one per step: done (filled), the current
2
+ // one (a filled pill), the rest (empty). A compact progress for rounds,
3
+ // onboarding pages or a quiz. Not interactive: the carousel's dots are
4
+ // buttons, these are a progressbar.
5
+
6
+ import { RayElement, css, define } from "../../core/core.js";
7
+ import { applyColor } from "../../core/styles.js";
8
+
9
+ const styles = css`
10
+ :host {
11
+ --_dot: 0.5rem;
12
+ display: block;
13
+ }
14
+ :host([size="xs"]) { --_dot: 0.3125rem; }
15
+ :host([size="md"]) { --_dot: 0.625rem; }
16
+ :host([size="lg"]) { --_dot: 0.75rem; }
17
+ :host([size="xl"]) { --_dot: 1rem; }
18
+ .dots {
19
+ display: flex;
20
+ flex-wrap: wrap;
21
+ align-items: center;
22
+ justify-content: var(--_justify, center);
23
+ gap: calc(var(--_dot) * 0.75);
24
+ }
25
+ :host([align="start"]) { --_justify: flex-start; }
26
+ :host([align="end"]) { --_justify: flex-end; }
27
+ .dot {
28
+ flex: none;
29
+ width: var(--_dot);
30
+ height: var(--_dot);
31
+ box-sizing: border-box;
32
+ border-radius: var(--ray-radius-full);
33
+ border: 1.5px solid color-mix(in srgb, var(--ray-foreground) 28%, transparent);
34
+ transition: width var(--ray-duration-base) var(--ray-easing-standard), background-color var(--ray-duration-base) var(--ray-easing-standard), border-color var(--ray-duration-base) var(--ray-easing-standard);
35
+ }
36
+ .dot[data-state="done"] { background: var(--_filled); border-color: var(--_filled); }
37
+ .dot[data-state="current"] { width: calc(var(--_dot) * 2.5); background: var(--_filled); border-color: var(--_filled); }
38
+ `;
39
+
40
+ export class RayStepDots extends RayElement {
41
+ static properties = {
42
+ count: { type: Number, default: 0 },
43
+ value: { type: Number, default: 0 },
44
+ color: { type: String, reflect: true },
45
+ size: { type: String, reflect: true, default: "sm" },
46
+ align: { type: String, reflect: true, default: "center" },
47
+ label: { type: String },
48
+ valueText: { type: String, attribute: "value-text" },
49
+ doneText: { type: String, attribute: "done-text" },
50
+ };
51
+ static styles = [styles];
52
+ static template = `<div class="dots" part="dots" aria-hidden="true"></div>`;
53
+
54
+ constructor() {
55
+ super();
56
+ this.internals = this.attachInternals();
57
+ this.internals.role = "progressbar";
58
+ }
59
+
60
+ update() {
61
+ applyColor(this, this.color);
62
+ const count = Math.max(0, Math.floor(this.count || 0));
63
+ // value: the current step, 0-based; count (or more) is all done.
64
+ const value = Math.min(count, Math.max(0, Math.floor(this.value || 0)));
65
+ const box = this.$(".dots");
66
+ while (box.children.length < count) {
67
+ const dot = document.createElement("span");
68
+ dot.className = "dot";
69
+ dot.setAttribute("part", "dot");
70
+ box.append(dot);
71
+ }
72
+ while (box.children.length > count) box.lastElementChild.remove();
73
+ [...box.children].forEach((dot, i) => {
74
+ dot.dataset.state = i < value ? "done" : i === value ? "current" : "todo";
75
+ });
76
+
77
+ // The announced text: value-text / done-text ({step}: the current one,
78
+ // 1-based; {count}), numbers in the element's language; English otherwise.
79
+ const lang = this.closest("[lang]")?.lang || undefined;
80
+ const n = (x) => new Intl.NumberFormat(lang).format(x);
81
+ const fill = (tpl) => tpl.replaceAll("{step}", n(Math.min(value + 1, count))).replaceAll("{count}", n(count));
82
+ const done = count > 0 && value >= count;
83
+ const text = done
84
+ ? fill(this.doneText ?? this.valueText ?? "{count} of {count}, done")
85
+ : fill(this.valueText ?? `${this.label || "Step"} {step} of {count}`);
86
+ const i = this.internals;
87
+ i.ariaLabel = this.label || this.getAttribute("aria-label") || "Steps";
88
+ i.ariaValueMin = "0";
89
+ i.ariaValueMax = String(count);
90
+ i.ariaValueNow = String(value);
91
+ i.ariaValueText = count === 0 ? null : text;
92
+ }
93
+ }
94
+
95
+ define("ray-step-dots", RayStepDots);
@@ -172,7 +172,7 @@ export class RayStepper extends RayElement {
172
172
  indicator.setAttribute("aria-hidden", "true");
173
173
  if (step.loading) indicator.innerHTML = `<span class="spinner"></span>`;
174
174
  else if (step.error) indicator.append(glyph("close"));
175
- else if (state === "done") indicator.append(glyph(step.completedIcon || "check"));
175
+ else if (state === "done") indicator.append(glyph(step.completedIcon || "ui.checked"));
176
176
  else if (step.icon) indicator.append(glyph(step.icon));
177
177
  else indicator.textContent = String(i + 1);
178
178
  const text = document.createElement("span");
@@ -67,7 +67,7 @@ const styles = css`
67
67
  .empty:hover td { background: inherit; }
68
68
  `;
69
69
 
70
- const SORT_ICON = `<span data-ray-icon="chevron-up" aria-hidden="true"></span>`;
70
+ const SORT_ICON = `<span data-ray-icon="ui.sort" aria-hidden="true"></span>`;
71
71
 
72
72
  export class RayTable extends RayElement {
73
73
  static properties = {
@@ -6,7 +6,7 @@ const ORDER = ["system", "light", "dark"];
6
6
  const LABEL = { system: "System theme", light: "Light theme", dark: "Dark theme" };
7
7
 
8
8
  // Icon of each theme (from the icon registry).
9
- const ICONS = { system: "monitor", light: "sun", dark: "moon" };
9
+ const ICONS = { system: "ui.theme-system", light: "ui.theme-light", dark: "ui.theme-dark" };
10
10
 
11
11
  const styles = css`
12
12
  :host {
@@ -41,7 +41,7 @@ export class RayThemeToggle extends RayElement {
41
41
  };
42
42
  static styles = [styles];
43
43
  static delegatesFocus = true;
44
- static template = `<button part="button" class="focus-ring" type="button"><span class="icon" data-ray-icon="monitor" aria-hidden="true"></span></button>`;
44
+ static template = `<button part="button" class="focus-ring" type="button"><span class="icon" data-ray-icon="ui.theme-system" aria-hidden="true"></span></button>`;
45
45
 
46
46
  connectedCallback() {
47
47
  super.connectedCallback();
package/core/core.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { getIcon, onIconsChange } from "./icons.js";
6
6
  import { parseColor, shades, contrastRatio, toHex, toOklch } from "./color.js";
7
7
 
8
- export { setIcons, getIcon, iconNames } from "./icons.js";
8
+ export { setIcons, getIcon, iconNames, iconAliases } from "./icons.js";
9
9
 
10
10
  export const PREFIX = "ray";
11
11
 
@@ -313,12 +313,13 @@ export class RayElement extends HTMLElement {
313
313
  * Protected. Fills every [data-ray-icon="name"] under `root` (default: the
314
314
  * shadow root) with that icon of the registry (setIcons). Components call
315
315
  * it for markup they create after the template. An empty name or "none"
316
- * (the value components use to hide an icon) leaves the slot empty.
316
+ * (the value components use to hide an icon) leaves the slot empty;
317
+ * data-ray-icon-filled asks for the filled version (the outline without one).
317
318
  */
318
319
  paintIcons(root = this.shadowRoot) {
319
320
  for (const slot of root.querySelectorAll("[data-ray-icon]")) {
320
321
  const name = slot.getAttribute("data-ray-icon");
321
- slot.innerHTML = name && name !== "none" ? getIcon(name) : "";
322
+ slot.innerHTML = name && name !== "none" ? getIcon(name, { filled: slot.hasAttribute("data-ray-icon-filled") }) : "";
322
323
  }
323
324
  }
324
325
  }
package/core/icons.js CHANGED
@@ -6,6 +6,16 @@
6
6
  //
7
7
  // setIcons() also adds new names, for <ray-icon name="…">. Icons are SVG
8
8
  // markup (or an SVGElement) drawn with currentColor; components size them.
9
+ //
10
+ // The components ask for roles, not glyphs: "ui.close" (a dialog's close
11
+ // button), "ui.remove" (a chip's ×), "ui.clear" (a field's ×)… Each role
12
+ // draws its glyph (ALIASES) unless the app registers the role itself, so
13
+ // setIcons({ close: … }) still changes every × and setIcons({ "ui.remove": … })
14
+ // only the chips'.
15
+ //
16
+ // An icon may have a filled version, "<name>:filled", for a state: the active
17
+ // tab, a favorite, unread. <ray-icon name="star" filled> draws it, and the
18
+ // outline one when there is none. setIcons({ "star:filled": … }) adds one.
9
19
  // The markup is inserted as HTML: register icons from your own code only,
10
20
  // never from user input.
11
21
 
@@ -62,12 +72,72 @@ const DEFAULTS = {
62
72
  "message-circle": svg(`<path d="M20 11.5a8 8 0 0 1-11.6 7.2L4 20l1.3-4.2A8 8 0 1 1 20 11.5z"/>`),
63
73
  };
64
74
 
65
- const registry = new Map(Object.entries(DEFAULTS));
75
+ // The filled versions: the outline's shapes filled (same silhouette: the
76
+ // stroke stays), or one even-odd path where a mark is cut out of a disc.
77
+ const solid = (body) =>
78
+ `<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
79
+ const cut = (d) => `<svg viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd" aria-hidden="true"><path d="${d}"/></svg>`;
80
+
81
+ /** The filled versions of the default set (registered as "<name>:filled"). */
82
+ const FILLED = {
83
+ "user": solid(`<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0z"/>`),
84
+ "info": cut("M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 15h-2v-6h2zm0-8h-2V7h2z"),
85
+ "circle-check": cut("M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.5 14.5-4-4 1.4-1.4 2.6 2.6 5.6-5.6 1.4 1.4z"),
86
+ "alert-triangle": cut("M10.3 3.2 1.6 18.1a2 2 0 0 0 1.7 3h17.4a2 2 0 0 0 1.7-3L13.7 3.2a2 2 0 0 0-3.4 0zM11 8.5h2v5.5h-2zM12 16a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5z"),
87
+ "circle-x": cut("M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM14.12 8.46 15.54 9.88 13.41 12 15.54 14.12 14.12 15.54 12 13.41 9.88 15.54 8.46 14.12 10.59 12 8.46 9.88 9.88 8.46 12 10.59z"),
88
+ "folder": solid(`<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>`),
89
+ bell: solid(`<path d="M10.3 21a1.9 1.9 0 0 0 3.4 0z"/><path d="M3.3 15.3A1 1 0 0 0 4 17h16a1 1 0 0 0 .7-1.7C19.4 14 18 12.6 18 8A6 6 0 0 0 6 8c0 4.6-1.4 6-2.7 7.3"/>`),
90
+ "star": solid(`<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9l-5.2 2.7 1-5.8-4.3-4.1 5.9-.9z"/>`),
91
+ "settings": solid(`<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>`),
92
+ "home": solid(`<path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1z"/>`),
93
+ "message-circle": solid(`<path d="M20 11.5a8 8 0 0 1-11.6 7.2L4 20l1.3-4.2A8 8 0 1 1 20 11.5z"/>`),
94
+ };
95
+
96
+ const FILLED_SUFFIX = ":filled";
97
+
98
+ /** The roles the components draw, and the glyph each one uses by default. */
99
+ const ALIASES = {
100
+ "ui.close": "close",
101
+ "ui.clear": "close",
102
+ "ui.remove": "close",
103
+ "ui.expand": "chevron-down",
104
+ "ui.disclosure": "chevron-right",
105
+ "ui.previous": "chevron-left",
106
+ "ui.next": "chevron-right",
107
+ "ui.first": "chevrons-left",
108
+ "ui.last": "chevrons-right",
109
+ "ui.separator": "chevron-right",
110
+ "ui.increment": "chevron-up",
111
+ "ui.decrement": "chevron-down",
112
+ "ui.sort": "chevron-up",
113
+ "ui.checked": "check",
114
+ "ui.indeterminate": "minus",
115
+ "ui.copy": "copy",
116
+ "ui.copied": "check",
117
+ "ui.show-password": "eye",
118
+ "ui.hide-password": "eye-off",
119
+ "ui.play": "play",
120
+ "ui.pause": "pause",
121
+ "ui.search": "search",
122
+ "ui.calendar": "calendar",
123
+ "ui.eyedropper": "pipette",
124
+ "ui.upload": "upload",
125
+ "ui.avatar": "user",
126
+ "ui.theme-light": "sun",
127
+ "ui.theme-dark": "moon",
128
+ "ui.theme-system": "monitor",
129
+ "ui.trend-up": "arrow-up-right",
130
+ "ui.trend-down": "arrow-down-right",
131
+ "ui.trend-flat": "minus",
132
+ };
133
+
134
+ const registry = new Map([...Object.entries(DEFAULTS), ...Object.entries(FILLED).map(([name, markup]) => [name + FILLED_SUFFIX, markup])]);
66
135
  const listeners = new Set();
67
136
  const warned = new Set();
68
137
 
69
138
  /**
70
- * Replaces or adds icons. Values are SVG markup or an SVGElement.
139
+ * Replaces or adds icons. Values are SVG markup or an SVGElement; a name
140
+ * ending in ":filled" is the filled version of an icon.
71
141
  * @param {Record<string, string | SVGElement>} icons
72
142
  */
73
143
  export function setIcons(icons) {
@@ -81,9 +151,19 @@ export function setIcons(icons) {
81
151
  for (const cb of listeners) cb();
82
152
  }
83
153
 
84
- /** The SVG markup of an icon ("" and one warning when it is not registered). */
85
- export function getIcon(name) {
86
- const markup = registry.get(name);
154
+ /**
155
+ * The SVG markup of an icon or a role ("" and one warning when it is not
156
+ * registered). With `filled`, its filled version, or the outline one when it
157
+ * has none.
158
+ */
159
+ export function getIcon(name, { filled = false } = {}) {
160
+ // A role the app has not registered draws its glyph.
161
+ const glyph = ALIASES[name];
162
+ if (filled) {
163
+ const solidMarkup = registry.get(name + FILLED_SUFFIX) ?? (glyph && !registry.has(name) ? registry.get(glyph + FILLED_SUFFIX) : undefined);
164
+ if (solidMarkup !== undefined) return solidMarkup;
165
+ }
166
+ const markup = registry.get(name) ?? (glyph ? registry.get(glyph) : undefined);
87
167
  if (markup !== undefined) return markup;
88
168
  if (!warned.has(name)) {
89
169
  warned.add(name);
@@ -92,9 +172,16 @@ export function getIcon(name) {
92
172
  return "";
93
173
  }
94
174
 
95
- /** The registered icon names. */
96
- export function iconNames() {
97
- return [...registry.keys()];
175
+ /** The roles the components draw ("ui.close"…) and the glyph each one uses unless registered. */
176
+ export function iconAliases() {
177
+ return { ...ALIASES };
178
+ }
179
+
180
+ /** The registered icon names; with `filled`, those that have a filled version. */
181
+ export function iconNames({ filled = false } = {}) {
182
+ const names = [...registry.keys()];
183
+ if (filled) return names.filter((n) => n.endsWith(FILLED_SUFFIX)).map((n) => n.slice(0, -FILLED_SUFFIX.length));
184
+ return names.filter((n) => !n.endsWith(FILLED_SUFFIX));
98
185
  }
99
186
 
100
187
  /** @internal Called by the core runtime to repaint components after setIcons. */