@raylang/elements 0.5.1 → 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.d.ts +45 -0
- package/check/check.js +419 -0
- package/check/cli.js +145 -0
- package/components/button/button.js +17 -0
- package/components/number-input/number-input.js +54 -8
- package/components/radio/radio.js +86 -6
- package/components/ring-progress/ring-progress.js +127 -0
- package/components/step-dots/step-dots.js +86 -0
- package/custom-elements.json +1457 -144
- package/index.js +3 -1
- package/llms-full.txt +97 -14
- package/llms.txt +15 -3
- package/package.json +16 -1
- package/ray-elements.d.ts +57 -9
- package/vscode.html-custom-data.json +612 -62
|
@@ -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];
|
|
@@ -217,11 +222,28 @@ export class RayRadio extends RayElement {
|
|
|
217
222
|
this._groupDisabled = false;
|
|
218
223
|
}
|
|
219
224
|
|
|
220
|
-
/** Whether this radio is the group's choice
|
|
225
|
+
/** Whether this radio is the group's choice. Setting it sets the group's
|
|
226
|
+
* value, like `checked` on a native radio (no events). */
|
|
221
227
|
get checked() {
|
|
222
228
|
return this._checked;
|
|
223
229
|
}
|
|
224
230
|
|
|
231
|
+
set checked(on) {
|
|
232
|
+
on = Boolean(on);
|
|
233
|
+
const group = this.closest("ray-radio-group");
|
|
234
|
+
if (!group) {
|
|
235
|
+
this._setChecked(on);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (on) {
|
|
239
|
+
group.value = this.value;
|
|
240
|
+
for (const r of group.radios) r._setChecked(r === this);
|
|
241
|
+
} else if (this._checked) {
|
|
242
|
+
group.value = undefined;
|
|
243
|
+
this._setChecked(false);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
225
247
|
_setChecked(on) {
|
|
226
248
|
this._checked = on;
|
|
227
249
|
this.toggleAttribute("data-checked", on);
|
|
@@ -249,5 +271,63 @@ export class RayRadio extends RayElement {
|
|
|
249
271
|
}
|
|
250
272
|
}
|
|
251
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
|
+
|
|
252
331
|
define("ray-radio-group", RayRadioGroup);
|
|
253
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);
|