@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,35 @@
1
+ const styles = `
2
+ :host { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
3
+ `;
4
+
5
+ class VelinAnnouncer extends HTMLElement {
6
+ connectedCallback() {
7
+ if (!this.shadowRoot) this.attachShadow({ mode: 'open' });
8
+ const live = this.getAttribute('polite') === 'false' ? 'assertive' : 'polite';
9
+ this.shadowRoot.innerHTML =
10
+ '<style>' + styles + '</style>' +
11
+ '<div role="status" aria-live="' + live + '" aria-atomic="true" part="region"></div>';
12
+ this._region = this.shadowRoot.querySelector('[role="status"]');
13
+ }
14
+
15
+ announce(message, { assertive = false } = {}) {
16
+ if (!this._region) this.connectedCallback();
17
+ this._region.setAttribute('aria-live', assertive ? 'assertive' : 'polite');
18
+ this._region.textContent = '';
19
+ requestAnimationFrame(() => {
20
+ this._region.textContent = typeof message === 'string' ? message : '';
21
+ });
22
+ }
23
+
24
+ static announceGlobal(message, options) {
25
+ let el = document.querySelector('velin-announcer');
26
+ if (!el) {
27
+ el = document.createElement('velin-announcer');
28
+ document.body.appendChild(el);
29
+ }
30
+ el.announce(message, options);
31
+ }
32
+ }
33
+
34
+ customElements.define('velin-announcer', VelinAnnouncer);
35
+ export default VelinAnnouncer;
@@ -0,0 +1,89 @@
1
+ import { escapeHTML } from './sanitize.js';
2
+
3
+ const styles = `
4
+ :host { display: block; }
5
+ nav {
6
+ display: flex;
7
+ justify-content: space-around;
8
+ align-items: center;
9
+ padding: var(--velin-space-2, 0.5rem) var(--velin-space-4, 1rem);
10
+ padding-block-end: max(var(--velin-space-2, 0.5rem), env(safe-area-inset-bottom, 0px));
11
+ background: var(--velin-color-surface-bright, #fff);
12
+ border-block-start: 1px solid var(--velin-color-border, #ddd);
13
+ }
14
+ ::slotted(a), ::slotted(button) {
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ gap: var(--velin-space-1, 0.25rem);
19
+ min-inline-size: 2.75rem;
20
+ min-block-size: 2.75rem;
21
+ padding: var(--velin-space-2, 0.5rem);
22
+ font-size: var(--velin-text-xs, 0.75rem);
23
+ color: var(--velin-color-text-muted, #666);
24
+ text-decoration: none;
25
+ background: none;
26
+ border: none;
27
+ cursor: pointer;
28
+ }
29
+ ::slotted([current]) {
30
+ color: var(--velin-color-primary, #2563eb);
31
+ font-weight: var(--velin-weight-semibold, 600);
32
+ }
33
+ `;
34
+
35
+ class VelinBottomNav extends HTMLElement {
36
+ constructor() {
37
+ super();
38
+ this.attachShadow({ mode: 'open' });
39
+ this._onSlot = this._onSlot.bind(this);
40
+ }
41
+
42
+ connectedCallback() {
43
+ const label = escapeHTML(this.getAttribute('aria-label') || 'Bottom navigation');
44
+ this.shadowRoot.innerHTML = `
45
+ <style>${styles}</style>
46
+ <nav role="navigation" aria-label="${label}"><slot></slot></nav>
47
+ `;
48
+ const slot = this.shadowRoot.querySelector('slot');
49
+ slot.addEventListener('slotchange', this._onSlot);
50
+ this._onSlot();
51
+ }
52
+
53
+ _onSlot() {
54
+ this._syncCurrent();
55
+ }
56
+
57
+ _syncCurrent() {
58
+ const slot = this.shadowRoot?.querySelector('slot');
59
+ if (!slot) return;
60
+ const hostKey = this.getAttribute('current');
61
+ slot.assignedElements().forEach((el) => {
62
+ const active =
63
+ el.hasAttribute('current') ||
64
+ (hostKey && (el.dataset.nav === hostKey || el.getAttribute('data-nav') === hostKey));
65
+ if (active) {
66
+ el.setAttribute('current', '');
67
+ el.setAttribute('aria-current', 'page');
68
+ } else {
69
+ el.removeAttribute('current');
70
+ el.removeAttribute('aria-current');
71
+ }
72
+ });
73
+ }
74
+
75
+ static get observedAttributes() {
76
+ return ['aria-label', 'current'];
77
+ }
78
+
79
+ attributeChangedCallback(name) {
80
+ if (name === 'aria-label') {
81
+ const nav = this.shadowRoot?.querySelector('nav');
82
+ if (nav) nav.setAttribute('aria-label', escapeHTML(this.getAttribute('aria-label') || 'Bottom navigation'));
83
+ }
84
+ if (name === 'current') this._syncCurrent();
85
+ }
86
+ }
87
+
88
+ customElements.define('velin-bottom-nav', VelinBottomNav);
89
+ export default VelinBottomNav;
@@ -25,9 +25,15 @@ const styles = `
25
25
  button:disabled { opacity: 0.3; cursor: not-allowed; }
26
26
  svg { width: 1.25rem; height: 1.25rem; }
27
27
  .indicators {
28
- display: flex; justify-content: center; gap: var(--velin-space-2, 0.5rem);
28
+ display: flex; justify-content: center; align-items: center; gap: var(--velin-space-2, 0.5rem);
29
29
  padding-block: var(--velin-space-3, 0.75rem);
30
30
  }
31
+ .pause-btn {
32
+ font-size: var(--velin-text-xs, 0.75rem);
33
+ padding-inline: var(--velin-space-3, 0.75rem);
34
+ min-inline-size: auto;
35
+ border-radius: var(--velin-radius-md, 0.375rem);
36
+ }
31
37
  .dot {
32
38
  display: inline-flex; align-items: center; justify-content: center;
33
39
  min-width: 2.75rem; min-height: 2.75rem;
@@ -54,6 +60,7 @@ class VelinCarousel extends HTMLElement {
54
60
  this._index = 0;
55
61
  this._timer = null;
56
62
  this._startX = 0;
63
+ this._autoplayPaused = false;
57
64
  }
58
65
 
59
66
  connectedCallback() {
@@ -68,7 +75,7 @@ class VelinCarousel extends HTMLElement {
68
75
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 6 15 12 9 18"/></svg>
69
76
  </button>
70
77
  </div>
71
- <div class="indicators" role="tablist" aria-label="Slide indicators" part="indicators"></div>
78
+ <div class="indicators" role="group" aria-label="Slide indicators" part="indicators"></div>
72
79
  `;
