@dile/ui 2.27.3 → 2.27.4

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.
@@ -0,0 +1,262 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import './app-drawer.js';
3
+ import { DILE_APP_DRAWER_EVENT_CLOSED, DILE_APP_DRAWER_EVENT_CLOSE_OUTSIDE } from './index.js';
4
+
5
+ describe('dile-app-drawer', () => {
6
+ afterEach(() => {
7
+ document.body.innerHTML = '';
8
+ });
9
+
10
+ function wait(ms) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
13
+
14
+ async function renderDrawer(html) {
15
+ document.body.innerHTML = html;
16
+ const el = document.body.querySelector('dile-app-drawer');
17
+ await el.updateComplete;
18
+ return el;
19
+ }
20
+
21
+ describe('Default state', () => {
22
+ it('defaults to direction "top" and closed', async () => {
23
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
24
+
25
+ expect(el.direction).toBe('top');
26
+ expect(el.opened).toBe(false);
27
+ });
28
+
29
+ it('reflects the opened property to the opened attribute', async () => {
30
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
31
+
32
+ el.open();
33
+ await el.updateComplete;
34
+
35
+ expect(el.hasAttribute('opened')).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe('Public API', () => {
40
+ it('open() sets opened to true', async () => {
41
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
42
+
43
+ el.open();
44
+ await el.updateComplete;
45
+
46
+ expect(el.opened).toBe(true);
47
+ });
48
+
49
+ it('close() sets opened to false and dispatches dile-app-drawer-closed', async () => {
50
+ const el = await renderDrawer('<dile-app-drawer opened></dile-app-drawer>');
51
+ let fired = false;
52
+ el.addEventListener(DILE_APP_DRAWER_EVENT_CLOSED, () => { fired = true; });
53
+
54
+ el.close();
55
+ await el.updateComplete;
56
+
57
+ expect(el.opened).toBe(false);
58
+ expect(fired).toBe(true);
59
+ });
60
+
61
+ it('toggle() flips the opened state', async () => {
62
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
63
+
64
+ el.toggle();
65
+ await el.updateComplete;
66
+ expect(el.opened).toBe(true);
67
+
68
+ el.toggle();
69
+ await el.updateComplete;
70
+ expect(el.opened).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe('Modal overlay', () => {
75
+ it('renders a modal overlay by default', async () => {
76
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
77
+
78
+ expect(el.shadowRoot.querySelector('.modal')).toBeTruthy();
79
+ });
80
+
81
+ it('does not render a modal overlay when no-modal is set', async () => {
82
+ const el = await renderDrawer('<dile-app-drawer no-modal></dile-app-drawer>');
83
+
84
+ expect(el.shadowRoot.querySelector('.modal')).toBeNull();
85
+ });
86
+
87
+ it('clicking the overlay closes the drawer and dispatches closed + click-outside events', async () => {
88
+ const el = await renderDrawer('<dile-app-drawer opened></dile-app-drawer>');
89
+ let closedFired = false;
90
+ let outsideFired = false;
91
+ el.addEventListener(DILE_APP_DRAWER_EVENT_CLOSED, () => { closedFired = true; });
92
+ el.addEventListener(DILE_APP_DRAWER_EVENT_CLOSE_OUTSIDE, () => { outsideFired = true; });
93
+
94
+ el.shadowRoot.querySelector('.modal').click();
95
+ await el.updateComplete;
96
+
97
+ expect(el.opened).toBe(false);
98
+ expect(closedFired).toBe(true);
99
+ expect(outsideFired).toBe(true);
100
+ });
101
+
102
+ it('clicking the overlay while already closed does not dispatch events', async () => {
103
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
104
+ let outsideFired = false;
105
+ el.addEventListener(DILE_APP_DRAWER_EVENT_CLOSE_OUTSIDE, () => { outsideFired = true; });
106
+
107
+ el.shadowRoot.querySelector('.modal').click();
108
+ await el.updateComplete;
109
+
110
+ expect(outsideFired).toBe(false);
111
+ });
112
+
113
+ it('stops click events on the menu content from bubbling past the drawer', async () => {
114
+ const el = await renderDrawer('<div id="wrapper"><dile-app-drawer opened></dile-app-drawer></div>');
115
+ const wrapper = document.querySelector('#wrapper');
116
+ let wrapperClicked = false;
117
+ wrapper.addEventListener('click', () => { wrapperClicked = true; });
118
+
119
+ el.shadowRoot.querySelector('.menu').click();
120
+
121
+ expect(wrapperClicked).toBe(false);
122
+ expect(el.opened).toBe(true);
123
+ });
124
+ });
125
+
126
+ describe('Dialog accessibility', () => {
127
+ it('renders the menu as a labeled, described dialog', async () => {
128
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
129
+ const menu = el.shadowRoot.querySelector('.menu');
130
+
131
+ expect(menu.getAttribute('role')).toBe('dialog');
132
+ expect(menu.getAttribute('aria-label')).toBe('Application menu');
133
+ expect(menu.getAttribute('aria-description')).toContain('Escape');
134
+ });
135
+
136
+ it('sets aria-modal to reflect the opened state', async () => {
137
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
138
+ const menu = el.shadowRoot.querySelector('.menu');
139
+
140
+ expect(menu.getAttribute('aria-modal')).toBe('false');
141
+
142
+ el.open();
143
+ await el.updateComplete;
144
+
145
+ expect(menu.getAttribute('aria-modal')).toBe('true');
146
+ });
147
+
148
+ it('marks the menu (and overlay) inert while closed, interactive while opened', async () => {
149
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
150
+ const menu = el.shadowRoot.querySelector('.menu');
151
+ const modal = el.shadowRoot.querySelector('.modal');
152
+
153
+ expect(menu.hasAttribute('inert')).toBe(true);
154
+ expect(modal.hasAttribute('inert')).toBe(true);
155
+
156
+ el.open();
157
+ await el.updateComplete;
158
+
159
+ expect(menu.hasAttribute('inert')).toBe(false);
160
+ expect(modal.hasAttribute('inert')).toBe(false);
161
+ });
162
+ });
163
+
164
+ describe('Keyboard - Escape', () => {
165
+ it('closes the drawer when Escape is pressed while opened', async () => {
166
+ const el = await renderDrawer('<dile-app-drawer opened></dile-app-drawer>');
167
+
168
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
169
+ await el.updateComplete;
170
+
171
+ expect(el.opened).toBe(false);
172
+ });
173
+
174
+ it('does nothing when Escape is pressed while already closed', async () => {
175
+ const el = await renderDrawer('<dile-app-drawer></dile-app-drawer>');
176
+
177
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
178
+ await el.updateComplete;
179
+
180
+ expect(el.opened).toBe(false);
181
+ });
182
+ });
183
+
184
+ describe('Focus management', () => {
185
+ it('moves focus into the menu when opened', async () => {
186
+ const el = await renderDrawer('<dile-app-drawer><button>Item</button></dile-app-drawer>');
187
+
188
+ el.open();
189
+ await el.updateComplete;
190
+
191
+ expect(el.shadowRoot.activeElement).toBe(el.shadowRoot.querySelector('.menu'));
192
+ });
193
+
194
+ it('restores focus to the trigger element after closing', async () => {
195
+ document.body.innerHTML = '<button id="trigger">Open</button><dile-app-drawer></dile-app-drawer>';
196
+ const trigger = document.querySelector('#trigger');
197
+ const el = document.querySelector('dile-app-drawer');
198
+ await el.updateComplete;
199
+
200
+ trigger.focus();
201
+ el.open();
202
+ await el.updateComplete;
203
+ el.close();
204
+ await el.updateComplete;
205
+ await wait(10);
206
+
207
+ expect(document.activeElement).toBe(trigger);
208
+ });
209
+
210
+ it('traps Tab focus: wraps from the last focusable element to the first', async () => {
211
+ const el = await renderDrawer(`
212
+ <dile-app-drawer opened>
213
+ <button id="first">First</button>
214
+ <button id="last">Last</button>
215
+ </dile-app-drawer>
216
+ `);
217
+ await el.updateComplete;
218
+ const first = el.querySelector('#first');
219
+ const last = el.querySelector('#last');
220
+
221
+ last.focus();
222
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
223
+
224
+ expect(document.activeElement).toBe(first);
225
+ });
226
+
227
+ it('traps Shift+Tab focus: wraps from the first focusable element to the last', async () => {
228
+ const el = await renderDrawer(`
229
+ <dile-app-drawer opened>
230
+ <button id="first">First</button>
231
+ <button id="last">Last</button>
232
+ </dile-app-drawer>
233
+ `);
234
+ await el.updateComplete;
235
+ const first = el.querySelector('#first');
236
+ const last = el.querySelector('#last');
237
+
238
+ first.focus();
239
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }));
240
+
241
+ expect(document.activeElement).toBe(last);
242
+ });
243
+
244
+ it('does not move focus when Tab is pressed with focus outside the drawer', async () => {
245
+ document.body.innerHTML = `
246
+ <input id="outside" />
247
+ <dile-app-drawer opened>
248
+ <button id="first">First</button>
249
+ <button id="last">Last</button>
250
+ </dile-app-drawer>
251
+ `;
252
+ const el = document.querySelector('dile-app-drawer');
253
+ await el.updateComplete;
254
+ const outside = document.querySelector('#outside');
255
+
256
+ outside.focus();
257
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
258
+
259
+ expect(document.activeElement).toBe(outside);
260
+ });
261
+ });
262
+ });
@@ -27,6 +27,9 @@ export class DileAppDrawer extends DileCloseOnEscPressed(LitElement) {
27
27
  this.direction = 'top';
28
28
  this.opened = false;
29
29
  this._documentClose = this._documentClose.bind(this);
30
+ this._triggerElement = null;
31
+ this._handleKeyDown = this._handleKeyDown.bind(this);
32
+ this._focusTrapActive = false;
30
33
  }
31
34
 
32
35
  static get styles() {
@@ -106,22 +109,113 @@ export class DileAppDrawer extends DileCloseOnEscPressed(LitElement) {
106
109
  transform: translateX(0);
107
110
  box-shadow: var(--dile-app-drawer-box-shadow, 1px 0 8px #000);
108
111
  }
112
+
113
+ /* Respect prefers-reduced-motion */
114
+ @media (prefers-reduced-motion: reduce) {
115
+ .modal {
116
+ transition: none;
117
+ }
118
+
119
+ .menu {
120
+ transition: none;
121
+ }
122
+ }
109
123
  `;
110
124
  }
111
125
 
112
126
  render() {
113
127
  return html`
114
128
  ${this.modalTemplate}
115
- <div class="menu" @click="${this._contentClick}">
116
- <section>
129
+ <div
130
+ class="menu"
131
+ role="dialog"
132
+ aria-modal="${this.opened ? 'true' : 'false'}"
133
+ aria-label="Application menu"
134
+ aria-description="Press Escape to close. You can also click outside the menu."
135
+ ?inert="${!this.opened}"
136
+ tabindex="-1"
137
+ @click="${this._contentClick}"
138
+ @keydown="${this._handleKeyDown}">
139
+ <nav aria-label="Main navigation">
117
140
  <slot></slot>
118
- </section>
141
+ </nav>
119
142
  </div>
120
143
  `;
121
144
  }
122
145
 
123
146
  get modalTemplate() {
124
- return this.noModal ? '' : html`<div class="modal" @click=${this._documentClose}></div>`;
147
+ return this.noModal ? '' : html`<div
148
+ class="modal"
149
+ aria-hidden="true"
150
+ ?inert="${!this.opened}"
151
+ @click=${this._documentClose}>
152
+ </div>`;
153
+ }
154
+
155
+ updated(changedProperties) {
156
+ if (changedProperties.has('opened')) {
157
+ if (this.opened) {
158
+ this._openDrawer();
159
+ } else {
160
+ this._closeDrawer();
161
+ }
162
+ }
163
+ }
164
+
165
+ _openDrawer() {
166
+ this._triggerElement = document.activeElement;
167
+ this.shadowRoot.querySelector('.menu').focus();
168
+ this._focusTrapActive = true;
169
+ document.addEventListener('keydown', this._handleKeyDown);
170
+ }
171
+
172
+ _closeDrawer() {
173
+ this._focusTrapActive = false;
174
+ document.removeEventListener('keydown', this._handleKeyDown);
175
+
176
+ if (this._triggerElement && typeof this._triggerElement.focus === 'function') {
177
+ setTimeout(() => {
178
+ this._triggerElement.focus();
179
+ }, 0);
180
+ }
181
+ }
182
+
183
+ _getFocusableElements() {
184
+ const focusableSelector = `
185
+ button:not([disabled]),
186
+ [href],
187
+ input:not([disabled]),
188
+ select:not([disabled]),
189
+ textarea:not([disabled]),
190
+ [tabindex]:not([tabindex="-1"]):not([disabled]),
191
+ [role="button"]:not([aria-disabled="true"])
192
+ `;
193
+ return Array.from(this.querySelectorAll(focusableSelector)).filter(el => {
194
+ return el.offsetParent !== null;
195
+ });
196
+ }
197
+
198
+ _handleKeyDown(e) {
199
+ if (!this._focusTrapActive || e.key !== 'Tab') return;
200
+
201
+ const focusableElements = this._getFocusableElements();
202
+ if (focusableElements.length === 0) return;
203
+
204
+ const firstElement = focusableElements[0];
205
+ const lastElement = focusableElements[focusableElements.length - 1];
206
+ const activeElement = document.activeElement;
207
+
208
+ const focusIsInsideDrawer = focusableElements.includes(activeElement);
209
+ if (!focusIsInsideDrawer) return;
210
+
211
+ if (e.shiftKey && activeElement === firstElement) {
212
+ e.preventDefault();
213
+ lastElement.focus();
214
+ }
215
+ else if (!e.shiftKey && activeElement === lastElement) {
216
+ e.preventDefault();
217
+ firstElement.focus();
218
+ }
125
219
  }
126
220
 
127
221
  _documentClose() {
@@ -125,6 +125,19 @@ export class DileHamburger extends LitElement {
125
125
  .hamburger:focus-visible .hamburger-inner::after {
126
126
  background-color: var(--dile-hamburger-focus-color, var(--dile-primary-dark-color, #0a66ff));
127
127
  }
128
+
129
+ /* Respect prefers-reduced-motion */
130
+ @media (prefers-reduced-motion: reduce) {
131
+ .hamburger {
132
+ transition: none;
133
+ }
134
+
135
+ .hamburger-inner,
136
+ .hamburger-inner::before,
137
+ .hamburger-inner::after {
138
+ transition: none;
139
+ }
140
+ }
128
141
  `;
129
142
  }
130
143
 
@@ -63,7 +63,15 @@ export class DileMenuHamburger extends LitElement {
63
63
 
64
64
  render() {
65
65
  return html`
66
- <dile-hamburger @click="${this.toggle}" ?active="${this.opened}"></dile-hamburger>
66
+ <dile-hamburger
67
+ @click="${this.toggle}"
68
+ ?active="${this.opened}"
69
+ aria-label="Open main menu"
70
+ aria-expanded="${this.opened}"
71
+ aria-controls="drawer"
72
+ aria-haspopup="dialog"
73
+ role="button">
74
+ </dile-hamburger>
67
75
  <dile-app-drawer
68
76
  id="drawer"
69
77
  ?opened="${this.opened}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/ui",
3
- "version": "2.27.3",
3
+ "version": "2.27.4",
4
4
  "description": "UI Core components from dile-components.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -27,5 +27,5 @@
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
30
- "gitHead": "8c1d820ed3098c991942d79b299327e2ea6aef07"
30
+ "gitHead": "d58cfec4206fe5fca4fa634be70f11ab3c9a6899"
31
31
  }