@birdapi/velinstyle 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.de.md +337 -316
  2. package/README.md +33 -12
  3. package/cli/blueprint.js +8 -0
  4. package/cli/blueprints/bottom-nav-mobile.html +17 -0
  5. package/cli/blueprints/cookie-consent.html +9 -0
  6. package/cli/blueprints/empty-state.html +5 -0
  7. package/cli/blueprints/filter-bar.html +15 -0
  8. package/cli/blueprints/notification-center.html +13 -0
  9. package/cli/blueprints/onboarding.html +23 -0
  10. package/cli/blueprints/pricing-table.html +20 -0
  11. package/cli/blueprints/settings-panel.html +20 -0
  12. package/cli/index.js +119 -3
  13. package/cli/layout-audit.js +325 -0
  14. package/cli/scaffold-recipes.json +70 -0
  15. package/cli/scaffold.js +155 -0
  16. package/cli/scanner.js +114 -0
  17. package/components/focus-manager.js +106 -80
  18. package/components/index.js +20 -1
  19. package/components/sanitize.js +29 -3
  20. package/components/shadow-a11y-styles.js +18 -0
  21. package/components/velin-accordion.js +112 -98
  22. package/components/velin-announcer.js +35 -0
  23. package/components/velin-bottom-nav.js +89 -0
  24. package/components/velin-carousel.js +40 -5
  25. package/components/velin-collapse.js +95 -65
  26. package/components/velin-combobox.js +149 -0
  27. package/components/velin-command.js +127 -0
  28. package/components/velin-counter.js +152 -0
  29. package/components/velin-drawer.js +6 -3
  30. package/components/velin-dropdown.js +33 -2
  31. package/components/velin-flip.js +220 -0
  32. package/components/velin-icon.js +43 -9
  33. package/components/velin-live-dot.js +85 -0
  34. package/components/velin-menubar.js +83 -0
  35. package/components/velin-modal.js +3 -1
  36. package/components/velin-popover.js +61 -21
  37. package/components/velin-rating.js +91 -0
  38. package/components/velin-reveal.js +80 -0
  39. package/components/velin-segmented-control.js +108 -0
  40. package/components/velin-sheet.js +107 -0
  41. package/components/velin-sparkline.js +207 -0
  42. package/components/velin-theme-toggle.js +277 -60
  43. package/components/velin-tooltip-wc.js +26 -2
  44. package/dist/velinstyle-components.iife.js +1849 -120
  45. package/dist/velinstyle-components.js +1871 -120
  46. package/dist/velinstyle-components.min.js +434 -91
  47. package/dist/velinstyle.css +640 -44
  48. package/dist/velinstyle.min.css +1 -1
  49. package/package.json +7 -3
  50. package/src/a11y/focus-not-obscured.css +21 -0
  51. package/src/a11y/forced-colors.css +86 -38
  52. package/src/a11y/high-contrast-aaa.css +37 -0
  53. package/src/a11y/preferences.css +106 -85
  54. package/src/a11y/security.css +119 -104
  55. package/src/a11y/target-size.css +32 -0
  56. package/src/base/reset.css +12 -1
  57. package/src/base/root.css +4 -4
  58. package/src/components/nav.css +152 -151
  59. package/src/tokens/motion.css +7 -0
  60. package/src/utilities/animation.css +97 -1
  61. package/src/utilities/chart-animation.css +101 -0
  62. package/src/utilities/filter-effects.css +103 -0
  63. package/src/utilities/safe-area.css +39 -0
  64. package/src/velinstyle.css +9 -1
@@ -0,0 +1,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;
@@ -1,4 +1,4 @@
1
- import { trapFocus, saveFocus, restoreFocus, getFocusableElements } from './focus-manager.js';
1
+ import { trapFocus, saveFocus, restoreFocus, getFocusableElements, setBackgroundInert, clearBackgroundInert } from './focus-manager.js';
2
2
  import { escapeHTML } from './sanitize.js';
3
3
 
4
4
  const styles = `
@@ -61,12 +61,13 @@ class VelinDrawer extends HTMLElement {
61
61
  connectedCallback() {
62
62
  const title = this.getAttribute('title') || '';
63
63
  const safeTitle = escapeHTML(title);
64
+ const titleId = 'velin-drawer-title';
64
65
  this.shadowRoot.innerHTML = `
65
66
  <style>${styles}</style>
