@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
|
@@ -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-
|
|
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">×</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
|
|
142
|
-
|
|
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 };
|
package/components/velin-icon.js
CHANGED
|
@@ -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/
|
|
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
|
|
64
|
-
|
|
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
|
|
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;
|