@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.
- package/README.de.md +26 -4
- package/README.md +26 -4
- 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 +116 -1
- package/cli/layout-audit.js +325 -0
- package/cli/scaffold-recipes.json +70 -0
- package/cli/scaffold.js +155 -0
- package/cli/scanner.js +68 -0
- package/components/index.js +19 -0
- package/components/sanitize.js +29 -3
- package/components/shadow-a11y-styles.js +18 -0
- package/components/velin-announcer.js +35 -0
- package/components/velin-bottom-nav.js +89 -0
- package/components/velin-combobox.js +149 -0
- package/components/velin-command.js +127 -0
- package/components/velin-counter.js +152 -0
- 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-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/dist/velinstyle-components.iife.js +1629 -69
- package/dist/velinstyle-components.js +1649 -69
- package/dist/velinstyle-components.min.js +424 -80
- package/dist/velinstyle.css +472 -3
- package/dist/velinstyle.min.css +1 -1
- package/package.json +3 -2
- package/src/a11y/security.css +18 -0
- package/src/base/reset.css +12 -1
- 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 +3 -0
|
@@ -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;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { rovingTabindex } 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: block; }
|
|
8
|
+
.menubar {
|
|
9
|
+
display: flex;
|
|
10
|
+
flex-wrap: wrap;
|
|
11
|
+
gap: var(--velin-space-1, 0.25rem);
|
|
12
|
+
padding: var(--velin-space-2, 0.5rem);
|
|
13
|
+
background: var(--velin-color-surface-bright, #fff);
|
|
14
|
+
border-bottom: 1px solid var(--velin-color-border, #ddd);
|
|
15
|
+
}
|
|
16
|
+
::slotted([role="menuitem"]) {
|
|
17
|
+
min-inline-size: 2.75rem;
|
|
18
|
+
min-block-size: 2.75rem;
|
|
19
|
+
padding: var(--velin-space-2, 0.5rem) var(--velin-space-4, 1rem);
|
|
20
|
+
background: none;
|
|
21
|
+
border: none;
|
|
22
|
+
border-radius: var(--velin-radius-sm, 0.25rem);
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
font-size: var(--velin-text-base, 1rem);
|
|
25
|
+
color: var(--velin-color-text, #111);
|
|
26
|
+
}
|
|
27
|
+
::slotted([role="menuitem"]:hover) {
|
|
28
|
+
background: var(--velin-color-surface-dim, #eee);
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
class VelinMenubar extends HTMLElement {
|
|
33
|
+
constructor() {
|
|
34
|
+
super();
|
|
35
|
+
this.attachShadow({ mode: 'open', delegatesFocus: true });
|
|
36
|
+
this._onKey = this._onKey.bind(this);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
connectedCallback() {
|
|
40
|
+
const label = escapeHTML(this.getAttribute('aria-label') || 'Menu bar');
|
|
41
|
+
this.shadowRoot.innerHTML = `
|
|
42
|
+
<style>${styles}</style>
|
|
43
|
+
<div class="menubar" role="menubar" aria-label="${label}"><slot></slot></div>
|
|
44
|
+
`;
|
|
45
|
+
this.addEventListener('keydown', this._onKey);
|
|
46
|
+
this.shadowRoot.querySelector('slot')?.addEventListener('slotchange', () => this._init());
|
|
47
|
+
this._init();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_getItems() {
|
|
51
|
+
const slot = this.shadowRoot.querySelector('slot');
|
|
52
|
+
return slot ? slot.assignedElements().filter((el) => !el.hasAttribute('disabled')) : [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_init() {
|
|
56
|
+
const items = this._getItems();
|
|
57
|
+
items.forEach((el, i) => {
|
|
58
|
+
if (!el.hasAttribute('role')) el.setAttribute('role', 'menuitem');
|
|
59
|
+
el.setAttribute('tabindex', i === 0 ? '0' : '-1');
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_onKey(e) {
|
|
64
|
+
const items = this._getItems();
|
|
65
|
+
if (items.includes(e.target)) rovingTabindex(this, items, e);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static get observedAttributes() { return ['aria-label']; }
|
|
69
|
+
|
|
70
|
+
attributeChangedCallback(name) {
|
|
71
|
+
if (name === 'aria-label') {
|
|
72
|
+
const bar = this.shadowRoot?.querySelector('.menubar');
|
|
73
|
+
if (bar) bar.setAttribute('aria-label', escapeHTML(this.getAttribute('aria-label') || 'Menu bar'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
disconnectedCallback() {
|
|
78
|
+
this.removeEventListener('keydown', this._onKey);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
customElements.define('velin-menubar', VelinMenubar);
|
|
83
|
+
export default VelinMenubar;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { rovingTabindex } 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: inline-block; }
|
|
8
|
+
.stars { display: inline-flex; gap: var(--velin-space-1, 0.25rem); }
|
|
9
|
+
button {
|
|
10
|
+
background: none; border: none; padding: var(--velin-space-1, 0.25rem);
|
|
11
|
+
font-size: 1.5rem; line-height: 1; cursor: pointer;
|
|
12
|
+
color: var(--velin-color-border, #ccc);
|
|
13
|
+
}
|
|
14
|
+
button[aria-checked="true"] { color: var(--velin-color-warning, #f59e0b); }
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const MAX = 5;
|
|
18
|
+
|
|
19
|
+
class VelinRating extends HTMLElement {
|
|
20
|
+
static get observedAttributes() { return ['value']; }
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
super();
|
|
24
|
+
this.attachShadow({ mode: 'open', delegatesFocus: true });
|
|
25
|
+
this._onClick = this._onClick.bind(this);
|
|
26
|
+
this._onKey = this._onKey.bind(this);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
connectedCallback() {
|
|
30
|
+
this.shadowRoot.innerHTML = `<style>${styles}</style><div class="stars" role="radiogroup"></div>`;
|
|
31
|
+
this._render();
|
|
32
|
+
this.shadowRoot.querySelector('.stars').addEventListener('click', this._onClick);
|
|
33
|
+
this.shadowRoot.querySelector('.stars').addEventListener('keydown', this._onKey);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_value() {
|
|
37
|
+
const v = parseInt(this.getAttribute('value') || '0', 10);
|
|
38
|
+
return Math.min(MAX, Math.max(0, Number.isNaN(v) ? 0 : v));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_render() {
|
|
42
|
+
const group = this.shadowRoot.querySelector('.stars');
|
|
43
|
+
const val = this._value();
|
|
44
|
+
const label = escapeHTML(this.getAttribute('aria-label') || 'Rating');
|
|
45
|
+
group.setAttribute('aria-label', label);
|
|
46
|
+
group.innerHTML = '';
|
|
47
|
+
for (let i = 1; i <= MAX; i++) {
|
|
48
|
+
const btn = document.createElement('button');
|
|
49
|
+
btn.type = 'button';
|
|
50
|
+
btn.setAttribute('role', 'radio');
|
|
51
|
+
btn.setAttribute('aria-checked', i <= val ? 'true' : 'false');
|
|
52
|
+
btn.setAttribute('aria-label', escapeHTML(`${i} star${i > 1 ? 's' : ''}`));
|
|
53
|
+
btn.setAttribute('tabindex', i === (val || 1) ? '0' : '-1');
|
|
54
|
+
btn.dataset.value = String(i);
|
|
55
|
+
btn.textContent = i <= val ? '\u2605' : '\u2606';
|
|
56
|
+
group.appendChild(btn);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_getButtons() {
|
|
61
|
+
return [...this.shadowRoot.querySelectorAll('button[role="radio"]')];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_onClick(e) {
|
|
65
|
+
const btn = e.target.closest('button');
|
|
66
|
+
if (!btn) return;
|
|
67
|
+
this._setValue(parseInt(btn.dataset.value, 10));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_onKey(e) {
|
|
71
|
+
const buttons = this._getButtons();
|
|
72
|
+
if (!buttons.includes(e.target)) return;
|
|
73
|
+
rovingTabindex(this, buttons, e);
|
|
74
|
+
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
|
|
75
|
+
const focused = buttons.find((b) => b.getAttribute('tabindex') === '0');
|
|
76
|
+
if (focused) this._setValue(parseInt(focused.dataset.value, 10));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_setValue(n) {
|
|
81
|
+
this.setAttribute('value', String(n));
|
|
82
|
+
this.dispatchEvent(new CustomEvent('velin-change', { bubbles: true, detail: { value: n } }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
attributeChangedCallback(name) {
|
|
86
|
+
if (name === 'value' && this.shadowRoot?.querySelector('.stars')) this._render();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
customElements.define('velin-rating', VelinRating);
|
|
91
|
+
export default VelinRating;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* velin-reveal.js — reusable scroll-into-view reveal helper.
|
|
3
|
+
*
|
|
4
|
+
* Replaces inline IntersectionObserver snippets that every project ends up
|
|
5
|
+
* copy-pasting. Just call `initReveal()` once, or opt into auto-init by
|
|
6
|
+
* adding `data-velin-reveal-auto` to <html>. Elements with the class
|
|
7
|
+
* `.velin-animate-on-scroll` get `is-visible` toggled when they enter the
|
|
8
|
+
* viewport.
|
|
9
|
+
*
|
|
10
|
+
* Honours `prefers-reduced-motion: reduce` by revealing immediately without
|
|
11
|
+
* animation, and falls back to immediate reveal if IntersectionObserver is
|
|
12
|
+
* unavailable.
|
|
13
|
+
*
|
|
14
|
+
* API:
|
|
15
|
+
* initReveal(options?) -> () => void teardown function.
|
|
16
|
+
* options.selector CSS selector (default '.velin-animate-on-scroll').
|
|
17
|
+
* options.threshold IO threshold (default 0.1).
|
|
18
|
+
* options.rootMargin IO rootMargin (default '0px 0px -40px 0px').
|
|
19
|
+
* options.once if true (default) stops observing once visible.
|
|
20
|
+
* options.visibleClass class added on intersect (default 'is-visible').
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULTS = {
|
|
24
|
+
selector: '.velin-animate-on-scroll',
|
|
25
|
+
threshold: 0.1,
|
|
26
|
+
rootMargin: '0px 0px -40px 0px',
|
|
27
|
+
once: true,
|
|
28
|
+
visibleClass: 'is-visible',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const _activeObservers = new WeakMap();
|
|
32
|
+
|
|
33
|
+
export function initReveal(options = {}) {
|
|
34
|
+
if (typeof document === 'undefined') return () => {};
|
|
35
|
+
const opts = { ...DEFAULTS, ...options };
|
|
36
|
+
const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
37
|
+
const targets = Array.from(document.querySelectorAll(opts.selector));
|
|
38
|
+
|
|
39
|
+
if (reduced || typeof IntersectionObserver === 'undefined') {
|
|
40
|
+
targets.forEach((el) => el.classList.add(opts.visibleClass));
|
|
41
|
+
return () => {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const observer = new IntersectionObserver(
|
|
45
|
+
(entries) => {
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (!entry.isIntersecting) continue;
|
|
48
|
+
entry.target.classList.add(opts.visibleClass);
|
|
49
|
+
if (opts.once) observer.unobserve(entry.target);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{ threshold: opts.threshold, rootMargin: opts.rootMargin },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
targets.forEach((el) => {
|
|
56
|
+
if (_activeObservers.has(el)) return;
|
|
57
|
+
_activeObservers.set(el, observer);
|
|
58
|
+
observer.observe(el);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
observer.disconnect();
|
|
63
|
+
targets.forEach((el) => _activeObservers.delete(el));
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (typeof document !== 'undefined') {
|
|
68
|
+
const autoInit = () => {
|
|
69
|
+
if (document.documentElement && document.documentElement.hasAttribute('data-velin-reveal-auto')) {
|
|
70
|
+
initReveal();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
if (document.readyState === 'loading') {
|
|
74
|
+
document.addEventListener('DOMContentLoaded', autoInit, { once: true });
|
|
75
|
+
} else {
|
|
76
|
+
autoInit();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default { initReveal };
|