@birdapi/velinstyle 0.6.1 → 0.8.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.
Files changed (64) hide show
  1. package/README.de.md +337 -316
  2. package/README.md +33 -12
  3. package/cli/blueprint.js +8 -0
  4. package/cli/blueprints/bottom-nav-mobile.html +17 -0
  5. package/cli/blueprints/cookie-consent.html +9 -0
  6. package/cli/blueprints/empty-state.html +5 -0
  7. package/cli/blueprints/filter-bar.html +15 -0
  8. package/cli/blueprints/notification-center.html +13 -0
  9. package/cli/blueprints/onboarding.html +23 -0
  10. package/cli/blueprints/pricing-table.html +20 -0
  11. package/cli/blueprints/settings-panel.html +20 -0
  12. package/cli/index.js +119 -3
  13. package/cli/layout-audit.js +325 -0
  14. package/cli/scaffold-recipes.json +70 -0
  15. package/cli/scaffold.js +155 -0
  16. package/cli/scanner.js +114 -0
  17. package/components/focus-manager.js +106 -80
  18. package/components/index.js +20 -1
  19. package/components/sanitize.js +29 -3
  20. package/components/shadow-a11y-styles.js +18 -0
  21. package/components/velin-accordion.js +112 -98
  22. package/components/velin-announcer.js +35 -0
  23. package/components/velin-bottom-nav.js +89 -0
  24. package/components/velin-carousel.js +40 -5
  25. package/components/velin-collapse.js +95 -65
  26. package/components/velin-combobox.js +149 -0
  27. package/components/velin-command.js +127 -0
  28. package/components/velin-counter.js +152 -0
  29. package/components/velin-drawer.js +6 -3
  30. package/components/velin-dropdown.js +33 -2
  31. package/components/velin-flip.js +220 -0
  32. package/components/velin-icon.js +43 -9
  33. package/components/velin-live-dot.js +85 -0
  34. package/components/velin-menubar.js +83 -0
  35. package/components/velin-modal.js +3 -1
  36. package/components/velin-popover.js +61 -21
  37. package/components/velin-rating.js +91 -0
  38. package/components/velin-reveal.js +80 -0
  39. package/components/velin-segmented-control.js +108 -0
  40. package/components/velin-sheet.js +107 -0
  41. package/components/velin-sparkline.js +207 -0
  42. package/components/velin-theme-toggle.js +277 -60
  43. package/components/velin-tooltip-wc.js +26 -2
  44. package/dist/velinstyle-components.iife.js +1849 -120
  45. package/dist/velinstyle-components.js +1871 -120
  46. package/dist/velinstyle-components.min.js +434 -91
  47. package/dist/velinstyle.css +640 -44
  48. package/dist/velinstyle.min.css +1 -1
  49. package/package.json +7 -3
  50. package/src/a11y/focus-not-obscured.css +21 -0
  51. package/src/a11y/forced-colors.css +86 -38
  52. package/src/a11y/high-contrast-aaa.css +37 -0
  53. package/src/a11y/preferences.css +106 -85
  54. package/src/a11y/security.css +119 -104
  55. package/src/a11y/target-size.css +32 -0
  56. package/src/base/reset.css +12 -1
  57. package/src/base/root.css +4 -4
  58. package/src/components/nav.css +152 -151
  59. package/src/tokens/motion.css +7 -0
  60. package/src/utilities/animation.css +97 -1
  61. package/src/utilities/chart-animation.css +101 -0
  62. package/src/utilities/filter-effects.css +103 -0
  63. package/src/utilities/safe-area.css +39 -0
  64. package/src/velinstyle.css +9 -1
