@birdapi/velinstyle 0.7.0 → 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 (49) hide show
  1. package/README.de.md +26 -4
  2. package/README.md +26 -4
  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 +116 -1
  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 +68 -0
  17. package/components/index.js +19 -0
  18. package/components/sanitize.js +29 -3
  19. package/components/shadow-a11y-styles.js +18 -0
  20. package/components/velin-announcer.js +35 -0
  21. package/components/velin-bottom-nav.js +89 -0
  22. package/components/velin-combobox.js +149 -0
  23. package/components/velin-command.js +127 -0
  24. package/components/velin-counter.js +152 -0
  25. package/components/velin-flip.js +220 -0
  26. package/components/velin-icon.js +43 -9
  27. package/components/velin-live-dot.js +85 -0
  28. package/components/velin-menubar.js +83 -0
  29. package/components/velin-rating.js +91 -0
  30. package/components/velin-reveal.js +80 -0
  31. package/components/velin-segmented-control.js +108 -0
  32. package/components/velin-sheet.js +107 -0
  33. package/components/velin-sparkline.js +207 -0
  34. package/components/velin-theme-toggle.js +277 -60
  35. package/dist/velinstyle-components.iife.js +1629 -69
  36. package/dist/velinstyle-components.js +1649 -69
  37. package/dist/velinstyle-components.min.js +424 -80
  38. package/dist/velinstyle.css +472 -3
  39. package/dist/velinstyle.min.css +1 -1
  40. package/package.json +3 -2
  41. package/src/a11y/security.css +18 -0
  42. package/src/base/reset.css +12 -1
  43. package/src/components/nav.css +152 -151
  44. package/src/tokens/motion.css +7 -0
  45. package/src/utilities/animation.css +97 -1
  46. package/src/utilities/chart-animation.css +101 -0
  47. package/src/utilities/filter-effects.css +103 -0
  48. package/src/utilities/safe-area.css +39 -0
  49. package/src/velinstyle.css +3 -0
