@birdapi/velinstyle 0.6.1 → 0.7.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.md CHANGED
@@ -8,15 +8,14 @@
8
8
  <br>
9
9
 
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-2563eb?style=flat-square)](LICENSE)
11
- [![Release](https://img.shields.io/badge/Release-v0.6.1-2563eb?style=flat-square)](CHANGELOG.md)
11
+ [![Release](https://img.shields.io/badge/Release-v0.7.0-2563eb?style=flat-square)](CHANGELOG.md)
12
12
  [![WCAG AA](https://img.shields.io/badge/WCAG-AA-16a34a?style=flat-square)](docs/a11y.html)
13
13
  [![CSS min](https://img.shields.io/badge/CSS_min-~46KB-15803d?style=flat-square)]()
14
14
  [![JS min](https://img.shields.io/badge/JS_min-~66KB-15803d?style=flat-square)]()
15
- [![npm](https://img.shields.io/badge/npm-publish_pending-64748b?style=flat-square)](https://www.npmjs.com/package/@birdapi/velinstyle)
15
+ [![npm version](https://img.shields.io/npm/v/@birdapi/velinstyle?style=flat-square)](https://www.npmjs.com/package/@birdapi/velinstyle)
16
+ [![GitHub Pages](https://img.shields.io/badge/docs-GitHub_Pages-2563eb?style=flat-square)](https://skylitedesign.github.io/velinstyle/)
16
17
 
17
- <!-- After npm publish: https://img.shields.io/npm/v/velinstyle?style=flat-square -->
18
-
19
- [Website](https://velinstyle.info) · [Docs](docs/index.html) · [Samples](samples/) · [Playground](tools/playground/index.html) · [Theme Builder](tools/theme-builder/index.html) · [Issues](https://github.com/SkyliteDesign/velinstyle/issues)
18
+ [Documentation](https://skylitedesign.github.io/velinstyle/) · [npm](https://www.npmjs.com/package/@birdapi/velinstyle) · [Samples](samples/) · [Playground](tools/playground/index.html) · [Theme Builder](tools/theme-builder/index.html) · [Issues](https://github.com/SkyliteDesign/velinstyle/issues)
20
19
 
21
20
  **English** · **[Deutsch](README.de.md)**
22
21
 
@@ -114,16 +113,16 @@ npm install @birdapi/velinstyle
114
113
  ### <span>②</span> Link CSS + components (ES modules)
115
114
 
116
115
  ```html
117
- <link rel="stylesheet" href="node_modules/velinstyle/dist/velinstyle.min.css">
118
- <script type="module" src="node_modules/velinstyle/dist/velinstyle-components.min.js"></script>
116
+ <link rel="stylesheet" href="node_modules/@birdapi/velinstyle/dist/velinstyle.min.css">
117
+ <script type="module" src="node_modules/@birdapi/velinstyle/dist/velinstyle-components.min.js"></script>
119
118
  ```
120
119
 
121
120
  <details>
122
121
  <summary><strong>CDN alternative</strong> (skip npm install)</summary>
123
122
 
124
123
  ```html
125
- <link rel="stylesheet" href="https://unpkg.com/velinstyle@latest/dist/velinstyle.min.css">
126
- <script type="module" src="https://unpkg.com/velinstyle@latest/dist/velinstyle-components.min.js"></script>
124
+ <link rel="stylesheet" href="https://unpkg.com/@birdapi/velinstyle@latest/dist/velinstyle.min.css">
125
+ <script type="module" src="https://unpkg.com/@birdapi/velinstyle@latest/dist/velinstyle-components.min.js"></script>
127
126
  ```
128
127
 
129
128
  </details>
package/cli/index.js CHANGED
@@ -11,7 +11,7 @@ const PKG_ROOT = join(__dirname, '..');
11
11
  const args = process.argv.slice(2);
12
12
  const command = args[0];
13
13
 
14
- const LAYERS = ['tokens', 'reset', 'base', 'a11y', 'layout', 'components', 'utilities', 'helpers'];
14
+ const LAYERS = ['tokens', 'reset', 'base', 'a11y', 'layout', 'components', 'utilities', 'security', 'helpers'];
15
15
 
16
16
  const LAYER_FILES = {
17
17
  tokens: [
@@ -24,8 +24,9 @@ const LAYER_FILES = {
24
24
  a11y: [
25
25
  'a11y/sr-only.css', 'a11y/skip-link.css', 'a11y/reduced-motion.css',
26
26
  'a11y/forced-colors.css', 'a11y/skeleton.css', 'a11y/preferences.css',
27
- 'a11y/security.css',
27
+ 'a11y/focus-not-obscured.css', 'a11y/target-size.css', 'a11y/high-contrast-aaa.css',
28
28
  ],
29
+ security: ['a11y/security.css'],
29
30
  layout: [
30
31
  'layout/breakpoints.css', 'layout/container.css', 'layout/grid.css',
31
32
  'layout/flex.css', 'layout/patterns.css',
@@ -78,7 +79,7 @@ function hasFlag(flag, alias) {
78
79
 
79
80
  function help() {
80
81
  console.log(`
81
- ${C.bold('VelinStyle CLI')} v0.6.1
82
+ ${C.bold('VelinStyle CLI')} v0.7.0
82
83
 
83
84
  ${C.bold('Usage:')}
84
85
  velinstyle init Create velinstyle.config.js
package/cli/scanner.js CHANGED
@@ -207,6 +207,52 @@ function scanA11yHTML(content, file) {
207
207
  });
208
208
  }
209
209
 
210
+ if (/<body/i.test(content) && !/<main\b/i.test(content) && !/id\s*=\s*["']main["']/i.test(content)) {
211
+ issues.push({
212
+ file, line: 1, severity: 1,
213
+ rule: 'a11y/landmark-main',
214
+ message: 'No <main> landmark or id="main" found. Add a main landmark for screen readers.',
215
+ fixable: false,
216
+ });
217
+ }
218
+
219
+ const headings = [...content.matchAll(/<h([1-6])\b/gi)].map((m) => parseInt(m[1], 10));
220
+ for (let i = 1; i < headings.length; i++) {
221
+ if (headings[i] - headings[i - 1] > 1) {
222
+ issues.push({
223
+ file, line: 1, severity: 1,
224
+ rule: 'a11y/heading-order',
225
+ message: `Heading level skips from h${headings[i - 1]} to h${headings[i]}. Use sequential heading levels.`,
226
+ fixable: false,
227
+ });
228
+ break;
229
+ }
230
+ }
231
+
232
+ lines.forEach((line, idx) => {
233
+ const ln = idx + 1;
234
+ if (/aria-hidden\s*=\s*["']true["']/i.test(line) && /<(button|a|input|select|textarea)\b/i.test(line)) {
235
+ issues.push({
236
+ file, line: ln, severity: 0,
237
+ rule: 'a11y/interactive-aria-hidden',
238
+ message: 'Interactive element with aria-hidden="true" is not exposed to assistive tech.',
239
+ fixable: false,
240
+ });
241
+ }
242
+
243
+ const iframeMatches = line.matchAll(/<iframe\b[^>]*>/gi);
244
+ for (const m of iframeMatches) {
245
+ if (!/\btitle\s*=/i.test(m[0])) {
246
+ issues.push({
247
+ file, line: ln, severity: 0,
248
+ rule: 'a11y/iframe-title',
249
+ message: '<iframe> without title attribute.',
250
+ fixable: false,
251
+ });
252
+ }
253
+ }
254
+ });
255
+
210
256
  return issues;
211
257
  }
212
258
 
@@ -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
+ }
@@ -20,7 +20,7 @@ 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
22
  export { VelinHapticObserver, vibrate, applyHaptic, PATTERNS as HapticPatterns } from './velin-haptic.js';
23
- export { trapFocus, rovingTabindex, saveFocus, restoreFocus, getFocusableElements } from './focus-manager.js';
23
+ export { trapFocus, rovingTabindex, saveFocus, restoreFocus, getFocusableElements, setBackgroundInert, clearBackgroundInert } from './focus-manager.js';
24
24
 
25
25
  import { VelinHapticObserver } from './velin-haptic.js';
26
26
  if (typeof document !== 'undefined') {
@@ -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;
@@ -25,9 +25,15 @@ const styles = `
25
25
  button:disabled { opacity: 0.3; cursor: not-allowed; }
26
26
  svg { width: 1.25rem; height: 1.25rem; }
27
27
  .indicators {
28
- display: flex; justify-content: center; gap: var(--velin-space-2, 0.5rem);
28
+ display: flex; justify-content: center; align-items: center; gap: var(--velin-space-2, 0.5rem);
29
29
  padding-block: var(--velin-space-3, 0.75rem);
30
30
  }
31
+ .pause-btn {
32
+ font-size: var(--velin-text-xs, 0.75rem);
33
+ padding-inline: var(--velin-space-3, 0.75rem);
34
+ min-inline-size: auto;
35
+ border-radius: var(--velin-radius-md, 0.375rem);
36
+ }
31
37
  .dot {
32
38
  display: inline-flex; align-items: center; justify-content: center;
33
39
  min-width: 2.75rem; min-height: 2.75rem;
@@ -54,6 +60,7 @@ class VelinCarousel extends HTMLElement {
54
60
  this._index = 0;
55
61
  this._timer = null;
56
62
  this._startX = 0;
63
+ this._autoplayPaused = false;
57
64
  }
58
65
 
59
66
  connectedCallback() {
@@ -68,7 +75,7 @@ class VelinCarousel extends HTMLElement {
68
75
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 6 15 12 9 18"/></svg>
69
76
  </button>
70
77
  </div>
71
- <div class="indicators" role="tablist" aria-label="Slide indicators" part="indicators"></div>
78
+ <div class="indicators" role="group" aria-label="Slide indicators" part="indicators"></div>
72
79
  `;
73
80
 
74
81
  this.shadowRoot.querySelector('.prev').addEventListener('click', () => this.prev());
@@ -128,18 +135,46 @@ class VelinCarousel extends HTMLElement {
128
135
  c.innerHTML = '';
129
136
  this._slides.forEach((_, i) => {
130
137
  const d = document.createElement('button');
138
+ d.type = 'button';
131
139
  d.className = 'dot';
132
- d.setAttribute('role', 'tab');
133
- d.setAttribute('aria-label', `Slide ${i + 1}`);
140
+ d.setAttribute('aria-label', `Go to slide ${i + 1}`);
134
141
  d.addEventListener('click', () => this.goTo(i));
135
142
  c.appendChild(d);
136
143
  });
144
+ if (this.hasAttribute('autoplay')) {
145
+ const pause = document.createElement('button');
146
+ pause.type = 'button';
147
+ pause.className = 'pause-btn';
148
+ pause.setAttribute('aria-pressed', 'false');
149
+ pause.setAttribute('aria-label', 'Pause automatic slide show');
150
+ pause.textContent = 'Pause';
151
+ pause.addEventListener('click', () => this._toggleAutoplayPause(pause));
152
+ c.appendChild(pause);
153
+ }
154
+ this._update();
155
+ }
156
+
157
+ _toggleAutoplayPause(btn) {
158
+ this._autoplayPaused = !this._autoplayPaused;
159
+ if (this._autoplayPaused) {
160
+ this._pause();
161
+ btn.setAttribute('aria-pressed', 'true');
162
+ btn.setAttribute('aria-label', 'Resume automatic slide show');
163
+ btn.textContent = 'Play';
164
+ } else {
165
+ this._resume();
166
+ btn.setAttribute('aria-pressed', 'false');
167
+ btn.setAttribute('aria-label', 'Pause automatic slide show');
168
+ btn.textContent = 'Pause';
169
+ }
137
170
  }
138
171
 
139
172
  _emit() { this.dispatchEvent(new CustomEvent('velin-slide-change', { bubbles: true, detail: { index: this._index } })); }
140
173
  _startAutoplay() { const ms = parseInt(this.getAttribute('interval') || '5000', 10); this._timer = setInterval(() => this.next(), ms); }
141
174
  _pause() { if (this._timer) { clearInterval(this._timer); this._timer = null; } }
142
- _resume() { if (this.hasAttribute('autoplay') && !this._timer) this._startAutoplay(); }
175
+ _resume() {
176
+ if (this.hasAttribute('autoplay') && !this._timer && !this._autoplayPaused) this._startAutoplay();
177
+ }
143
178
 
144
179
  disconnectedCallback() { this._pause(); }
145
180
  }