@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.
- package/check/check.d.ts +2 -0
- package/check/check.js +27 -6
- package/check/cli.js +10 -6
- package/components/accordion/accordion.js +1 -1
- package/components/alert/alert.js +1 -1
- package/components/attachment/attachment.js +1 -1
- package/components/avatar/avatar.js +1 -1
- package/components/bottom-nav/bottom-nav.js +2 -0
- package/components/breadcrumbs/breadcrumbs.js +1 -1
- package/components/button/button.js +17 -0
- package/components/carousel/carousel.js +4 -4
- package/components/checkbox/checkbox.js +1 -1
- package/components/code/code.js +1 -1
- package/components/color-picker/color-picker.js +1 -1
- package/components/combobox/combobox.js +3 -3
- package/components/command-palette/command-palette.js +1 -1
- package/components/date-picker/date-picker.js +4 -4
- package/components/dialog/dialog.js +1 -1
- package/components/drawer/drawer.js +1 -1
- package/components/file-upload/file-upload.js +1 -1
- package/components/icon/icon.js +6 -1
- package/components/nav-link/nav-link.js +1 -1
- package/components/notification/notification.js +1 -1
- package/components/number-input/number-input.js +55 -9
- package/components/pagination/pagination.js +4 -4
- package/components/password-input/password-input.js +2 -2
- package/components/radio/radio.js +68 -5
- package/components/ring-progress/ring-progress.js +127 -0
- package/components/search/search.js +2 -2
- package/components/select/select.js +2 -2
- package/components/stat/stat.js +1 -1
- package/components/step-dots/step-dots.js +95 -0
- package/components/stepper/stepper.js +1 -1
- package/components/table/table.js +1 -1
- package/components/theme-toggle/theme-toggle.js +2 -2
- package/core/core.js +4 -3
- package/core/icons.js +95 -8
- package/custom-elements.json +700 -43
- package/index.js +3 -1
- package/llms-full.txt +117 -21
- package/llms.txt +18 -5
- package/package.json +9 -1
- package/ray-elements.d.ts +77 -19
- 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
|
-
|
|
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
|
-
|
|
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 (
|
|
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))
|
|
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-]*)\
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
154
|
-
<button class="control next" part="next" type="button" aria-label="Next slide"><span data-ray-icon="
|
|
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="
|
|
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>.
|
package/components/code/code.js
CHANGED
|
@@ -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="
|
|
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="
|
|
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="
|
|
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="
|
|
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", "
|
|
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="
|
|
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="
|
|
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="
|
|
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>
|
package/components/icon/icon.js
CHANGED
|
@@ -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
|
-
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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, "
|
|
143
|
-
items.push(control("previous", 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, "
|
|
150
|
-
if (this.withEdges) items.push(control("last", 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);
|