73
80
 
74
81
  this.shadowRoot.querySelector('.prev').addEventListener('click', () => this.prev());
@@ -128,18 +135,46 @@ class VelinCarousel extends HTMLElement {
128
135
  c.innerHTML = '';
129
136
  this._slides.forEach((_, i) => {
130
137
  const d = document.createElement('button');
138
+ d.type = 'button';
131
139
  d.className = 'dot';
132
- d.setAttribute('role', 'tab');
133
- d.setAttribute('aria-label', `Slide ${i + 1}`);
140
+ d.setAttribute('aria-label', `Go to slide ${i + 1}`);
134
141
  d.addEventListener('click', () => this.goTo(i));
135
142
  c.appendChild(d);
136
143
  });
144
+ if (this.hasAttribute('autoplay')) {
145
+ const pause = document.createElement('button');
146
+ pause.type = 'button';
147
+ pause.className = 'pause-btn';
148
+ pause.setAttribute('aria-pressed', 'false');
149
+ pause.setAttribute('aria-label', 'Pause automatic slide show');
150
+ pause.textContent = 'Pause';
151
+ pause.addEventListener('click', () => this._toggleAutoplayPause(pause));
152
+ c.appendChild(pause);
153
+ }
154
+ this._update();
155
+ }
156
+
157
+ _toggleAutoplayPause(btn) {
158
+ this._autoplayPaused = !this._autoplayPaused;
159
+ if (this._autoplayPaused) {
160
+ this._pause();
161
+ btn.setAttribute('aria-pressed', 'true');
162
+ btn.setAttribute('aria-label', 'Resume automatic slide show');
163
+ btn.textContent = 'Play';
164
+ } else {
165
+ this._resume();
166
+ btn.setAttribute('aria-pressed', 'false');
167
+ btn.setAttribute('aria-label', 'Pause automatic slide show');
168
+ btn.textContent = 'Pause';
169
+ }
137
170
  }