66
67
  <div class="overlay" part="overlay"></div>
67
- <div class="drawer" role="dialog" aria-modal="true" aria-label="${safeTitle}" part="drawer">
68
+ <div class="drawer" role="dialog" aria-modal="true" aria-labelledby="${titleId}" part="drawer">
68
69
  <div class="header" part="header">
69
- <h2 class="title">${safeTitle}</h2>
70
+ <h2 class="title" id="${titleId}">${safeTitle}</h2>
70
71
  <button class="close-btn" aria-label="Close" part="close">&#215;</button>
71
72
  </div>
72
73
  <div class="body" part="body"><slot></slot></div>
@@ -85,6 +86,7 @@ class VelinDrawer extends HTMLElement {
85
86
 
86
87
  _open() {
87
88
  this._prev = saveFocus();
89
+ setBackgroundInert(this);
88
90
  document.addEventListener('keydown', this._onKey);
89
91
  document.body.style.overflow = 'hidden';
90
92
  requestAnimationFrame(() => {
@@ -96,6 +98,7 @@ class VelinDrawer extends HTMLElement {
96
98
  _close() {
97
99
  document.removeEventListener('keydown', this._onKey);
98
100
  document.body.style.overflow = '';
101
+ clearBackgroundInert();
99
102
  restoreFocus(this._prev);
100
103
  }
101
104
 
@@ -62,6 +62,8 @@ class VelinDropdown extends HTMLElement {
62
62
  this.attachShadow({ mode: 'open', delegatesFocus: true });
63
63
  this._onDocClick = this._onDocClick.bind(this);
64
64
  this._onKeydown = this._onKeydown.bind(this);
65
+ this._typeahead = '';
66
+ this._typeaheadTimer = null;
65
67
  }
66
68
 
67
69
  connectedCallback() {
@@ -75,17 +77,31 @@ class VelinDropdown extends HTMLElement {
75
77
  `;
76
78
 
77
79
  const triggerSlot = this.shadowRoot.querySelector('slot[name="trigger"]');
80
+ const menuSlot = this.shadowRoot.querySelector('slot:not([name])');
78
81
  triggerSlot.addEventListener('click', () => this.toggle());
79
82
  triggerSlot.addEventListener('slotchange', () => {
80
83
  const trigger = triggerSlot.assignedElements()[0];
81
84
  if (trigger) {
82
85
  trigger.setAttribute('aria-haspopup', 'menu');
83
86
  trigger.setAttribute('aria-expanded', this.hasAttribute('open') ? 'true' : 'false');
87
+ const menuId = this._menuId || (this._menuId = `velin-dropdown-menu-${Math.random().toString(36).slice(2, 9)}`);
88
+ trigger.setAttribute('aria-controls', menuId);
89
+ this.shadowRoot.querySelector('.menu')?.setAttribute('id', menuId);
84
90
  }
85
91
  });
92
+ menuSlot?.addEventListener('slotchange', () => this._normalizeMenuItems());
93
+ this._normalizeMenuItems();
86
94
  this.addEventListener('keydown', this._onKeydown);
87
95
  }
88
96
 
97
+ _normalizeMenuItems() {
98
+ const items = this._getMenuItems();
99
+ items.forEach((el, i) => {
100
+ if (!el.hasAttribute('role')) el.setAttribute('role', 'menuitem');
101
+ el.setAttribute('tabindex', i === 0 ? '0' : '-1');
102
+ });
103
+ }
104
+
89
105
  toggle() {
90
106
  if (this.hasAttribute('open')) {
91
107
  this.close();
@@ -138,9 +154,24 @@ class VelinDropdown extends HTMLElement {
138
154
  }
139
155
 
140
156
  const items = this._getMenuItems();
141
- if (items.length > 0) {
142
- rovingTabindex(this, items, event);
157
+ if (items.length === 0) return;
158
+
159
+ if (event.key.length === 1 && /[a-z0-9]/i.test(event.key)) {
160
+ clearTimeout(this._typeaheadTimer);
161
+ this._typeahead += event.key.toLowerCase();
162
+ this._typeaheadTimer = setTimeout(() => { this._typeahead = ''; }, 500);
163
+ const match = items.find((el) =>
164
+ (el.textContent?.trim().toLowerCase() || '').startsWith(this._typeahead)
165
+ );
166
+ if (match) {
167
+ event.preventDefault();
168
+ items.forEach((item) => item.setAttribute('tabindex', item === match ? '0' : '-1'));
169
+ match.focus();
170
+ }
171
+ return;
143
172
  }
173
+
174
+ rovingTabindex(this, items, event);
144
175
  }
145
176
 
146
177
  disconnectedCallback() {
@@ -0,0 +1,220 @@
1
+ /*
2
+ * velin-flip.js — FLIP-style reorder + filter helper.
3
+ *
4
+ * FLIP (First, Last, Invert, Play) measures item rects before and after a
5
+ * DOM mutation, then animates the delta. Used for sorting/filtering UIs
6
+ * where rows reorder visibly. Pair it with the [data-velin-flip] attribute
7
+ * on a container plus [data-velin-filter-value] chips or [data-velin-filter-input]
8
+ * inputs to wire chip/search filtering with zero JS in your demo.
9
+ *
10
+ * API:
11
+ * flipReorder(container, mutateFn, opts?)
12
+ * filterList(container, predicateFn, opts?)
13
+ *
14
+ * opts.duration ms (default 250); reduced-motion forces 0
15
+ * opts.easing CSS timing function (default expo-out token)
16
+ * opts.itemSelector children selector (default ':scope > *')
17
+ *
18
+ * Auto-init:
19
+ * <ul data-velin-flip data-velin-filter-attr="tags" id="myList">
20
+ * <li data-tags="a b">..</li> ...
21
+ * </ul>
22
+ * <button data-velin-filter-value="a" data-velin-filter-target="#myList">A</button>
23
+ * <input data-velin-filter-input data-velin-filter-target="#myList">
24
+ *
25
+ * Any chip or input with data-velin-filter-target pointing at a flip
26
+ * container is wired automatically. The active chip carries
27
+ * data-velin-filter-active so you can style it.
28
+ */
29
+
30
+ const REDUCED_MOTION_MQ =
31
+ typeof window !== 'undefined' && window.matchMedia
32
+ ? window.matchMedia('(prefers-reduced-motion: reduce)')
33
+ : null;
34
+
35
+ const DEFAULTS = {
36
+ duration: 250,
37
+ easing: 'var(--velin-ease-expo-out, cubic-bezier(0.16, 1, 0.3, 1))',
38
+ itemSelector: ':scope > *',
39
+ };
40
+
41
+ function getItems(container, selector) {
42
+ return Array.from(container.querySelectorAll(selector));
43
+ }
44
+
45
+ export function flipReorder(container, mutateFn, options = {}) {
46
+ if (!container || typeof mutateFn !== 'function') return;
47
+ const opts = { ...DEFAULTS, ...options };
48
+ const reduced = REDUCED_MOTION_MQ && REDUCED_MOTION_MQ.matches;
49
+
50
+ const items = getItems(container, opts.itemSelector);
51
+ const before = new Map();
52
+ items.forEach((el) => {
53
+ if (!el.hidden) before.set(el, el.getBoundingClientRect());
54
+ });
55
+
56
+ mutateFn();
57
+
58
+ if (reduced) return;
59
+
60
+ const items2 = getItems(container, opts.itemSelector);
61
+ items2.forEach((el) => {
62
+ if (el.hidden) return;
63
+ const prev = before.get(el);
64
+ const next = el.getBoundingClientRect();
65
+ if (!prev) {
66
+ if (typeof el.animate !== 'function') return;
67
+ el.animate(
68
+ [
69
+ { opacity: 0, transform: 'scale(0.96)' },
70
+ { opacity: 1, transform: 'scale(1)' },
71
+ ],
72
+ { duration: opts.duration, easing: opts.easing, fill: 'both' },
73
+ );
74
+ return;
75
+ }
76
+ const dx = prev.left - next.left;
77
+ const dy = prev.top - next.top;
78
+ if (dx === 0 && dy === 0) return;
79
+ if (typeof el.animate !== 'function') return;
80
+ el.animate(
81
+ [
82
+ { transform: `translate(${dx}px, ${dy}px)` },
83
+ { transform: 'translate(0, 0)' },
84
+ ],
85
+ { duration: opts.duration, easing: opts.easing, fill: 'both' },
86
+ );
87
+ });
88
+ }
89
+
90
+ export function filterList(container, predicate, options = {}) {
91
+ if (!container || typeof predicate !== 'function') return;
92
+ const opts = { ...DEFAULTS, ...options };
93
+ flipReorder(
94
+ container,
95
+ () => {
96
+ getItems(container, opts.itemSelector).forEach((el) => {
97
+ el.hidden = !predicate(el);
98
+ });
99
+ },
100
+ opts,
101
+ );
102
+ }
103
+
104
+ function readTokens(value) {
105
+ if (!value) return [];
106
+ return String(value)
107
+ .toLowerCase()
108
+ .split(/[\s,|]+/)
109
+ .map((s) => s.trim())
110
+ .filter(Boolean);
111
+ }
112
+
113
+ function matchTokens(itemTokens, queryTokens, mode) {
114
+ if (!queryTokens.length) return true;
115
+ if (mode === 'all') return queryTokens.every((q) => itemTokens.includes(q));
116
+ return queryTokens.some((q) => itemTokens.includes(q));
117
+ }
118
+
119
+ function matchSearch(item, query) {
120
+ if (!query) return true;
121
+ const haystack =
122
+ (item.getAttribute('data-tags') || '') +
123
+ ' ' +
124
+ (item.getAttribute('data-search') || '') +
125
+ ' ' +
126
+ (item.textContent || '');
127
+ return haystack.toLowerCase().includes(query.toLowerCase());
128
+ }
129
+
130
+ class FilterController {
131
+ constructor(container) {
132
+ this.container = container;
133
+ this.tag = '';
134
+ this.search = '';
135
+ this.matchMode = container.getAttribute('data-velin-filter-mode') === 'all' ? 'all' : 'any';
136
+ this.itemSelector = container.getAttribute('data-velin-filter-item') || ':scope > *';
137
+ }
138
+
139
+ apply() {
140
+ const queryTokens = readTokens(this.tag);
141
+ const term = this.search;
142
+ filterList(
143
+ this.container,
144
+ (el) => {
145
+ const tokens = readTokens(el.getAttribute('data-tags'));
146
+ return matchTokens(tokens, queryTokens, this.matchMode) && matchSearch(el, term);
147
+ },
148
+ { itemSelector: this.itemSelector },
149
+ );
150
+ }
151
+ }
152
+
153
+ const _controllers = new WeakMap();
154
+
155
+ function getController(container) {
156
+ let ctrl = _controllers.get(container);
157
+ if (!ctrl) {
158
+ ctrl = new FilterController(container);
159
+ _controllers.set(container, ctrl);
160
+ }
161
+ return ctrl;
162
+ }
163
+
164
+ function resolveTarget(triggerEl) {
165
+ const sel = triggerEl.getAttribute('data-velin-filter-target');
166
+ if (!sel) return null;
167
+ try {
168
+ return document.querySelector(sel);
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function highlightActive(group, active) {
175
+ if (!group) return;
176
+ group.querySelectorAll('[data-velin-filter-value]').forEach((btn) => {
177
+ if (btn === active) btn.setAttribute('data-velin-filter-active', '');
178
+ else btn.removeAttribute('data-velin-filter-active');
179
+ });
180
+ }
181
+
182
+ function autoInit() {
183
+ if (typeof document === 'undefined') return;
184
+
185
+ document.addEventListener('click', (event) => {
186
+ const target = event.target.closest('[data-velin-filter-value]');
187
+ if (!target) return;
188
+ const container = resolveTarget(target);
189
+ if (!container) return;
190
+ const group = target.closest('[data-velin-filter-group]') || target.parentElement;
191
+ highlightActive(group, target);
192
+ const ctrl = getController(container);
193
+ ctrl.tag = target.getAttribute('data-velin-filter-value') || '';
194
+ if (ctrl.tag.toLowerCase() === 'all' || ctrl.tag === '*') ctrl.tag = '';
195
+ ctrl.apply();
196
+ });
197
+
198
+ const handleInput = (event) => {
199
+ const input = event.target.closest('[data-velin-filter-input]');
200
+ if (!input) return;
201
+ const container = resolveTarget(input);
202
+ if (!container) return;
203
+ const ctrl = getController(container);
204
+ const raw = input.value || (typeof input.getAttribute === 'function' ? input.getAttribute('value') : '');
205
+ ctrl.search = (raw || '').trim();
206
+ ctrl.apply();
207
+ };
208
+ document.addEventListener('input', handleInput);
209
+ document.addEventListener('change', handleInput);
210
+ }
211
+
212
+ if (typeof document !== 'undefined') {
213
+ if (document.readyState === 'loading') {
214
+ document.addEventListener('DOMContentLoaded', autoInit, { once: true });
215
+ } else {
216
+ autoInit();
217
+ }
218
+ }
219
+
220
+ export default { flipReorder, filterList };
@@ -3,14 +3,33 @@ const PROVIDER_CDNS = {
3
3
  heroicons: 'https://unpkg.com/heroicons@2/24/outline/{name}.svg',
4
4
  bootstrap: 'https://unpkg.com/bootstrap-icons@latest/icons/{name}.svg',
5
5
  material: 'https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/{name}/default/24px.svg',
6
- fontawesome: 'https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/regular/{name}.svg',
6
+ fontawesome: 'https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/{name}.svg',
7
7
  };
8
8
 
9
+ const PROVIDER_VARIANTS = {
10
+ fontawesome: {
11
+ regular: 'https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/regular/{name}.svg',
12
+ solid: 'https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/{name}.svg',
13
+ brands: 'https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/{name}.svg',
14
+ },
15
+ heroicons: {
16
+ outline: 'https://unpkg.com/heroicons@2/24/outline/{name}.svg',
17
+ solid: 'https://unpkg.com/heroicons@2/24/solid/{name}.svg',
18
+ mini: 'https://unpkg.com/heroicons@2/20/solid/{name}.svg',
19
+ },
20
+ };
21
+
22
+ function resolveProviderUrl(provider, variant) {
23
+ const variants = PROVIDER_VARIANTS[provider];
24
+ if (variant && variants?.[variant]) return variants[variant];
25
+ return PROVIDER_CDNS[provider];
26
+ }
27
+
9
28
  const _svgCache = new Map();
10
29
 
11
30
  class VelinIcon extends HTMLElement {
12
31
  static get observedAttributes() {
13
- return ['name', 'size', 'label', 'provider', 'sprite'];
32
+ return ['name', 'size', 'label', 'provider', 'variant', 'sprite'];
14
33
  }
15
34
 
16
35
  constructor() {
@@ -31,14 +50,15 @@ class VelinIcon extends HTMLElement {
31
50
  const size = this.getAttribute('size') || '24';
32
51
  const label = this.getAttribute('label');
33
52
  const provider = this.getAttribute('provider');
53
+ const variant = this.getAttribute('variant');
34
54
 
35
55
  if (!name) {
36
56
  this.innerHTML = '';
37
57
  return;
38
58
  }
39
59
 
40
- if (provider && PROVIDER_CDNS[provider]) {
41
- this._renderFromCDN(name, size, label, provider);
60
+ if (provider && (PROVIDER_CDNS[provider] || PROVIDER_VARIANTS[provider])) {
61
+ this._renderFromCDN(name, size, label, provider, variant);
42
62
  return;
43
63
  }
44
64
 
@@ -60,8 +80,17 @@ class VelinIcon extends HTMLElement {
60
80
  this._applyA11y(svg, label);
61
81
 
62
82
  const use = document.createElementNS(svgNS, 'use');
63
- const spriteUrl = this.getAttribute('sprite') || 'velin-icons.svg';
64
- use.setAttribute('href', `${spriteUrl}#${name}`);
83
+ const spriteAttr = this.getAttribute('sprite');
84
+ const localSymbol = document.getElementById(name);
85
+ const isLocalSymbol = localSymbol && localSymbol.tagName && localSymbol.tagName.toLowerCase() === 'symbol';
86
+ let href;
87
+ if (spriteAttr === '' || (spriteAttr == null && isLocalSymbol)) {
88
+ href = `#${name}`;
89
+ } else {
90
+ const spriteUrl = spriteAttr || 'velin-icons.svg';
91
+ href = `${spriteUrl}#${name}`;
92
+ }
93
+ use.setAttribute('href', href);
65
94
  svg.appendChild(use);
66
95
 
67
96
  this.innerHTML = '';
@@ -69,15 +98,20 @@ class VelinIcon extends HTMLElement {
69
98
  this._rendered = true;
70
99
  }
71
100
 
72
- async _renderFromCDN(name, size, label, provider) {
73
- const cacheKey = `${provider}:${name}`;
101
+ async _renderFromCDN(name, size, label, provider, variant) {
102
+ const cacheKey = `${provider}:${variant || 'default'}:${name}`;
74
103
 
75
104
  if (_svgCache.has(cacheKey)) {
76
105
  this._injectSVG(_svgCache.get(cacheKey), size, label);
77
106
  return;
78
107
  }
79
108
 
80
- const url = PROVIDER_CDNS[provider].replace('{name}', name);
109
+ const template = resolveProviderUrl(provider, variant);
110
+ if (!template) {
111
+ this._renderFromSprite(name, size, label);
112
+ return;
113
+ }
114
+ const url = template.replace('{name}', name);
81
115
  try {
82
116
  const res = await fetch(url);
83
117
  if (!res.ok) throw new Error(`${res.status}`);
@@ -0,0 +1,85 @@
1
+ /*
2
+ * <velin-live-dot status="live">Realtime</velin-live-dot>
3
+ *
4
+ * Tiny status indicator: a coloured dot with an optional concentric pulse
5
+ * (driven by the velin-live-pulse keyframe in chart-animation.css). Slot
6
+ * children render after the dot for an inline "Live - Streaming" label.
7
+ *
8
+ * Attributes:
9
+ * status "live" (default) | "paused" | "warning" | "error" | "muted"
10
+ * Determines dot colour via the CSS custom prop --velin-live-color.
11
+ * pulse "true" (default) | "false" Disables the looped pulse.
12
+ */
13
+
14
+ const STATUS_COLORS = {
15
+ live: 'var(--velin-color-success, oklch(60% 0.16 145))',
16
+ paused: 'var(--velin-color-text-muted, oklch(60% 0.02 240))',
17
+ warning: 'var(--velin-color-warning, oklch(75% 0.16 80))',
18
+ error: 'var(--velin-color-danger, oklch(60% 0.2 25))',
19
+ muted: 'var(--velin-color-border, oklch(85% 0.01 240))',
20
+ };
21
+
22
+ const styles = `
23
+ :host {
24
+ display: inline-flex;
25
+ align-items: center;
26
+ gap: var(--velin-space-2, 0.5rem);
27
+ font-size: inherit;
28
+ color: inherit;
29
+ line-height: 1.2;
30
+ }
31
+ .dot {
32
+ inline-size: 0.55rem;
33
+ block-size: 0.55rem;
34
+ border-radius: 50%;
35
+ background: var(--velin-live-color);
36
+ flex-shrink: 0;
37
+ }
38
+ :host([pulse="false"]) .dot { animation: none; }
39
+ :host(:not([pulse="false"])) .dot { animation: velin-live-pulse 1.8s var(--velin-ease-out, ease-out) infinite; }
40
+ @media (prefers-reduced-motion: reduce) {
41
+ .dot { animation: none !important; }
42
+ }
43
+ `;
44
+
45
+ const KEYFRAMES_FALLBACK = `
46
+ @keyframes velin-live-pulse {
47
+ 0% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--velin-live-color) 65%, transparent); }
48
+ 70% { box-shadow: 0 0 0 0.6rem color-mix(in oklch, var(--velin-live-color) 0%, transparent); }
49
+ 100% { box-shadow: 0 0 0 0 transparent; }
50
+ }`;
51
+
52
+ class VelinLiveDot extends HTMLElement {
53
+ static get observedAttributes() {
54
+ return ['status', 'pulse'];
55
+ }
56
+
57
+ constructor() {
58
+ super();
59
+ this.attachShadow({ mode: 'open' });
60
+ }
61
+
62
+ connectedCallback() {
63
+ this._render();
64
+ }
65
+
66
+ attributeChangedCallback() {
67
+ if (this.shadowRoot) this._render();
68
+ }
69
+
70
+ _render() {
71
+ const status = this.getAttribute('status') || 'live';
72
+ const color = STATUS_COLORS[status] || STATUS_COLORS.live;
73
+ this.style.setProperty('--velin-live-color', color);
74
+ this.shadowRoot.innerHTML = `
75
+ <style>${styles}${KEYFRAMES_FALLBACK}</style>
76
+ <span class="dot" aria-hidden="true"></span><slot></slot>
77
+ `;
78
+ }
79
+ }
80
+
81
+ if (typeof customElements !== 'undefined' && !customElements.get('velin-live-dot')) {
82
+ customElements.define('velin-live-dot', VelinLiveDot);
83
+ }
84
+
85
+ export default VelinLiveDot;