@@ -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
+ `;
@@ -0,0 +1,35 @@
1
+ const styles = `
2
+ :host { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
3
+ `;
4
+
5
+ class VelinAnnouncer extends HTMLElement {
6
+ connectedCallback() {
7
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
8
+ const live = this.getAttribute('polite') === 'false' ? 'assertive' : 'polite';
9
+ this.shadowRoot.innerHTML =
10
+ '<style>' + styles + '</style>' +
11
+ '<div role="status" aria-live="' + live + '" aria-atomic="true" part="region"></div>';
12
+ this._region = this.shadowRoot.querySelector('[role="status"]');
13
+ }
14
+
15
+ announce(message, { assertive = false } = {}) {
16
+ if (!this._region) this.connectedCallback();
17
+ this._region.setAttribute('aria-live', assertive ? 'assertive' : 'polite');
18
+ this._region.textContent = '';
19
+ requestAnimationFrame(() => {
20
+ this._region.textContent = typeof message === 'string' ? message : '';
21
+ });
22
+ }
23
+
24
+ static announceGlobal(message, options) {
25
+ let el = document.querySelector('velin-announcer');
26
+ if (!el) {
27
+ el = document.createElement('velin-announcer');
28
+ document.body.appendChild(el);
29
+ }
30
+ el.announce(message, options);
31
+ }
32
+ }
33
+
34
+ customElements.define('velin-announcer', VelinAnnouncer);
35
+ export default VelinAnnouncer;
@@ -0,0 +1,89 @@
1
+ import { escapeHTML } from './sanitize.js';
2
+
3
+ const styles = `
4
+ :host { display: block; }
5
+ nav {
6
+ display: flex;
7
+ justify-content: space-around;
8
+ align-items: center;
9
+ padding: var(--velin-space-2, 0.5rem) var(--velin-space-4, 1rem);
10
+ padding-block-end: max(var(--velin-space-2, 0.5rem), env(safe-area-inset-bottom, 0px));
11
+ background: var(--velin-color-surface-bright, #fff);
12
+ border-block-start: 1px solid var(--velin-color-border, #ddd);
13
+ }
14
+ ::slotted(a), ::slotted(button) {
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ gap: var(--velin-space-1, 0.25rem);
19
+ min-inline-size: 2.75rem;
20
+ min-block-size: 2.75rem;
21
+ padding: var(--velin-space-2, 0.5rem);
22
+ font-size: var(--velin-text-xs, 0.75rem);
23
+ color: var(--velin-color-text-muted, #666);
24
+ text-decoration: none;
25
+ background: none;
26
+ border: none;
27
+ cursor: pointer;
28
+ }
29
+ ::slotted([current]) {
30
+ color: var(--velin-color-primary, #2563eb);
31
+ font-weight: var(--velin-weight-semibold, 600);
32
+ }
33
+ `;
34
+
35
+ class VelinBottomNav extends HTMLElement {
36
+ constructor() {
37
+ super();
38
+ this.attachShadow({ mode: 'open' });
39
+ this._onSlot = this._onSlot.bind(this);
40
+ }
41
+
42
+ connectedCallback() {
43
+ const label = escapeHTML(this.getAttribute('aria-label') || 'Bottom navigation');
44
+ this.shadowRoot.innerHTML = `
45
+ <style>${styles}</style>
46
+ <nav role="navigation" aria-label="${label}"><slot></slot></nav>
47
+ `;
48
+ const slot = this.shadowRoot.querySelector('slot');
49
+ slot.addEventListener('slotchange', this._onSlot);
50
+ this._onSlot();
51
+ }
52
+
53
+ _onSlot() {
54
+ this._syncCurrent();
55
+ }
56
+
57
+ _syncCurrent() {
58
+ const slot = this.shadowRoot?.querySelector('slot');
59
+ if (!slot) return;
60
+ const hostKey = this.getAttribute('current');
61
+ slot.assignedElements().forEach((el) => {
62
+ const active =
63
+ el.hasAttribute('current') ||
64
+ (hostKey && (el.dataset.nav === hostKey || el.getAttribute('data-nav') === hostKey));
65
+ if (active) {
66
+ el.setAttribute('current', '');
67
+ el.setAttribute('aria-current', 'page');
68
+ } else {
69
+ el.removeAttribute('current');
70
+ el.removeAttribute('aria-current');
71
+ }
72
+ });
73
+ }
74
+
75
+ static get observedAttributes() {
76
+ return ['aria-label', 'current'];
77
+ }
78
+
79
+ attributeChangedCallback(name) {
80
+ if (name === 'aria-label') {
81
+ const nav = this.shadowRoot?.querySelector('nav');
82
+ if (nav) nav.setAttribute('aria-label', escapeHTML(this.getAttribute('aria-label') || 'Bottom navigation'));
83
+ }
84
+ if (name === 'current') this._syncCurrent();
85
+ }
86
+ }
87
+
88
+ customElements.define('velin-bottom-nav', VelinBottomNav);
89
+ export default VelinBottomNav;
@@ -0,0 +1,149 @@
1
+ import { rovingTabindex } from './focus-manager.js';
2
+ import { escapeHTML } from './sanitize.js';
3
+
4
+ const styles = `
5
+ :host { display: inline-block; position: relative; }
6
+ .listbox {
7
+ position: absolute; z-index: var(--velin-z-dropdown, 100);
8
+ inset-block-start: 100%; inset-inline-start: 0;
9
+ min-inline-size: 100%; margin-block-start: var(--velin-space-1, 0.25rem);
10
+ padding-block: var(--velin-space-1, 0.25rem);
11
+ background: var(--velin-color-surface-bright, #fff);
12
+ border: 1px solid var(--velin-color-border, #ddd);
13
+ border-radius: var(--velin-radius-md, 0.5rem);
14
+ box-shadow: var(--velin-shadow-lg, 0 10px 15px rgba(0,0,0,0.08));
15
+ opacity: 0; visibility: hidden;
16
+ transition: opacity 150ms ease, visibility 150ms ease;
17
+ }
18
+ :host([open]) .listbox { opacity: 1; visibility: visible; }
19
+ ::slotted([role="option"]) {
20
+ display: block; inline-size: 100%;
21
+ padding: var(--velin-space-2, 0.5rem) var(--velin-space-4, 1rem);
22
+ min-block-size: 2.5rem;
23
+ text-align: start; background: none; border: none;
24
+ cursor: pointer; font-size: var(--velin-text-base, 1rem);
25
+ }
26
+ ::slotted([role="option"][aria-selected="true"]) {
27
+ background: var(--velin-color-surface-dim, #eee);
28
+ }
29
+ `;
30
+
31
+ class VelinCombobox extends HTMLElement {
32
+ static get observedAttributes() { return ['open', 'aria-label']; }
33
+
34
+ constructor() {
35
+ super();
36
+ this.attachShadow({ mode: 'open', delegatesFocus: true });
37
+ this._onDocClick = this._onDocClick.bind(this);
38
+ this._onKey = this._onKey.bind(this);
39
+ }
40
+
41
+ connectedCallback() {
42
+ const listId = `velin-combobox-list-${Math.random().toString(36).slice(2, 9)}`;
43
+ this._listId = listId;
44
+ const listLabel = escapeHTML(this.getAttribute('aria-label') || 'Options');
45
+ this.shadowRoot.innerHTML = `
46
+ <style>${styles}</style>
47
+ <slot name="trigger"></slot>
48
+ <div class="listbox" id="${listId}" role="listbox" aria-label="${listLabel}" part="listbox"><slot></slot></div>
49
+ `;
50
+ const triggerSlot = this.shadowRoot.querySelector('slot[name="trigger"]');
51
+ triggerSlot.addEventListener('slotchange', () => this._wireTrigger());
52
+ this.shadowRoot.querySelector('slot:not([name])')?.addEventListener('slotchange', () => this._wireOptions());
53
+ this._wireTrigger();
54
+ this._wireOptions();
55
+ this.addEventListener('keydown', this._onKey);
56
+ }
57
+
58
+ _wireTrigger() {
59
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
60
+ if (!trigger) return;
61
+ trigger.setAttribute('role', 'combobox');
62
+ trigger.setAttribute('aria-expanded', this.hasAttribute('open') ? 'true' : 'false');
63
+ trigger.setAttribute('aria-controls', this._listId);
64
+ trigger.setAttribute('aria-autocomplete', 'list');
65
+ if (!trigger.id) trigger.id = `velin-combobox-trigger-${Math.random().toString(36).slice(2, 9)}`;
66
+ const list = this.shadowRoot.querySelector('.listbox');
67
+ if (list) list.setAttribute('aria-labelledby', trigger.id);
68
+ if (!trigger.dataset.velinComboWired) {
69
+ trigger.dataset.velinComboWired = '1';
70
+ trigger.addEventListener('click', () => this.toggle());
71
+ trigger.addEventListener('keydown', (e) => {
72
+ if (e.key === 'ArrowDown' || e.key === 'Enter') { e.preventDefault(); this.open(); }
73
+ });
74
+ }
75
+ }
76
+
77
+ _wireOptions() {
78
+ const options = this._getOptions();
79
+ options.forEach((el, i) => {
80
+ el.setAttribute('role', 'option');
81
+ el.setAttribute('aria-selected', el.hasAttribute('selected') ? 'true' : 'false');
82
+ el.setAttribute('tabindex', i === 0 ? '0' : '-1');
83
+ });
84
+ }
85
+
86
+ _getOptions() {
87
+ const slot = this.shadowRoot.querySelector('slot:not([name])');
88
+ return slot ? slot.assignedElements().filter((el) => !el.hidden) : [];
89
+ }
90
+
91
+ toggle() { this.hasAttribute('open') ? this.close() : this.open(); }
92
+
93
+ open() {
94
+ this.setAttribute('open', '');
95
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
96
+ if (trigger) trigger.setAttribute('aria-expanded', 'true');
97
+ document.addEventListener('click', this._onDocClick, true);
98
+ requestAnimationFrame(() => {
99
+ const opts = this._getOptions();
100
+ if (opts.length) opts[0].focus();
101
+ });
102
+ }
103
+
104
+ close() {
105
+ this.removeAttribute('open');
106
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
107
+ if (trigger) { trigger.setAttribute('aria-expanded', 'false'); trigger.focus(); }
108
+ document.removeEventListener('click', this._onDocClick, true);
109
+ this.dispatchEvent(new CustomEvent('velin-close', { bubbles: true }));
110
+ }
111
+
112
+ _onDocClick(e) {
113
+ if (!this.contains(e.target)) this.close();
114
+ }
115
+
116
+ _onKey(e) {
117
+ if (!this.hasAttribute('open')) return;
118
+ if (e.key === 'Escape') { this.close(); return; }
119
+ const options = this._getOptions();
120
+ if (!options.length) return;
121
+ rovingTabindex(this, options, e);
122
+ if (e.key === 'Enter' && options.includes(e.target)) {
123
+ this._selectOption(e.target);
124
+ this.close();
125
+ }
126
+ }
127
+
128
+ _selectOption(el) {
129
+ this._getOptions().forEach((o) => o.setAttribute('aria-selected', o === el ? 'true' : 'false'));
130
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
131
+ if (trigger && 'value' in trigger) trigger.value = el.textContent?.trim() || '';
132
+ this.dispatchEvent(new CustomEvent('velin-select', { bubbles: true, detail: { option: el } }));
133
+ }
134
+
135
+ attributeChangedCallback(name) {
136
+ if (name === 'open') this._wireTrigger();
137
+ if (name === 'aria-label') {
138
+ const list = this.shadowRoot?.querySelector('.listbox');
139
+ if (list) list.setAttribute('aria-label', escapeHTML(this.getAttribute('aria-label') || 'Options'));
140
+ }
141
+ }
142
+
143
+ disconnectedCallback() {
144
+ document.removeEventListener('click', this._onDocClick, true);
145
+ }
146
+ }
147
+
148
+ customElements.define('velin-combobox', VelinCombobox);
149
+ export default VelinCombobox;
@@ -0,0 +1,127 @@
1
+ import { trapFocus, saveFocus, restoreFocus, getFocusableElements, setBackgroundInert, clearBackgroundInert } from './focus-manager.js';
2
+ import { escapeHTML } from './sanitize.js';
3
+ import { SHADOW_A11Y_STYLES } from './shadow-a11y-styles.js';
4
+
5
+ const styles = `
6
+ ${SHADOW_A11Y_STYLES}
7
+ :host { display: contents; }
8
+ .overlay {
9
+ position: fixed; inset: 0; z-index: var(--velin-z-modal, 500);
10
+ display: flex; align-items: flex-start; justify-content: center;
11
+ padding: 10vh var(--velin-space-4, 1rem) var(--velin-space-4, 1rem);
12
+ background: var(--velin-color-overlay, rgba(0,0,0,0.4));
13
+ opacity: 0; visibility: hidden;
14
+ transition: opacity 150ms ease, visibility 150ms ease;
15
+ }
16
+ :host([open]) .overlay { opacity: 1; visibility: visible; }
17
+ .panel {
18
+ inline-size: min(32rem, 100%);
19
+ background: var(--velin-color-surface-bright, #fff);
20
+ border-radius: var(--velin-radius-lg, 0.75rem);
21
+ box-shadow: var(--velin-shadow-xl, 0 20px 25px rgba(0,0,0,0.1));
22
+ overflow: hidden;
23
+ }
24
+ .search {
25
+ inline-size: 100%; padding: var(--velin-space-4, 1rem);
26
+ border: none; border-bottom: 1px solid var(--velin-color-border, #ddd);
27
+ font-size: var(--velin-text-base, 1rem);
28
+ background: transparent;
29
+ color: var(--velin-color-text, #111);
30
+ }
31
+ .results { max-block-size: 20rem; overflow-y: auto; padding: var(--velin-space-2, 0.5rem); }
32
+ ::slotted(button) {
33
+ display: flex; inline-size: 100%;
34
+ padding: var(--velin-space-3, 0.75rem) var(--velin-space-4, 1rem);
35
+ min-block-size: 2.5rem;
36
+ border: none; background: none; text-align: start;
37
+ cursor: pointer; font-size: var(--velin-text-base, 1rem);
38
+ border-radius: var(--velin-radius-sm, 0.25rem);
39
+ }
40
+ ::slotted(button[hidden]) { display: none; }
41
+ ::slotted(button:focus-visible) {
42
+ background: var(--velin-color-surface-dim, #eee);
43
+ }
44
+ `;
45
+
46
+ class VelinCommand extends HTMLElement {
47
+ static get observedAttributes() { return ['open']; }
48
+
49
+ constructor() {
50
+ super();
51
+ this.attachShadow({ mode: 'open', delegatesFocus: true });
52
+ this._prev = null;
53
+ this._onKey = this._onKey.bind(this);
54
+ this._onInput = this._onInput.bind(this);
55
+ }
56
+
57
+ connectedCallback() {
58
+ const placeholder = escapeHTML(this.getAttribute('placeholder') || 'Search commands…');
59
+ this.shadowRoot.innerHTML = `
60
+ <style>${styles}</style>
61
+ <div class="overlay" part="overlay">
62
+ <div class="panel" role="dialog" aria-modal="true" aria-label="Command palette" part="panel">
63
+ <input class="search" type="search" autocomplete="off" placeholder="${placeholder}" aria-label="Search" part="search" />
64
+ <div class="results" part="results"><slot></slot></div>
65
+ </div>
66
+ </div>
67
+ `;
68
+ this.shadowRoot.querySelector('.search').addEventListener('input', this._onInput);
69
+ this.shadowRoot.querySelector('slot')?.addEventListener('slotchange', () => this._filter(''));
70
+ this._filter('');
71
+ }
72
+
73
+ attributeChangedCallback(name) {
74
+ if (name === 'open') this.hasAttribute('open') ? this._open() : this._close();
75
+ }
76
+
77
+ open() { this.setAttribute('open', ''); }
78
+ close() {
79
+ this.removeAttribute('open');
80
+ this.dispatchEvent(new CustomEvent('velin-close', { bubbles: true }));
81
+ }
82
+
83
+ _open() {
84
+ this._prev = saveFocus();
85
+ setBackgroundInert(this);
86
+ document.addEventListener('keydown', this._onKey);
87
+ requestAnimationFrame(() => {
88
+ this.shadowRoot.querySelector('.search')?.focus();
89
+ this._filter('');
90
+ });
91
+ }
92
+
93
+ _close() {
94
+ document.removeEventListener('keydown', this._onKey);
95
+ clearBackgroundInert();
96
+ restoreFocus(this._prev);
97
+ const input = this.shadowRoot.querySelector('.search');
98
+ if (input) input.value = '';
99
+ this._filter('');
100
+ }
101
+
102
+ _onInput(e) {
103
+ this._filter(e.target.value);
104
+ }
105
+
106
+ _filter(query) {
107
+ const q = query.trim().toLowerCase();
108
+ const slot = this.shadowRoot.querySelector('slot');
109
+ slot?.assignedElements().forEach((btn) => {
110
+ const text = btn.textContent?.trim().toLowerCase() || '';
111
+ const match = !q || text.includes(q);
112
+ btn.hidden = !match;
113
+ });
114
+ }
115
+
116
+ _onKey(e) {
117
+ if (e.key === 'Escape') { this.close(); return; }
118
+ trapFocus(this.shadowRoot, e);
119
+ }
120
+
121
+ disconnectedCallback() {
122
+ document.removeEventListener('keydown', this._onKey);
123
+ }
124
+ }
125
+
126
+ customElements.define('velin-command', VelinCommand);
127
+ export default VelinCommand;
@@ -0,0 +1,152 @@
1
+ /*
2
+ * <velin-counter from="0" to="61840" duration="900" format="currency" currency="EUR">
3
+ *
4
+ * Animated count-up/down using requestAnimationFrame plus an exponential
5
+ * ease-out for the classic "money fly-in" effect. Renders the current value
6
+ * as text content inside the element (light DOM), so it inherits typography
7
+ * tokens automatically.
8
+ *
9
+ * Attributes:
10
+ * from starting value (default 0)
11
+ * to target value
12
+ * duration ms (default 900)
13
+ * decimals fixed decimal places
14
+ * prefix string before the number
15
+ * suffix string after the number
16
+ * format "number" (default) | "currency" | "percent"
17
+ * currency ISO code (default EUR), used when format=currency
18
+ * locale BCP47 locale (default browser default)
19
+ * autostart "false" disables auto-start on connect/intersect
20
+ *
21
+ * Public API:
22
+ * start(): runs the animation from `from` to `to`.
23
+ * reset(): jumps back to `from` without animating.
24
+ */
25
+
26
+ const easeOutExpo = (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));
27
+
28
+ function buildFormatter(host) {
29
+ const format = (host.getAttribute('format') || 'number').toLowerCase();
30
+ const locale = host.getAttribute('locale') || undefined;
31
+ const decimalsAttr = host.getAttribute('decimals');
32
+ const decimals = decimalsAttr != null ? Math.max(0, Number.parseInt(decimalsAttr, 10) || 0) : null;
33
+ const opts = {};
34
+ if (decimals != null) {
35
+ opts.minimumFractionDigits = decimals;
36
+ opts.maximumFractionDigits = decimals;
37
+ }
38
+ if (format === 'currency') {
39
+ opts.style = 'currency';
40
+ opts.currency = host.getAttribute('currency') || 'EUR';
41
+ } else if (format === 'percent') {
42
+ opts.style = 'percent';
43
+ }
44
+ try {
45
+ return new Intl.NumberFormat(locale, opts);
46
+ } catch {
47
+ return new Intl.NumberFormat(undefined, opts);
48
+ }
49
+ }
50
+
51
+ class VelinCounter extends HTMLElement {
52
+ static get observedAttributes() {
53
+ return ['from', 'to', 'duration', 'decimals', 'prefix', 'suffix', 'format', 'currency', 'locale'];
54
+ }
55
+
56
+ constructor() {
57
+ super();
58
+ this._rafId = 0;
59
+ this._started = false;
60
+ this._observer = null;
61
+ }
62
+
63
+ connectedCallback() {
64
+ this._render(this._fromValue());
65
+ if (this.getAttribute('autostart') === 'false') return;
66
+ this._scheduleStart();
67
+ }
68
+
69
+ disconnectedCallback() {
70
+ cancelAnimationFrame(this._rafId);
71
+ this._observer?.disconnect();
72
+ }
73
+
74
+ attributeChangedCallback(name) {
75
+ if (!this.isConnected) return;
76
+ if (name === 'to' || name === 'from') {
77
+ this.start();
78
+ } else {
79
+ this._render(this._lastValue ?? this._toValue());
80
+ }
81
+ }
82
+
83
+ _fromValue() {
84
+ return Number.parseFloat(this.getAttribute('from')) || 0;
85
+ }
86
+ _toValue() {
87
+ return Number.parseFloat(this.getAttribute('to')) || 0;
88
+ }
89
+ _duration() {
90
+ return Math.max(0, Number.parseFloat(this.getAttribute('duration')) || 900);
91
+ }
92
+
93
+ _scheduleStart() {
94
+ if (this._started) return;
95
+ if (typeof IntersectionObserver === 'undefined') {
96
+ this.start();
97
+ return;
98
+ }
99
+ this._observer = new IntersectionObserver((entries) => {
100
+ for (const entry of entries) {
101
+ if (entry.isIntersecting) {
102
+ this.start();
103
+ this._observer.disconnect();
104
+ this._observer = null;
105
+ break;
106
+ }
107
+ }
108
+ }, { threshold: 0.2 });
109
+ this._observer.observe(this);
110
+ }
111
+
112
+ start() {
113
+ cancelAnimationFrame(this._rafId);
114
+ this._started = true;
115
+ const from = this._fromValue();
116
+ const to = this._toValue();
117
+ const duration = this._duration();
118
+ const reduced = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
119
+ if (reduced || duration === 0) {
120
+ this._render(to);
121
+ return;
122
+ }
123
+ const start = performance.now();
124
+ const tick = (now) => {
125
+ const t = Math.min(1, (now - start) / duration);
126
+ const value = from + (to - from) * easeOutExpo(t);
127
+ this._render(value);
128
+ if (t < 1) this._rafId = requestAnimationFrame(tick);
129
+ };
130
+ this._rafId = requestAnimationFrame(tick);
131
+ }
132
+
133
+ reset() {
134
+ cancelAnimationFrame(this._rafId);
135
+ this._started = false;
136
+ this._render(this._fromValue());
137
+ }
138
+
139
+ _render(value) {
140
+ this._lastValue = value;
141
+ const formatter = buildFormatter(this);
142
+ const prefix = this.getAttribute('prefix') || '';
143
+ const suffix = this.getAttribute('suffix') || '';
144
+ this.textContent = `${prefix}${formatter.format(value)}${suffix}`;
145
+ }
146
+ }
147
+
148
+ if (typeof customElements !== 'undefined' && !customElements.get('velin-counter')) {
149
+ customElements.define('velin-counter', VelinCounter);
150
+ }
151
+
152
+ export default VelinCounter;