@birdapi/velinstyle 0.6.1 → 0.7.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.
@@ -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;
@@ -1,4 +1,4 @@
1
- import { trapFocus, saveFocus, restoreFocus, getFocusableElements } from './focus-manager.js';
1
+ import { trapFocus, saveFocus, restoreFocus, getFocusableElements, setBackgroundInert, clearBackgroundInert } from './focus-manager.js';
2
2
  import { escapeHTML } from './sanitize.js';
3
3
 
4
4
  const styles = `
@@ -61,12 +61,13 @@ class VelinDrawer extends HTMLElement {
61
61
  connectedCallback() {
62
62
  const title = this.getAttribute('title') || '';
63
63
  const safeTitle = escapeHTML(title);
64
+ const titleId = 'velin-drawer-title';
64
65
  this.shadowRoot.innerHTML = `
65
66
  <style>${styles}</style>
66
67
  <div class="overlay" part="overlay"></div>
67
- <div class="drawer" role="dialog" aria-modal="true" aria-label="${safeTitle}" part="drawer">
68
+ <div class="drawer" role="dialog" aria-modal="true" aria-labelledby="${titleId}" part="drawer">
68
69
  <div class="header" part="header">
69
- <h2 class="title">${safeTitle}</h2>
70
+ <h2 class="title" id="${titleId}">${safeTitle}</h2>
70
71
  <button class="close-btn" aria-label="Close" part="close">&#215;</button>
71
72
  </div>
72
73
  <div class="body" part="body"><slot></slot></div>
