@raylang/elements 0.5.2 → 0.6.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.
- package/check/check.js +10 -3
- package/components/button/button.js +17 -0
- package/components/number-input/number-input.js +54 -8
- package/components/radio/radio.js +68 -5
- package/components/ring-progress/ring-progress.js +127 -0
- package/components/step-dots/step-dots.js +86 -0
- package/custom-elements.json +647 -44
- package/index.js +3 -1
- package/llms-full.txt +83 -7
- package/llms.txt +5 -3
- package/package.json +9 -1
- package/ray-elements.d.ts +54 -6
- package/vscode.html-custom-data.json +184 -55
package/check/check.js
CHANGED
|
@@ -74,14 +74,20 @@ function indexManifest(manifests) {
|
|
|
74
74
|
return index;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// The literals of a union are its values. `(string & {})` beside them (keeps
|
|
78
|
+
// the literals in autocompletion) is the system's "or any CSS length" (size
|
|
79
|
+
// of buttons, dialogs, drawers).
|
|
77
80
|
function attribute(a) {
|
|
78
81
|
const text = a.type?.text ?? "string";
|
|
79
82
|
const parts = text.split("|").map((s) => s.trim());
|
|
80
83
|
const literals = parts.filter((s) => /^"[^"]*"$/.test(s)).map((s) => s.slice(1, -1));
|
|
81
84
|
const others = parts.filter((s) => !/^"[^"]*"$/.test(s) && s !== "undefined" && s !== "null");
|
|
82
|
-
|
|
85
|
+
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 };
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
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;
|
|
90
|
+
|
|
85
91
|
/** The language of a file by its extension: "html" (also Vue, Svelte, Astro) or "jsx". */
|
|
86
92
|
export function languageOf(file) {
|
|
87
93
|
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
@@ -220,8 +226,9 @@ function attributeChecks(a, tok, el, parent, language, icons, allow, report) {
|
|
|
220
226
|
if (a.value === null || a.dynamic || n.bound) return;
|
|
221
227
|
if (attr.boolean && a.value === "false") {
|
|
222
228
|
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
|
-
|
|
229
|
+
} else if (attr.values && !attr.values.includes(a.value) && !allow[name]?.includes(a.value) && !(attr.length && LENGTH.test(a.value.trim()))) {
|
|
230
|
+
const or = attr.length ? " or a CSS length" : "";
|
|
231
|
+
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
232
|
}
|
|
226
233
|
// "none" (or nothing) draws no icon: a blank that keeps the alignment.
|
|
227
234
|
if (el.tag === "ray-icon" && name === "name" && icons && a.value !== "" && a.value !== "none" && !icons.has(a.value)) {
|
|
@@ -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);
|
|
@@ -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,6 +109,7 @@ 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];
|
|
@@ -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
|
-
|
|
190
|
-
|
|
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.
|
|
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
|
}
|
|
@@ -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?.(
|
|
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(
|
|
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?.(
|
|
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);
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
};
|
|
49
|
+
static styles = [styles];
|
|
50
|
+
static template = `<div class="dots" part="dots" aria-hidden="true"></div>`;
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
super();
|
|
54
|
+
this.internals = this.attachInternals();
|
|
55
|
+
this.internals.role = "progressbar";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
update() {
|
|
59
|
+
applyColor(this, this.color);
|
|
60
|
+
const count = Math.max(0, Math.floor(this.count || 0));
|
|
61
|
+
// value: the current step, 0-based; count (or more) is all done.
|
|
62
|
+
const value = Math.min(count, Math.max(0, Math.floor(this.value || 0)));
|
|
63
|
+
const box = this.$(".dots");
|
|
64
|
+
while (box.children.length < count) {
|
|
65
|
+
const dot = document.createElement("span");
|
|
66
|
+
dot.className = "dot";
|
|
67
|
+
dot.setAttribute("part", "dot");
|
|
68
|
+
box.append(dot);
|
|
69
|
+
}
|
|
70
|
+
while (box.children.length > count) box.lastElementChild.remove();
|
|
71
|
+
[...box.children].forEach((dot, i) => {
|
|
72
|
+
dot.dataset.state = i < value ? "done" : i === value ? "current" : "todo";
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const noun = this.label || "Step";
|
|
76
|
+
const done = count > 0 && value >= count;
|
|
77
|
+
const i = this.internals;
|
|
78
|
+
i.ariaLabel = this.label || this.getAttribute("aria-label") || "Steps";
|
|
79
|
+
i.ariaValueMin = "0";
|
|
80
|
+
i.ariaValueMax = String(count);
|
|
81
|
+
i.ariaValueNow = String(value);
|
|
82
|
+
i.ariaValueText = count === 0 ? null : done ? `${count} of ${count}, done` : `${noun} ${value + 1} of ${count}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
define("ray-step-dots", RayStepDots);
|