@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
package/README.de.md
CHANGED
|
@@ -42,12 +42,14 @@ Gedacht für Teams mit **lesbarem HTML**, **AAA-Token-Defaults** (AA über `data
|
|
|
42
42
|
- **35+ Komponenten** — semantische BEM-Klassen (`velin-btn`, `velin-card`, `velin-grid`, …)
|
|
43
43
|
- **Motion-Runtime** — Reveal, Stagger, scroll-getriebene Animation, rAF-Scheduler
|
|
44
44
|
- **VelinSearch** — fuzzy Offline-Suche mit Highlighting und Kategorien
|
|
45
|
-
- **Syntax-Highlighting** — lazy In-View für JS, HTML, CSS, JSON und mehr
|
|
45
|
+
- **Syntax-Highlighting** — lazy In-View für JS, TS, HTML, CSS, JSON, Bash, Python, YAML, Go, Rust und mehr
|
|
46
46
|
- **HTML-Attribute** — 27 deklarative Bridges (`velin-modal`, `velin-reveal`, `velin-scroll-top`, `velin-code`, …)
|
|
47
|
-
- **Qualität** —
|
|
47
|
+
- **Qualität** — 38/38 A11y-Component-Contracts, Playwright Cross-Browser-Smoke (`npm run test:e2e`), CLS-Platzhalter (`wc-placeholder.css`)
|
|
48
48
|
- **Security-Tools** — `scan`, PII-Regeln, Sanitize-API, gehärtete Komponenten
|
|
49
49
|
- **CLI** — init, build, scan, scaffold, tokens, docs generate, perf audit, layout audit
|
|
50
50
|
- **Velin-Meta** — `velin-agent.json`, `llms.txt` und Page-Level-Agent-JSON für KI-Assistenten
|
|
51
|
+
- **React-Adapter** — `@velinstyle/react` umhüllt jede kanonische Web Component mit typisierten Props und Events
|
|
52
|
+
- **Release-Guard** — `npm run release:check` bricht den Build bei Versions- oder Docs-Drift zwischen Framework und Site ab
|
|
51
53
|
|
|
52
54
|
---
|
|
53
55
|
|
|
@@ -70,8 +72,8 @@ npm i @birdapi/velinstyle
|
|
|
70
72
|
**CDN (Version pinnen):**
|
|
71
73
|
|
|
72
74
|
```html
|
|
73
|
-
<link rel="stylesheet" href="https://unpkg.com/@birdapi/velinstyle@1.
|
|
74
|
-
<script type="module" src="https://unpkg.com/@birdapi/velinstyle@1.
|
|
75
|
+
<link rel="stylesheet" href="https://unpkg.com/@birdapi/velinstyle@1.1.0/dist/velinstyle.min.css">
|
|
76
|
+
<script type="module" src="https://unpkg.com/@birdapi/velinstyle@1.1.0/dist/velinstyle-components.min.js"></script>
|
|
75
77
|
```
|
|
76
78
|
|
|
77
79
|
Nach dem Klonen: `npm install && npm run build` — `dist/` wird erzeugt, nicht committet.
|
|
@@ -86,8 +88,8 @@ Nach dem Klonen: `npm install && npm run build` — `dist/` wird erzeugt, nicht
|
|
|
86
88
|
<head>
|
|
87
89
|
<meta charset="UTF-8">
|
|
88
90
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
89
|
-
<link rel="stylesheet" href="https://unpkg.com/@birdapi/velinstyle@1.
|
|
90
|
-
<script type="module" src="https://unpkg.com/@birdapi/velinstyle@1.
|
|
91
|
+
<link rel="stylesheet" href="https://unpkg.com/@birdapi/velinstyle@1.1.0/dist/velinstyle.min.css">
|
|
92
|
+
<script type="module" src="https://unpkg.com/@birdapi/velinstyle@1.1.0/dist/velinstyle-components.min.js"></script>
|
|
91
93
|
</head>
|
|
92
94
|
<body class="velin-p-6">
|
|
93
95
|
<button type="button" class="velin-btn velin-btn--primary" velin-reveal="slide-up">Loslegen</button>
|
|
@@ -112,9 +114,11 @@ Nach dem Klonen: `npm install && npm run build` — `dist/` wird erzeugt, nicht
|
|
|
112
114
|
|
|
113
115
|
## Web Components
|
|
114
116
|
|
|
115
|
-
**
|
|
117
|
+
**38 kanonische** Custom Elements (40 Lazy-Loader-Einträge inkl. Legacy `velin-tooltip-wc` und `velin-stepper-wc`) — reines CSS, wenn kein Verhalten nötig ist. `src/base/wc-placeholder.css` verringert Layout-Shift vor dem Upgrade.
|
|
116
118
|
|
|
117
|
-
Beispiele: `velin-modal`, `velin-search`, `velin-code-block`, `velin-drawer`, `velin-stepper`, `velin-tooltip`, `velin-toast`, `velin-persist`.
|
|
119
|
+
Beispiele: `velin-modal`, `velin-search`, `velin-code-block`, `velin-drawer`, `velin-stepper`, `velin-tooltip`, `velin-toast`, `velin-persist`, `velin-data-table`, `velin-form-summary`.
|
|
120
|
+
|
|
121
|
+
React-Wrapper für alle kanonischen Elemente liefert [`@velinstyle/react`](./packages/react/README.md).
|
|
118
122
|
|
|
119
123
|
- [Komponenten-Dokumentation](https://velinstyle.info/docs/components/buttons.html)
|
|
120
124
|
- [Generierte Web-Component-API](https://velinstyle.info/docs/generated/components/README.md)
|
package/README.md
CHANGED
|
@@ -42,12 +42,14 @@ Built for teams who want **readable HTML**, **AAA token defaults** (AA via `data
|
|
|
42
42
|
- **35+ components** — semantic BEM classes (`velin-btn`, `velin-card`, `velin-grid`, …)
|
|
43
43
|
- **Motion runtime** — reveal, stagger, scroll-driven animation, rAF scheduler
|
|
44
44
|
- **VelinSearch** — fuzzy offline search with highlighting and categories
|
|
45
|
-
- **Syntax highlighting** — lazy in-view highlighting for JS, HTML, CSS, JSON, and more
|
|
45
|
+
- **Syntax highlighting** — lazy in-view highlighting for JS, TS, HTML, CSS, JSON, Bash, Python, YAML, Go, Rust, and more
|
|
46
46
|
- **HTML attributes** — 27 declarative bridges (`velin-modal`, `velin-reveal`, `velin-scroll-top`, `velin-code`, …)
|
|
47
|
-
- **Quality** —
|
|
47
|
+
- **Quality** — 38/38 component a11y contracts, Playwright cross-browser smoke (`npm run test:e2e`), CLS placeholders (`wc-placeholder.css`)
|
|
48
48
|
- **Security tools** — `scan`, PII rules, sanitize API, hardened components
|
|
49
49
|
- **CLI** — init, build, scan, scaffold, tokens, docs generate, perf audit, layout audit
|
|
50
50
|
- **Velin-Meta** — `velin-agent.json`, `llms.txt`, and page-level agent JSON for AI assistants
|
|
51
|
+
- **React adapter** — `@velinstyle/react` wraps every canonical Web Component with typed props and events
|
|
52
|
+
- **Release guard** — `npm run release:check` fails the build on version or docs drift between framework and site
|
|
51
53
|
|
|
52
54
|
---
|
|
53
55
|
|
|
@@ -70,8 +72,8 @@ npm i @birdapi/velinstyle
|
|
|
70
72
|
**CDN (pin version):**
|
|
71
73
|
|
|
72
74
|
```html
|
|
73
|
-
<link rel="stylesheet" href="https://unpkg.com/@birdapi/velinstyle@1.
|
|
74
|
-
<script type="module" src="https://unpkg.com/@birdapi/velinstyle@1.
|
|
75
|
+
<link rel="stylesheet" href="https://unpkg.com/@birdapi/velinstyle@1.1.0/dist/velinstyle.min.css">
|
|
76
|
+
<script type="module" src="https://unpkg.com/@birdapi/velinstyle@1.1.0/dist/velinstyle-components.min.js"></script>
|
|
75
77
|
```
|
|
76
78
|
|
|
77
79
|
After cloning, run `npm install && npm run build` — `dist/` is generated, not committed.
|
|
@@ -86,8 +88,8 @@ After cloning, run `npm install && npm run build` — `dist/` is generated, not
|
|
|
86
88
|
<head>
|
|
87
89
|
<meta charset="UTF-8">
|
|
88
90
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
89
|
-
<link rel="stylesheet" href="https://unpkg.com/@birdapi/velinstyle@1.
|
|
90
|
-
<script type="module" src="https://unpkg.com/@birdapi/velinstyle@1.
|
|
91
|
+
<link rel="stylesheet" href="https://unpkg.com/@birdapi/velinstyle@1.1.0/dist/velinstyle.min.css">
|
|
92
|
+
<script type="module" src="https://unpkg.com/@birdapi/velinstyle@1.1.0/dist/velinstyle-components.min.js"></script>
|
|
91
93
|
</head>
|
|
92
94
|
<body class="velin-p-6">
|
|
93
95
|
<button type="button" class="velin-btn velin-btn--primary" velin-reveal="slide-up">Ship it</button>
|
|
@@ -112,9 +114,11 @@ After cloning, run `npm install && npm run build` — `dist/` is generated, not
|
|
|
112
114
|
|
|
113
115
|
## Web Components
|
|
114
116
|
|
|
115
|
-
**
|
|
117
|
+
**38 canonical** custom elements (40 lazy-loader entries including legacy `velin-tooltip-wc` and `velin-stepper-wc`) — use plain CSS when you do not need behavior. `src/base/wc-placeholder.css` reduces layout shift before elements upgrade.
|
|
116
118
|
|
|
117
|
-
Examples: `velin-modal`, `velin-search`, `velin-code-block`, `velin-drawer`, `velin-stepper`, `velin-tooltip`, `velin-toast`, `velin-persist`.
|
|
119
|
+
Examples: `velin-modal`, `velin-search`, `velin-code-block`, `velin-drawer`, `velin-stepper`, `velin-tooltip`, `velin-toast`, `velin-persist`, `velin-data-table`, `velin-form-summary`.
|
|
120
|
+
|
|
121
|
+
React wrappers for every canonical element ship in [`@velinstyle/react`](./packages/react/README.md).
|
|
118
122
|
|
|
119
123
|
- [Component documentation](https://velinstyle.info/docs/components/buttons.html)
|
|
120
124
|
- [Generated Web Component API](https://velinstyle.info/docs/generated/components/README.md)
|
package/cli/cli-manifest.json
CHANGED
|
@@ -21,10 +21,12 @@ export const WCAG_AAA_CRITERIA = [
|
|
|
21
21
|
{ id: '2.5.7', level: 'AA', name: 'Dragging Movements', owner: 'CSS + Sample', status: 'pass', testId: 'wcag22-dragging' },
|
|
22
22
|
{ id: '2.5.8', level: 'AA', name: 'Target Size', owner: 'CSS + WC', status: 'pass', testId: 'target-size' },
|
|
23
23
|
{ id: '3.2.6', level: 'A', name: 'Consistent Help', owner: 'CSS', status: 'pass', testId: 'consistent-help' },
|
|
24
|
-
{ id: '3.3.
|
|
24
|
+
{ id: '3.3.1', level: 'A', name: 'Error Identification', owner: 'velin-form-summary', status: 'pass', testId: 'velin-form-summary' },
|
|
25
|
+
{ id: '3.3.2', level: 'A', name: 'Labels or Instructions', owner: 'Forms + WC + velin-form-summary', status: 'partial', testId: 'scan-a11y' },
|
|
26
|
+
{ id: '3.3.3', level: 'AA', name: 'Error Suggestion', owner: 'velin-form-summary', status: 'pass', testId: 'velin-form-summary' },
|
|
25
27
|
{ id: '3.3.7', level: 'A', name: 'Redundant Entry', owner: 'velin-persist + Author', status: 'partial', testId: 'velin-persist' },
|
|
26
28
|
{ id: '3.3.8', level: 'AA', name: 'Accessible Authentication', owner: 'CSS + Sample', status: 'pass', testId: 'wcag22-auth' },
|
|
27
|
-
{ id: '4.1.2', level: 'A', name: 'Name, Role, Value', owner: 'Web Components', status: 'partial', testId: 'contracts' },
|
|
29
|
+
{ id: '4.1.2', level: 'A', name: 'Name, Role, Value', owner: 'Web Components + velin-form-summary', status: 'partial', testId: 'contracts' },
|
|
28
30
|
{ id: '4.1.3', level: 'AA', name: 'Status Messages', owner: 'Announcer + WC', status: 'pass', testId: 'live-regions' },
|
|
29
31
|
];
|
|
30
32
|
|
package/cli/index.js
CHANGED
|
@@ -11,6 +11,8 @@ const PKG_ROOT = join(__dirname, '..');
|
|
|
11
11
|
const args = process.argv.slice(2);
|
|
12
12
|
const command = args[0];
|
|
13
13
|
|
|
14
|
+
const CLI_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')).version;
|
|
15
|
+
|
|
14
16
|
const LAYERS = ['tokens', 'reset', 'base', 'a11y', 'layout', 'components', 'utilities', 'security', 'helpers'];
|
|
15
17
|
|
|
16
18
|
const LAYER_FILES = {
|
|
@@ -36,7 +38,7 @@ const LAYER_FILES = {
|
|
|
36
38
|
components: [
|
|
37
39
|
'components/button.css', 'components/card.css', 'components/input.css',
|
|
38
40
|
'components/nav.css', 'components/alert.css', 'components/badge.css',
|
|
39
|
-
'components/table.css', 'components/tooltip.css', 'components/modal.css',
|
|
41
|
+
'components/table.css', 'components/data-table.css', 'components/tooltip.css', 'components/modal.css',
|
|
40
42
|
'components/breadcrumb.css', 'components/pagination.css', 'components/progress.css',
|
|
41
43
|
'components/spinner.css', 'components/list-group.css', 'components/avatar.css',
|
|
42
44
|
'components/switch.css', 'components/divider.css', 'components/chip.css',
|
|
@@ -83,7 +85,7 @@ function hasFlag(flag, alias) {
|
|
|
83
85
|
|
|
84
86
|
function help() {
|
|
85
87
|
console.log(`
|
|
86
|
-
${C.bold('VelinStyle CLI')}
|
|
88
|
+
${C.bold('VelinStyle CLI')} v${CLI_VERSION}
|
|
87
89
|
|
|
88
90
|
${C.bold('Usage:')}
|
|
89
91
|
velinstyle init Create velinstyle.config.js
|
package/components/index.js
CHANGED
|
@@ -33,6 +33,8 @@ export { default as VelinSparkline } from './velin-sparkline.js';
|
|
|
33
33
|
export { default as VelinCounter } from './velin-counter.js';
|
|
34
34
|
export { default as VelinLiveDot } from './velin-live-dot.js';
|
|
35
35
|
export { default as VelinCodeBlock } from './velin-code-block.js';
|
|
36
|
+
export { default as VelinDataTable } from './velin-data-table.js';
|
|
37
|
+
export { default as VelinFormSummary } from './velin-form-summary.js';
|
|
36
38
|
export { default as VelinSearch } from './velin-search.js';
|
|
37
39
|
export { bindDeclarativeSearch } from './velin-search.js';
|
|
38
40
|
export { initReveal, initMotion, velinMotion } from './velin-reveal.js';
|
|
@@ -13,10 +13,12 @@ export const COMPONENT_LOADERS = {
|
|
|
13
13
|
'velin-copy': () => import('../velin-copy.js'),
|
|
14
14
|
'velin-countdown': () => import('../velin-countdown.js'),
|
|
15
15
|
'velin-counter': () => import('../velin-counter.js'),
|
|
16
|
+
'velin-data-table': () => import('../velin-data-table.js'),
|
|
16
17
|
'velin-dialog': () => import('../velin-dialog.js'),
|
|
17
18
|
'velin-drawer': () => import('../velin-drawer.js'),
|
|
18
19
|
'velin-dropdown': () => import('../velin-dropdown.js'),
|
|
19
20
|
'velin-email': () => import('../velin-email.js'),
|
|
21
|
+
'velin-form-summary': () => import('../velin-form-summary.js'),
|
|
20
22
|
'velin-icon': () => import('../velin-icon.js'),
|
|
21
23
|
'velin-lightbox': () => import('../velin-lightbox.js'),
|
|
22
24
|
'velin-live-dot': () => import('../velin-live-dot.js'),
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { announce } from './a11y-utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Progressive enhancement for a light-DOM `<table>`: sorting, filtering and
|
|
5
|
+
* pagination without replacing the semantic markup.
|
|
6
|
+
*
|
|
7
|
+
* The table keeps working with JavaScript disabled — every feature is layered
|
|
8
|
+
* on top of the rows that are already in the document.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const SORT_TYPES = new Set(['text', 'number', 'date']);
|
|
12
|
+
|
|
13
|
+
let warnedMissingName = false;
|
|
14
|
+
|
|
15
|
+
/** @param {HTMLElement} cell */
|
|
16
|
+
function sortValue(cell, type) {
|
|
17
|
+
const raw = cell?.dataset?.sortValue ?? cell?.textContent ?? '';
|
|
18
|
+
const text = raw.trim();
|
|
19
|
+
if (type === 'number') {
|
|
20
|
+
const num = Number.parseFloat(text.replace(/[^\d.,-]/g, '').replace(',', '.'));
|
|
21
|
+
return Number.isNaN(num) ? Number.NEGATIVE_INFINITY : num;
|
|
22
|
+
}
|
|
23
|
+
if (type === 'date') {
|
|
24
|
+
const time = Date.parse(text);
|
|
25
|
+
return Number.isNaN(time) ? Number.NEGATIVE_INFINITY : time;
|
|
26
|
+
}
|
|
27
|
+
return text.toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class VelinDataTable extends HTMLElement {
|
|
31
|
+
static get observedAttributes() {
|
|
32
|
+
return ['page-size', 'filter-input', 'empty-text', 'label'];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
super();
|
|
37
|
+
this._table = null;
|
|
38
|
+
this._page = 1;
|
|
39
|
+
this._query = '';
|
|
40
|
+
this._sortIndex = -1;
|
|
41
|
+
this._sortDirection = 'ascending';
|
|
42
|
+
this._filterEl = null;
|
|
43
|
+
this._pagination = null;
|
|
44
|
+
this._emptyRow = null;
|
|
45
|
+
this._filterTimer = null;
|
|
46
|
+
this._onFilterInput = this._onFilterInput.bind(this);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
connectedCallback() {
|
|
50
|
+
this.classList.add('velin-data-table');
|
|
51
|
+
// Rows may still be streaming in when the element upgrades.
|
|
52
|
+
requestAnimationFrame(() => this._init());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
disconnectedCallback() {
|
|
56
|
+
if (this._filterEl) this._filterEl.removeEventListener('input', this._onFilterInput);
|
|
57
|
+
if (this._filterTimer) clearTimeout(this._filterTimer);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
attributeChangedCallback(name, previous, next) {
|
|
61
|
+
if (previous === next || !this._table) return;
|
|
62
|
+
if (name === 'page-size') {
|
|
63
|
+
this._page = 1;
|
|
64
|
+
this._render();
|
|
65
|
+
} else if (name === 'filter-input') {
|
|
66
|
+
this._bindFilterInput();
|
|
67
|
+
} else if (name === 'label') {
|
|
68
|
+
this._ensureAccessibleName();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** @returns {HTMLTableRowElement[]} */
|
|
75
|
+
get rows() {
|
|
76
|
+
const body = this._table?.tBodies?.[0];
|
|
77
|
+
return body ? [...body.rows].filter((row) => row !== this._emptyRow) : [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Rows matching the current filter, across all pages. */
|
|
81
|
+
get matchingRows() {
|
|
82
|
+
return this.rows.filter((row) => !row.dataset.velinFiltered);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Rows visible on the current page. */
|
|
86
|
+
get visibleRows() {
|
|
87
|
+
return this.matchingRows.filter((row) => !row.hidden);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get page() {
|
|
91
|
+
return this._page;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get pageSize() {
|
|
95
|
+
const size = Number.parseInt(this.getAttribute('page-size') || '', 10);
|
|
96
|
+
return Number.isFinite(size) && size > 0 ? size : 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get pageCount() {
|
|
100
|
+
const size = this.pageSize;
|
|
101
|
+
if (!size) return 1;
|
|
102
|
+
return Math.max(1, Math.ceil(this.matchingRows.length / size));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {number} index Column index
|
|
107
|
+
* @param {'ascending' | 'descending'} [direction]
|
|
108
|
+
*/
|
|
109
|
+
sort(index, direction) {
|
|
110
|
+
const headers = this._headers();
|
|
111
|
+
const header = headers[index];
|
|
112
|
+
if (!header) return;
|
|
113
|
+
const type = this._sortType(header);
|
|
114
|
+
if (type === 'none') return;
|
|
115
|
+
|
|
116
|
+
this._sortDirection = direction
|
|
117
|
+
|| (this._sortIndex === index && this._sortDirection === 'ascending' ? 'descending' : 'ascending');
|
|
118
|
+
this._sortIndex = index;
|
|
119
|
+
|
|
120
|
+
const factor = this._sortDirection === 'ascending' ? 1 : -1;
|
|
121
|
+
const body = this._table.tBodies[0];
|
|
122
|
+
const sorted = this.rows.slice().sort((a, b) => {
|
|
123
|
+
const av = sortValue(a.cells[index], type);
|
|
124
|
+
const bv = sortValue(b.cells[index], type);
|
|
125
|
+
if (av < bv) return -1 * factor;
|
|
126
|
+
if (av > bv) return 1 * factor;
|
|
127
|
+
return 0;
|
|
128
|
+
});
|
|
129
|
+
for (const row of sorted) body.appendChild(row);
|
|
130
|
+
|
|
131
|
+
this._syncSortState();
|
|
132
|
+
this._page = 1;
|
|
133
|
+
this._render();
|
|
134
|
+
|
|
135
|
+
const label = header.dataset.sortLabel || header.textContent.trim();
|
|
136
|
+
announce(`${label} sorted ${this._sortDirection}`);
|
|
137
|
+
this.dispatchEvent(new CustomEvent('velin-data-table-sort', {
|
|
138
|
+
bubbles: true,
|
|
139
|
+
detail: { index, direction: this._sortDirection, column: label },
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @param {string} query */
|
|
144
|
+
filter(query) {
|
|
145
|
+
this._query = String(query || '').trim().toLowerCase();
|
|
146
|
+
for (const row of this.rows) {
|
|
147
|
+
const match = !this._query || this._rowText(row).includes(this._query);
|
|
148
|
+
if (match) delete row.dataset.velinFiltered;
|
|
149
|
+
else row.dataset.velinFiltered = 'true';
|
|
150
|
+
}
|
|
151
|
+
this._page = 1;
|
|
152
|
+
this._render();
|
|
153
|
+
|
|
154
|
+
const count = this.matchingRows.length;
|
|
155
|
+
announce(count === 1 ? '1 row matches' : `${count} rows match`);
|
|
156
|
+
this.dispatchEvent(new CustomEvent('velin-data-table-filter', {
|
|
157
|
+
bubbles: true,
|
|
158
|
+
detail: { query: this._query, count },
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @param {number} page */
|
|
163
|
+
goToPage(page) {
|
|
164
|
+
const target = Math.min(Math.max(1, Math.trunc(page) || 1), this.pageCount);
|
|
165
|
+
if (target === this._page) return;
|
|
166
|
+
this._page = target;
|
|
167
|
+
this._render();
|
|
168
|
+
announce(`Page ${this._page} of ${this.pageCount}`);
|
|
169
|
+
this.dispatchEvent(new CustomEvent('velin-data-table-page', {
|
|
170
|
+
bubbles: true,
|
|
171
|
+
detail: { page: this._page, pageCount: this.pageCount },
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Setup ──────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
_init() {
|
|
178
|
+
this._table = this.querySelector('table');
|
|
179
|
+
if (!this._table || !this._table.tBodies.length) return;
|
|
180
|
+
|
|
181
|
+
this._ensureAccessibleName();
|
|
182
|
+
this._setupSorting();
|
|
183
|
+
this._bindFilterInput();
|
|
184
|
+
this._render();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_headers() {
|
|
188
|
+
const headRow = this._table.tHead?.rows?.[0];
|
|
189
|
+
return headRow ? [...headRow.cells] : [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @param {HTMLTableCellElement} header */
|
|
193
|
+
_sortType(header) {
|
|
194
|
+
const declared = (header.dataset.sort || '').toLowerCase();
|
|
195
|
+
if (declared === 'none') return 'none';
|
|
196
|
+
if (SORT_TYPES.has(declared)) return declared;
|
|
197
|
+
return this.hasAttribute('sortable') ? 'text' : 'none';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A table without a name is unusable with a screen reader, so surface it
|
|
202
|
+
* instead of failing silently (WCAG 1.3.1 / 2.4.6).
|
|
203
|
+
*/
|
|
204
|
+
_ensureAccessibleName() {
|
|
205
|
+
const table = this._table;
|
|
206
|
+
if (!table) return;
|
|
207
|
+
const label = this.getAttribute('label');
|
|
208
|
+
const hasName = table.caption?.textContent.trim()
|
|
209
|
+
|| table.getAttribute('aria-label')?.trim()
|
|
210
|
+
|| table.getAttribute('aria-labelledby')?.trim();
|
|
211
|
+
|
|
212
|
+
if (!hasName && label) {
|
|
213
|
+
table.setAttribute('aria-label', label);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!hasName && !warnedMissingName) {
|
|
217
|
+
warnedMissingName = true;
|
|
218
|
+
console.warn('[velinstyle] <velin-data-table> needs a <caption>, aria-label, or a label attribute.');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Sortable headers get a real button so keyboard and AT support come for free. */
|
|
223
|
+
_setupSorting() {
|
|
224
|
+
this._headers().forEach((header, index) => {
|
|
225
|
+
if (this._sortType(header) === 'none') return;
|
|
226
|
+
if (header.querySelector('.velin-data-table__sort')) return;
|
|
227
|
+
|
|
228
|
+
const label = header.textContent.trim();
|
|
229
|
+
header.dataset.sortLabel = label;
|
|
230
|
+
header.setAttribute('aria-sort', 'none');
|
|
231
|
+
header.classList.add('velin-data-table__th');
|
|
232
|
+
|
|
233
|
+
const button = document.createElement('button');
|
|
234
|
+
button.type = 'button';
|
|
235
|
+
button.className = 'velin-data-table__sort';
|
|
236
|
+
button.textContent = label;
|
|
237
|
+
|
|
238
|
+
const icon = document.createElement('span');
|
|
239
|
+
icon.className = 'velin-data-table__sort-icon';
|
|
240
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
241
|
+
button.appendChild(icon);
|
|
242
|
+
|
|
243
|
+
button.addEventListener('click', () => this.sort(index));
|
|
244
|
+
header.textContent = '';
|
|
245
|
+
header.appendChild(button);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_syncSortState() {
|
|
250
|
+
this._headers().forEach((header, index) => {
|
|
251
|
+
if (this._sortType(header) === 'none') return;
|
|
252
|
+
const active = index === this._sortIndex;
|
|
253
|
+
header.setAttribute('aria-sort', active ? this._sortDirection : 'none');
|
|
254
|
+
header.querySelector('.velin-data-table__sort')?.classList.toggle('velin-data-table__sort--active', active);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_bindFilterInput() {
|
|
259
|
+
if (this._filterEl) this._filterEl.removeEventListener('input', this._onFilterInput);
|
|
260
|
+
const selector = this.getAttribute('filter-input');
|
|
261
|
+
this._filterEl = selector ? document.querySelector(selector) : null;
|
|
262
|
+
if (this._filterEl) this._filterEl.addEventListener('input', this._onFilterInput);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_onFilterInput(event) {
|
|
266
|
+
if (this._filterTimer) clearTimeout(this._filterTimer);
|
|
267
|
+
const value = event.target.value;
|
|
268
|
+
this._filterTimer = setTimeout(() => this.filter(value), 150);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @param {HTMLTableRowElement} row */
|
|
272
|
+
_rowText(row) {
|
|
273
|
+
const scoped = [...row.cells].filter((cell) => cell.hasAttribute('data-filter'));
|
|
274
|
+
const cells = scoped.length ? scoped : [...row.cells];
|
|
275
|
+
return cells.map((cell) => cell.textContent || '').join(' ').toLowerCase();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Rendering ──────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
_render() {
|
|
281
|
+
const size = this.pageSize;
|
|
282
|
+
this._page = Math.min(this._page, this.pageCount);
|
|
283
|
+
const matching = this.matchingRows;
|
|
284
|
+
const start = size ? (this._page - 1) * size : 0;
|
|
285
|
+
const end = size ? start + size : matching.length;
|
|
286
|
+
|
|
287
|
+
for (const row of this.rows) {
|
|
288
|
+
const index = matching.indexOf(row);
|
|
289
|
+
row.hidden = index === -1 || index < start || index >= end;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this._renderEmptyState(matching.length === 0);
|
|
293
|
+
this._renderPagination();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** @param {boolean} isEmpty */
|
|
297
|
+
_renderEmptyState(isEmpty) {
|
|
298
|
+
if (!isEmpty) {
|
|
299
|
+
this._emptyRow?.remove();
|
|
300
|
+
this._emptyRow = null;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (this._emptyRow?.isConnected) return;
|
|
304
|
+
|
|
305
|
+
const columns = this._headers().length || 1;
|
|
306
|
+
const row = document.createElement('tr');
|
|
307
|
+
row.className = 'velin-data-table__empty';
|
|
308
|
+
const cell = document.createElement('td');
|
|
309
|
+
cell.colSpan = columns;
|
|
310
|
+
cell.textContent = this.getAttribute('empty-text') || 'No matching rows';
|
|
311
|
+
row.appendChild(cell);
|
|
312
|
+
this._table.tBodies[0].appendChild(row);
|
|
313
|
+
this._emptyRow = row;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
_renderPagination() {
|
|
317
|
+
if (!this.pageSize || this.pageCount <= 1) {
|
|
318
|
+
this._pagination?.remove();
|
|
319
|
+
this._pagination = null;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!this._pagination?.isConnected) {
|
|
324
|
+
const nav = document.createElement('nav');
|
|
325
|
+
nav.className = 'velin-data-table__pagination';
|
|
326
|
+
nav.setAttribute('aria-label', this.getAttribute('pagination-label') || 'Table pagination');
|
|
327
|
+
|
|
328
|
+
const previous = document.createElement('button');
|
|
329
|
+
previous.type = 'button';
|
|
330
|
+
previous.className = 'velin-btn velin-btn--outline velin-btn--sm';
|
|
331
|
+
previous.dataset.velinPage = 'previous';
|
|
332
|
+
previous.textContent = this.getAttribute('previous-text') || 'Previous';
|
|
333
|
+
previous.addEventListener('click', () => this.goToPage(this._page - 1));
|
|
334
|
+
|
|
335
|
+
const status = document.createElement('p');
|
|
336
|
+
status.className = 'velin-data-table__page-status';
|
|
337
|
+
status.dataset.velinPage = 'status';
|
|
338
|
+
|
|
339
|
+
const next = document.createElement('button');
|
|
340
|
+
next.type = 'button';
|
|
341
|
+
next.className = 'velin-btn velin-btn--outline velin-btn--sm';
|
|
342
|
+
next.dataset.velinPage = 'next';
|
|
343
|
+
next.textContent = this.getAttribute('next-text') || 'Next';
|
|
344
|
+
next.addEventListener('click', () => this.goToPage(this._page + 1));
|
|
345
|
+
|
|
346
|
+
nav.append(previous, status, next);
|
|
347
|
+
this.appendChild(nav);
|
|
348
|
+
this._pagination = nav;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const status = this._pagination.querySelector('[data-velin-page="status"]');
|
|
352
|
+
if (status) status.textContent = `Page ${this._page} of ${this.pageCount}`;
|
|
353
|
+
const previous = this._pagination.querySelector('[data-velin-page="previous"]');
|
|
354
|
+
const next = this._pagination.querySelector('[data-velin-page="next"]');
|
|
355
|
+
if (previous) previous.disabled = this._page <= 1;
|
|
356
|
+
if (next) next.disabled = this._page >= this.pageCount;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
customElements.define('velin-data-table', VelinDataTable);
|
|
361
|
+
export default VelinDataTable;
|