@@ -85,6 +86,7 @@ class VelinDrawer extends HTMLElement {
85
86
 
86
87
  _open() {
87
88
  this._prev = saveFocus();
89
+ setBackgroundInert(this);
88
90
  document.addEventListener('keydown', this._onKey);
89
91
  document.body.style.overflow = 'hidden';
90
92
  requestAnimationFrame(() => {
@@ -96,6 +98,7 @@ class VelinDrawer extends HTMLElement {
96
98
  _close() {
97
99
  document.removeEventListener('keydown', this._onKey);
98
100
  document.body.style.overflow = '';
101
+ clearBackgroundInert();
99
102
  restoreFocus(this._prev);
100
103
  }
101
104
 
@@ -62,6 +62,8 @@ class VelinDropdown extends HTMLElement {
62
62
  this.attachShadow({ mode: 'open', delegatesFocus: true });
63
63
  this._onDocClick = this._onDocClick.bind(this);
64
64
  this._onKeydown = this._onKeydown.bind(this);
65
+ this._typeahead = '';
66
+ this._typeaheadTimer = null;
65
67
  }
66
68
 
67
69
  connectedCallback() {
@@ -75,17 +77,31 @@ class VelinDropdown extends HTMLElement {
75
77
  `;
76
78
 
77
79
  const triggerSlot = this.shadowRoot.querySelector('slot[name="trigger"]');
80
+ const menuSlot = this.shadowRoot.querySelector('slot:not([name])');
78
81
  triggerSlot.addEventListener('click', () => this.toggle());
79
82
  triggerSlot.addEventListener('slotchange', () => {
80
83
  const trigger = triggerSlot.assignedElements()[0];
81
84
  if (trigger) {
82
85
  trigger.setAttribute('aria-haspopup', 'menu');
83
86
  trigger.setAttribute('aria-expanded', this.hasAttribute('open') ? 'true' : 'false');
87
+ const menuId = this._menuId || (this._menuId = `velin-dropdown-menu-${Math.random().toString(36).slice(2, 9)}`);
88
+ trigger.setAttribute('aria-controls', menuId);
89
+ this.shadowRoot.querySelector('.menu')?.setAttribute('id', menuId);
84
90
  }
85
91
  });
92
+ menuSlot?.addEventListener('slotchange', () => this._normalizeMenuItems());
93
+ this._normalizeMenuItems();
86
94
  this.addEventListener('keydown', this._onKeydown);
87
95
  }
88
96
 
97
+ _normalizeMenuItems() {
98
+ const items = this._getMenuItems();
99
+ items.forEach((el, i) => {
100
+ if (!el.hasAttribute('role')) el.setAttribute('role', 'menuitem');
101
+ el.setAttribute('tabindex', i === 0 ? '0' : '-1');
102
+ });
103
+ }
104
+
89
105
  toggle() {
90
106
  if (this.hasAttribute('open')) {
91
107
  this.close();
@@ -138,9 +154,24 @@ class VelinDropdown extends HTMLElement {
138
154
  }
139
155
 
140
156
  const items = this._getMenuItems();
141
- if (items.length > 0) {
142
- rovingTabindex(this, items, event);
157
+ if (items.length === 0) return;
158
+
159
+ if (event.key.length === 1 && /[a-z0-9]/i.test(event.key)) {
160
+ clearTimeout(this._typeaheadTimer);
161
+ this._typeahead += event.key.toLowerCase();
162
+ this._typeaheadTimer = setTimeout(() => { this._typeahead = ''; }, 500);
163
+ const match = items.find((el) =>
164
+ (el.textContent?.trim().toLowerCase() || '').startsWith(this._typeahead)
165
+ );
166
+ if (match) {
167
+ event.preventDefault();
168
+ items.forEach((item) => item.setAttribute('tabindex', item === match ? '0' : '-1'));
169
+ match.focus();
170
+ }
171
+ return;
143
172
  }
173
+
174
+ rovingTabindex(this, items, event);
144
175
  }
145
176
 
146
177
  disconnectedCallback() {
@@ -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);
@@ -21,20 +21,24 @@ const styles = `
21
21
  @media (prefers-reduced-motion: reduce) { .tip { transition: none; } }
22
22
  `;
23
23
 
24
+ let tooltipId = 0;
25
+
24
26
  class VelinTooltipWC extends HTMLElement {
25
27
  static get observedAttributes() { return ['content', 'placement']; }
26
28
 
27
29
  constructor() {
28
30
  super();
29
31
  this.attachShadow({ mode: 'open' });
32
+ this._tipId = `velin-tooltip-${++tooltipId}`;
30
33
  }
31
34
 
32
35
  connectedCallback() {
33
36
  const placement = this.getAttribute('placement') || 'top';
37
+ const D = 'div';
34
38
  this.shadowRoot.innerHTML = `
35
39
  <style>${styles}</style>
36
40
  <slot></slot>
37
- <div class="tip" role="tooltip" data-placement="${escapeHTML(placement)}" part="tip">${escapeHTML(this.getAttribute('content') || '')}</div>
41
+ <${D} class="tip" id="${this._tipId}" role="tooltip" data-placement="${escapeHTML(placement)}" part="tip">${escapeHTML(this.getAttribute('content') || '')}</${D}>
38
42
  `;
39
43
 
40
44
  this.addEventListener('mouseenter', () => this._show());
@@ -42,14 +46,34 @@ class VelinTooltipWC extends HTMLElement {
42
46
  this.addEventListener('focusin', () => this._show());
43
47
  this.addEventListener('focusout', () => this._hide());
44
48
  this.addEventListener('keydown', (e) => { if (e.key === 'Escape') this._hide(); });
49
+
50
+ const slot = this.shadowRoot.querySelector('slot');
51
+ slot.addEventListener('slotchange', () => this._linkTrigger());
52
+ this._linkTrigger();
53
+ }
54
+
55
+ _linkTrigger() {
56
+ const trigger = this.shadowRoot.querySelector('slot')?.assignedElements()[0];
57
+ if (!trigger) return;
58
+ if (this.hasAttribute('visible')) {
59
+ trigger.setAttribute('aria-describedby', this._tipId);
60
+ } else {
61
+ trigger.removeAttribute('aria-describedby');
62
+ }
45
63
  }
46
64
 
47
65
  _show() {
48
66
  this.setAttribute('visible', '');
67
+ const trigger = this.shadowRoot.querySelector('slot')?.assignedElements()[0];
68
+ if (trigger) trigger.setAttribute('aria-describedby', this._tipId);
49
69
  this._flip();
50
70
  }
51
71
 
52
- _hide() { this.removeAttribute('visible'); }
72
+ _hide() {
73
+ this.removeAttribute('visible');
74
+ const trigger = this.shadowRoot.querySelector('slot')?.assignedElements()[0];
75
+ if (trigger) trigger.removeAttribute('aria-describedby');
76
+ }
53
77
 
54
78
  _flip() {
55
79
  const tip = this.shadowRoot.querySelector('.tip');