@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,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;
@@ -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 = `
@@ -141,6 +141,7 @@ class VelinModal extends HTMLElement {
141
141
 
142
142
  _open() {
143
143
  this._previouslyFocused = saveFocus();
144
+ setBackgroundInert(this);
144
145
  document.addEventListener('keydown', this._onKeydown);
145
146
  document.body.style.overflow = 'hidden';
146
147
 
@@ -153,6 +154,7 @@ class VelinModal extends HTMLElement {
153
154
  _close() {
154
155
  document.removeEventListener('keydown', this._onKeydown);
155
156
  document.body.style.overflow = '';
157
+ clearBackgroundInert();
156
158
  restoreFocus(this._previouslyFocused);
157
159
  }
158
160
 
@@ -1,4 +1,5 @@
1
1
  import { escapeHTML } from './sanitize.js';
2
+ import { trapFocus, saveFocus, restoreFocus, getFocusableElements } from './focus-manager.js';
2
3
 
3
4
  const styles = `
4
5
  :host { position: relative; display: inline-block; }
@@ -14,7 +15,6 @@ const styles = `
14
15
  transition: opacity 150ms ease, visibility 150ms ease;
15
16
  }
16
17
  :host([open]) .popover { opacity: 1; visibility: visible; }
17
- /* Placement */
18
18
  .popover--top { inset-block-end: calc(100% + 0.5rem); inset-inline-start: 50%; transform: translateX(-50%); }
19
19
  .popover--bottom { inset-block-start: calc(100% + 0.5rem); inset-inline-start: 50%; transform: translateX(-50%); }
20
20
  .popover--start { inset-inline-end: calc(100% + 0.5rem); inset-block-start: 50%; transform: translateY(-50%); }
@@ -28,14 +28,19 @@ const styles = `
28
28
  @media (prefers-reduced-motion: reduce) { .popover { transition: none; } }
29
29
  `;
30
30
 
31
+ let popoverId = 0;
32
+
31
33
  class VelinPopover extends HTMLElement {
32
34
  static get observedAttributes() { return ['open']; }
33
35
 
34
36
  constructor() {
35
37
  super();
36
38
  this.attachShadow({ mode: 'open' });
39
+ this._popoverId = `velin-popover-${++popoverId}`;
37
40
  this._onOutside = this._onOutside.bind(this);
38
41
  this._onKey = this._onKey.bind(this);
42
+ this._prevFocus = null;
43
+ this._isDialog = false;
39
44
  }
40
45
 
41
46
  connectedCallback() {
@@ -43,35 +48,44 @@ class VelinPopover extends HTMLElement {
43
48
  const triggerType = this.getAttribute('trigger') || 'click';
44
49
  const title = this.getAttribute('title') || '';
45
50
  const role = triggerType === 'hover' ? 'tooltip' : 'dialog';
51
+ this._isDialog = role === 'dialog';
46
52
 
47
53
  this.shadowRoot.innerHTML = `
48
54
  <style>${styles}</style>
49
55
  <slot name="trigger"></slot>
50
- <div class="popover popover--${placement}" role="${role}" part="popover">
56
+ <div class="popover popover--${placement}" id="${this._popoverId}" role="${role}" part="popover">
51
57
  ${title ? `<div class="popover__title" part="title">${escapeHTML(title)}</div>` : ''}
52
58
  <slot></slot>
53
59
  </div>