138
171
 
139
172
  _emit() { this.dispatchEvent(new CustomEvent('velin-slide-change', { bubbles: true, detail: { index: this._index } })); }
140
173
  _startAutoplay() { const ms = parseInt(this.getAttribute('interval') || '5000', 10); this._timer = setInterval(() => this.next(), ms); }
141
174
  _pause() { if (this._timer) { clearInterval(this._timer); this._timer = null; } }
142
- _resume() { if (this.hasAttribute('autoplay') && !this._timer) this._startAutoplay(); }
175
+ _resume() {
176
+ if (this.hasAttribute('autoplay') && !this._timer && !this._autoplayPaused) this._startAutoplay();
177
+ }
143
178
 
144
179
  disconnectedCallback() { this._pause(); }
145
180
  }
@@ -1,65 +1,95 @@
1
- const styles = `
2
- :host { display: block; }
3
- .content {
4
- overflow: hidden;
5
- transition: grid-template-rows 300ms ease;
6
- display: grid;
7
- grid-template-rows: 0fr;
8
- }
9
- :host([open]) .content { grid-template-rows: 1fr; }
10
- .inner { min-height: 0; }
11
- @media (prefers-reduced-motion: reduce) { .content { transition: none; } }
12
- `;
13
-
14
- class VelinCollapse extends HTMLElement {
15
- static get observedAttributes() { return ['open']; }
16
-
17
- constructor() {
18
- super();
19
- this.attachShadow({ mode: 'open' });
20
- }
21
-
22
- connectedCallback() {
23
- this.shadowRoot.innerHTML = `
24
- <style>${styles}</style>
25
- <slot name="trigger"></slot>
26
- <div class="content" role="region" part="content">
27
- <div class="inner"><slot></slot></div>
28
- </div>
29
- `;
30
-
31
- const triggerSlot = this.shadowRoot.querySelector('slot[name="trigger"]');
32
- triggerSlot.addEventListener('slotchange', () => {
33
- const trigger = triggerSlot.assignedElements()[0];
34
- if (trigger) {
35
- trigger.addEventListener('click', () => this.toggle());
36
- trigger.setAttribute('aria-expanded', this.hasAttribute('open'));
37
- }
38
- });
39
- }
40
-
41
- attributeChangedCallback(name) {
42
- if (name === 'open') {
43
- const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
44
- if (trigger) trigger.setAttribute('aria-expanded', this.hasAttribute('open'));
45
- }
46
- }
47
-
48
- toggle() {
49
- if (this.hasAttribute('open')) this.close();
50
- else this.open();
51
- }
52
-
53
- open() {
54
- this.setAttribute('open', '');
55
- this.dispatchEvent(new CustomEvent('velin-open', { bubbles: true }));
56
- }
57
-
58
- close() {
59
- this.removeAttribute('open');
60
- this.dispatchEvent(new CustomEvent('velin-close', { bubbles: true }));
61
- }
62
- }
63
-
64
- customElements.define('velin-collapse', VelinCollapse);
65
- export default VelinCollapse;
1
+ const styles = `
2
+ :host { display: block; }
3
+ .content {
4
+ overflow: hidden;
5
+ transition: grid-template-rows 300ms ease;
6
+ display: grid;
7
+ grid-template-rows: 0fr;
8
+ }
9
+ :host([open]) .content { grid-template-rows: 1fr; }
10
+ .inner { min-height: 0; }
11
+ @media (prefers-reduced-motion: reduce) { .content { transition: none; } }
12
+ `;
13
+
14
+ let collapseId = 0;
15
+
16
+ function isButtonLike(el) {
17
+ const tag = el.tagName;
18
+ return tag === 'BUTTON' || (tag === 'A' && el.hasAttribute('href')) || el.getAttribute('role') === 'button';
19
+ }
20
+
21
+ class VelinCollapse extends HTMLElement {
22
+ static get observedAttributes() { return ['open']; }
23
+
24
+ constructor() {
25
+ super();
26
+ this.attachShadow({ mode: 'open' });
27
+ this._contentId = `velin-collapse-panel-${++collapseId}`;
28
+ this._onTriggerKey = this._onTriggerKey.bind(this);
29
+ }
30
+
31
+ connectedCallback() {
32
+ const panelId = this._contentId;
33
+ this.shadowRoot.innerHTML =
34
+ '<style>' + styles + '</style>' +
35
+ '<slot name="trigger"></slot>' +
36
+ '<div class="content" id="' + panelId + '" part="content">' +
37
+ '<div class="inner"><slot></slot></div>' +
38
+ '</div>';
39
+
40
+ const triggerSlot = this.shadowRoot.querySelector('slot[name="trigger"]');
41
+ triggerSlot.addEventListener('slotchange', () => this._wireTrigger());
42
+ this._wireTrigger();
43
+ }
44
+
45
+ _wireTrigger() {
46
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
47
+ if (!trigger) return;
48
+
49
+ if (!isButtonLike(trigger)) {
50
+ trigger.setAttribute('role', 'button');
51
+ if (!trigger.hasAttribute('tabindex')) trigger.setAttribute('tabindex', '0');
52
+ }
53
+
54
+ trigger.setAttribute('aria-controls', this._contentId);
55
+ trigger.setAttribute('aria-expanded', this.hasAttribute('open') ? 'true' : 'false');
56
+
57
+ trigger.removeEventListener('click', this._onClick);
58
+ trigger.removeEventListener('keydown', this._onTriggerKey);
59
+ this._onClick = () => this.toggle();
60
+ trigger.addEventListener('click', this._onClick);
61
+ trigger.addEventListener('keydown', this._onTriggerKey);
62
+ }
63
+
64
+ _onTriggerKey(e) {
65
+ if (e.key === 'Enter' || e.key === ' ') {
66
+ e.preventDefault();
67
+ this.toggle();
68
+ }
69
+ }
70
+
71
+ attributeChangedCallback(name) {
72
+ if (name === 'open') {
73
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
74
+ if (trigger) trigger.setAttribute('aria-expanded', this.hasAttribute('open') ? 'true' : 'false');
75
+ }
76
+ }
77
+
78
+ toggle() {
79
+ if (this.hasAttribute('open')) this.close();
80
+ else this.open();
81
+ }
82
+
83
+ open() {
84
+ this.setAttribute('open', '');
85
+ this.dispatchEvent(new CustomEvent('velin-open', { bubbles: true }));
86
+ }
87
+
88
+ close() {
89
+ this.removeAttribute('open');
90
+ this.dispatchEvent(new CustomEvent('velin-close', { bubbles: true }));
91
+ }
92
+ }
93
+
94
+ customElements.define('velin-collapse', VelinCollapse);
95
+ export default VelinCollapse;
@@ -0,0 +1,149 @@
1
+ import { rovingTabindex } from './focus-manager.js';
2
+ import { escapeHTML } from './sanitize.js';
3
+
4
+ const styles = `
5
+ :host { display: inline-block; position: relative; }
6
+ .listbox {
7
+ position: absolute; z-index: var(--velin-z-dropdown, 100);
8
+ inset-block-start: 100%; inset-inline-start: 0;
9
+ min-inline-size: 100%; margin-block-start: var(--velin-space-1, 0.25rem);
10
+ padding-block: var(--velin-space-1, 0.25rem);
11
+ background: var(--velin-color-surface-bright, #fff);
12
+ border: 1px solid var(--velin-color-border, #ddd);
13
+ border-radius: var(--velin-radius-md, 0.5rem);
14
+ box-shadow: var(--velin-shadow-lg, 0 10px 15px rgba(0,0,0,0.08));
15
+ opacity: 0; visibility: hidden;
16
+ transition: opacity 150ms ease, visibility 150ms ease;
17
+ }
18
+ :host([open]) .listbox { opacity: 1; visibility: visible; }
19
+ ::slotted([role="option"]) {
20
+ display: block; inline-size: 100%;
21
+ padding: var(--velin-space-2, 0.5rem) var(--velin-space-4, 1rem);
22
+ min-block-size: 2.5rem;
23
+ text-align: start; background: none; border: none;
24
+ cursor: pointer; font-size: var(--velin-text-base, 1rem);
25
+ }
26
+ ::slotted([role="option"][aria-selected="true"]) {
27
+ background: var(--velin-color-surface-dim, #eee);
28
+ }
29
+ `;
30
+
31
+ class VelinCombobox extends HTMLElement {
32
+ static get observedAttributes() { return ['open', 'aria-label']; }
33
+
34
+ constructor() {
35
+ super();
36
+ this.attachShadow({ mode: 'open', delegatesFocus: true });
37
+ this._onDocClick = this._onDocClick.bind(this);
38
+ this._onKey = this._onKey.bind(this);
39
+ }
40
+
41
+ connectedCallback() {
42
+ const listId = `velin-combobox-list-${Math.random().toString(36).slice(2, 9)}`;
43
+ this._listId = listId;
44
+ const listLabel = escapeHTML(this.getAttribute('aria-label') || 'Options');
45
+ this.shadowRoot.innerHTML = `
46
+ <style>${styles}</style>
47
+ <slot name="trigger"></slot>
48
+ <div class="listbox" id="${listId}" role="listbox" aria-label="${listLabel}" part="listbox"><slot></slot></div>
49
+ `;
50
+ const triggerSlot = this.shadowRoot.querySelector('slot[name="trigger"]');
51
+ triggerSlot.addEventListener('slotchange', () => this._wireTrigger());
52
+ this.shadowRoot.querySelector('slot:not([name])')?.addEventListener('slotchange', () => this._wireOptions());
53
+ this._wireTrigger();
54
+ this._wireOptions();
55
+ this.addEventListener('keydown', this._onKey);
56
+ }
57
+
58
+ _wireTrigger() {
59
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
60
+ if (!trigger) return;
61
+ trigger.setAttribute('role', 'combobox');
62
+ trigger.setAttribute('aria-expanded', this.hasAttribute('open') ? 'true' : 'false');
63
+ trigger.setAttribute('aria-controls', this._listId);
64
+ trigger.setAttribute('aria-autocomplete', 'list');
65
+ if (!trigger.id) trigger.id = `velin-combobox-trigger-${Math.random().toString(36).slice(2, 9)}`;
66
+ const list = this.shadowRoot.querySelector('.listbox');
67
+ if (list) list.setAttribute('aria-labelledby', trigger.id);
68
+ if (!trigger.dataset.velinComboWired) {
69
+ trigger.dataset.velinComboWired = '1';
70
+ trigger.addEventListener('click', () => this.toggle());
71
+ trigger.addEventListener('keydown', (e) => {
72
+ if (e.key === 'ArrowDown' || e.key === 'Enter') { e.preventDefault(); this.open(); }
73
+ });
74
+ }
75
+ }
76
+
77
+ _wireOptions() {
78
+ const options = this._getOptions();
79
+ options.forEach((el, i) => {
80
+ el.setAttribute('role', 'option');
81
+ el.setAttribute('aria-selected', el.hasAttribute('selected') ? 'true' : 'false');
82
+ el.setAttribute('tabindex', i === 0 ? '0' : '-1');
83
+ });
84
+ }
85
+
86
+ _getOptions() {
87
+ const slot = this.shadowRoot.querySelector('slot:not([name])');
88
+ return slot ? slot.assignedElements().filter((el) => !el.hidden) : [];
89
+ }
90
+
91
+ toggle() { this.hasAttribute('open') ? this.close() : this.open(); }
92
+
93
+ open() {
94
+ this.setAttribute('open', '');
95
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
96
+ if (trigger) trigger.setAttribute('aria-expanded', 'true');
97
+ document.addEventListener('click', this._onDocClick, true);
98
+ requestAnimationFrame(() => {
99
+ const opts = this._getOptions();
100
+ if (opts.length) opts[0].focus();
101
+ });
102
+ }
103
+
104
+ close() {
105
+ this.removeAttribute('open');
106
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
107
+ if (trigger) { trigger.setAttribute('aria-expanded', 'false'); trigger.focus(); }
108
+ document.removeEventListener('click', this._onDocClick, true);
109
+ this.dispatchEvent(new CustomEvent('velin-close', { bubbles: true }));
110
+ }
111
+
112
+ _onDocClick(e) {
113
+ if (!this.contains(e.target)) this.close();
114
+ }
115
+
116
+ _onKey(e) {
117
+ if (!this.hasAttribute('open')) return;
118
+ if (e.key === 'Escape') { this.close(); return; }
119
+ const options = this._getOptions();
120
+ if (!options.length) return;
121
+ rovingTabindex(this, options, e);
122
+ if (e.key === 'Enter' && options.includes(e.target)) {
123
+ this._selectOption(e.target);
124
+ this.close();
125
+ }
126
+ }
127
+
128
+ _selectOption(el) {
129
+ this._getOptions().forEach((o) => o.setAttribute('aria-selected', o === el ? 'true' : 'false'));
130
+ const trigger = this.shadowRoot.querySelector('slot[name="trigger"]')?.assignedElements()[0];
131
+ if (trigger && 'value' in trigger) trigger.value = el.textContent?.trim() || '';
132
+ this.dispatchEvent(new CustomEvent('velin-select', { bubbles: true, detail: { option: el } }));
133
+ }
134
+
135
+ attributeChangedCallback(name) {
136
+ if (name === 'open') this._wireTrigger();
137
+ if (name === 'aria-label') {
138
+ const list = this.shadowRoot?.querySelector('.listbox');
139
+ if (list) list.setAttribute('aria-label', escapeHTML(this.getAttribute('aria-label') || 'Options'));
140
+ }
141
+ }
142
+
143
+ disconnectedCallback() {
144
+ document.removeEventListener('click', this._onDocClick, true);
145
+ }
146
+ }
147
+
148
+ customElements.define('velin-combobox', VelinCombobox);
149
+ export default VelinCombobox;
@@ -0,0 +1,127 @@
1
+ import { trapFocus, saveFocus, restoreFocus, getFocusableElements, setBackgroundInert, clearBackgroundInert } from './focus-manager.js';
2
+ import { escapeHTML } from './sanitize.js';
3
+ import { SHADOW_A11Y_STYLES } from './shadow-a11y-styles.js';
4
+
5
+ const styles = `
6
+ ${SHADOW_A11Y_STYLES}
7
+ :host { display: contents; }
8
+ .overlay {
9
+ position: fixed; inset: 0; z-index: var(--velin-z-modal, 500);
10
+ display: flex; align-items: flex-start; justify-content: center;
11
+ padding: 10vh var(--velin-space-4, 1rem) var(--velin-space-4, 1rem);
12
+ background: var(--velin-color-overlay, rgba(0,0,0,0.4));
13
+ opacity: 0; visibility: hidden;
14
+ transition: opacity 150ms ease, visibility 150ms ease;
15
+ }
16
+ :host([open]) .overlay { opacity: 1; visibility: visible; }
17
+ .panel {
18
+ inline-size: min(32rem, 100%);
19
+ background: var(--velin-color-surface-bright, #fff);
20
+ border-radius: var(--velin-radius-lg, 0.75rem);
21
+ box-shadow: var(--velin-shadow-xl, 0 20px 25px rgba(0,0,0,0.1));
22
+ overflow: hidden;
23
+ }
24
+ .search {
25
+ inline-size: 100%; padding: var(--velin-space-4, 1rem);
26
+ border: none; border-bottom: 1px solid var(--velin-color-border, #ddd);
27
+ font-size: var(--velin-text-base, 1rem);
28
+ background: transparent;
29
+ color: var(--velin-color-text, #111);
30
+ }
31
+ .results { max-block-size: 20rem; overflow-y: auto; padding: var(--velin-space-2, 0.5rem); }
32
+ ::slotted(button) {
33
+ display: flex; inline-size: 100%;
34
+ padding: var(--velin-space-3, 0.75rem) var(--velin-space-4, 1rem);
35
+ min-block-size: 2.5rem;
36
+ border: none; background: none; text-align: start;
37
+ cursor: pointer; font-size: var(--velin-text-base, 1rem);
38
+ border-radius: var(--velin-radius-sm, 0.25rem);
39
+ }
40
+ ::slotted(button[hidden]) { display: none; }
41
+ ::slotted(button:focus-visible) {
42
+ background: var(--velin-color-surface-dim, #eee);
43
+ }
44
+ `;
45
+
46
+ class VelinCommand extends HTMLElement {
47
+ static get observedAttributes() { return ['open']; }
48
+
49
+ constructor() {
50
+ super();
51
+ this.attachShadow({ mode: 'open', delegatesFocus: true });
52
+ this._prev = null;
53
+ this._onKey = this._onKey.bind(this);
54
+ this._onInput = this._onInput.bind(this);
55
+ }
56
+
57
+ connectedCallback() {
58
+ const placeholder = escapeHTML(this.getAttribute('placeholder') || 'Search commands…');
59
+ this.shadowRoot.innerHTML = `
60
+ <style>${styles}</style>
61
+ <div class="overlay" part="overlay">
62
+ <div class="panel" role="dialog" aria-modal="true" aria-label="Command palette" part="panel">
63
+ <input class="search" type="search" autocomplete="off" placeholder="${placeholder}" aria-label="Search" part="search" />
64
+ <div class="results" part="results"><slot></slot></div>
65
+ </div>
66
+ </div>
67
+ `;
68
+ this.shadowRoot.querySelector('.search').addEventListener('input', this._onInput);
69
+ this.shadowRoot.querySelector('slot')?.addEventListener('slotchange', () => this._filter(''));
70
+ this._filter('');
71
+ }
72
+
73
+ attributeChangedCallback(name) {
74
+ if (name === 'open') this.hasAttribute('open') ? this._open() : this._close();
75
+ }
76
+
77
+ open() { this.setAttribute('open', ''); }
78
+ close() {
79
+ this.removeAttribute('open');
80
+ this.dispatchEvent(new CustomEvent('velin-close', { bubbles: true }));
81
+ }
82
+
83
+ _open() {
84
+ this._prev = saveFocus();
85
+ setBackgroundInert(this);
86
+ document.addEventListener('keydown', this._onKey);
87
+ requestAnimationFrame(() => {
88
+ this.shadowRoot.querySelector('.search')?.focus();
89
+ this._filter('');
90
+ });
91
+ }
92
+
93
+ _close() {
94
+ document.removeEventListener('keydown', this._onKey);
95
+ clearBackgroundInert();
96
+ restoreFocus(this._prev);
97
+ const input = this.shadowRoot.querySelector('.search');
98
+ if (input) input.value = '';
99
+ this._filter('');
100
+ }
101
+
102
+ _onInput(e) {
103
+ this._filter(e.target.value);
104
+ }
105
+
106
+ _filter(query) {
107
+ const q = query.trim().toLowerCase();
108
+ const slot = this.shadowRoot.querySelector('slot');
109
+ slot?.assignedElements().forEach((btn) => {
110
+ const text = btn.textContent?.trim().toLowerCase() || '';
111
+ const match = !q || text.includes(q);
112
+ btn.hidden = !match;
113
+ });
114
+ }
115
+
116
+ _onKey(e) {
117
+ if (e.key === 'Escape') { this.close(); return; }
118
+ trapFocus(this.shadowRoot, e);
119
+ }
120
+
121
+ disconnectedCallback() {
122
+ document.removeEventListener('keydown', this._onKey);
123
+ }
124
+ }
125
+
126
+ customElements.define('velin-command', VelinCommand);
127
+ export default VelinCommand;