@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
package/cli/scanner.js CHANGED
@@ -80,6 +80,51 @@ function scanSecurityHTML(content, file) {
80
80
  fix: (currentLine) => fixSafeExternalLinkLine(currentLine),
81
81
  });
82
82
  }
83
+
84
+ if (/<meta\s[^>]*http-equiv\s*=\s*["']refresh["']/i.test(line)) {
85
+ issues.push({
86
+ file, line: ln, severity: 0,
87
+ rule: 'security/no-meta-refresh',
88
+ message: '<meta http-equiv="refresh"> can redirect users without consent.',
89
+ fixable: false,
90
+ });
91
+ }
92
+
93
+ if (/\sstyle\s*=\s*["'][^"']+["']/i.test(line) && !/velin-user-content/i.test(line)) {
94
+ issues.push({
95
+ file, line: ln, severity: 1,
96
+ rule: 'security/no-inline-style',
97
+ message: 'Inline style attribute. Prefer CSS classes to reduce XSS surface.',
98
+ fixable: false,
99
+ });
100
+ }
101
+
102
+ if (/(?:href|src)\s*=\s*["']data:text\/html/i.test(line)) {
103
+ issues.push({
104
+ file, line: ln, severity: 0,
105
+ rule: 'security/no-data-html-uri',
106
+ message: 'data:text/html URI can execute script when mishandled.',
107
+ fixable: false,
108
+ });
109
+ }
110
+
111
+ if (/<form\b[^>]*\btarget\s*=\s*["']_blank["']/i.test(line)) {
112
+ issues.push({
113
+ file, line: ln, severity: 1,
114
+ rule: 'security/dangerous-target',
115
+ message: '<form target="_blank"> is unusual and can be abused. Prefer same-tab navigation.',
116
+ fixable: false,
117
+ });
118
+ }
119
+
120
+ if (/<script\b[^>]*\bsrc\s*=\s*["']https?:\/\//i.test(line) && !/\bintegrity\s*=/i.test(line)) {
121
+ issues.push({
122
+ file, line: ln, severity: 2,
123
+ rule: 'security/integrity-missing',
124
+ message: 'External <script> without integrity attribute. Use SRI for CDN scripts.',
125
+ fixable: false,
126
+ });
127
+ }
83
128
  });
84
129
 
85
130
  if (!/<meta\s[^>]*http-equiv\s*=\s*["']Content-Security-Policy["']/i.test(content)) {
@@ -136,11 +181,27 @@ function scanSecurityJS(content, file) {
136
181
  fixable: false,
137
182
  });
138
183
  }
184
+
185
+ if (/\.postMessage\s*\([^)]*,\s*['"]\*['"]\s*\)/.test(line)) {
186
+ issues.push({
187
+ file, line: ln, severity: 1,
188
+ rule: 'security/postmessage-wildcard',
189
+ message: 'postMessage with targetOrigin "*" accepts any origin.',
190
+ fixable: false,
191
+ });
192
+ }
139
193
  });
140
194
 
141
195
  return issues;
142
196
  }
143
197
 
198
+ function issueCategory(rule) {
199
+ if (rule.startsWith('security/')) return 'security';
200
+ if (rule.startsWith('a11y/')) return 'a11y';
201
+ if (rule.startsWith('css/')) return 'css';
202
+ return 'other';
203
+ }
204
+
144
205
  // ── Accessibility Scanner ────────────────────────────────────────────────────
145
206
 
146
207
  function scanA11yHTML(content, file) {
@@ -207,6 +268,52 @@ function scanA11yHTML(content, file) {
207
268
  });
208
269
  }
209
270
 