54
60
  `;
55
61
 
56
62
  const triggerSlot = this.shadowRoot.querySelector('slot[name="trigger"]');
57
- triggerSlot.addEventListener('slotchange', () => {
58
- const trigger = triggerSlot.assignedElements()[0];
59
- if (!trigger) return;
60
- trigger.setAttribute('aria-haspopup', role === 'tooltip' ? 'true' : 'dialog');
61
- trigger.setAttribute('aria-expanded', 'false');
62
-
63
- if (triggerType === 'click') {
64
- trigger.addEventListener('click', () => this.toggle());
65
- } else if (triggerType === 'hover') {
66
- this.addEventListener('mouseenter', () => this.open());
67
- this.addEventListener('mouseleave', () => this.close());
68
- this.addEventListener('focusin', () => this.open());
69
- this.addEventListener('focusout', (e) => { if (!this.contains(e.relatedTarget)) this.close(); });
70
- } else if (triggerType === 'focus') {
71
- trigger.addEventListener('focusin', () => this.open());
72
- trigger.addEventListener('focusout', () => this.close());
73
- }
74
- });
63
+ triggerSlot.addEventListener('slotchange', () => this._wireTrigger(triggerType));
64
+ this._wireTrigger(triggerType);
65
+ }
66
+
67
+ _wireTrigger(triggerType) {
68
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
69
+ if (!trigger) return;
70
+
71
+ const isHover = triggerType === 'hover';
72
+ trigger.setAttribute('aria-haspopup', isHover ? 'true' : 'dialog');
73
+ trigger.setAttribute('aria-expanded', this.hasAttribute('open') ? 'true' : 'false');
74
+ if (this._isDialog) {
75
+ trigger.setAttribute('aria-controls', this._popoverId);
76
+ }
77
+
78
+ if (triggerType === 'click') {
79
+ trigger.onclick = () => this.toggle();
80
+ } else if (triggerType === 'hover') {
81
+ this.onmouseenter = () => this.open();
82
+ this.onmouseleave = () => this.close();
83
+ this.onfocusin = () => this.open();
84
+ this.onfocusout = (e) => { if (!this.contains(e.relatedTarget)) this.close(); };
85
+ } else if (triggerType === 'focus') {
86
+ trigger.onfocusin = () => this.open();
87
+ trigger.onfocusout = () => this.close();
88
+ }
75
89
  }
76
90
 
77
91
  open() {
@@ -80,18 +94,44 @@ class VelinPopover extends HTMLElement {
80
94
  if (trigger) trigger.setAttribute('aria-expanded', 'true');
81
95
  document.addEventListener('click', this._onOutside, true);
82
96
  document.addEventListener('keydown', this._onKey);
97
+
98
+ if (this._isDialog) {
99
+ this._prevFocus = saveFocus();
100
+ requestAnimationFrame(() => {
101
+ const focusable = getFocusableElements(this.shadowRoot.querySelector('.popover'));
102
+ if (focusable.length) focusable[0].focus();
103
+ else this.shadowRoot.querySelector('.popover')?.focus();
104
+ });
105
+ }
83
106
  }
107
+
84
108
  close() {
85
109
  this.removeAttribute('open');
86
110
  const trigger = this.querySelector('[slot="trigger"]');
87
111
  if (trigger) trigger.setAttribute('aria-expanded', 'false');
88
112
  document.removeEventListener('click', this._onOutside, true);
89
113
  document.removeEventListener('keydown', this._onKey);
114
+ if (this._isDialog && this._prevFocus) {
115
+ restoreFocus(this._prevFocus);
116
+ this._prevFocus = null;
117
+ }
90
118
  }
119
+
91
120
  toggle() { this.hasAttribute('open') ? this.close() : this.open(); }
92
121
 
93
122
  _onOutside(e) { if (!this.contains(e.target)) this.close(); }
94
- _onKey(e) { if (e.key === 'Escape') this.close(); }
123
+
124
+ _onKey(e) {
125
+ if (e.key === 'Escape') {
126
+ this.close();
127
+ const trigger = this.querySelector('[slot="trigger"]');
128
+ if (trigger) trigger.focus();
129
+ return;
130
+ }
131
+ if (this._isDialog && this.hasAttribute('open')) {
132
+ trapFocus(this.shadowRoot.querySelector('.popover'), e);
133
+ }
134
+ }
95
135
 
96
136
  disconnectedCallback() {
97
137
  document.removeEventListener('click', this._onOutside, true);
@@ -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 };
@@ -0,0 +1,108 @@
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
+ .group {
9
+ display: inline-flex;
10
+ gap: var(--velin-space-1, 0.25rem);
11
+ padding: var(--velin-space-1, 0.25rem);
12
+ background: var(--velin-color-surface-dim, #eee);
13
+ border-radius: var(--velin-radius-md, 0.5rem);
14
+ }
15
+ ::slotted(button) {
16
+ min-inline-size: 2.75rem;
17
+ min-block-size: 2.75rem;
18
+ padding: var(--velin-space-2, 0.5rem) var(--velin-space-4, 1rem);
19
+ border: none;
20
+ border-radius: var(--velin-radius-sm, 0.25rem);
21
+ background: transparent;
22
+ color: var(--velin-color-text-muted, #666);
23
+ cursor: pointer;
24
+ font-size: var(--velin-text-sm, 0.875rem);
25
+ }
26
+ ::slotted(button[aria-pressed="true"]) {
27
+ background: var(--velin-color-surface-bright, #fff);
28
+ color: var(--velin-color-text, #111);
29
+ font-weight: var(--velin-weight-semibold, 600);
30
+ box-shadow: var(--velin-shadow-sm, 0 1px 2px rgba(0,0,0,0.06));
31
+ }
32
+ `;
33
+
34
+ class VelinSegmentedControl extends HTMLElement {
35
+ constructor() {
36
+ super();
37
+ this.attachShadow({ mode: 'open', delegatesFocus: true });
38
+ this._onClick = this._onClick.bind(this);
39
+ this._onKey = this._onKey.bind(this);
40
+ }
41
+
42
+ connectedCallback() {
43
+ const label = escapeHTML(this.getAttribute('aria-label') || 'Segmented control');
44
+ this.shadowRoot.innerHTML = `
45
+ <style>${styles}</style>
46
+ <div class="group" role="group" aria-label="${label}"><slot></slot></div>
47
+ `;
48
+ this.addEventListener('click', this._onClick);
49
+ this.addEventListener('keydown', this._onKey);
50
+ this.shadowRoot.querySelector('slot')?.addEventListener('slotchange', () => this._init());
51
+ this._init();
52
+ }
53
+
54
+ _getButtons() {
55
+ const slot = this.shadowRoot.querySelector('slot');
56
+ return slot ? slot.assignedElements().filter((el) => el.tagName === 'BUTTON') : [];
57
+ }
58
+
59
+ _init() {
60
+ const buttons = this._getButtons();
61
+ const selected = buttons.find((b) => b.hasAttribute('selected')) || buttons[0];
62
+ buttons.forEach((btn, i) => {
63
+ btn.setAttribute('aria-pressed', btn === selected ? 'true' : 'false');
64
+ btn.setAttribute('tabindex', btn === selected ? '0' : '-1');
65
+ });
66
+ }
67
+
68
+ _onClick(e) {
69
+ const btn = e.target.closest('button');
70
+ if (!btn || !this.contains(btn)) return;
71
+ this._select(btn);
72
+ }
73
+
74
+ _select(btn) {
75
+ this._getButtons().forEach((b) => {
76
+ b.setAttribute('aria-pressed', b === btn ? 'true' : 'false');
77
+ b.setAttribute('tabindex', b === btn ? '0' : '-1');
78
+ });
79
+ this.dispatchEvent(new CustomEvent('velin-change', { bubbles: true, detail: { value: btn.value || btn.textContent?.trim() } }));
80
+ }
81
+
82
+ _onKey(e) {
83
+ const buttons = this._getButtons();
84
+ if (!buttons.includes(e.target)) return;
85
+ rovingTabindex(this, buttons, e);
86
+ if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
87
+ const focused = buttons.find((b) => b.getAttribute('tabindex') === '0');
88
+ if (focused) this._select(focused);
89
+ }
90
+ }
91
+
92
+ static get observedAttributes() { return ['aria-label']; }
93
+
94
+ attributeChangedCallback(name) {
95
+ if (name === 'aria-label') {
96
+ const group = this.shadowRoot?.querySelector('.group');
97
+ if (group) group.setAttribute('aria-label', escapeHTML(this.getAttribute('aria-label') || 'Segmented control'));
98
+ }
99
+ }
100
+
101
+ disconnectedCallback() {
102
+ this.removeEventListener('click', this._onClick);
103
+ this.removeEventListener('keydown', this._onKey);
104
+ }
105
+ }
106
+
107
+ customElements.define('velin-segmented-control', VelinSegmentedControl);
108
+ export default VelinSegmentedControl;
@@ -0,0 +1,107 @@
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-overlay, 400);
10
+ background: var(--velin-color-overlay, rgba(0,0,0,0.4));
11
+ opacity: 0; visibility: hidden;
12
+ transition: opacity 200ms ease, visibility 200ms ease;
13
+ }
14
+ :host([open]) .overlay { opacity: 1; visibility: visible; }
15
+ .sheet {
16
+ position: fixed; inset-inline: 0; inset-block-end: 0;
17
+ z-index: var(--velin-z-modal, 500);
18
+ max-block-size: min(85vh, 32rem);
19
+ background: var(--velin-color-surface-bright, #fff);
20
+ border-radius: var(--velin-radius-lg, 0.75rem) var(--velin-radius-lg, 0.75rem) 0 0;
21
+ box-shadow: var(--velin-shadow-xl, 0 -4px 24px rgba(0,0,0,0.12));
22
+ display: flex; flex-direction: column;
23
+ transform: translateY(100%);
24
+ transition: transform 250ms ease;
25
+ padding-block-end: env(safe-area-inset-bottom, 0px);
26
+ }
27
+ :host([open]) .sheet { transform: translateY(0); }
28
+ .header {
29
+ display: flex; align-items: center; justify-content: space-between;
30
+ padding: var(--velin-space-4, 1rem) var(--velin-space-5, 1.25rem);
31
+ border-bottom: 1px solid var(--velin-color-border, #ddd);
32
+ }
33
+ .title { font-size: var(--velin-text-lg, 1.25rem); font-weight: 600; margin: 0; }
34
+ .body { flex: 1; overflow-y: auto; padding: var(--velin-space-5, 1.25rem); }
35
+ @media (prefers-reduced-motion: reduce) { .overlay, .sheet { transition: none; } }
36
+ `;
37
+
38
+ class VelinSheet extends HTMLElement {
39
+ static get observedAttributes() { return ['open']; }
40
+
41
+ constructor() {
42
+ super();
43
+ this.attachShadow({ mode: 'open', delegatesFocus: true });
44
+ this._prev = null;
45
+ this._onKey = this._onKey.bind(this);
46
+ }
47
+
48
+ connectedCallback() {
49
+ const title = escapeHTML(this.getAttribute('title') || this.getAttribute('label') || '');
50
+ const titleId = 'velin-sheet-title';
51
+ this.shadowRoot.innerHTML = `
52
+ <style>${styles}</style>
53
+ <div class="overlay" part="overlay"></div>
54
+ <div class="sheet" role="dialog" aria-modal="true" aria-labelledby="${titleId}" part="sheet">
55
+ <div class="header" part="header">
56
+ <h2 class="title" id="${titleId}">${title}</h2>
57
+ <button class="close-btn" aria-label="Close" part="close">&#215;</button>
58
+ </div>
59
+ <div class="body" part="body"><slot></slot></div>
60
+ </div>
61
+ `;
62
+ this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => this.close());
63
+ this.shadowRoot.querySelector('.overlay').addEventListener('click', () => this.close());
64
+ if (this.hasAttribute('open')) this._open();
65
+ }
66
+
67
+ attributeChangedCallback(name) {
68
+ if (name === 'open') this.hasAttribute('open') ? this._open() : this._close();
69
+ }
70
+
71
+ open() { this.setAttribute('open', ''); }
72
+ close() {
73
+ this.removeAttribute('open');
74
+ this.dispatchEvent(new CustomEvent('velin-close', { bubbles: true }));
75
+ }
76
+
77
+ _open() {
78
+ this._prev = saveFocus();
79
+ setBackgroundInert(this);
80
+ document.addEventListener('keydown', this._onKey);
81
+ document.body.style.overflow = 'hidden';
82
+ requestAnimationFrame(() => {
83
+ const f = getFocusableElements(this.shadowRoot);
84
+ if (f.length) f[0].focus();
85
+ });
86
+ }
87
+
88
+ _close() {
89
+ document.removeEventListener('keydown', this._onKey);
90
+ document.body.style.overflow = '';
91
+ clearBackgroundInert();
92
+ restoreFocus(this._prev);
93
+ }
94
+
95
+ _onKey(e) {
96
+ if (e.key === 'Escape') { this.close(); return; }
97
+ trapFocus(this.shadowRoot, e);
98
+ }
99
+
100
+ disconnectedCallback() {
101
+ document.removeEventListener('keydown', this._onKey);
102
+ document.body.style.overflow = '';
103
+ }
104
+ }
105
+
106
+ customElements.define('velin-sheet', VelinSheet);
107
+ export default VelinSheet;