@@ -0,0 +1,207 @@
1
+ /*
2
+ * <velin-sparkline values="1,3,2,5,7,6,9" area glow animate="draw">
3
+ *
4
+ * Tiny inline-SVG line chart for KPI tiles, live dashboards, and "this week"
5
+ * trends. Renders into light DOM (no Shadow) so theme tokens cascade in for
6
+ * stroke color via `currentColor`. Animates the stroke draw-in once on mount
7
+ * using the chart-animation utility classes; calling `update(values)` swaps
8
+ * the path and runs a short value-bump on the host.
9
+ *
10
+ * Attributes:
11
+ * values CSV or JSON-array of numbers
12
+ * width viewBox width (default 320)
13
+ * height viewBox height (default 96)
14
+ * min/max clamp range (optional; otherwise derived from values)
15
+ * area truthy enables the gradient area fill
16
+ * glow truthy enables continuous drop-shadow pulse
17
+ * animate "draw" (default) or "none"
18
+ * label accessible label; sets role=img + aria-label
19
+ *
20
+ * Public API:
21
+ * element.update(values: number[]): replaces the path with a new dataset.
22
+ * element.values: number[] reflect getter/setter.
23
+ */
24
+
25
+ const NS = 'http://www.w3.org/2000/svg';
26
+
27
+ function parseValues(raw) {
28
+ if (!raw) return [];
29
+ const trimmed = String(raw).trim();
30
+ if (trimmed.startsWith('[')) {
31
+ try {
32
+ const parsed = JSON.parse(trimmed);
33
+ return Array.isArray(parsed) ? parsed.map(Number).filter((n) => Number.isFinite(n)) : [];
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+ return trimmed
39
+ .split(/[\s,]+/)
40
+ .map((s) => Number.parseFloat(s))
41
+ .filter((n) => Number.isFinite(n));
42
+ }
43
+
44
+ function buildPoints(values, w, h, min, max) {
45
+ const n = values.length;
46
+ if (n === 0) return [];
47
+ if (n === 1) {
48
+ return [[0, h / 2], [w, h / 2]];
49
+ }
50
+ const range = Math.max(max - min, 1e-6);
51
+ const stepX = w / (n - 1);
52
+ return values.map((v, i) => {
53
+ const x = i * stepX;
54
+ const y = h - ((v - min) / range) * h;
55
+ return [x, y];
56
+ });
57
+ }
58
+
59
+ function pointsToPath(points) {
60
+ if (!points.length) return '';
61
+ return points.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');
62
+ }
63
+
64
+ function pointsToArea(points, h) {
65
+ if (!points.length) return '';
66
+ const line = pointsToPath(points);
67
+ const last = points[points.length - 1][0];
68
+ const first = points[0][0];
69
+ return `${line} L${last.toFixed(2)},${h} L${first.toFixed(2)},${h} Z`;
70
+ }
71
+
72
+ class VelinSparkline extends HTMLElement {
73
+ static get observedAttributes() {
74
+ return ['values', 'width', 'height', 'min', 'max', 'area', 'glow', 'animate', 'label'];
75
+ }
76
+
77
+ constructor() {
78
+ super();
79
+ this._values = [];
80
+ this._gradientId = `velin-spark-grad-${Math.random().toString(36).slice(2, 8)}`;
81
+ }
82
+
83
+ connectedCallback() {
84
+ this._render();
85
+ }
86
+
87
+ attributeChangedCallback() {
88
+ if (this.isConnected) this._render();
89
+ }
90
+
91
+ get values() {
92
+ return this._values.slice();
93
+ }
94
+ set values(arr) {
95
+ if (!Array.isArray(arr)) return;
96
+ this._values = arr.filter((n) => Number.isFinite(Number(n))).map(Number);
97
+ this.setAttribute('values', this._values.join(','));
98
+ }
99
+
100
+ update(values) {
101
+ if (!Array.isArray(values)) return;
102
+ this._values = values.filter((n) => Number.isFinite(Number(n))).map(Number);
103
+ this._render({ tick: true });
104
+ }
105
+
106
+ _render({ tick = false } = {}) {
107
+ const w = Number.parseFloat(this.getAttribute('width')) || 320;
108
+ const h = Number.parseFloat(this.getAttribute('height')) || 96;
109
+ const values = this._values.length ? this._values : parseValues(this.getAttribute('values'));
110
+ this._values = values;
111
+ if (!values.length) {
112
+ this.innerHTML = '';
113
+ return;
114
+ }
115
+ const minAttr = Number.parseFloat(this.getAttribute('min'));
116
+ const maxAttr = Number.parseFloat(this.getAttribute('max'));
117
+ const min = Number.isFinite(minAttr) ? minAttr : Math.min(...values);
118
+ const max = Number.isFinite(maxAttr) ? maxAttr : Math.max(...values);
119
+
120
+ const wantsArea = this.hasAttribute('area') && this.getAttribute('area') !== 'false';
121
+ const wantsGlow = this.hasAttribute('glow') && this.getAttribute('glow') !== 'false';
122
+ const animate = (this.getAttribute('animate') || 'draw').toLowerCase();
123
+ const label = this.getAttribute('label');
124
+
125
+ const points = buildPoints(values, w, h, min, max);
126
+ const linePath = pointsToPath(points);
127
+ const areaPath = wantsArea ? pointsToArea(points, h) : '';
128
+
129
+ this.innerHTML = '';
130
+ const svg = document.createElementNS(NS, 'svg');
131
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
132
+ svg.setAttribute('preserveAspectRatio', 'none');
133
+ svg.style.display = 'block';
134
+ svg.style.width = '100%';
135
+ svg.style.height = '100%';
136
+ if (label) {
137
+ svg.setAttribute('role', 'img');
138
+ svg.setAttribute('aria-label', label);
139
+ } else {
140
+ svg.setAttribute('aria-hidden', 'true');
141
+ }
142
+
143
+ if (wantsArea) {
144
+ const defs = document.createElementNS(NS, 'defs');
145
+ const grad = document.createElementNS(NS, 'linearGradient');
146
+ grad.setAttribute('id', this._gradientId);
147
+ grad.setAttribute('x1', '0');
148
+ grad.setAttribute('x2', '0');
149
+ grad.setAttribute('y1', '0');
150
+ grad.setAttribute('y2', '1');
151
+ const stops = [
152
+ ['0%', 'currentColor', '0.35'],
153
+ ['100%', 'currentColor', '0'],
154
+ ];
155
+ stops.forEach(([offset, color, op]) => {
156
+ const stop = document.createElementNS(NS, 'stop');
157
+ stop.setAttribute('offset', offset);
158
+ stop.setAttribute('stop-color', color);
159
+ stop.setAttribute('stop-opacity', op);
160
+ grad.appendChild(stop);
161
+ });
162
+ defs.appendChild(grad);
163
+ svg.appendChild(defs);
164
+
165
+ const area = document.createElementNS(NS, 'path');
166
+ area.setAttribute('d', areaPath);
167
+ area.setAttribute('fill', `url(#${this._gradientId})`);
168
+ area.setAttribute('stroke', 'none');
169
+ area.classList.add('velin-chart-area');
170
+ svg.appendChild(area);
171
+ }
172
+
173
+ const line = document.createElementNS(NS, 'path');
174
+ line.setAttribute('d', linePath);
175
+ line.setAttribute('fill', 'none');
176
+ line.setAttribute('stroke', 'currentColor');
177
+ line.setAttribute('stroke-width', '2');
178
+ line.setAttribute('stroke-linecap', 'round');
179
+ line.setAttribute('stroke-linejoin', 'round');
180
+ line.setAttribute('vector-effect', 'non-scaling-stroke');
181
+ svg.appendChild(line);
182
+ if (wantsGlow) svg.classList.add('velin-chart-glow');
183
+
184
+ this.appendChild(svg);
185
+
186
+ if (animate !== 'none') {
187
+ const len = (typeof line.getTotalLength === 'function' && line.getTotalLength()) || w;
188
+ line.style.setProperty('--velin-chart-len', len.toFixed(2));
189
+ line.classList.add('velin-chart-line');
190
+ } else {
191
+ line.style.strokeDasharray = '';
192
+ line.style.strokeDashoffset = '';
193
+ }
194
+
195
+ if (tick) {
196
+ this.classList.remove('velin-spark-tick');
197
+ void this.offsetWidth;
198
+ this.classList.add('velin-spark-tick');
199
+ }
200
+ }
201
+ }
202
+
203
+ if (typeof customElements !== 'undefined' && !customElements.get('velin-sparkline')) {
204
+ customElements.define('velin-sparkline', VelinSparkline);
205
+ }
206
+
207
+ export default VelinSparkline;
@@ -1,91 +1,308 @@
1
+ const THEMES = [
2
+ { slug: '', label: 'Default (Light)' },
3
+ { slug: 'dark', label: 'Dark' },
4
+ { slug: 'brutalist', label: 'Brutalist' },
5
+ { slug: 'corporate', label: 'Corporate' },
6
+ { slug: 'earth', label: 'Earth' },
7
+ { slug: 'forest', label: 'Forest' },
8
+ { slug: 'midnight', label: 'Midnight' },
9
+ { slug: 'neon', label: 'Neon' },
10
+ { slug: 'nordic', label: 'Nordic' },
11
+ { slug: 'ocean', label: 'Ocean' },
12
+ { slug: 'pastel', label: 'Pastel' },
13
+ { slug: 'retro', label: 'Retro' },
14
+ { slug: 'sharp', label: 'Sharp' },
15
+ { slug: 'soft', label: 'Soft' },
16
+ { slug: 'sunset', label: 'Sunset' },
17
+ ];
18
+
19
+ const BUILTIN_THEMES = new Set(['', 'dark']);
20
+
21
+ const loadedThemeStylesheets = new Set();
22
+
23
+ function ensureThemeStylesheet(slug, base) {
24
+ if (!slug || BUILTIN_THEMES.has(slug)) return;
25
+ if (loadedThemeStylesheets.has(slug)) return;
26
+ const existing = document.querySelector(`link[data-velin-theme-css="${slug}"]`);
27
+ if (existing) {
28
+ loadedThemeStylesheets.add(slug);
29
+ return;
30
+ }
31
+ const link = document.createElement('link');
32
+ link.rel = 'stylesheet';
33
+ link.href = `${base.replace(/\/$/, '')}/${slug}.min.css`;
34
+ link.setAttribute('data-velin-theme-css', slug);
35
+ document.head.appendChild(link);
36
+ loadedThemeStylesheets.add(slug);
37
+ }
38
+
1
39
  const styles = `
2
- :host { display: inline-flex; }
40
+ :host { display: inline-flex; position: relative; }
41
+ .group {
42
+ display: inline-flex; align-items: stretch;
43
+ border: 2px solid var(--velin-color-border, #ddd);
44
+ border-radius: var(--velin-radius-md, 0.5rem);
45
+ background: none;
46
+ overflow: hidden;
47
+ }
3
48
  button {
4
49
  display: inline-flex; align-items: center; justify-content: center;
5
- min-width: 2.75rem; min-height: 2.75rem; padding: 0.5rem;
6
- background: none; border: 2px solid var(--velin-color-border, #ddd);
7
- border-radius: var(--velin-radius-md, 0.5rem); cursor: pointer;
8
- color: var(--velin-color-text, #111); transition: background 150ms ease, border-color 150ms ease;
50
+ min-height: 2.75rem; padding: 0.5rem;
51
+ background: none; border: 0; cursor: pointer;
52
+ color: var(--velin-color-text, #111);
53
+ transition: background 150ms ease;
54
+ }
55
+ button:hover { background: var(--velin-color-surface-dim, #eee); }
56
+ button:focus-visible {
57
+ outline: 3px solid var(--velin-color-focus, #2563eb);
58
+ outline-offset: 2px;
59
+ }
60
+ .toggle { min-width: 2.75rem; }
61
+ .picker {
62
+ min-width: 1.75rem;
63
+ border-inline-start: 1px solid var(--velin-color-border, #ddd);
64
+ color: var(--velin-color-text-muted, #555);
9
65
  }
10
- button:hover { background: var(--velin-color-surface-dim, #eee); border-color: var(--velin-color-border-strong, #999); }
11
- button:focus-visible { outline: 3px solid var(--velin-color-focus, #2563eb); outline-offset: 2px; }
12
66
  svg { width: 1.25rem; height: 1.25rem; transition: transform 300ms ease; }
67
+ .chev { width: 0.75rem; height: 0.75rem; }
13
68
  :host([theme="dark"]) .sun { display: none; }
14
69
  :host(:not([theme="dark"])) .moon { display: none; }
70
+ :host([compact]) .picker { display: none; }
71
+ :host([compact]) .toggle { border-inline-end: 0; }
15
72
  @media (prefers-reduced-motion: reduce) { svg { transition: none; } }
73
+
74
+ .menu {
75
+ position: absolute;
76
+ top: calc(100% + 0.5rem);
77
+ inset-inline-end: 0;
78
+ z-index: 1000;
79
+ min-width: 12rem;
80
+ padding: 0.375rem;
81
+ background: var(--velin-color-surface-bright, #fff);
82
+ border: 1px solid var(--velin-color-border, #ddd);
83
+ border-radius: var(--velin-radius-md, 0.5rem);
84
+ box-shadow: var(--velin-shadow-lg, 0 12px 32px rgba(0,0,0,0.12));
85
+ list-style: none;
86
+ margin: 0;
87
+ display: none;
88
+ max-height: min(70vh, 24rem);
89
+ overflow-y: auto;
90
+ }
91
+ :host([menu-open]) .menu { display: block; }
92
+ .menu li { margin: 0; }
93
+ .menu button {
94
+ width: 100%;
95
+ justify-content: flex-start;
96
+ padding: 0.4rem 0.75rem;
97
+ font-size: 0.875rem;
98
+ color: var(--velin-color-text, #111);
99
+ border-radius: var(--velin-radius-sm, 0.25rem);
100
+ min-height: 2rem;
101
+ text-align: start;
102
+ }
103
+ .menu button:hover,
104
+ .menu button[aria-current="true"] {
105
+ background: var(--velin-color-primary-subtle, #eef);
106
+ color: var(--velin-color-primary, #2a4cf0);
107
+ }
108
+ .menu button[aria-current="true"] {
109
+ font-weight: 600;
110
+ }
111
+ .menu .swatch {
112
+ width: 0.75rem; height: 0.75rem;
113
+ border-radius: 50%;
114
+ margin-inline-end: 0.5rem;
115
+ background: currentColor;
116
+ border: 1px solid var(--velin-color-border, #ddd);
117
+ }
16
118
  `;
17
119
 
18
120
  class VelinThemeToggle extends HTMLElement {
19
121
  constructor() {
20
122
  super();
21
123
  this.attachShadow({ mode: 'open' });
124
+ this._onDocClick = this._onDocClick.bind(this);
125
+ this._onKeyDown = this._onKeyDown.bind(this);
22
126
  }
23
127
 
24
128
  connectedCallback() {
25
129
  this.shadowRoot.innerHTML = `
26
130
  <style>${styles}</style>
27
- <button part="button" aria-label="Toggle dark mode">
28
- <svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
29
- <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
30
- <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
31
- <line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
32
- <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
33
- </svg>
34
- <svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
35
- <path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
36
- </svg>
37
- </button>
131
+ <div class="group" part="group">
132
+ <button class="toggle" part="button" aria-label="Toggle dark mode">
133
+ <svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
134
+ <circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
135
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
136
+ <line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
137
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
138
+ </svg>
139
+ <svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
140
+ <path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>
141
+ </svg>
142
+ </button>
143
+ <button class="picker" part="picker" aria-label="Choose theme" aria-haspopup="menu" aria-expanded="false">
144
+ <svg class="chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
145
+ <polyline points="6 9 12 15 18 9"/>
146
+ </svg>
147
+ </button>
148
+ </div>
149
+ <ul class="menu" role="menu" hidden></ul>
38
150
  `;
39
151
 
40
- const target = this.getAttribute('target') || 'html';
41
- const el = document.querySelector(target);
42
- const stored = localStorage.getItem('velin-theme');
43
- const prefersDarkMq = window.matchMedia('(prefers-color-scheme: dark)');
152
+ this._target = document.querySelector(this.getAttribute('target') || 'html');
153
+ this._themesBase = this.getAttribute('themes-base') || 'dist/themes';
154
+ this._menu = this.shadowRoot.querySelector('.menu');
155
+ this._toggleBtn = this.shadowRoot.querySelector('.toggle');
156
+ this._pickerBtn = this.shadowRoot.querySelector('.picker');
44
157
 
45
- const applyFromPreference = () => {
46
- if (localStorage.getItem('velin-theme')) return;
47
- if (prefersDarkMq.matches) {
48
- el?.setAttribute('data-velin-theme', 'dark');
49
- this.setAttribute('theme', 'dark');
50
- } else {
51
- el?.removeAttribute('data-velin-theme');
52
- this.removeAttribute('theme');
53
- }
54
- };
55
-
56
- const prefersDark = prefersDarkMq.matches;
57
-
58
- if (stored === 'dark' || (!stored && prefersDark)) {
59
- el?.setAttribute('data-velin-theme', 'dark');
60
- this.setAttribute('theme', 'dark');
61
- }
158
+ this._renderMenu();
159
+ this._initPreference();
62
160
 
63
- prefersDarkMq.addEventListener('change', applyFromPreference);
161
+ this._toggleBtn.addEventListener('click', () => this._toggleDarkMode());
162
+ this._pickerBtn.addEventListener('click', (e) => {
163
+ e.stopPropagation();
164
+ this._toggleMenu();
165
+ });
166
+
167
+ document.addEventListener('click', this._onDocClick);
168
+ this.shadowRoot.addEventListener('keydown', this._onKeyDown);
169
+
170
+ const prefersDarkMq = window.matchMedia('(prefers-color-scheme: dark)');
171
+ prefersDarkMq.addEventListener('change', () => this._applyFromPreference());
64
172
  window.addEventListener('storage', (e) => {
65
- if (e.key !== 'velin-theme' || !el) return;
66
- if (e.newValue === 'dark') {
67
- el.setAttribute('data-velin-theme', 'dark');
68
- this.setAttribute('theme', 'dark');
69
- } else {
70
- el.removeAttribute('data-velin-theme');
71
- this.removeAttribute('theme');
72
- }
173
+ if (e.key === 'velin-theme') this._readStorage();
73
174
  });
175
+ }
176
+
177
+ disconnectedCallback() {
178
+ document.removeEventListener('click', this._onDocClick);
179
+ }
74
180
 
75
- this.shadowRoot.querySelector('button').addEventListener('click', () => {
76
- const isDark = el?.getAttribute('data-velin-theme') === 'dark';
77
- if (isDark) {
78
- el?.removeAttribute('data-velin-theme');
79
- this.removeAttribute('theme');
80
- localStorage.setItem('velin-theme', 'light');
81
- } else {
82
- el?.setAttribute('data-velin-theme', 'dark');
83
- this.setAttribute('theme', 'dark');
84
- localStorage.setItem('velin-theme', 'dark');
85
- }
86
- this.dispatchEvent(new CustomEvent('velin-theme-change', { bubbles: true, detail: { theme: isDark ? 'light' : 'dark', dark: !isDark } }));
181
+ _renderMenu() {
182
+ this._menu.innerHTML = THEMES.map((t) => `
183
+ <li role="none">
184
+ <button type="button" role="menuitem" data-theme="${t.slug}">
185
+ <span class="swatch" aria-hidden="true" data-theme-swatch="${t.slug}"></span>
186
+ ${t.label}
187
+ </button>
188
+ </li>
189
+ `).join('');
190
+ this._menu.removeAttribute('hidden');
191
+ this._menu.querySelectorAll('button[data-theme]').forEach((btn) => {
192
+ btn.addEventListener('click', () => {
193
+ const slug = btn.getAttribute('data-theme');
194
+ this._applyTheme(slug, { persist: true });
195
+ this._closeMenu();
196
+ });
87
197
  });
88
198
  }
199
+
200
+ _toggleMenu() {
201
+ if (this.hasAttribute('menu-open')) this._closeMenu();
202
+ else this._openMenu();
203
+ }
204
+
205
+ _openMenu() {
206
+ this.setAttribute('menu-open', '');
207
+ this._pickerBtn.setAttribute('aria-expanded', 'true');
208
+ this._highlightActive();
209
+ const first = this._menu.querySelector('button[data-theme]');
210
+ if (first) first.focus();
211
+ }
212
+
213
+ _closeMenu() {
214
+ this.removeAttribute('menu-open');
215
+ this._pickerBtn.setAttribute('aria-expanded', 'false');
216
+ }
217
+
218
+ _onDocClick(e) {
219
+ if (!this.hasAttribute('menu-open')) return;
220
+ if (e.composedPath().includes(this)) return;
221
+ this._closeMenu();
222
+ }
223
+
224
+ _onKeyDown(e) {
225
+ if (!this.hasAttribute('menu-open')) return;
226
+ if (e.key === 'Escape') {
227
+ e.preventDefault();
228
+ this._closeMenu();
229
+ this._pickerBtn.focus();
230
+ return;
231
+ }
232
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
233
+ e.preventDefault();
234
+ const items = Array.from(this._menu.querySelectorAll('button[data-theme]'));
235
+ const idx = items.indexOf(this.shadowRoot.activeElement);
236
+ const next = e.key === 'ArrowDown'
237
+ ? items[(idx + 1) % items.length]
238
+ : items[(idx - 1 + items.length) % items.length];
239
+ next?.focus();
240
+ }
241
+ }
242
+
243
+ _highlightActive() {
244
+ const current = this._currentSlug();
245
+ this._menu.querySelectorAll('button[data-theme]').forEach((btn) => {
246
+ const slug = btn.getAttribute('data-theme');
247
+ if (slug === current) btn.setAttribute('aria-current', 'true');
248
+ else btn.removeAttribute('aria-current');
249
+ });
250
+ }
251
+
252
+ _currentSlug() {
253
+ if (!this._target) return '';
254
+ const value = this._target.getAttribute('data-velin-theme');
255
+ if (!value || value === 'light') return '';
256
+ return value;
257
+ }
258
+
259
+ _initPreference() {
260
+ const stored = localStorage.getItem('velin-theme');
261
+ if (stored) {
262
+ this._applyTheme(stored, { persist: false });
263
+ return;
264
+ }
265
+ this._applyFromPreference();
266
+ }
267
+
268
+ _readStorage() {
269
+ const stored = localStorage.getItem('velin-theme');
270
+ this._applyTheme(stored || '', { persist: false });
271
+ }
272
+
273
+ _applyFromPreference() {
274
+ if (localStorage.getItem('velin-theme')) return;
275
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
276
+ this._applyTheme(prefersDark ? 'dark' : '', { persist: false });
277
+ }
278
+
279
+ _applyTheme(slug, { persist }) {
280
+ const normalized = !slug || slug === 'light' ? '' : slug;
281
+ if (!this._target) return;
282
+ if (!normalized) {
283
+ this._target.removeAttribute('data-velin-theme');
284
+ this.removeAttribute('theme');
285
+ } else {
286
+ this._target.setAttribute('data-velin-theme', normalized);
287
+ this.setAttribute('theme', normalized === 'dark' ? 'dark' : normalized);
288
+ ensureThemeStylesheet(normalized, this._themesBase);
289
+ }
290
+ if (persist) {
291
+ if (!normalized) localStorage.removeItem('velin-theme');
292
+ else localStorage.setItem('velin-theme', normalized);
293
+ }
294
+ this._highlightActive();
295
+ this.dispatchEvent(new CustomEvent('velin-theme-change', {
296
+ bubbles: true,
297
+ detail: { theme: normalized || 'light', dark: normalized === 'dark', slug: normalized },
298
+ }));
299
+ }
300
+
301
+ _toggleDarkMode() {
302
+ const current = this._currentSlug();
303
+ const next = current === 'dark' ? '' : 'dark';
304
+ this._applyTheme(next, { persist: true });
305
+ }
89
306
  }
90
307
 
91
308
  customElements.define('velin-theme-toggle', VelinThemeToggle);
@@ -21,20 +21,24 @@ const styles = `
21
21
  @media (prefers-reduced-motion: reduce) { .tip { transition: none; } }
22
22
  `;
23
23
 
24
+ let tooltipId = 0;
25
+
24
26
  class VelinTooltipWC extends HTMLElement {
25
27
  static get observedAttributes() { return ['content', 'placement']; }
26
28
 
27
29
  constructor() {
28
30
  super();
29
31
  this.attachShadow({ mode: 'open' });
32
+ this._tipId = `velin-tooltip-${++tooltipId}`;
30
33
  }
31
34
 
32
35
  connectedCallback() {
33
36
  const placement = this.getAttribute('placement') || 'top';
37
+ const D = 'div';
34
38
  this.shadowRoot.innerHTML = `
35
39
  <style>${styles}</style>
36
40
  <slot></slot>
37
- <div class="tip" role="tooltip" data-placement="${escapeHTML(placement)}" part="tip">${escapeHTML(this.getAttribute('content') || '')}</div>
41
+ <${D} class="tip" id="${this._tipId}" role="tooltip" data-placement="${escapeHTML(placement)}" part="tip">${escapeHTML(this.getAttribute('content') || '')}</${D}>
38
42
  `;
39
43
 
40
44
  this.addEventListener('mouseenter', () => this._show());
@@ -42,14 +46,34 @@ class VelinTooltipWC extends HTMLElement {
42
46
  this.addEventListener('focusin', () => this._show());
43
47
  this.addEventListener('focusout', () => this._hide());
44
48
  this.addEventListener('keydown', (e) => { if (e.key === 'Escape') this._hide(); });
49
+
50
+ const slot = this.shadowRoot.querySelector('slot');
51
+ slot.addEventListener('slotchange', () => this._linkTrigger());
52
+ this._linkTrigger();
53
+ }
54
+
55
+ _linkTrigger() {
56
+ const trigger = this.shadowRoot.querySelector('slot')?.assignedElements()[0];
57
+ if (!trigger) return;
58
+ if (this.hasAttribute('visible')) {
59
+ trigger.setAttribute('aria-describedby', this._tipId);
60
+ } else {
61
+ trigger.removeAttribute('aria-describedby');
62
+ }
45
63
  }
46
64
 
47
65
  _show() {
48
66
  this.setAttribute('visible', '');
67
+ const trigger = this.shadowRoot.querySelector('slot')?.assignedElements()[0];
68
+ if (trigger) trigger.setAttribute('aria-describedby', this._tipId);
49
69
  this._flip();
50
70
  }
51
71
 
52
- _hide() { this.removeAttribute('visible'); }
72
+ _hide() {
73
+ this.removeAttribute('visible');
74
+ const trigger = this.shadowRoot.querySelector('slot')?.assignedElements()[0];
75
+ if (trigger) trigger.removeAttribute('aria-describedby');
76
+ }
53
77
 
54
78
  _flip() {
55
79
  const tip = this.shadowRoot.querySelector('.tip');