@birdapi/velinstyle 1.2.1 → 1.2.2
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 +134 -11
- package/README.md +139 -16
- package/cli/__fixtures__/atelier-library/showcases/01-login/app.css +1 -0
- package/cli/__fixtures__/atelier-library/showcases/01-login/app.js +2 -0
- package/cli/__fixtures__/atelier-library/showcases/01-login/index.html +5 -0
- package/cli/__fixtures__/atelier-library/showcases/04-pricing/app.css +1 -0
- package/cli/__fixtures__/atelier-library/showcases/04-pricing/app.js +2 -0
- package/cli/__fixtures__/atelier-library/showcases/04-pricing/index.html +5 -0
- package/cli/__fixtures__/atelier-library/showcases/36-calendar/app.css +1 -0
- package/cli/__fixtures__/atelier-library/showcases/36-calendar/app.js +2 -0
- package/cli/__fixtures__/atelier-library/showcases/36-calendar/index.html +5 -0
- package/cli/atelier-catalog.json +1267 -0
- package/cli/atelier-formats.js +159 -0
- package/cli/atelier.js +382 -0
- package/cli/blueprints/empty-state.html +3 -5
- package/cli/cli-manifest.json +23 -10
- package/cli/docgen/extract-cli.js +1 -1
- package/cli/index.js +149 -3
- package/cli/production/component-graph.json +166 -0
- package/cli/production/explain.js +52 -0
- package/cli/production/extract.js +238 -0
- package/cli/production/graph.js +102 -0
- package/cli/production/report.js +78 -0
- package/cli/production/run.js +312 -0
- package/cli/production/trim-css.js +177 -0
- package/cli/production/trim-fonts.js +23 -0
- package/cli/production/trim-icons.js +38 -0
- package/cli/production/trim-js.js +48 -0
- package/cli/production/trim-motion.js +22 -0
- package/cli/production/trim-themes.js +50 -0
- package/cli/production/watch.js +38 -0
- package/cli/review.js +48 -1
- package/cli/scripts/write-atelier-fixtures.mjs +33 -0
- package/components/index.js +3 -0
- package/components/runtime/component-loaders.js +3 -0
- package/components/velin-drawer.js +43 -4
- package/components/velin-empty-state.js +83 -0
- package/components/velin-modal.js +76 -15
- package/components/velin-otp-input.js +220 -0
- package/components/velin-password-strength.js +147 -0
- package/components/velin-sheet.js +49 -4
- package/core/a11y/component-contracts.json +23 -4
- package/core/meta/knowledge/components.json +75 -3
- package/dist/chunks/attributes-2ORR27KA.js +404 -0
- package/dist/chunks/chunk-Y6HN7HWX.js +116 -0
- package/dist/chunks/runtime-entry.js +1 -1
- package/dist/chunks/velin-drawer-A7Q7EBOS.js +162 -0
- package/dist/chunks/velin-empty-state-3NTUH2RF.js +81 -0
- package/dist/chunks/velin-modal-3MV4FYBK.js +231 -0
- package/dist/chunks/velin-otp-input-PSK42MM6.js +207 -0
- package/dist/chunks/velin-password-strength-TAXMLSED.js +136 -0
- package/dist/chunks/velin-sheet-N2VVYXFP.js +157 -0
- package/dist/llms.txt +3 -3
- package/dist/search-index.json +68 -5
- package/dist/velin-agent.json +283 -20
- package/dist/velinstyle-components.iife.js +2716 -2138
- package/dist/velinstyle-components.js +2772 -2190
- package/dist/velinstyle-components.min.js +254 -112
- package/dist/velinstyle.css +54 -6
- package/dist/velinstyle.d.ts +3 -0
- package/dist/velinstyle.min.css +1 -1
- package/package.json +143 -143
- package/packages/velinstyle-skills/catalog.json +1 -1
- package/src/components/data-table.css +19 -0
- package/src/components/empty-auth.css +33 -0
- package/src/components/table.css +16 -6
- package/src/velinstyle.css +1 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch content files and re-run production pipeline (debounced, Windows-safe).
|
|
3
|
+
*/
|
|
4
|
+
import { watch as fsWatch } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
|
|
7
|
+
export function watchProduction(rootPath, onChange, { debounceMs = 300, ignoreNames = ['node_modules', 'dist', '.git'] } = {}) {
|
|
8
|
+
const abs = resolve(rootPath);
|
|
9
|
+
let timer = null;
|
|
10
|
+
let closed = false;
|
|
11
|
+
|
|
12
|
+
const kick = (event, filename) => {
|
|
13
|
+
if (closed) return;
|
|
14
|
+
if (filename && ignoreNames.some((n) => String(filename).includes(n))) return;
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
timer = setTimeout(() => {
|
|
17
|
+
Promise.resolve()
|
|
18
|
+
.then(() => onChange({ event, filename }))
|
|
19
|
+
.catch((err) => console.error('[velinstyle production --watch]', err));
|
|
20
|
+
}, debounceMs);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let watcher;
|
|
24
|
+
try {
|
|
25
|
+
watcher = fsWatch(abs, { recursive: true }, kick);
|
|
26
|
+
} catch {
|
|
27
|
+
// recursive watch unsupported — watch root only
|
|
28
|
+
watcher = fsWatch(abs, kick);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
close() {
|
|
33
|
+
closed = true;
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
try { watcher.close(); } catch { /* ignore */ }
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/cli/review.js
CHANGED
|
@@ -225,6 +225,43 @@ export function reviewHtml(html, ctx = {}) {
|
|
|
225
225
|
const errors = issues.filter((i) => i.severity === 'error').length;
|
|
226
226
|
const warnings = issues.filter((i) => i.severity === 'warning').length;
|
|
227
227
|
|
|
228
|
+
// Optimization heuristics (soft) — full vendor without production output hints
|
|
229
|
+
const usesFullCss = /velinstyle\.min\.css|velinstyle\.css/i.test(text) && !/velin-production/i.test(text);
|
|
230
|
+
const usesFullIife = /velinstyle-components\.min\.js/i.test(text);
|
|
231
|
+
const unusedThemeLinks = [...text.matchAll(/themes\/([a-z0-9-]+)\.min\.css/gi)]
|
|
232
|
+
.map((m) => m[1].toLowerCase());
|
|
233
|
+
const themeAttrs = [...text.matchAll(/data-velin-theme\s*=\s*["']([^"']+)["']/gi)]
|
|
234
|
+
.flatMap((m) => m[1].toLowerCase().split(/[\s,|]+/));
|
|
235
|
+
const unusedThemes = unusedThemeLinks.filter((t) => themeAttrs.length && !themeAttrs.includes(t));
|
|
236
|
+
|
|
237
|
+
if (usesFullCss && usesFullIife) {
|
|
238
|
+
issues.push({
|
|
239
|
+
code: 'optimization.full-bundle',
|
|
240
|
+
severity: 'warning',
|
|
241
|
+
message: 'Full CSS + components IIFE without production output',
|
|
242
|
+
fix: 'Run `velinstyle build --production` and link dist/velin-production assets for publish.',
|
|
243
|
+
});
|
|
244
|
+
} else if (usesFullCss) {
|
|
245
|
+
issues.push({
|
|
246
|
+
code: 'optimization.full-css',
|
|
247
|
+
severity: 'warning',
|
|
248
|
+
message: 'Full VelinStyle CSS bundle linked',
|
|
249
|
+
fix: 'Prefer production CSS from `velinstyle production` for go-live.',
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (unusedThemes.length) {
|
|
253
|
+
issues.push({
|
|
254
|
+
code: 'optimization.unused-themes',
|
|
255
|
+
severity: 'warning',
|
|
256
|
+
message: `${unusedThemes.length} theme stylesheet(s) not referenced by data-velin-theme`,
|
|
257
|
+
fix: `Remove unused theme links (${unusedThemes.slice(0, 5).join(', ')}) or run production theme trim.`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const optIssues = issues.filter((i) => i.code.startsWith('optimization'));
|
|
262
|
+
const unusedComponentHint = [...text.matchAll(/<(velin-[a-z0-9-]+)\b/gi)].length;
|
|
263
|
+
const classHits = [...text.matchAll(/\bvelin-[\w:-]+\b/g)].length;
|
|
264
|
+
|
|
228
265
|
const score = (base, penalty) => Math.max(0, Math.round((base - penalty) * 10) / 10);
|
|
229
266
|
|
|
230
267
|
let scores = {
|
|
@@ -234,6 +271,7 @@ export function reviewHtml(html, ctx = {}) {
|
|
|
234
271
|
performance: score(10, issues.filter((i) => i.code.startsWith('perf')).length * 2),
|
|
235
272
|
conversion: score(10, issues.filter((i) => i.code.startsWith('conversion')).length * 2),
|
|
236
273
|
visual: score(9, issues.filter((i) => i.code.startsWith('design')).length * 1.2),
|
|
274
|
+
optimization: score(10, optIssues.length * 2 + (unusedThemes.length ? Math.min(3, unusedThemes.length * 0.5) : 0)),
|
|
237
275
|
};
|
|
238
276
|
|
|
239
277
|
if (thin) {
|
|
@@ -250,10 +288,13 @@ export function reviewHtml(html, ctx = {}) {
|
|
|
250
288
|
if (profile === 'app') scores.seo = Math.min(scores.seo, 7);
|
|
251
289
|
}
|
|
252
290
|
|
|
291
|
+
const finalErrors = issues.filter((i) => i.severity === 'error').length;
|
|
292
|
+
const finalWarnings = issues.filter((i) => i.severity === 'warning').length;
|
|
293
|
+
|
|
253
294
|
const avg = Object.values(scores).reduce((a, b) => a + b, 0) / Object.values(scores).length;
|
|
254
295
|
const promptScore = Math.max(0, Math.min(10, avg - (ctx.plan?.warnings?.length ? 1 : 0)));
|
|
255
296
|
|
|
256
|
-
const gate =
|
|
297
|
+
const gate = finalErrors > 0 ? 'fail' : finalWarnings > 0 ? 'warn' : 'pass';
|
|
257
298
|
|
|
258
299
|
return {
|
|
259
300
|
version: 1,
|
|
@@ -262,6 +303,12 @@ export function reviewHtml(html, ctx = {}) {
|
|
|
262
303
|
scores,
|
|
263
304
|
issues,
|
|
264
305
|
gate,
|
|
306
|
+
optimization: {
|
|
307
|
+
fullBundle: Boolean(usesFullCss && usesFullIife),
|
|
308
|
+
unusedThemes: unusedThemes.length,
|
|
309
|
+
componentTags: unusedComponentHint,
|
|
310
|
+
classTokens: classHits,
|
|
311
|
+
},
|
|
265
312
|
};
|
|
266
313
|
}
|
|
267
314
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
|
|
5
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '__fixtures__', 'atelier-library', 'showcases');
|
|
6
|
+
const items = [
|
|
7
|
+
{ id: '01-login', title: 'Fixture Login' },
|
|
8
|
+
{ id: '04-pricing', title: 'Fixture Pricing' },
|
|
9
|
+
{ id: '36-calendar', title: 'Fixture Calendar' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
for (const it of items) {
|
|
13
|
+
const d = join(root, it.id);
|
|
14
|
+
mkdirSync(d, { recursive: true });
|
|
15
|
+
writeFileSync(
|
|
16
|
+
join(d, 'index.html'),
|
|
17
|
+
`<!DOCTYPE html>
|
|
18
|
+
<html lang="en"><head><meta charset="UTF-8"><title>${it.title}</title>
|
|
19
|
+
<link rel="stylesheet" href="app.css"></head>
|
|
20
|
+
<body><div id="root" data-atelier-id="${it.id}"></div>
|
|
21
|
+
<script type="module" src="app.js"></script></body></html>
|
|
22
|
+
`,
|
|
23
|
+
);
|
|
24
|
+
writeFileSync(
|
|
25
|
+
join(d, 'app.js'),
|
|
26
|
+
`const root = document.getElementById('root');
|
|
27
|
+
root.innerHTML = '<section class="velin-section" data-fixture="${it.id}"><h1>${it.title}</h1></section>';
|
|
28
|
+
`,
|
|
29
|
+
);
|
|
30
|
+
writeFileSync(join(d, 'app.css'), `[data-fixture="${it.id}"] { padding: 1rem; }\n`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log('fixtures at', root);
|
package/components/index.js
CHANGED
|
@@ -38,6 +38,9 @@ export { default as VelinFormSummary } from './velin-form-summary.js';
|
|
|
38
38
|
export { default as VelinCalendar } from './velin-calendar.js';
|
|
39
39
|
export { default as VelinFileDropzone } from './velin-file-dropzone.js';
|
|
40
40
|
export { default as VelinSearch } from './velin-search.js';
|
|
41
|
+
export { default as VelinOtpInput } from './velin-otp-input.js';
|
|
42
|
+
export { default as VelinPasswordStrength, scorePassword } from './velin-password-strength.js';
|
|
43
|
+
export { default as VelinEmptyState } from './velin-empty-state.js';
|
|
41
44
|
export { bindDeclarativeSearch } from './velin-search.js';
|
|
42
45
|
export { initReveal, initMotion, velinMotion } from './velin-reveal.js';
|
|
43
46
|
export { initA11y, announce, getAnnouncer } from './a11y-entry.js';
|
|
@@ -19,6 +19,7 @@ export const COMPONENT_LOADERS = {
|
|
|
19
19
|
'velin-drawer': () => import('../velin-drawer.js'),
|
|
20
20
|
'velin-dropdown': () => import('../velin-dropdown.js'),
|
|
21
21
|
'velin-email': () => import('../velin-email.js'),
|
|
22
|
+
'velin-empty-state': () => import('../velin-empty-state.js'),
|
|
22
23
|
'velin-file-dropzone': () => import('../velin-file-dropzone.js'),
|
|
23
24
|
'velin-form-summary': () => import('../velin-form-summary.js'),
|
|
24
25
|
'velin-icon': () => import('../velin-icon.js'),
|
|
@@ -26,6 +27,8 @@ export const COMPONENT_LOADERS = {
|
|
|
26
27
|
'velin-live-dot': () => import('../velin-live-dot.js'),
|
|
27
28
|
'velin-menubar': () => import('../velin-menubar.js'),
|
|
28
29
|
'velin-modal': () => import('../velin-modal.js'),
|
|
30
|
+
'velin-otp-input': () => import('../velin-otp-input.js'),
|
|
31
|
+
'velin-password-strength': () => import('../velin-password-strength.js'),
|
|
29
32
|
'velin-persist': () => import('../velin-persist.js'),
|
|
30
33
|
'velin-popover': () => import('../velin-popover.js'),
|
|
31
34
|
'velin-progress-ring': () => import('../velin-progress-ring.js'),
|
|
@@ -49,25 +49,28 @@ const styles = `
|
|
|
49
49
|
`;
|
|
50
50
|
|
|
51
51
|
class VelinDrawer extends HTMLElement {
|
|
52
|
-
static get observedAttributes() { return ['open']; }
|
|
52
|
+
static get observedAttributes() { return ['open', 'title']; }
|
|
53
53
|
|
|
54
54
|
constructor() {
|
|
55
55
|
super();
|
|
56
56
|
this.attachShadow({ mode: 'open', delegatesFocus: true });
|
|
57
57
|
this._prev = null;
|
|
58
58
|
this._onKey = this._onKey.bind(this);
|
|
59
|
+
this._onTitleSlot = this._onTitleSlot.bind(this);
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
connectedCallback() {
|
|
62
|
-
|
|
63
|
-
const safeTitle = escapeHTML(title);
|
|
63
|
+
if (this.shadowRoot.querySelector('.drawer')) return;
|
|
64
64
|
const titleId = 'velin-drawer-title';
|
|
65
65
|
this.shadowRoot.innerHTML = `
|
|
66
66
|
<style>${styles}</style>
|
|
67
67
|
<div class="overlay" part="overlay"></div>
|
|
68
68
|
<div class="drawer" role="dialog" aria-modal="true" aria-labelledby="${titleId}" part="drawer">
|
|
69
69
|
<div class="header" part="header">
|
|
70
|
-
<h2 class="title" id="${titleId}"
|
|
70
|
+
<h2 class="title" id="${titleId}" part="title">
|
|
71
|
+
<slot name="title"></slot>
|
|
72
|
+
<span class="title-fallback"></span>
|
|
73
|
+
</h2>
|
|
71
74
|
<button class="close-btn" aria-label="Close" part="close">×</button>
|
|
72
75
|
</div>
|
|
73
76
|
<div class="body" part="body"><slot></slot></div>
|
|
@@ -75,15 +78,51 @@ class VelinDrawer extends HTMLElement {
|
|
|
75
78
|
`;
|
|
76
79
|
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => this.close());
|
|
77
80
|
this.shadowRoot.querySelector('.overlay').addEventListener('click', () => this.close());
|
|
81
|
+
this.shadowRoot.querySelector('slot[name="title"]').addEventListener('slotchange', this._onTitleSlot);
|
|
82
|
+
this._syncTitle();
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
attributeChangedCallback(name) {
|
|
81
86
|
if (name === 'open') this.hasAttribute('open') ? this._open() : this._close();
|
|
87
|
+
if (name === 'title') this._syncTitle();
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
open() { this.setAttribute('open', ''); }
|
|
85
91
|
close() { this.removeAttribute('open'); this.dispatchEvent(new CustomEvent('velin-close', { bubbles: true })); }
|
|
86
92
|
|
|
93
|
+
_syncTitle() {
|
|
94
|
+
const fallback = this.shadowRoot?.querySelector('.title-fallback');
|
|
95
|
+
if (!fallback) return;
|
|
96
|
+
fallback.textContent = this.getAttribute('title') || '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_hasTitleSlot() {
|
|
100
|
+
const slot = this.shadowRoot?.querySelector('slot[name="title"]');
|
|
101
|
+
if (!slot) return false;
|
|
102
|
+
return slot.assignedNodes({ flatten: true }).some((n) => {
|
|
103
|
+
if (n.nodeType === Node.TEXT_NODE) return Boolean(n.textContent.trim());
|
|
104
|
+
return n.nodeType === Node.ELEMENT_NODE;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_onTitleSlot() {
|
|
109
|
+
const dialog = this.shadowRoot?.querySelector('[role="dialog"]');
|
|
110
|
+
const fallback = this.shadowRoot?.querySelector('.title-fallback');
|
|
111
|
+
if (!dialog || !fallback) return;
|
|
112
|
+
if (this._hasTitleSlot()) {
|
|
113
|
+
fallback.hidden = true;
|
|
114
|
+
dialog.removeAttribute('aria-labelledby');
|
|
115
|
+
const slot = this.shadowRoot.querySelector('slot[name="title"]');
|
|
116
|
+
const label = slot.assignedNodes({ flatten: true }).map((n) => n.textContent || '').join(' ').trim();
|
|
117
|
+
if (label) dialog.setAttribute('aria-label', label);
|
|
118
|
+
} else {
|
|
119
|
+
fallback.hidden = false;
|
|
120
|
+
dialog.removeAttribute('aria-label');
|
|
121
|
+
dialog.setAttribute('aria-labelledby', 'velin-drawer-title');
|
|
122
|
+
this._syncTitle();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
87
126
|
_open() {
|
|
88
127
|
this._prev = saveFocus();
|
|
89
128
|
setBackgroundInert(this);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const styles = `
|
|
2
|
+
:host {
|
|
3
|
+
display: block;
|
|
4
|
+
text-align: center;
|
|
5
|
+
padding: var(--velin-space-8, 2rem) var(--velin-space-4, 1rem);
|
|
6
|
+
}
|
|
7
|
+
.illustration {
|
|
8
|
+
display: flex;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
margin-block-end: var(--velin-space-4, 1rem);
|
|
11
|
+
color: var(--velin-color-text-muted, #666);
|
|
12
|
+
}
|
|
13
|
+
.title {
|
|
14
|
+
margin: 0 0 var(--velin-space-2, 0.5rem);
|
|
15
|
+
font-size: var(--velin-text-xl, 1.25rem);
|
|
16
|
+
font-weight: var(--velin-weight-bold, 700);
|
|
17
|
+
color: var(--velin-color-text, #111);
|
|
18
|
+
}
|
|
19
|
+
.description {
|
|
20
|
+
margin: 0 0 var(--velin-space-4, 1rem);
|
|
21
|
+
color: var(--velin-color-text-muted, #666);
|
|
22
|
+
max-inline-size: 36rem;
|
|
23
|
+
margin-inline: auto;
|
|
24
|
+
}
|
|
25
|
+
.actions {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-wrap: wrap;
|
|
28
|
+
gap: var(--velin-space-3, 0.75rem);
|
|
29
|
+
justify-content: center;
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
class VelinEmptyState extends HTMLElement {
|
|
34
|
+
static get observedAttributes() {
|
|
35
|
+
return ['heading', 'description'];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
constructor() {
|
|
39
|
+
super();
|
|
40
|
+
this.attachShadow({ mode: 'open' });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
connectedCallback() {
|
|
44
|
+
if (this.shadowRoot.querySelector('.root')) {
|
|
45
|
+
this._syncText();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const titleId = 'velin-empty-title';
|
|
49
|
+
this.shadowRoot.innerHTML = `
|
|
50
|
+
<style>${styles}</style>
|
|
51
|
+
<section class="root" part="root" role="status" aria-labelledby="${titleId}">
|
|
52
|
+
<div class="illustration" part="illustration"><slot name="illustration"></slot></div>
|
|
53
|
+
<h2 class="title" id="${titleId}" part="title">
|
|
54
|
+
<slot name="title"><span class="heading-fallback"></span></slot>
|
|
55
|
+
</h2>
|
|
56
|
+
<div class="description" part="description">
|
|
57
|
+
<slot name="description"><span class="description-fallback"></span></slot>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="actions" part="actions"><slot name="actions"></slot></div>
|
|
60
|
+
<slot></slot>
|
|
61
|
+
</section>
|
|
62
|
+
`;
|
|
63
|
+
this._syncText();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
attributeChangedCallback() {
|
|
67
|
+
this._syncText();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_syncText() {
|
|
71
|
+
const heading = this.shadowRoot?.querySelector('.heading-fallback');
|
|
72
|
+
const description = this.shadowRoot?.querySelector('.description-fallback');
|
|
73
|
+
if (heading) heading.textContent = this.getAttribute('heading') || 'Nothing here yet';
|
|
74
|
+
if (description) {
|
|
75
|
+
const text = this.getAttribute('description') || '';
|
|
76
|
+
description.textContent = text;
|
|
77
|
+
description.hidden = !text;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
customElements.define('velin-empty-state', VelinEmptyState);
|
|
83
|
+
export default VelinEmptyState;
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { trapFocus, saveFocus, restoreFocus, getFocusableElements, setBackgroundInert, clearBackgroundInert } from './focus-manager.js';
|
|
2
|
-
import { escapeHTML } from './sanitize.js';
|
|
3
2
|
|
|
4
3
|
const styles = `
|
|
5
4
|
:host {
|
|
6
5
|
display: contents;
|
|
7
6
|
}
|
|
7
|
+
.sr-only {
|
|
8
|
+
position: absolute;
|
|
9
|
+
inline-size: 1px;
|
|
10
|
+
block-size: 1px;
|
|
11
|
+
padding: 0;
|
|
12
|
+
margin: -1px;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
clip: rect(0, 0, 0, 0);
|
|
15
|
+
white-space: nowrap;
|
|
16
|
+
border: 0;
|
|
17
|
+
}
|
|
8
18
|
.overlay {
|
|
9
19
|
position: fixed;
|
|
10
20
|
inset: 0;
|
|
@@ -87,7 +97,7 @@ const styles = `
|
|
|
87
97
|
|
|
88
98
|
class VelinModal extends HTMLElement {
|
|
89
99
|
static get observedAttributes() {
|
|
90
|
-
return ['open'];
|
|
100
|
+
return ['open', 'title'];
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
constructor() {
|
|
@@ -95,20 +105,20 @@ class VelinModal extends HTMLElement {
|
|
|
95
105
|
this.attachShadow({ mode: 'open', delegatesFocus: true });
|
|
96
106
|
this._previouslyFocused = null;
|
|
97
107
|
this._onKeydown = this._onKeydown.bind(this);
|
|
108
|
+
this._onTitleSlot = this._onTitleSlot.bind(this);
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
connectedCallback() {
|
|
101
|
-
|
|
102
|
-
const safeTitle = escapeHTML(title);
|
|
103
|
-
const dialogLabel = title
|
|
104
|
-
? 'aria-labelledby="velin-modal-title"'
|
|
105
|
-
: `aria-label="${escapeHTML(this.getAttribute('aria-label') || 'Dialog')}"`;
|
|
112
|
+
if (this.shadowRoot.querySelector('.dialog')) return;
|
|
106
113
|
this.shadowRoot.innerHTML = `
|
|
107
114
|
<style>${styles}</style>
|
|
108
115
|
<div class="overlay" part="overlay">
|
|
109
|
-
<div class="dialog" role="dialog" aria-modal="true"
|
|
116
|
+
<div class="dialog" role="dialog" aria-modal="true" part="dialog">
|
|
110
117
|
<div class="header" part="header">
|
|
111
|
-
<h2 class="title" id="velin-modal-title"
|
|
118
|
+
<h2 class="title" id="velin-modal-title" part="title">
|
|
119
|
+
<slot name="title"></slot>
|
|
120
|
+
<span class="title-fallback"></span>
|
|
121
|
+
</h2>
|
|
112
122
|
<button class="close-btn" aria-label="Close" part="close">×</button>
|
|
113
123
|
</div>
|
|
114
124
|
<div class="body" part="body"><slot></slot></div>
|
|
@@ -121,16 +131,16 @@ class VelinModal extends HTMLElement {
|
|
|
121
131
|
this.shadowRoot.querySelector('.overlay').addEventListener('click', (e) => {
|
|
122
132
|
if (e.target === e.currentTarget) this.close();
|
|
123
133
|
});
|
|
134
|
+
this.shadowRoot.querySelector('slot[name="title"]').addEventListener('slotchange', this._onTitleSlot);
|
|
135
|
+
this._syncTitle();
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
attributeChangedCallback(name
|
|
138
|
+
attributeChangedCallback(name) {
|
|
127
139
|
if (name === 'open') {
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
} else {
|
|
131
|
-
this._close();
|
|
132
|
-
}
|
|
140
|
+
if (this.hasAttribute('open')) this._open();
|
|
141
|
+
else this._close();
|
|
133
142
|
}
|
|
143
|
+
if (name === 'title') this._syncTitle();
|
|
134
144
|
}
|
|
135
145
|
|
|
136
146
|
open() {
|
|
@@ -142,6 +152,57 @@ class VelinModal extends HTMLElement {
|
|
|
142
152
|
this.dispatchEvent(new CustomEvent('velin-close', { bubbles: true }));
|
|
143
153
|
}
|
|
144
154
|
|
|
155
|
+
_syncTitle() {
|
|
156
|
+
const fallback = this.shadowRoot?.querySelector('.title-fallback');
|
|
157
|
+
const heading = this.shadowRoot?.querySelector('.title');
|
|
158
|
+
const dialog = this.shadowRoot?.querySelector('[role="dialog"]');
|
|
159
|
+
if (!fallback || !heading || !dialog) return;
|
|
160
|
+
|
|
161
|
+
if (this._hasTitleSlot()) {
|
|
162
|
+
fallback.hidden = true;
|
|
163
|
+
heading.classList.remove('sr-only');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fallback.hidden = false;
|
|
168
|
+
const title = (this.getAttribute('title') || '').trim();
|
|
169
|
+
fallback.textContent = title || 'Dialog';
|
|
170
|
+
heading.classList.toggle('sr-only', !title);
|
|
171
|
+
if (title) {
|
|
172
|
+
dialog.setAttribute('aria-labelledby', 'velin-modal-title');
|
|
173
|
+
dialog.removeAttribute('aria-label');
|
|
174
|
+
} else {
|
|
175
|
+
dialog.removeAttribute('aria-labelledby');
|
|
176
|
+
dialog.setAttribute('aria-label', this.getAttribute('aria-label') || 'Dialog');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_hasTitleSlot() {
|
|
181
|
+
const slot = this.shadowRoot?.querySelector('slot[name="title"]');
|
|
182
|
+
if (!slot) return false;
|
|
183
|
+
return slot.assignedNodes({ flatten: true }).some((n) => {
|
|
184
|
+
if (n.nodeType === Node.TEXT_NODE) return Boolean(n.textContent.trim());
|
|
185
|
+
return n.nodeType === Node.ELEMENT_NODE;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_onTitleSlot() {
|
|
190
|
+
const slot = this.shadowRoot?.querySelector('slot[name="title"]');
|
|
191
|
+
const dialog = this.shadowRoot?.querySelector('[role="dialog"]');
|
|
192
|
+
const heading = this.shadowRoot?.querySelector('.title');
|
|
193
|
+
const fallback = this.shadowRoot?.querySelector('.title-fallback');
|
|
194
|
+
if (!slot || !dialog || !heading || !fallback) return;
|
|
195
|
+
if (this._hasTitleSlot()) {
|
|
196
|
+
fallback.hidden = true;
|
|
197
|
+
heading.classList.remove('sr-only');
|
|
198
|
+
dialog.removeAttribute('aria-labelledby');
|
|
199
|
+
const label = slot.assignedNodes({ flatten: true }).map((n) => n.textContent || '').join(' ').trim();
|
|
200
|
+
if (label) dialog.setAttribute('aria-label', label);
|
|
201
|
+
} else {
|
|
202
|
+
this._syncTitle();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
145
206
|
_open() {
|
|
146
207
|
this._previouslyFocused = saveFocus();
|
|
147
208
|
setBackgroundInert(this);
|