@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.
Files changed (67) hide show
  1. package/README.de.md +134 -11
  2. package/README.md +139 -16
  3. package/cli/__fixtures__/atelier-library/showcases/01-login/app.css +1 -0
  4. package/cli/__fixtures__/atelier-library/showcases/01-login/app.js +2 -0
  5. package/cli/__fixtures__/atelier-library/showcases/01-login/index.html +5 -0
  6. package/cli/__fixtures__/atelier-library/showcases/04-pricing/app.css +1 -0
  7. package/cli/__fixtures__/atelier-library/showcases/04-pricing/app.js +2 -0
  8. package/cli/__fixtures__/atelier-library/showcases/04-pricing/index.html +5 -0
  9. package/cli/__fixtures__/atelier-library/showcases/36-calendar/app.css +1 -0
  10. package/cli/__fixtures__/atelier-library/showcases/36-calendar/app.js +2 -0
  11. package/cli/__fixtures__/atelier-library/showcases/36-calendar/index.html +5 -0
  12. package/cli/atelier-catalog.json +1267 -0
  13. package/cli/atelier-formats.js +159 -0
  14. package/cli/atelier.js +382 -0
  15. package/cli/blueprints/empty-state.html +3 -5
  16. package/cli/cli-manifest.json +23 -10
  17. package/cli/docgen/extract-cli.js +1 -1
  18. package/cli/index.js +149 -3
  19. package/cli/production/component-graph.json +166 -0
  20. package/cli/production/explain.js +52 -0
  21. package/cli/production/extract.js +238 -0
  22. package/cli/production/graph.js +102 -0
  23. package/cli/production/report.js +78 -0
  24. package/cli/production/run.js +312 -0
  25. package/cli/production/trim-css.js +177 -0
  26. package/cli/production/trim-fonts.js +23 -0
  27. package/cli/production/trim-icons.js +38 -0
  28. package/cli/production/trim-js.js +48 -0
  29. package/cli/production/trim-motion.js +22 -0
  30. package/cli/production/trim-themes.js +50 -0
  31. package/cli/production/watch.js +38 -0
  32. package/cli/review.js +48 -1
  33. package/cli/scripts/write-atelier-fixtures.mjs +33 -0
  34. package/components/index.js +3 -0
  35. package/components/runtime/component-loaders.js +3 -0
  36. package/components/velin-drawer.js +43 -4
  37. package/components/velin-empty-state.js +83 -0
  38. package/components/velin-modal.js +76 -15
  39. package/components/velin-otp-input.js +220 -0
  40. package/components/velin-password-strength.js +147 -0
  41. package/components/velin-sheet.js +49 -4
  42. package/core/a11y/component-contracts.json +23 -4
  43. package/core/meta/knowledge/components.json +75 -3
  44. package/dist/chunks/attributes-2ORR27KA.js +404 -0
  45. package/dist/chunks/chunk-Y6HN7HWX.js +116 -0
  46. package/dist/chunks/runtime-entry.js +1 -1
  47. package/dist/chunks/velin-drawer-A7Q7EBOS.js +162 -0
  48. package/dist/chunks/velin-empty-state-3NTUH2RF.js +81 -0
  49. package/dist/chunks/velin-modal-3MV4FYBK.js +231 -0
  50. package/dist/chunks/velin-otp-input-PSK42MM6.js +207 -0
  51. package/dist/chunks/velin-password-strength-TAXMLSED.js +136 -0
  52. package/dist/chunks/velin-sheet-N2VVYXFP.js +157 -0
  53. package/dist/llms.txt +3 -3
  54. package/dist/search-index.json +68 -5
  55. package/dist/velin-agent.json +283 -20
  56. package/dist/velinstyle-components.iife.js +2716 -2138
  57. package/dist/velinstyle-components.js +2772 -2190
  58. package/dist/velinstyle-components.min.js +254 -112
  59. package/dist/velinstyle.css +54 -6
  60. package/dist/velinstyle.d.ts +3 -0
  61. package/dist/velinstyle.min.css +1 -1
  62. package/package.json +143 -143
  63. package/packages/velinstyle-skills/catalog.json +1 -1
  64. package/src/components/data-table.css +19 -0
  65. package/src/components/empty-auth.css +33 -0
  66. package/src/components/table.css +16 -6
  67. 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 = errors > 0 ? 'fail' : warnings > 0 ? 'warn' : 'pass';
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);
@@ -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
- const title = this.getAttribute('title') || '';
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}">${safeTitle}</h2>
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">&#215;</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
- const title = (this.getAttribute('title') || '').trim();
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" ${dialogLabel} part="dialog">
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"${title ? '' : ' class="velin-sr-only"'}>${safeTitle || 'Dialog'}</h2>
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">&#215;</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, oldVal, newVal) {
138
+ attributeChangedCallback(name) {
127
139
  if (name === 'open') {
128
- if (newVal !== null) {
129
- this._open();
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);