@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.
- package/README.de.md +337 -316
- package/README.md +33 -12
- package/cli/blueprint.js +8 -0
- package/cli/blueprints/bottom-nav-mobile.html +17 -0
- package/cli/blueprints/cookie-consent.html +9 -0
- package/cli/blueprints/empty-state.html +5 -0
- package/cli/blueprints/filter-bar.html +15 -0
- package/cli/blueprints/notification-center.html +13 -0
- package/cli/blueprints/onboarding.html +23 -0
- package/cli/blueprints/pricing-table.html +20 -0
- package/cli/blueprints/settings-panel.html +20 -0
- package/cli/index.js +119 -3
- package/cli/layout-audit.js +325 -0
- package/cli/scaffold-recipes.json +70 -0
- package/cli/scaffold.js +155 -0
- package/cli/scanner.js +114 -0
- package/components/focus-manager.js +106 -80
- package/components/index.js +20 -1
- package/components/sanitize.js +29 -3
- package/components/shadow-a11y-styles.js +18 -0
- package/components/velin-accordion.js +112 -98
- package/components/velin-announcer.js +35 -0
- package/components/velin-bottom-nav.js +89 -0
- package/components/velin-carousel.js +40 -5
- package/components/velin-collapse.js +95 -65
- package/components/velin-combobox.js +149 -0
- package/components/velin-command.js +127 -0
- package/components/velin-counter.js +152 -0
- package/components/velin-drawer.js +6 -3
- package/components/velin-dropdown.js +33 -2
- package/components/velin-flip.js +220 -0
- package/components/velin-icon.js +43 -9
- package/components/velin-live-dot.js +85 -0
- package/components/velin-menubar.js +83 -0
- package/components/velin-modal.js +3 -1
- package/components/velin-popover.js +61 -21
- package/components/velin-rating.js +91 -0
- package/components/velin-reveal.js +80 -0
- package/components/velin-segmented-control.js +108 -0
- package/components/velin-sheet.js +107 -0
- package/components/velin-sparkline.js +207 -0
- package/components/velin-theme-toggle.js +277 -60
- package/components/velin-tooltip-wc.js +26 -2
- package/dist/velinstyle-components.iife.js +1849 -120
- package/dist/velinstyle-components.js +1871 -120
- package/dist/velinstyle-components.min.js +434 -91
- package/dist/velinstyle.css +640 -44
- package/dist/velinstyle.min.css +1 -1
- package/package.json +7 -3
- package/src/a11y/focus-not-obscured.css +21 -0
- package/src/a11y/forced-colors.css +86 -38
- package/src/a11y/high-contrast-aaa.css +37 -0
- package/src/a11y/preferences.css +106 -85
- package/src/a11y/security.css +119 -104
- package/src/a11y/target-size.css +32 -0
- package/src/base/reset.css +12 -1
- package/src/base/root.css +4 -4
- package/src/components/nav.css +152 -151
- package/src/tokens/motion.css +7 -0
- package/src/utilities/animation.css +97 -1
- package/src/utilities/chart-animation.css +101 -0
- package/src/utilities/filter-effects.css +103 -0
- package/src/utilities/safe-area.css +39 -0
- 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
case '
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
}
|
package/components/index.js
CHANGED
|
@@ -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); };
|
package/components/sanitize.js
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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(
|
|
13
|
-
if (
|
|
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
|
-
}
|
|
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
|
-
<
|
|
42
|
-
`;
|
|
43
|
-
|
|
44
|
-
this._exclusive = this.hasAttribute('exclusive');
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
this.addEventListener('
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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;
|