271
+ if (/<body/i.test(content) && !/<main\b/i.test(content) && !/id\s*=\s*["']main["']/i.test(content)) {
272
+ issues.push({
273
+ file, line: 1, severity: 1,
274
+ rule: 'a11y/landmark-main',
275
+ message: 'No <main> landmark or id="main" found. Add a main landmark for screen readers.',
276
+ fixable: false,
277
+ });
278
+ }
279
+
280
+ const headings = [...content.matchAll(/<h([1-6])\b/gi)].map((m) => parseInt(m[1], 10));
281
+ for (let i = 1; i < headings.length; i++) {
282
+ if (headings[i] - headings[i - 1] > 1) {
283
+ issues.push({
284
+ file, line: 1, severity: 1,
285
+ rule: 'a11y/heading-order',
286
+ message: `Heading level skips from h${headings[i - 1]} to h${headings[i]}. Use sequential heading levels.`,
287
+ fixable: false,
288
+ });
289
+ break;
290
+ }
291
+ }
292
+
293
+ lines.forEach((line, idx) => {
294
+ const ln = idx + 1;
295
+ if (/aria-hidden\s*=\s*["']true["']/i.test(line) && /<(button|a|input|select|textarea)\b/i.test(line)) {
296
+ issues.push({
297
+ file, line: ln, severity: 0,
298
+ rule: 'a11y/interactive-aria-hidden',
299
+ message: 'Interactive element with aria-hidden="true" is not exposed to assistive tech.',
300
+ fixable: false,
301
+ });
302
+ }
303
+
304
+ const iframeMatches = line.matchAll(/<iframe\b[^>]*>/gi);
305
+ for (const m of iframeMatches) {
306
+ if (!/\btitle\s*=/i.test(m[0])) {
307
+ issues.push({
308
+ file, line: ln, severity: 0,
309
+ rule: 'a11y/iframe-title',
310
+ message: '<iframe> without title attribute.',
311
+ fixable: false,
312
+ });
313
+ }
314
+ }
315
+ });
316
+
210
317
  return issues;
211
318
  }
212
319
 
@@ -279,6 +386,9 @@ export function scan(targetPath, options = {}) {
279
386
  const writeFixes = doFix && !fixDryRun;
280
387
  const runFixPipeline = doFix || fixDryRun;
281
388
  const ignore = options.ignore || DEFAULT_IGNORE;
389
+ const onlyCategories = options.only
390
+ ? options.only.split(',').map((s) => s.trim().toLowerCase())
391
+ : null;
282
392
 
283
393
  const htmlFiles = walkFiles(targetPath, ['.html', '.htm'], ignore);
284
394
  const cssFiles = walkFiles(targetPath, ['.css'], ignore);
@@ -303,6 +413,9 @@ export function scan(targetPath, options = {}) {
303
413
  }
304
414
 
305
415
  allIssues = allIssues.filter(i => i.severity <= minSeverity);
416
+ if (onlyCategories?.length) {
417
+ allIssues = allIssues.filter((i) => onlyCategories.includes(issueCategory(i.rule)));
418
+ }
306
419
  allIssues.sort((a, b) => a.severity - b.severity || a.file.localeCompare(b.file) || a.line - b.line);
307
420
 
308
421
  let fixSummary = null;
@@ -322,6 +435,7 @@ export function scan(targetPath, options = {}) {
322
435
  file: relative(targetPath, i.file),
323
436
  line: i.line,
324
437
  severity: SEVERITY_LABEL[i.severity],
438
+ category: issueCategory(i.rule),
325
439
  rule: i.rule,
326
440
  message: i.message,
327
441
  fixable: !!i.fixable,
@@ -1,80 +1,106 @@
1
- const FOCUSABLE_SELECTOR = [
2
- 'a[href]',
3
- 'button:not([disabled])',
4
- 'input:not([disabled])',
5
- 'select:not([disabled])',
6
- 'textarea:not([disabled])',
7
- '[tabindex]:not([tabindex="-1"])',
8
- 'summary',
9
- 'details',
10
- ].join(', ');
11
-
12
- export function getFocusableElements(root) {
13
- return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter(
14
- (el) => !el.hasAttribute('disabled') && el.offsetParent !== null
15
- );
16
- }
17
-
18
- export function trapFocus(root, event) {
19
- if (event.key !== 'Tab') return;
20
-
21
- const focusable = getFocusableElements(root);
22
- if (focusable.length === 0) return;
23
-
24
- const first = focusable[0];
25
- const last = focusable[focusable.length - 1];
26
- const active = root.getRootNode().activeElement;
27
-
28
- if (event.shiftKey && active === first) {
29
- event.preventDefault();
30
- last.focus();
31
- } else if (!event.shiftKey && active === last) {
32
- event.preventDefault();
33
- first.focus();
34
- }
35
- }
36
-
37
- export function rovingTabindex(container, items, event) {
38
- const currentIndex = items.indexOf(event.target);
39
- if (currentIndex === -1) return;
40
-
41
- let nextIndex;
42
-
43
- switch (event.key) {
44
- case 'ArrowDown':
45
- case 'ArrowRight':
46
- event.preventDefault();
47
- nextIndex = (currentIndex + 1) % items.length;
48
- break;
49
- case 'ArrowUp':
50
- case 'ArrowLeft':
51
- event.preventDefault();
52
- nextIndex = (currentIndex - 1 + items.length) % items.length;
53
- break;
54
- case 'Home':
55
- event.preventDefault();
56
- nextIndex = 0;
57
- break;
58
- case 'End':
59
- event.preventDefault();
60
- nextIndex = items.length - 1;
61
- break;
62
- default:
63
- return;
64
- }
65
-
66
- items.forEach((item, i) => {
67
- item.setAttribute('tabindex', i === nextIndex ? '0' : '-1');
68
- });
69
- items[nextIndex].focus();
70
- }
71
-
72
- export function saveFocus() {
73
- return document.activeElement;
74
- }
75
-
76
- export function restoreFocus(element) {
77
- if (element && typeof element.focus === 'function') {
78
- element.focus();
79
- }
80
- }
1
+ const FOCUSABLE_SELECTOR = [
2
+ 'a[href]',
3
+ 'button:not([disabled])',
4
+ 'input:not([disabled])',
5
+ 'select:not([disabled])',
6
+ 'textarea:not([disabled])',
7
+ '[tabindex]:not([tabindex="-1"])',
8
+ 'summary',
9
+ 'details',
10
+ ].join(', ');
11
+
12
+ function isFocusable(el) {
13
+ if (el.hasAttribute('disabled') || el.getAttribute('aria-hidden') === 'true') return false;
14
+ if (el.closest('[inert]')) return false;
15
+ const style = el.ownerDocument.defaultView?.getComputedStyle(el);
16
+ if (style && (style.visibility === 'hidden' || style.display === 'none')) return false;
17
+ return el.getClientRects().length > 0;
18
+ }
19
+
20
+ export function getFocusableElements(root) {
21
+ return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
22
+ }
23
+
24
+ export function trapFocus(root, event) {
25
+ if (event.key !== 'Tab') return;
26
+
27
+ const focusable = getFocusableElements(root);
28
+ if (focusable.length === 0) return;
29
+
30
+ const first = focusable[0];
31
+ const last = focusable[focusable.length - 1];
32
+ const active = root.getRootNode().activeElement;
33
+
34
+ if (event.shiftKey && active === first) {
35
+ event.preventDefault();
36
+ last.focus();
37
+ } else if (!event.shiftKey && active === last) {
38
+ event.preventDefault();
39
+ first.focus();
40
+ }
41
+ }
42
+
43
+ export function rovingTabindex(container, items, event) {
44
+ const currentIndex = items.indexOf(event.target);
45
+ if (currentIndex === -1) return;
46
+
47
+ let nextIndex;
48
+
49
+ switch (event.key) {
50
+ case 'ArrowDown':
51
+ case 'ArrowRight':
52
+ event.preventDefault();
53
+ nextIndex = (currentIndex + 1) % items.length;
54
+ break;
55
+ case 'ArrowUp':
56
+ case 'ArrowLeft':
57
+ event.preventDefault();
58
+ nextIndex = (currentIndex - 1 + items.length) % items.length;
59
+ break;
60
+ case 'Home':
61
+ event.preventDefault();
62
+ nextIndex = 0;
63
+ break;
64
+ case 'End':
65
+ event.preventDefault();
66
+ nextIndex = items.length - 1;
67
+ break;
68
+ default:
69
+ return;
70
+ }
71
+
72
+ items.forEach((item, i) => {
73
+ item.setAttribute('tabindex', i === nextIndex ? '0' : '-1');
74
+ });
75
+ items[nextIndex].focus();
76
+ }
77
+
78
+ export function saveFocus() {
79
+ return document.activeElement;
80
+ }
81
+
82
+ export function restoreFocus(element) {
83
+ if (element && typeof element.focus === 'function') {
84
+ element.focus();
85
+ }
86
+ }
87
+
88
+ let _inertSiblings = [];
89
+
90
+ export function setBackgroundInert(except) {
91
+ _inertSiblings = [];
92
+ for (const child of document.body.children) {
93
+ if (child === except || child.contains(except)) continue;
94
+ if (!child.hasAttribute('inert')) {
95
+ child.setAttribute('inert', '');
96
+ _inertSiblings.push(child);
97
+ }
98
+ }
99
+ }
100
+
101
+ export function clearBackgroundInert() {
102
+ for (const el of _inertSiblings) {
103
+ el.removeAttribute('inert');
104
+ }
105
+ _inertSiblings = [];
106
+ }
@@ -19,9 +19,28 @@ export { default as VelinDialog } from './velin-dialog.js';
19
19
  export { default as VelinCountdown } from './velin-countdown.js';
20
20
  export { default as VelinProgressRing } from './velin-progress-ring.js';
21
21
  export { default as VelinPersist } from './velin-persist.js';
22
+ export { default as VelinCombobox } from './velin-combobox.js';
23
+ export { default as VelinBottomNav } from './velin-bottom-nav.js';
24
+ export { default as VelinSheet } from './velin-sheet.js';
25
+ export { default as VelinSegmentedControl } from './velin-segmented-control.js';
26
+ export { default as VelinRating } from './velin-rating.js';
27
+ export { default as VelinMenubar } from './velin-menubar.js';
28
+ export { default as VelinCommand } from './velin-command.js';
29
+ export { default as VelinAnnouncer } from './velin-announcer.js';
30
+ export { default as VelinSparkline } from './velin-sparkline.js';
31
+ export { default as VelinCounter } from './velin-counter.js';
32
+ export { default as VelinLiveDot } from './velin-live-dot.js';
33
+ export { initReveal } from './velin-reveal.js';
34
+ export { flipReorder, filterList } from './velin-flip.js';
35
+ export { escapeHTML, escapeHTMLAttribute, sanitizeURL, stripControlChars, createSafeHTML, getTrustedPolicy } from './sanitize.js';
22
36
  export { VelinHapticObserver, vibrate, applyHaptic, PATTERNS as HapticPatterns } from './velin-haptic.js';
23
- export { trapFocus, rovingTabindex, saveFocus, restoreFocus, getFocusableElements } from './focus-manager.js';
37
+ export { trapFocus, rovingTabindex, saveFocus, restoreFocus, getFocusableElements, setBackgroundInert, clearBackgroundInert } from './focus-manager.js';
24
38
 
39
+ import './velin-sparkline.js';
40
+ import './velin-counter.js';
41
+ import './velin-live-dot.js';
42
+ import './velin-reveal.js';
43
+ import './velin-flip.js';
25
44
  import { VelinHapticObserver } from './velin-haptic.js';
26
45
  if (typeof document !== 'undefined') {
27
46
  const _hapticInit = () => { new VelinHapticObserver().start(document.body); };
@@ -1,18 +1,37 @@
1
1
  const ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
2
2
  const ESCAPE_RE = /[&<>"']/g;
3
+ const CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
4
+
5
+ const ALLOWED_URL_PROTOCOLS = new Set(['http:', 'https:', 'data:', 'mailto:', 'tel:']);
6
+ const BLOCKED_DATA_MIME = /^data:text\/html/i;
3
7
 
4
8
  export function escapeHTML(str) {
5
9
  if (typeof str !== 'string') return '';
6
10
  return str.replace(ESCAPE_RE, (ch) => ESCAPE_MAP[ch]);
7
11
  }
8
12
 
13
+ export function stripControlChars(str) {
14
+ if (typeof str !== 'string') return '';
15
+ return str.replace(CONTROL_RE, '');
16
+ }
17
+
18
+ export function escapeHTMLAttribute(str) {
19
+ return escapeHTML(stripControlChars(str));
20
+ }
21
+
9
22
  export function sanitizeURL(url) {
10
23
  if (typeof url !== 'string') return '';
24
+ const trimmed = url.trim();
25
+ if (/^\s*javascript:/i.test(trimmed) || /^\s*vbscript:/i.test(trimmed)) return '';
26
+ if (BLOCKED_DATA_MIME.test(trimmed)) return '';
11
27
  try {
12
- const parsed = new URL(url, location.href);
13
- if (['http:', 'https:', 'data:'].includes(parsed.protocol)) return url;
28
+ const parsed = new URL(trimmed, typeof location !== 'undefined' ? location.href : 'https://example.invalid/');
29
+ if (!ALLOWED_URL_PROTOCOLS.has(parsed.protocol)) return '';
30
+ if (parsed.protocol === 'data:' && BLOCKED_DATA_MIME.test(trimmed)) return '';
31
+ return trimmed;
32
+ } catch {
14
33
  return '';
15
- } catch { return ''; }
34
+ }
16
35
  }
17
36
 
18
37
  let _policy = null;
@@ -26,3 +45,10 @@ export function getTrustedPolicy() {
26
45
  }
27
46
  return _policy;
28
47
  }
48
+
49
+ export function createSafeHTML(str) {
50
+ const policy = getTrustedPolicy();
51
+ const safe = escapeHTML(stripControlChars(str));
52
+ if (policy?.createHTML) return policy.createHTML(safe);
53
+ return safe;
54
+ }
@@ -0,0 +1,18 @@
1
+ /** Shared shadow DOM focus + target-size styles for VelinStyle web components. */
2
+ export const SHADOW_A11Y_STYLES = `
3
+ :host { display: block; }
4
+ button, [role="button"] {
5
+ min-inline-size: 2.75rem;
6
+ min-block-size: 2.75rem;
7
+ cursor: pointer;
8
+ }
9
+ button:focus-visible, [role="button"]:focus-visible {
10
+ outline: 3px solid var(--velin-color-focus, #2563eb);
11
+ outline-offset: 2px;
12
+ }
13
+ @media (forced-colors: active) {
14
+ button, [role="button"] {
15
+ border: 1px solid ButtonText;
16
+ }
17
+ }
18
+ `;
@@ -1,98 +1,112 @@
1
- const styles = `
2
- :host {
3
- display: block;
4
- border: 1px solid var(--velin-color-border, #ddd);
5
- border-radius: var(--velin-radius-md, 0.5rem);
6
- overflow: hidden;
7
- }
8
- ::slotted(details) {
9
- border-bottom: 1px solid var(--velin-color-border, #ddd);
10
- }
11
- ::slotted(details:last-child) {
12
- border-bottom: none;
13
- }
14
- ::slotted(details > summary) {
15
- display: flex;
16
- align-items: center;
17
- justify-content: space-between;
18
- padding: var(--velin-space-4, 1rem);
19
- min-block-size: 2.75rem;
20
- font-size: var(--velin-text-base, 1rem);
21
- font-weight: var(--velin-weight-medium, 500);
22
- cursor: pointer;
23
- user-select: none;
24
- list-style: none;
25
- }
26
- ::slotted(details > summary::-webkit-details-marker) {
27
- display: none;
28
- }
29
- `;
30
-
31
- class VelinAccordion extends HTMLElement {
32
- constructor() {
33
- super();
34
- this.attachShadow({ mode: 'open' });
35
- this._onToggle = this._onToggle.bind(this);
36
- }
37
-
38
- connectedCallback() {
39
- this.shadowRoot.innerHTML = `
40
- <style>${styles}</style>
41
- <div role="region" part="region"><slot></slot></div>
42
- `;
43
-
44
- this._exclusive = this.hasAttribute('exclusive');
45
-
46
- this.addEventListener('toggle', this._onToggle, true);
47
- this.addEventListener('keydown', this._onKeydown.bind(this));
48
- }
49
-
50
- _onToggle(event) {
51
- if (!this._exclusive) return;
52
- const openedDetail = event.target;
53
- if (!openedDetail.open) return;
54
-
55
- const details = [...this.querySelectorAll('details')];
56
- details.forEach((d) => {
57
- if (d !== openedDetail && d.open) {
58
- d.open = false;
59
- }
60
- });
61
- }
62
-
63
- _onKeydown(event) {
64
- const summaries = [...this.querySelectorAll('summary')];
65
- const currentIndex = summaries.indexOf(event.target);
66
- if (currentIndex === -1) return;
67
-
68
- let nextIndex;
69
- switch (event.key) {
70
- case 'ArrowDown':
71
- event.preventDefault();
72
- nextIndex = (currentIndex + 1) % summaries.length;
73
- break;
74
- case 'ArrowUp':
75
- event.preventDefault();
76
- nextIndex = (currentIndex - 1 + summaries.length) % summaries.length;
77
- break;
78
- case 'Home':
79
- event.preventDefault();
80
- nextIndex = 0;
81
- break;
82
- case 'End':
83
- event.preventDefault();
84
- nextIndex = summaries.length - 1;
85
- break;
86
- default:
87
- return;
88
- }
89
- summaries[nextIndex].focus();
90
- }
91
-
92
- disconnectedCallback() {
93
- this.removeEventListener('toggle', this._onToggle, true);
94
- }
95
- }
96
-
97
- customElements.define('velin-accordion', VelinAccordion);
98
- export default VelinAccordion;
1
+ const styles = `
2
+ :host {
3
+ display: block;
4
+ border: 1px solid var(--velin-color-border, #ddd);
5
+ border-radius: var(--velin-radius-md, 0.5rem);
6
+ overflow: hidden;
7
+ }
8
+ ::slotted(details) {
9
+ border-bottom: 1px solid var(--velin-color-border, #ddd);
10
+ }
11
+ ::slotted(details:last-child) {
12
+ border-bottom: none;
13
+ }
14
+ ::slotted(details > summary) {
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: space-between;
18
+ padding: var(--velin-space-4, 1rem);
19
+ min-block-size: 2.75rem;
20
+ font-size: var(--velin-text-base, 1rem);
21
+ font-weight: var(--velin-weight-medium, 500);
22
+ cursor: pointer;
23
+ user-select: none;
24
+ list-style: none;
25
+ }
26
+ ::slotted(details > summary::-webkit-details-marker) {
27
+ display: none;
28
+ }
29
+ `;
30
+
31
+ class VelinAccordion extends HTMLElement {
32
+ constructor() {
33
+ super();
34
+ this.attachShadow({ mode: 'open' });
35
+ this._onToggle = this._onToggle.bind(this);
36
+ }
37
+
38
+ connectedCallback() {
39
+ this.shadowRoot.innerHTML = `
40
+ <style>${styles}</style>
41
+ <slot></slot>
42
+ `;
43
+
44
+ this._exclusive = this.hasAttribute('exclusive');
45
+ this._wireDetails();
46
+
47
+ this.addEventListener('toggle', this._onToggle, true);
48
+ this.addEventListener('keydown', this._onKeydown.bind(this));
49
+ }
50
+
51
+ _wireDetails() {
52
+ let panelIndex = 0;
53
+ for (const details of this.querySelectorAll('details')) {
54
+ const summary = details.querySelector('summary');
55
+ const panel = details.querySelector(':scope > :not(summary)');
56
+ const panelId = panel?.id || `velin-accordion-panel-${++panelIndex}`;
57
+ if (panel && !panel.id) panel.id = panelId;
58
+ if (summary && panel) {
59
+ summary.setAttribute('aria-controls', panelId);
60
+ }
61
+ }
62
+ }
63
+
64
+ _onToggle(event) {
65
+ if (!this._exclusive) return;
66
+ const openedDetail = event.target;
67
+ if (!openedDetail.open) return;
68
+
69
+ const details = [...this.querySelectorAll('details')];
70
+ details.forEach((d) => {
71
+ if (d !== openedDetail && d.open) {
72
+ d.open = false;
73
+ }
74
+ });
75
+ }
76
+
77
+ _onKeydown(event) {
78
+ const summaries = [...this.querySelectorAll('summary')];
79
+ const currentIndex = summaries.indexOf(event.target);
80
+ if (currentIndex === -1) return;
81
+
82
+ let nextIndex;
83
+ switch (event.key) {
84
+ case 'ArrowDown':
85
+ event.preventDefault();
86
+ nextIndex = (currentIndex + 1) % summaries.length;
87
+ break;
88
+ case 'ArrowUp':
89
+ event.preventDefault();
90
+ nextIndex = (currentIndex - 1 + summaries.length) % summaries.length;
91
+ break;
92
+ case 'Home':
93
+ event.preventDefault();
94
+ nextIndex = 0;
95
+ break;
96
+ case 'End':
97
+ event.preventDefault();
98
+ nextIndex = summaries.length - 1;
99
+ break;
100
+ default:
101
+ return;
102
+ }
103
+ summaries[nextIndex].focus();
104
+ }
105
+
106
+ disconnectedCallback() {
107
+ this.removeEventListener('toggle', this._onToggle, true);
108
+ }
109
+ }
110
+
111
+ customElements.define('velin-accordion', VelinAccordion);
112
+ export default VelinAccordion;