@birdapi/velinstyle 1.0.0 → 1.1.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/README.de.md +12 -8
- package/README.md +12 -8
- package/cli/cli-manifest.json +1 -1
- package/cli/docgen/extract-a11y.js +4 -2
- package/cli/index.js +4 -2
- package/components/index.js +2 -0
- package/components/runtime/component-loaders.js +2 -0
- package/components/velin-data-table.js +361 -0
- package/components/velin-form-summary.js +348 -0
- package/core/a11y/component-contracts.json +18 -1
- package/core/highlight/languages/go.js +28 -0
- package/core/highlight/languages/python.js +29 -0
- package/core/highlight/languages/rust.js +32 -0
- package/core/highlight/languages/yaml.js +23 -0
- package/core/highlight/registry.js +13 -2
- package/core/meta/build.js +1 -1
- package/dist/chunks/attributes-353XDOAX.js +391 -0
- package/dist/chunks/chunk-ERF5YVP4.js +253 -0
- package/dist/chunks/chunk-QVHYL3R4.js +111 -0
- package/dist/chunks/go-HZ6XTFCK.js +31 -0
- package/dist/chunks/highlight-IBDX4XWS.js +37 -0
- package/dist/chunks/python-BMPKDKNO.js +32 -0
- package/dist/chunks/runtime-entry.js +1 -1
- package/dist/chunks/rust-2BWKI2MN.js +35 -0
- package/dist/chunks/velin-code-block-ITCLIC6T.js +132 -0
- package/dist/chunks/velin-data-table-KK7IDGTP.js +302 -0
- package/dist/chunks/velin-form-summary-XRQ7COQH.js +273 -0
- package/dist/chunks/velin-lightbox-NXA3U2FM.js +149 -0
- package/dist/chunks/yaml-O67LEQYT.js +26 -0
- package/dist/llms.txt +2 -2
- package/dist/search-index.json +28 -2
- package/dist/velin-agent.json +31 -5
- package/dist/velinstyle-components.iife.js +742 -4
- package/dist/velinstyle-components.js +744 -4
- package/dist/velinstyle-components.min.js +85 -85
- package/dist/velinstyle.css +129 -0
- package/dist/velinstyle.d.ts +2 -0
- package/dist/velinstyle.min.css +1 -1
- package/package.json +8 -2
- package/src/base/wc-placeholder.css +7 -0
- package/src/components/data-table.css +93 -0
- package/src/components/form-validation.css +40 -0
- package/src/velinstyle.css +1 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import {
|
|
2
|
+
announce
|
|
3
|
+
} from "./chunk-Z4JYQ2XX.js";
|
|
4
|
+
|
|
5
|
+
// components/velin-data-table.js
|
|
6
|
+
var SORT_TYPES = /* @__PURE__ */ new Set(["text", "number", "date"]);
|
|
7
|
+
var warnedMissingName = false;
|
|
8
|
+
function sortValue(cell, type) {
|
|
9
|
+
const raw = cell?.dataset?.sortValue ?? cell?.textContent ?? "";
|
|
10
|
+
const text = raw.trim();
|
|
11
|
+
if (type === "number") {
|
|
12
|
+
const num = Number.parseFloat(text.replace(/[^\d.,-]/g, "").replace(",", "."));
|
|
13
|
+
return Number.isNaN(num) ? Number.NEGATIVE_INFINITY : num;
|
|
14
|
+
}
|
|
15
|
+
if (type === "date") {
|
|
16
|
+
const time = Date.parse(text);
|
|
17
|
+
return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
|
|
18
|
+
}
|
|
19
|
+
return text.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
var VelinDataTable = class extends HTMLElement {
|
|
22
|
+
static get observedAttributes() {
|
|
23
|
+
return ["page-size", "filter-input", "empty-text", "label"];
|
|
24
|
+
}
|
|
25
|
+
constructor() {
|
|
26
|
+
super();
|
|
27
|
+
this._table = null;
|
|
28
|
+
this._page = 1;
|
|
29
|
+
this._query = "";
|
|
30
|
+
this._sortIndex = -1;
|
|
31
|
+
this._sortDirection = "ascending";
|
|
32
|
+
this._filterEl = null;
|
|
33
|
+
this._pagination = null;
|
|
34
|
+
this._emptyRow = null;
|
|
35
|
+
this._filterTimer = null;
|
|
36
|
+
this._onFilterInput = this._onFilterInput.bind(this);
|
|
37
|
+
}
|
|
38
|
+
connectedCallback() {
|
|
39
|
+
this.classList.add("velin-data-table");
|
|
40
|
+
requestAnimationFrame(() => this._init());
|
|
41
|
+
}
|
|
42
|
+
disconnectedCallback() {
|
|
43
|
+
if (this._filterEl) this._filterEl.removeEventListener("input", this._onFilterInput);
|
|
44
|
+
if (this._filterTimer) clearTimeout(this._filterTimer);
|
|
45
|
+
}
|
|
46
|
+
attributeChangedCallback(name, previous, next) {
|
|
47
|
+
if (previous === next || !this._table) return;
|
|
48
|
+
if (name === "page-size") {
|
|
49
|
+
this._page = 1;
|
|
50
|
+
this._render();
|
|
51
|
+
} else if (name === "filter-input") {
|
|
52
|
+
this._bindFilterInput();
|
|
53
|
+
} else if (name === "label") {
|
|
54
|
+
this._ensureAccessibleName();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
58
|
+
/** @returns {HTMLTableRowElement[]} */
|
|
59
|
+
get rows() {
|
|
60
|
+
const body = this._table?.tBodies?.[0];
|
|
61
|
+
return body ? [...body.rows].filter((row) => row !== this._emptyRow) : [];
|
|
62
|
+
}
|
|
63
|
+
/** Rows matching the current filter, across all pages. */
|
|
64
|
+
get matchingRows() {
|
|
65
|
+
return this.rows.filter((row) => !row.dataset.velinFiltered);
|
|
66
|
+
}
|
|
67
|
+
/** Rows visible on the current page. */
|
|
68
|
+
get visibleRows() {
|
|
69
|
+
return this.matchingRows.filter((row) => !row.hidden);
|
|
70
|
+
}
|
|
71
|
+
get page() {
|
|
72
|
+
return this._page;
|
|
73
|
+
}
|
|
74
|
+
get pageSize() {
|
|
75
|
+
const size = Number.parseInt(this.getAttribute("page-size") || "", 10);
|
|
76
|
+
return Number.isFinite(size) && size > 0 ? size : 0;
|
|
77
|
+
}
|
|
78
|
+
get pageCount() {
|
|
79
|
+
const size = this.pageSize;
|
|
80
|
+
if (!size) return 1;
|
|
81
|
+
return Math.max(1, Math.ceil(this.matchingRows.length / size));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* @param {number} index Column index
|
|
85
|
+
* @param {'ascending' | 'descending'} [direction]
|
|
86
|
+
*/
|
|
87
|
+
sort(index, direction) {
|
|
88
|
+
const headers = this._headers();
|
|
89
|
+
const header = headers[index];
|
|
90
|
+
if (!header) return;
|
|
91
|
+
const type = this._sortType(header);
|
|
92
|
+
if (type === "none") return;
|
|
93
|
+
this._sortDirection = direction || (this._sortIndex === index && this._sortDirection === "ascending" ? "descending" : "ascending");
|
|
94
|
+
this._sortIndex = index;
|
|
95
|
+
const factor = this._sortDirection === "ascending" ? 1 : -1;
|
|
96
|
+
const body = this._table.tBodies[0];
|
|
97
|
+
const sorted = this.rows.slice().sort((a, b) => {
|
|
98
|
+
const av = sortValue(a.cells[index], type);
|
|
99
|
+
const bv = sortValue(b.cells[index], type);
|
|
100
|
+
if (av < bv) return -1 * factor;
|
|
101
|
+
if (av > bv) return 1 * factor;
|
|
102
|
+
return 0;
|
|
103
|
+
});
|
|
104
|
+
for (const row of sorted) body.appendChild(row);
|
|
105
|
+
this._syncSortState();
|
|
106
|
+
this._page = 1;
|
|
107
|
+
this._render();
|
|
108
|
+
const label = header.dataset.sortLabel || header.textContent.trim();
|
|
109
|
+
announce(`${label} sorted ${this._sortDirection}`);
|
|
110
|
+
this.dispatchEvent(new CustomEvent("velin-data-table-sort", {
|
|
111
|
+
bubbles: true,
|
|
112
|
+
detail: { index, direction: this._sortDirection, column: label }
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
/** @param {string} query */
|
|
116
|
+
filter(query) {
|
|
117
|
+
this._query = String(query || "").trim().toLowerCase();
|
|
118
|
+
for (const row of this.rows) {
|
|
119
|
+
const match = !this._query || this._rowText(row).includes(this._query);
|
|
120
|
+
if (match) delete row.dataset.velinFiltered;
|
|
121
|
+
else row.dataset.velinFiltered = "true";
|
|
122
|
+
}
|
|
123
|
+
this._page = 1;
|
|
124
|
+
this._render();
|
|
125
|
+
const count = this.matchingRows.length;
|
|
126
|
+
announce(count === 1 ? "1 row matches" : `${count} rows match`);
|
|
127
|
+
this.dispatchEvent(new CustomEvent("velin-data-table-filter", {
|
|
128
|
+
bubbles: true,
|
|
129
|
+
detail: { query: this._query, count }
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
/** @param {number} page */
|
|
133
|
+
goToPage(page) {
|
|
134
|
+
const target = Math.min(Math.max(1, Math.trunc(page) || 1), this.pageCount);
|
|
135
|
+
if (target === this._page) return;
|
|
136
|
+
this._page = target;
|
|
137
|
+
this._render();
|
|
138
|
+
announce(`Page ${this._page} of ${this.pageCount}`);
|
|
139
|
+
this.dispatchEvent(new CustomEvent("velin-data-table-page", {
|
|
140
|
+
bubbles: true,
|
|
141
|
+
detail: { page: this._page, pageCount: this.pageCount }
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
// ── Setup ──────────────────────────────────────────────────────────────────
|
|
145
|
+
_init() {
|
|
146
|
+
this._table = this.querySelector("table");
|
|
147
|
+
if (!this._table || !this._table.tBodies.length) return;
|
|
148
|
+
this._ensureAccessibleName();
|
|
149
|
+
this._setupSorting();
|
|
150
|
+
this._bindFilterInput();
|
|
151
|
+
this._render();
|
|
152
|
+
}
|
|
153
|
+
_headers() {
|
|
154
|
+
const headRow = this._table.tHead?.rows?.[0];
|
|
155
|
+
return headRow ? [...headRow.cells] : [];
|
|
156
|
+
}
|
|
157
|
+
/** @param {HTMLTableCellElement} header */
|
|
158
|
+
_sortType(header) {
|
|
159
|
+
const declared = (header.dataset.sort || "").toLowerCase();
|
|
160
|
+
if (declared === "none") return "none";
|
|
161
|
+
if (SORT_TYPES.has(declared)) return declared;
|
|
162
|
+
return this.hasAttribute("sortable") ? "text" : "none";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A table without a name is unusable with a screen reader, so surface it
|
|
166
|
+
* instead of failing silently (WCAG 1.3.1 / 2.4.6).
|
|
167
|
+
*/
|
|
168
|
+
_ensureAccessibleName() {
|
|
169
|
+
const table = this._table;
|
|
170
|
+
if (!table) return;
|
|
171
|
+
const label = this.getAttribute("label");
|
|
172
|
+
const hasName = table.caption?.textContent.trim() || table.getAttribute("aria-label")?.trim() || table.getAttribute("aria-labelledby")?.trim();
|
|
173
|
+
if (!hasName && label) {
|
|
174
|
+
table.setAttribute("aria-label", label);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!hasName && !warnedMissingName) {
|
|
178
|
+
warnedMissingName = true;
|
|
179
|
+
console.warn("[velinstyle] <velin-data-table> needs a <caption>, aria-label, or a label attribute.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Sortable headers get a real button so keyboard and AT support come for free. */
|
|
183
|
+
_setupSorting() {
|
|
184
|
+
this._headers().forEach((header, index) => {
|
|
185
|
+
if (this._sortType(header) === "none") return;
|
|
186
|
+
if (header.querySelector(".velin-data-table__sort")) return;
|
|
187
|
+
const label = header.textContent.trim();
|
|
188
|
+
header.dataset.sortLabel = label;
|
|
189
|
+
header.setAttribute("aria-sort", "none");
|
|
190
|
+
header.classList.add("velin-data-table__th");
|
|
191
|
+
const button = document.createElement("button");
|
|
192
|
+
button.type = "button";
|
|
193
|
+
button.className = "velin-data-table__sort";
|
|
194
|
+
button.textContent = label;
|
|
195
|
+
const icon = document.createElement("span");
|
|
196
|
+
icon.className = "velin-data-table__sort-icon";
|
|
197
|
+
icon.setAttribute("aria-hidden", "true");
|
|
198
|
+
button.appendChild(icon);
|
|
199
|
+
button.addEventListener("click", () => this.sort(index));
|
|
200
|
+
header.textContent = "";
|
|
201
|
+
header.appendChild(button);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
_syncSortState() {
|
|
205
|
+
this._headers().forEach((header, index) => {
|
|
206
|
+
if (this._sortType(header) === "none") return;
|
|
207
|
+
const active = index === this._sortIndex;
|
|
208
|
+
header.setAttribute("aria-sort", active ? this._sortDirection : "none");
|
|
209
|
+
header.querySelector(".velin-data-table__sort")?.classList.toggle("velin-data-table__sort--active", active);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
_bindFilterInput() {
|
|
213
|
+
if (this._filterEl) this._filterEl.removeEventListener("input", this._onFilterInput);
|
|
214
|
+
const selector = this.getAttribute("filter-input");
|
|
215
|
+
this._filterEl = selector ? document.querySelector(selector) : null;
|
|
216
|
+
if (this._filterEl) this._filterEl.addEventListener("input", this._onFilterInput);
|
|
217
|
+
}
|
|
218
|
+
_onFilterInput(event) {
|
|
219
|
+
if (this._filterTimer) clearTimeout(this._filterTimer);
|
|
220
|
+
const value = event.target.value;
|
|
221
|
+
this._filterTimer = setTimeout(() => this.filter(value), 150);
|
|
222
|
+
}
|
|
223
|
+
/** @param {HTMLTableRowElement} row */
|
|
224
|
+
_rowText(row) {
|
|
225
|
+
const scoped = [...row.cells].filter((cell) => cell.hasAttribute("data-filter"));
|
|
226
|
+
const cells = scoped.length ? scoped : [...row.cells];
|
|
227
|
+
return cells.map((cell) => cell.textContent || "").join(" ").toLowerCase();
|
|
228
|
+
}
|
|
229
|
+
// ── Rendering ──────────────────────────────────────────────────────────────
|
|
230
|
+
_render() {
|
|
231
|
+
const size = this.pageSize;
|
|
232
|
+
this._page = Math.min(this._page, this.pageCount);
|
|
233
|
+
const matching = this.matchingRows;
|
|
234
|
+
const start = size ? (this._page - 1) * size : 0;
|
|
235
|
+
const end = size ? start + size : matching.length;
|
|
236
|
+
for (const row of this.rows) {
|
|
237
|
+
const index = matching.indexOf(row);
|
|
238
|
+
row.hidden = index === -1 || index < start || index >= end;
|
|
239
|
+
}
|
|
240
|
+
this._renderEmptyState(matching.length === 0);
|
|
241
|
+
this._renderPagination();
|
|
242
|
+
}
|
|
243
|
+
/** @param {boolean} isEmpty */
|
|
244
|
+
_renderEmptyState(isEmpty) {
|
|
245
|
+
if (!isEmpty) {
|
|
246
|
+
this._emptyRow?.remove();
|
|
247
|
+
this._emptyRow = null;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (this._emptyRow?.isConnected) return;
|
|
251
|
+
const columns = this._headers().length || 1;
|
|
252
|
+
const row = document.createElement("tr");
|
|
253
|
+
row.className = "velin-data-table__empty";
|
|
254
|
+
const cell = document.createElement("td");
|
|
255
|
+
cell.colSpan = columns;
|
|
256
|
+
cell.textContent = this.getAttribute("empty-text") || "No matching rows";
|
|
257
|
+
row.appendChild(cell);
|
|
258
|
+
this._table.tBodies[0].appendChild(row);
|
|
259
|
+
this._emptyRow = row;
|
|
260
|
+
}
|
|
261
|
+
_renderPagination() {
|
|
262
|
+
if (!this.pageSize || this.pageCount <= 1) {
|
|
263
|
+
this._pagination?.remove();
|
|
264
|
+
this._pagination = null;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (!this._pagination?.isConnected) {
|
|
268
|
+
const nav = document.createElement("nav");
|
|
269
|
+
nav.className = "velin-data-table__pagination";
|
|
270
|
+
nav.setAttribute("aria-label", this.getAttribute("pagination-label") || "Table pagination");
|
|
271
|
+
const previous2 = document.createElement("button");
|
|
272
|
+
previous2.type = "button";
|
|
273
|
+
previous2.className = "velin-btn velin-btn--outline velin-btn--sm";
|
|
274
|
+
previous2.dataset.velinPage = "previous";
|
|
275
|
+
previous2.textContent = this.getAttribute("previous-text") || "Previous";
|
|
276
|
+
previous2.addEventListener("click", () => this.goToPage(this._page - 1));
|
|
277
|
+
const status2 = document.createElement("p");
|
|
278
|
+
status2.className = "velin-data-table__page-status";
|
|
279
|
+
status2.dataset.velinPage = "status";
|
|
280
|
+
const next2 = document.createElement("button");
|
|
281
|
+
next2.type = "button";
|
|
282
|
+
next2.className = "velin-btn velin-btn--outline velin-btn--sm";
|
|
283
|
+
next2.dataset.velinPage = "next";
|
|
284
|
+
next2.textContent = this.getAttribute("next-text") || "Next";
|
|
285
|
+
next2.addEventListener("click", () => this.goToPage(this._page + 1));
|
|
286
|
+
nav.append(previous2, status2, next2);
|
|
287
|
+
this.appendChild(nav);
|
|
288
|
+
this._pagination = nav;
|
|
289
|
+
}
|
|
290
|
+
const status = this._pagination.querySelector('[data-velin-page="status"]');
|
|
291
|
+
if (status) status.textContent = `Page ${this._page} of ${this.pageCount}`;
|
|
292
|
+
const previous = this._pagination.querySelector('[data-velin-page="previous"]');
|
|
293
|
+
const next = this._pagination.querySelector('[data-velin-page="next"]');
|
|
294
|
+
if (previous) previous.disabled = this._page <= 1;
|
|
295
|
+
if (next) next.disabled = this._page >= this.pageCount;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
customElements.define("velin-data-table", VelinDataTable);
|
|
299
|
+
var velin_data_table_default = VelinDataTable;
|
|
300
|
+
export {
|
|
301
|
+
velin_data_table_default as default
|
|
302
|
+
};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import {
|
|
2
|
+
announce
|
|
3
|
+
} from "./chunk-Z4JYQ2XX.js";
|
|
4
|
+
|
|
5
|
+
// components/velin-form-summary.js
|
|
6
|
+
var FIELD_SELECTOR = "input, select, textarea";
|
|
7
|
+
var IGNORED_TYPES = /* @__PURE__ */ new Set(["submit", "reset", "button", "image", "hidden"]);
|
|
8
|
+
var fieldIdCounter = 0;
|
|
9
|
+
function escapeSelector(value) {
|
|
10
|
+
const text = String(value ?? "");
|
|
11
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(text);
|
|
12
|
+
return text.replace(/[^\w-]/g, (char) => `\\${char}`);
|
|
13
|
+
}
|
|
14
|
+
function fieldLabel(field) {
|
|
15
|
+
const explicit = field.getAttribute("data-error-label");
|
|
16
|
+
if (explicit) return explicit;
|
|
17
|
+
const ariaLabel = field.getAttribute("aria-label");
|
|
18
|
+
if (ariaLabel?.trim()) return ariaLabel.trim();
|
|
19
|
+
const labelledBy = field.getAttribute("aria-labelledby");
|
|
20
|
+
if (labelledBy) {
|
|
21
|
+
const text = labelledBy.split(/\s+/).map((id) => field.ownerDocument.getElementById(id)?.textContent?.trim() || "").filter(Boolean).join(" ");
|
|
22
|
+
if (text) return text;
|
|
23
|
+
}
|
|
24
|
+
if (field.id) {
|
|
25
|
+
const label = field.ownerDocument.querySelector(`label[for="${escapeSelector(field.id)}"]`);
|
|
26
|
+
if (label?.textContent.trim()) return label.textContent.trim();
|
|
27
|
+
}
|
|
28
|
+
const wrapping = field.closest("label");
|
|
29
|
+
if (wrapping?.textContent.trim()) return wrapping.textContent.trim();
|
|
30
|
+
return field.name || "This field";
|
|
31
|
+
}
|
|
32
|
+
function fieldMessage(field) {
|
|
33
|
+
return field.getAttribute("data-error-message")?.trim() || field.validationMessage || "Invalid value";
|
|
34
|
+
}
|
|
35
|
+
function addDescribedBy(field, id) {
|
|
36
|
+
const ids = (field.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean);
|
|
37
|
+
if (!ids.includes(id)) {
|
|
38
|
+
ids.push(id);
|
|
39
|
+
field.setAttribute("aria-describedby", ids.join(" "));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function removeDescribedBy(field, id) {
|
|
43
|
+
const ids = (field.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean);
|
|
44
|
+
const next = ids.filter((value) => value !== id);
|
|
45
|
+
if (next.length) field.setAttribute("aria-describedby", next.join(" "));
|
|
46
|
+
else field.removeAttribute("aria-describedby");
|
|
47
|
+
}
|
|
48
|
+
var VelinFormSummary = class extends HTMLElement {
|
|
49
|
+
static get observedAttributes() {
|
|
50
|
+
return ["for", "heading"];
|
|
51
|
+
}
|
|
52
|
+
constructor() {
|
|
53
|
+
super();
|
|
54
|
+
this._form = null;
|
|
55
|
+
this._panel = null;
|
|
56
|
+
this._errors = [];
|
|
57
|
+
this._onSubmit = this._onSubmit.bind(this);
|
|
58
|
+
this._onFieldChange = this._onFieldChange.bind(this);
|
|
59
|
+
this._onReset = this._onReset.bind(this);
|
|
60
|
+
}
|
|
61
|
+
connectedCallback() {
|
|
62
|
+
this.classList.add("velin-form-summary");
|
|
63
|
+
requestAnimationFrame(() => this._bindForm());
|
|
64
|
+
}
|
|
65
|
+
disconnectedCallback() {
|
|
66
|
+
this._unbindForm();
|
|
67
|
+
}
|
|
68
|
+
attributeChangedCallback(name, previous, next) {
|
|
69
|
+
if (previous === next) return;
|
|
70
|
+
if (name === "for" && this.isConnected) {
|
|
71
|
+
this._unbindForm();
|
|
72
|
+
this._bindForm();
|
|
73
|
+
} else if (name === "heading" && this._panel) {
|
|
74
|
+
const heading = this._panel.querySelector(".velin-form-summary__heading");
|
|
75
|
+
if (heading) heading.textContent = this.headingText;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
79
|
+
get form() {
|
|
80
|
+
return this._form;
|
|
81
|
+
}
|
|
82
|
+
/** @returns {{ field: HTMLElement, label: string, message: string }[]} */
|
|
83
|
+
get errors() {
|
|
84
|
+
return this._errors.slice();
|
|
85
|
+
}
|
|
86
|
+
get headingText() {
|
|
87
|
+
return this.getAttribute("heading") || "There is a problem";
|
|
88
|
+
}
|
|
89
|
+
/** Validate the form and render the summary. @returns {boolean} valid */
|
|
90
|
+
validate() {
|
|
91
|
+
if (!this._form) return true;
|
|
92
|
+
const errors = [];
|
|
93
|
+
for (const field of this._fields()) {
|
|
94
|
+
if (field.checkValidity()) {
|
|
95
|
+
this._clearFieldError(field);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const error = { field, label: fieldLabel(field), message: fieldMessage(field) };
|
|
99
|
+
this._markFieldError(field, error.message);
|
|
100
|
+
errors.push(error);
|
|
101
|
+
}
|
|
102
|
+
this._errors = errors;
|
|
103
|
+
this._render();
|
|
104
|
+
return errors.length === 0;
|
|
105
|
+
}
|
|
106
|
+
/** Remove the summary and all field error state. */
|
|
107
|
+
clear() {
|
|
108
|
+
for (const field of this._fields()) this._clearFieldError(field);
|
|
109
|
+
this._errors = [];
|
|
110
|
+
this._render();
|
|
111
|
+
}
|
|
112
|
+
/** Move focus to the first field with an error. */
|
|
113
|
+
focusFirstError() {
|
|
114
|
+
const first = this._errors[0];
|
|
115
|
+
if (first) this._focusField(first.field);
|
|
116
|
+
}
|
|
117
|
+
// ── Form wiring ────────────────────────────────────────────────────────────
|
|
118
|
+
_bindForm() {
|
|
119
|
+
const id = this.getAttribute("for");
|
|
120
|
+
this._form = id ? this.ownerDocument.getElementById(id) : this.closest("form");
|
|
121
|
+
if (!this._form) return;
|
|
122
|
+
if (!this.hasAttribute("native-validation")) this._form.noValidate = true;
|
|
123
|
+
this._form.addEventListener("submit", this._onSubmit);
|
|
124
|
+
this._form.addEventListener("reset", this._onReset);
|
|
125
|
+
this._form.addEventListener("input", this._onFieldChange);
|
|
126
|
+
this._form.addEventListener("change", this._onFieldChange);
|
|
127
|
+
}
|
|
128
|
+
_unbindForm() {
|
|
129
|
+
if (!this._form) return;
|
|
130
|
+
this._form.removeEventListener("submit", this._onSubmit);
|
|
131
|
+
this._form.removeEventListener("reset", this._onReset);
|
|
132
|
+
this._form.removeEventListener("input", this._onFieldChange);
|
|
133
|
+
this._form.removeEventListener("change", this._onFieldChange);
|
|
134
|
+
this._form = null;
|
|
135
|
+
}
|
|
136
|
+
/** @returns {HTMLElement[]} */
|
|
137
|
+
_fields() {
|
|
138
|
+
if (!this._form) return [];
|
|
139
|
+
const seenRadioNames = /* @__PURE__ */ new Set();
|
|
140
|
+
return [...this._form.querySelectorAll(FIELD_SELECTOR)].filter((field) => {
|
|
141
|
+
if (IGNORED_TYPES.has(field.type)) return false;
|
|
142
|
+
if (field.disabled || field.hasAttribute("data-error-ignore")) return false;
|
|
143
|
+
if (typeof field.checkValidity !== "function") return false;
|
|
144
|
+
if (field.type === "radio" && field.name) {
|
|
145
|
+
if (seenRadioNames.has(field.name)) return false;
|
|
146
|
+
seenRadioNames.add(field.name);
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
_onSubmit(event) {
|
|
152
|
+
if (this.validate()) return;
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
this._announceErrors();
|
|
155
|
+
this._focusPanel();
|
|
156
|
+
this.dispatchEvent(new CustomEvent("velin-form-invalid", {
|
|
157
|
+
bubbles: true,
|
|
158
|
+
detail: { errors: this.errors.map(({ label, message }) => ({ label, message })) }
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
_onReset() {
|
|
162
|
+
requestAnimationFrame(() => this.clear());
|
|
163
|
+
}
|
|
164
|
+
/** Re-validate a single field once it already had an error, never before. */
|
|
165
|
+
_onFieldChange(event) {
|
|
166
|
+
const field = event.target;
|
|
167
|
+
if (!field || !this._errors.some((error) => error.field === field)) return;
|
|
168
|
+
if (!field.checkValidity()) return;
|
|
169
|
+
this._clearFieldError(field);
|
|
170
|
+
this._errors = this._errors.filter((error) => error.field !== field);
|
|
171
|
+
this._render();
|
|
172
|
+
if (this._errors.length === 0) {
|
|
173
|
+
this.dispatchEvent(new CustomEvent("velin-form-valid", { bubbles: true }));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ── Field state ────────────────────────────────────────────────────────────
|
|
177
|
+
/** @param {HTMLElement} field */
|
|
178
|
+
_errorId(field) {
|
|
179
|
+
if (!field.id) field.id = `velin-field-${++fieldIdCounter}`;
|
|
180
|
+
return `${field.id}-error`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* @param {HTMLElement} field
|
|
184
|
+
* @param {string} message
|
|
185
|
+
*/
|
|
186
|
+
_markFieldError(field, message) {
|
|
187
|
+
const errorId = this._errorId(field);
|
|
188
|
+
field.setAttribute("aria-invalid", "true");
|
|
189
|
+
let holder = this.ownerDocument.getElementById(errorId);
|
|
190
|
+
if (!holder) {
|
|
191
|
+
holder = this._form.querySelector(`[data-velin-error-for="${escapeSelector(field.name || field.id)}"]`);
|
|
192
|
+
}
|
|
193
|
+
if (!holder) {
|
|
194
|
+
holder = this.ownerDocument.createElement("p");
|
|
195
|
+
holder.dataset.velinErrorGenerated = "true";
|
|
196
|
+
field.insertAdjacentElement("afterend", holder);
|
|
197
|
+
}
|
|
198
|
+
holder.id = errorId;
|
|
199
|
+
holder.classList.add("velin-field-error");
|
|
200
|
+
holder.textContent = message;
|
|
201
|
+
addDescribedBy(field, errorId);
|
|
202
|
+
}
|
|
203
|
+
/** @param {HTMLElement} field */
|
|
204
|
+
_clearFieldError(field) {
|
|
205
|
+
if (!field.id) return;
|
|
206
|
+
const errorId = `${field.id}-error`;
|
|
207
|
+
field.removeAttribute("aria-invalid");
|
|
208
|
+
removeDescribedBy(field, errorId);
|
|
209
|
+
const holder = this.ownerDocument.getElementById(errorId);
|
|
210
|
+
if (!holder) return;
|
|
211
|
+
if (holder.dataset.velinErrorGenerated) holder.remove();
|
|
212
|
+
else holder.textContent = "";
|
|
213
|
+
}
|
|
214
|
+
/** @param {HTMLElement} field */
|
|
215
|
+
_focusField(field) {
|
|
216
|
+
const target = field.type === "radio" && field.name ? this._form.querySelector(`input[type="radio"][name="${escapeSelector(field.name)}"]`) || field : field;
|
|
217
|
+
target.focus();
|
|
218
|
+
this.dispatchEvent(new CustomEvent("velin-form-error-focus", {
|
|
219
|
+
bubbles: true,
|
|
220
|
+
detail: { name: target.name || target.id }
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
// ── Summary panel ──────────────────────────────────────────────────────────
|
|
224
|
+
_render() {
|
|
225
|
+
if (!this._errors.length) {
|
|
226
|
+
this._panel?.remove();
|
|
227
|
+
this._panel = null;
|
|
228
|
+
this.hidden = true;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.hidden = false;
|
|
232
|
+
if (!this._panel?.isConnected) {
|
|
233
|
+
const panel = this.ownerDocument.createElement("div");
|
|
234
|
+
panel.className = "velin-form-summary__panel velin-alert velin-alert--danger";
|
|
235
|
+
panel.setAttribute("role", "alert");
|
|
236
|
+
panel.tabIndex = -1;
|
|
237
|
+
const heading = this.ownerDocument.createElement("p");
|
|
238
|
+
heading.className = "velin-form-summary__heading";
|
|
239
|
+
heading.textContent = this.headingText;
|
|
240
|
+
const list2 = this.ownerDocument.createElement("ul");
|
|
241
|
+
list2.className = "velin-form-summary__list";
|
|
242
|
+
panel.append(heading, list2);
|
|
243
|
+
this.appendChild(panel);
|
|
244
|
+
this._panel = panel;
|
|
245
|
+
}
|
|
246
|
+
const list = this._panel.querySelector(".velin-form-summary__list");
|
|
247
|
+
list.textContent = "";
|
|
248
|
+
for (const error of this._errors) {
|
|
249
|
+
const item = this.ownerDocument.createElement("li");
|
|
250
|
+
const link = this.ownerDocument.createElement("a");
|
|
251
|
+
link.href = `#${this._errorId(error.field).replace(/-error$/, "")}`;
|
|
252
|
+
link.textContent = `${error.label}: ${error.message}`;
|
|
253
|
+
link.addEventListener("click", (event) => {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
this._focusField(error.field);
|
|
256
|
+
});
|
|
257
|
+
item.appendChild(link);
|
|
258
|
+
list.appendChild(item);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
_focusPanel() {
|
|
262
|
+
this._panel?.focus();
|
|
263
|
+
}
|
|
264
|
+
_announceErrors() {
|
|
265
|
+
const count = this._errors.length;
|
|
266
|
+
announce(count === 1 ? "1 field needs attention" : `${count} fields need attention`, "assertive");
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
customElements.define("velin-form-summary", VelinFormSummary);
|
|
270
|
+
var velin_form_summary_default = VelinFormSummary;
|
|
271
|
+
export {
|
|
272
|
+
velin_form_summary_default as default
|
|
273
|
+
};
|