@dile/ui 3.16.2 → 3.17.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.
@@ -0,0 +1,5 @@
1
+ import { DileBottomNavItem } from "./src/DileBottomNavItem.js";
2
+
3
+ if (!customElements.get("dile-bottom-nav-item")) {
4
+ window.customElements.define("dile-bottom-nav-item", DileBottomNavItem);
5
+ }
@@ -0,0 +1,6 @@
1
+ import './bottom-nav-item.js';
2
+ import { DileBottomNav } from "./src/DileBottomNav.js";
3
+
4
+ if (!customElements.get("dile-bottom-nav")) {
5
+ window.customElements.define("dile-bottom-nav", DileBottomNav);
6
+ }
@@ -0,0 +1,340 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import '@dile/iconlib/lucide-icons/house.js';
3
+ import './bottom-nav.js';
4
+
5
+ describe('dile-bottom-nav', () => {
6
+ afterEach(() => {
7
+ document.body.innerHTML = '';
8
+ document.documentElement.style.overflow = '';
9
+ });
10
+
11
+ async function renderNav(html) {
12
+ document.body.innerHTML = html;
13
+ const el = document.body.querySelector('dile-bottom-nav');
14
+ await el.updateComplete;
15
+ const items = [...el.querySelectorAll('dile-bottom-nav-item')];
16
+ await Promise.all(items.map((item) => item.updateComplete));
17
+ // Wait for slotchange to detect the sheets.
18
+ await new Promise((resolve) => setTimeout(resolve));
19
+ await Promise.all(items.map((item) => item.updateComplete));
20
+ return { el, items };
21
+ }
22
+
23
+ function trigger(item) {
24
+ return item.shadowRoot.querySelector('.trigger');
25
+ }
26
+
27
+ it('renders a navigation landmark with the slotted items', async () => {
28
+ const { el, items } = await renderNav(`
29
+ <dile-bottom-nav label="App navigation">
30
+ <dile-bottom-nav-item label="Home"></dile-bottom-nav-item>
31
+ <dile-bottom-nav-item label="Search"></dile-bottom-nav-item>
32
+ </dile-bottom-nav>
33
+ `);
34
+
35
+ const nav = el.shadowRoot.querySelector('nav');
36
+ expect(nav.getAttribute('aria-label')).toBe('App navigation');
37
+ expect(items.length).toBe(2);
38
+ expect(items[0].shadowRoot.querySelector('.label').textContent).toBe('Home');
39
+ });
40
+
41
+ it('is fixed by default and in the document flow when static', async () => {
42
+ const { el } = await renderNav(`<dile-bottom-nav></dile-bottom-nav>`);
43
+ expect(getComputedStyle(el).position).toBe('fixed');
44
+
45
+ el.static = true;
46
+ await el.updateComplete;
47
+ expect(getComputedStyle(el).position).toBe('relative');
48
+ });
49
+
50
+ it('compensates the scrollbar width in the fixed bar while the page scroll is locked', async () => {
51
+ const { el } = await renderNav(`<dile-bottom-nav></dile-bottom-nav>`);
52
+ document.documentElement.style.setProperty('--dile-bottom-nav-scrollbar-width', '15px');
53
+
54
+ expect(getComputedStyle(el).paddingRight).toBe('15px');
55
+ el.static = true;
56
+ await el.updateComplete;
57
+ expect(getComputedStyle(el).paddingRight).toBe('0px');
58
+
59
+ document.documentElement.style.removeProperty('--dile-bottom-nav-scrollbar-width');
60
+ });
61
+
62
+ it('renders the icon of the item from a dile-iconlib string', async () => {
63
+ const { items } = await renderNav(`
64
+ <dile-bottom-nav>
65
+ <dile-bottom-nav-item label="Home" icon="lucide.house"></dile-bottom-nav-item>
66
+ <dile-bottom-nav-item label="Search"></dile-bottom-nav-item>
67
+ </dile-bottom-nav>
68
+ `);
69
+
70
+ const icon = items[0].shadowRoot.querySelector('dile-iconlib');
71
+ expect(icon.getAttribute('icon')).toBe('lucide.house');
72
+ expect(items[1].shadowRoot.querySelector('dile-iconlib')).toBeNull();
73
+ });
74
+
75
+ it('marks the selected item by attribute and changes selection on click', async () => {
76
+ const { el, items } = await renderNav(`
77
+ <dile-bottom-nav attrForSelected="name" selected="home">
78
+ <dile-bottom-nav-item name="home" label="Home"></dile-bottom-nav-item>
79
+ <dile-bottom-nav-item name="search" label="Search"></dile-bottom-nav-item>
80
+ </dile-bottom-nav>
81
+ `);
82
+
83
+ expect(items[0].selected).toBe(true);
84
+ expect(trigger(items[0]).getAttribute('aria-current')).toBe('page');
85
+
86
+ let detail;
87
+ el.addEventListener('dile-selected-changed', (e) => { detail = e.detail; });
88
+ trigger(items[1]).click();
89
+ await el.updateComplete;
90
+
91
+ expect(el.selected).toBe('search');
92
+ expect(detail.selected).toBe('search');
93
+ expect(items[1].selected).toBe(true);
94
+ expect(items[0].selected).toBe(false);
95
+ });
96
+
97
+ it('dispatches dile-bottom-nav-item-click with the item name', async () => {
98
+ const { el, items } = await renderNav(`
99
+ <dile-bottom-nav>
100
+ <dile-bottom-nav-item name="home" label="Home"></dile-bottom-nav-item>
101
+ </dile-bottom-nav>
102
+ `);
103
+
104
+ let detail;
105
+ el.addEventListener('dile-bottom-nav-item-click', (e) => { detail = e.detail; });
106
+ trigger(items[0]).click();
107
+
108
+ expect(detail.name).toBe('home');
109
+ });
110
+
111
+ it('renders a native link when the item has an href', async () => {
112
+ const { items } = await renderNav(`
113
+ <dile-bottom-nav>
114
+ <dile-bottom-nav-item label="Home" href="#home" target="_self"></dile-bottom-nav-item>
115
+ <dile-bottom-nav-item label="Search"></dile-bottom-nav-item>
116
+ </dile-bottom-nav>
117
+ `);
118
+
119
+ const link = items[0].shadowRoot.querySelector('a');
120
+ expect(link.getAttribute('href')).toBe('#home');
121
+ expect(link.getAttribute('target')).toBe('_self');
122
+ expect(items[1].shadowRoot.querySelector('a')).toBeNull();
123
+ });
124
+
125
+ it('opens the bottom sheet of an item with slotted content without changing the selection', async () => {
126
+ const { el, items } = await renderNav(`
127
+ <dile-bottom-nav attrForSelected="name" selected="home">
128
+ <dile-bottom-nav-item name="home" label="Home"></dile-bottom-nav-item>
129
+ <dile-bottom-nav-item name="create" label="Create">
130
+ <a href="#new">New</a>
131
+ </dile-bottom-nav-item>
132
+ </dile-bottom-nav>
133
+ `);
134
+
135
+ const button = trigger(items[1]);
136
+ expect(button.getAttribute('aria-haspopup')).toBe('dialog');
137
+ expect(button.getAttribute('aria-expanded')).toBe('false');
138
+
139
+ let opened = false;
140
+ el.addEventListener('dile-bottom-nav-sheet-opened', () => { opened = true; });
141
+ button.click();
142
+ await items[1].updateComplete;
143
+
144
+ expect(items[1].opened).toBe(true);
145
+ expect(opened).toBe(true);
146
+ expect(button.getAttribute('aria-expanded')).toBe('true');
147
+ expect(document.documentElement.style.overflow).toBe('hidden');
148
+ expect(el.selected).toBe('home');
149
+ expect(items[0].selected).toBe(true);
150
+ });
151
+
152
+ it('closes the sheet when clicking the overlay', async () => {
153
+ const { items } = await renderNav(`
154
+ <dile-bottom-nav>
155
+ <dile-bottom-nav-item label="Create"><p>Content</p></dile-bottom-nav-item>
156
+ </dile-bottom-nav>
157
+ `);
158
+
159
+ await items[0].open();
160
+ let closed = false;
161
+ items[0].addEventListener('dile-bottom-nav-sheet-closed', () => { closed = true; });
162
+ items[0].shadowRoot.querySelector('.overlay').click();
163
+ await items[0].updateComplete;
164
+
165
+ expect(items[0].opened).toBe(false);
166
+ expect(closed).toBe(true);
167
+ expect(document.documentElement.style.overflow).toBe('');
168
+ });
169
+
170
+ it('closes the sheet when pressing Escape', async () => {
171
+ const { items } = await renderNav(`
172
+ <dile-bottom-nav>
173
+ <dile-bottom-nav-item label="Create"><p>Content</p></dile-bottom-nav-item>
174
+ </dile-bottom-nav>
175
+ `);
176
+
177
+ await items[0].open();
178
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
179
+ await items[0].updateComplete;
180
+
181
+ expect(items[0].opened).toBe(false);
182
+ });
183
+
184
+ it('closes the sheet when clicking an element with data-close', async () => {
185
+ const { items } = await renderNav(`
186
+ <dile-bottom-nav>
187
+ <dile-bottom-nav-item label="Create">
188
+ <button id="keep">Keep</button>
189
+ <button id="close" data-close>Close</button>
190
+ </dile-bottom-nav-item>
191
+ </dile-bottom-nav>
192
+ `);
193
+
194
+ await items[0].open();
195
+ document.getElementById('keep').click();
196
+ await items[0].updateComplete;
197
+ expect(items[0].opened).toBe(true);
198
+
199
+ document.getElementById('close').click();
200
+ await items[0].updateComplete;
201
+ expect(items[0].opened).toBe(false);
202
+ });
203
+
204
+ it('closes the sheet when clicking any link or button with close-on-click', async () => {
205
+ const { items } = await renderNav(`
206
+ <dile-bottom-nav>
207
+ <dile-bottom-nav-item label="Create" close-on-click>
208
+ <button id="action">Action</button>
209
+ </dile-bottom-nav-item>
210
+ </dile-bottom-nav>
211
+ `);
212
+
213
+ await items[0].open();
214
+ document.getElementById('action').click();
215
+ await items[0].updateComplete;
216
+
217
+ expect(items[0].opened).toBe(false);
218
+ });
219
+
220
+ it('keeps only one sheet opened at a time', async () => {
221
+ const { items } = await renderNav(`
222
+ <dile-bottom-nav>
223
+ <dile-bottom-nav-item label="One"><p>One</p></dile-bottom-nav-item>
224
+ <dile-bottom-nav-item label="Two"><p>Two</p></dile-bottom-nav-item>
225
+ </dile-bottom-nav>
226
+ `);
227
+
228
+ await items[0].open();
229
+ await items[1].open();
230
+ await items[0].updateComplete;
231
+
232
+ expect(items[0].opened).toBe(false);
233
+ expect(items[1].opened).toBe(true);
234
+ });
235
+
236
+ it('replaces the item icon with the close icon while the sheet is opened', async () => {
237
+ const { items } = await renderNav(`
238
+ <dile-bottom-nav>
239
+ <dile-bottom-nav-item label="Create"><p>Content</p></dile-bottom-nav-item>
240
+ </dile-bottom-nav>
241
+ `);
242
+
243
+ expect(items[0].shadowRoot.querySelector('.close-icon')).toBeNull();
244
+ await items[0].open();
245
+ expect(items[0].shadowRoot.querySelector('.close-icon dile-iconlib').getAttribute('icon')).toBe('lucide.x');
246
+ expect(items[0].shadowRoot.querySelector('.trigger').textContent).toContain('Create');
247
+
248
+ trigger(items[0]).click();
249
+ await items[0].updateComplete;
250
+ expect(items[0].opened).toBe(false);
251
+ expect(items[0].shadowRoot.querySelector('.close-icon')).toBeNull();
252
+ });
253
+
254
+ it('places the sheet and the overlay above the bar', async () => {
255
+ const { el, items } = await renderNav(`
256
+ <dile-bottom-nav>
257
+ <dile-bottom-nav-item label="Create"><p>Content</p></dile-bottom-nav-item>
258
+ </dile-bottom-nav>
259
+ `);
260
+
261
+ await items[0].open();
262
+ await items[0].updateComplete;
263
+ const barTop = el.getBoundingClientRect().top;
264
+ const barHeight = window.innerHeight - barTop;
265
+ // Read the computed bottom instead of the bounding box, which is affected by the opening transition.
266
+ const sheetBottom = parseFloat(getComputedStyle(items[0].shadowRoot.querySelector('.sheet')).bottom);
267
+ const overlay = items[0].shadowRoot.querySelector('.overlay').getBoundingClientRect();
268
+
269
+ expect(sheetBottom).toBeGreaterThanOrEqual(Math.floor(barHeight));
270
+ expect(Math.round(overlay.bottom)).toBe(Math.round(barTop));
271
+ });
272
+
273
+ describe('static bar', () => {
274
+ async function renderStatic() {
275
+ const result = await renderNav(`
276
+ <div style="height: 400px"></div>
277
+ <dile-bottom-nav static style="width: 300px; margin: 0 auto;">
278
+ <dile-bottom-nav-item label="Home"></dile-bottom-nav-item>
279
+ <dile-bottom-nav-item label="Create"><button id="inside">Inside</button></dile-bottom-nav-item>
280
+ </dile-bottom-nav>
281
+ <button id="outside">Outside</button>
282
+ `);
283
+ await result.items[1].open();
284
+ await result.items[1].updateComplete;
285
+ return result;
286
+ }
287
+
288
+ it('opens a floating sheet without overlay and without locking the page scroll', async () => {
289
+ const { items } = await renderStatic();
290
+ const overlay = items[1].shadowRoot.querySelector('.overlay');
291
+ const sheet = items[1].shadowRoot.querySelector('.sheet');
292
+
293
+ expect(getComputedStyle(overlay).display).toBe('none');
294
+ expect(sheet.classList.contains('floating')).toBe(true);
295
+ expect(sheet.getAttribute('aria-modal')).toBe('false');
296
+ expect(document.documentElement.style.overflow).toBe('');
297
+ });
298
+
299
+ it('places the floating sheet above the trigger, centered and inside the viewport', async () => {
300
+ const { items } = await renderStatic();
301
+ const sheet = items[1].shadowRoot.querySelector('.sheet');
302
+ const triggerRect = trigger(items[1]).getBoundingClientRect();
303
+ // Inline position set by DileOverlay, not affected by the opening transition.
304
+ const top = parseFloat(sheet.style.top);
305
+ const left = parseFloat(sheet.style.left);
306
+ const width = sheet.offsetWidth;
307
+
308
+ expect(top + sheet.offsetHeight).toBeLessThanOrEqual(triggerRect.top);
309
+ expect(left).toBeGreaterThanOrEqual(10);
310
+ expect(left + width).toBeLessThanOrEqual(document.documentElement.clientWidth - 10);
311
+ const centered = triggerRect.left + triggerRect.width / 2 - width / 2;
312
+ const clamped = Math.min(Math.max(centered, 10), document.documentElement.clientWidth - width - 15);
313
+ expect(Math.round(left)).toBe(Math.round(clamped));
314
+ });
315
+
316
+ it('closes the floating sheet when clicking outside, but not inside', async () => {
317
+ const { items } = await renderStatic();
318
+
319
+ document.getElementById('inside').click();
320
+ await items[1].updateComplete;
321
+ expect(items[1].opened).toBe(true);
322
+
323
+ document.getElementById('outside').click();
324
+ await items[1].updateComplete;
325
+ expect(items[1].opened).toBe(false);
326
+ });
327
+
328
+ it('toggles the floating sheet from the trigger', async () => {
329
+ const { items } = await renderStatic();
330
+
331
+ trigger(items[1]).click();
332
+ await items[1].updateComplete;
333
+ expect(items[1].opened).toBe(false);
334
+
335
+ trigger(items[1]).click();
336
+ await items[1].updateComplete;
337
+ expect(items[1].opened).toBe(true);
338
+ });
339
+ });
340
+ });
@@ -0,0 +1,2 @@
1
+ export { DileBottomNav } from './src/DileBottomNav.js';
2
+ export { DileBottomNavItem } from './src/DileBottomNavItem.js';
@@ -0,0 +1,88 @@
1
+ import { html, css, LitElement } from "lit";
2
+ import { DileSelectable } from "../../../mixins/selectable/index.js";
3
+
4
+ export class DileBottomNav extends DileSelectable(LitElement) {
5
+ static get properties() {
6
+ return {
7
+ /**
8
+ * If true the bar is not fixed at the bottom of the viewport, it is rendered in the document flow.
9
+ */
10
+ static: { type: Boolean, reflect: true },
11
+ /**
12
+ * Accessible name of the navigation landmark.
13
+ */
14
+ label: { type: String },
15
+ };
16
+ }
17
+
18
+ constructor() {
19
+ super();
20
+ this.static = false;
21
+ this.label = 'Main navigation';
22
+ this.sheetOpenedHandler = this._closeOtherSheets.bind(this);
23
+ }
24
+
25
+ static get styles() {
26
+ return css`
27
+ * {
28
+ box-sizing: border-box;
29
+ }
30
+ :host {
31
+ display: block;
32
+ position: fixed;
33
+ left: 0;
34
+ right: 0;
35
+ bottom: 0;
36
+ z-index: var(--dile-bottom-nav-z-index, 100);
37
+ background-color: var(--dile-bottom-nav-background-color, var(--dile-background-color, #fff));
38
+ border-top: var(--dile-bottom-nav-border-top, 1px solid rgba(0, 0, 0, 0.1));
39
+ box-shadow: var(--dile-bottom-nav-box-shadow, 0 -2px 8px rgba(0, 0, 0, 0.08));
40
+ padding-bottom: env(safe-area-inset-bottom, 0);
41
+ }
42
+ :host(:not([static])) {
43
+ /* Set while a bottom sheet locks the page scroll, so the items don't move when the scrollbar disappears. */
44
+ padding-right: var(--dile-bottom-nav-scrollbar-width, 0px);
45
+ }
46
+ :host([static]) {
47
+ position: relative;
48
+ }
49
+ nav {
50
+ display: flex;
51
+ align-items: stretch;
52
+ height: var(--dile-bottom-nav-height, 56px);
53
+ max-width: var(--dile-bottom-nav-max-width, none);
54
+ margin: 0 auto;
55
+ }
56
+ ::slotted(*) {
57
+ flex: 1 1 0;
58
+ min-width: 0;
59
+ }
60
+ `;
61
+ }
62
+
63
+ connectedCallback() {
64
+ super.connectedCallback();
65
+ this.addEventListener('dile-bottom-nav-sheet-opened', this.sheetOpenedHandler);
66
+ }
67
+
68
+ disconnectedCallback() {
69
+ super.disconnectedCallback();
70
+ this.removeEventListener('dile-bottom-nav-sheet-opened', this.sheetOpenedHandler);
71
+ }
72
+
73
+ render() {
74
+ return html`
75
+ <nav aria-label="${this.label}">
76
+ <slot></slot>
77
+ </nav>
78
+ `;
79
+ }
80
+
81
+ _closeOtherSheets(e) {
82
+ this.getItems().forEach(item => {
83
+ if (item !== e.detail.item && item.opened && typeof item.close === 'function') {
84
+ item.close();
85
+ }
86
+ });
87
+ }
88
+ }
@@ -0,0 +1,482 @@
1
+ import { html, css, LitElement } from "lit";
2
+ import { ifDefined } from "lit/directives/if-defined.js";
3
+ import { styleMap } from "lit/directives/style-map.js";
4
+ import { DileSelectableItem } from "../../../mixins/selectable/index.js";
5
+ import { DileCloseOnEscPressed } from "../../../mixins/close-on-esc-pressed/index.js";
6
+ import { DileOverlay } from "../../../mixins/overlay/index.js";
7
+ import '@dile/iconlib/dile-iconlib.js';
8
+ // Glyph of the default close icon.
9
+ import '@dile/iconlib/lucide-icons/x.js';
10
+
11
+ // Number of opened sheets. The page scroll is locked while any sheet is opened.
12
+ let openedSheets = 0;
13
+ // Inline styles of the document before locking the page scroll.
14
+ let scrollLockBackup = null;
15
+
16
+ function updateScrollLock() {
17
+ const root = document.documentElement;
18
+ if (openedSheets > 0 && !scrollLockBackup) {
19
+ const scrollbarWidth = window.innerWidth - root.clientWidth;
20
+ scrollLockBackup = {
21
+ overflow: root.style.overflow,
22
+ paddingRight: root.style.paddingRight,
23
+ };
24
+ root.style.overflow = 'hidden';
25
+ if (scrollbarWidth > 0) {
26
+ // Avoids the layout shift caused by the scrollbar disappearing, in the page and in the fixed bar.
27
+ const paddingRight = parseFloat(getComputedStyle(root).paddingRight) || 0;
28
+ root.style.paddingRight = `${paddingRight + scrollbarWidth}px`;
29
+ root.style.setProperty('--dile-bottom-nav-scrollbar-width', `${scrollbarWidth}px`);
30
+ }
31
+ } else if (openedSheets === 0 && scrollLockBackup) {
32
+ root.style.overflow = scrollLockBackup.overflow;
33
+ root.style.paddingRight = scrollLockBackup.paddingRight;
34
+ root.style.removeProperty('--dile-bottom-nav-scrollbar-width');
35
+ scrollLockBackup = null;
36
+ }
37
+ }
38
+
39
+ // DileOverlay is only used for its updatePosition() method, which places the floating sheet
40
+ // next to the trigger and keeps it inside the viewport. open(), close() and toggle() are overridden.
41
+ export class DileBottomNavItem extends DileOverlay(DileCloseOnEscPressed(DileSelectableItem(LitElement))) {
42
+ static get properties() {
43
+ return {
44
+ /**
45
+ * Icon to display, in the dile-iconlib `"family.name"` format (e.g. "lucide.house").
46
+ */
47
+ icon: { type: String },
48
+ /**
49
+ * Text displayed under the icon.
50
+ */
51
+ label: { type: String },
52
+ /**
53
+ * Value used to select the item when the bar has attrForSelected="name".
54
+ */
55
+ name: { type: String, reflect: true },
56
+ /**
57
+ * If set, the item is rendered as a native link.
58
+ */
59
+ href: { type: String },
60
+ /**
61
+ * Target of the native link.
62
+ */
63
+ target: { type: String },
64
+ /**
65
+ * Opened status of the bottom sheet. Only applies when the item has slotted content.
66
+ */
67
+ opened: { type: Boolean, reflect: true },
68
+ /**
69
+ * If true, an item with a bottom sheet also becomes selected when it is clicked.
70
+ */
71
+ selectable: { type: Boolean },
72
+ /**
73
+ * If true, the bottom sheet closes when any link or button inside it is clicked.
74
+ */
75
+ closeOnClick: { type: Boolean, attribute: 'close-on-click' },
76
+ /**
77
+ * Icon displayed in the item while its bottom sheet is opened, in the dile-iconlib `"family.name"` format.
78
+ */
79
+ closeIcon: { type: String, attribute: 'close-icon' },
80
+ _hasSheet: { state: true },
81
+ _offset: { state: true },
82
+ _floating: { state: true },
83
+ };
84
+ }
85
+
86
+ constructor() {
87
+ super();
88
+ this.label = '';
89
+ this.opened = false;
90
+ this.selectable = false;
91
+ this.closeOnClick = false;
92
+ this._hasSheet = false;
93
+ this._offset = 0;
94
+ this._floating = false;
95
+ this.icon = '';
96
+ this.closeIcon = 'lucide.x';
97
+ // Floating sheet position, used when the bar is static (DileOverlay properties).
98
+ this.verticalAlign = 'top';
99
+ this.horizontalAlign = 'center';
100
+ this.moveTop = -8;
101
+ this._positionRafId = null;
102
+ this.viewportChangeHandler = this._onViewportChange.bind(this);
103
+ this.documentClickHandler = this._onDocumentClick.bind(this);
104
+ }
105
+
106
+ static get styles() {
107
+ return css`
108
+ * {
109
+ box-sizing: border-box;
110
+ }
111
+ :host {
112
+ display: block;
113
+ --dile-icon-size: var(--dile-bottom-nav-icon-size, 24px);
114
+ --dile-icon-color: var(--dile-bottom-nav-item-color, var(--dile-on-background-color, #666));
115
+ }
116
+ :host([selected]) {
117
+ --dile-icon-color: var(--dile-bottom-nav-item-selected-color, var(--dile-primary-color, #039be5));
118
+ }
119
+ .trigger {
120
+ all: unset;
121
+ box-sizing: border-box;
122
+ display: flex;
123
+ flex-direction: column;
124
+ align-items: center;
125
+ justify-content: center;
126
+ gap: var(--dile-bottom-nav-item-gap, 2px);
127
+ width: 100%;
128
+ height: 100%;
129
+ padding: var(--dile-bottom-nav-item-padding, 4px 2px);
130
+ cursor: pointer;
131
+ color: var(--dile-bottom-nav-item-color, var(--dile-on-background-color, #666));
132
+ text-decoration: none;
133
+ -webkit-tap-highlight-color: transparent;
134
+ transition: color 0.2s ease;
135
+ }
136
+ :host([selected]) .trigger {
137
+ color: var(--dile-bottom-nav-item-selected-color, var(--dile-primary-color, #039be5));
138
+ }
139
+ .trigger:focus-visible {
140
+ outline: 2px solid var(--dile-bottom-nav-item-selected-color, var(--dile-primary-color, #039be5));
141
+ outline-offset: -2px;
142
+ border-radius: 4px;
143
+ }
144
+ .icon {
145
+ display: flex;
146
+ }
147
+ .label {
148
+ max-width: 100%;
149
+ font-size: var(--dile-bottom-nav-label-font-size, 0.75rem);
150
+ font-weight: var(--dile-bottom-nav-label-font-weight, normal);
151
+ white-space: nowrap;
152
+ overflow: hidden;
153
+ text-overflow: ellipsis;
154
+ }
155
+ :host([selected]) .label {
156
+ font-weight: var(--dile-bottom-nav-label-selected-font-weight, bold);
157
+ }
158
+
159
+ .close-icon {
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ width: var(--dile-bottom-nav-close-icon-size, 44px);
164
+ height: var(--dile-bottom-nav-close-icon-size, 44px);
165
+ border-radius: 50%;
166
+ background-color: var(--dile-bottom-nav-close-icon-background-color, var(--dile-bottom-nav-item-selected-color, var(--dile-primary-color, #039be5)));
167
+ --dile-icon-color: var(--dile-bottom-nav-close-icon-color, var(--dile-bottom-nav-background-color, var(--dile-background-color, #fff)));
168
+ animation: dile-bottom-nav-close-in 0.3s ease;
169
+ }
170
+ @keyframes dile-bottom-nav-close-in {
171
+ from {
172
+ transform: rotate(-90deg) scale(0.5);
173
+ opacity: 0;
174
+ }
175
+ }
176
+ .visually-hidden {
177
+ position: absolute;
178
+ width: 1px;
179
+ height: 1px;
180
+ overflow: hidden;
181
+ clip: rect(0 0 0 0);
182
+ white-space: nowrap;
183
+ }
184
+
185
+ .overlay {
186
+ position: fixed;
187
+ top: 0;
188
+ left: 0;
189
+ right: 0;
190
+ z-index: 1;
191
+ background-color: var(--dile-bottom-nav-overlay-background-color, rgba(0, 0, 0, 0.5));
192
+ opacity: 0;
193
+ visibility: hidden;
194
+ transition: opacity 0.3s ease, visibility 0s linear 0.3s;
195
+ }
196
+ .sheet {
197
+ position: fixed;
198
+ left: var(--dile-bottom-nav-sheet-gap, 8px);
199
+ right: calc(var(--dile-bottom-nav-sheet-gap, 8px) + var(--dile-bottom-nav-scrollbar-width, 0px));
200
+ z-index: 2;
201
+ max-width: var(--dile-bottom-nav-sheet-max-width, none);
202
+ max-height: var(--dile-bottom-nav-sheet-max-height, 75vh);
203
+ margin: 0 auto;
204
+ overflow-y: auto;
205
+ overscroll-behavior: contain;
206
+ background-color: var(--dile-bottom-nav-sheet-background-color, var(--dile-background-color, #fff));
207
+ color: var(--dile-bottom-nav-sheet-text-color, var(--dile-on-background-color, #303030));
208
+ border-radius: var(--dile-bottom-nav-sheet-border-radius, 12px);
209
+ box-shadow: var(--dile-bottom-nav-sheet-box-shadow, 0 -4px 16px rgba(0, 0, 0, 0.2));
210
+ padding: var(--dile-bottom-nav-sheet-padding, 1rem);
211
+ opacity: 0;
212
+ transform: translateY(24px);
213
+ visibility: hidden;
214
+ transition: transform 0.3s ease, opacity 0.3s ease, visibility 0s linear 0.3s;
215
+ outline: none;
216
+ }
217
+ .sheet.floating {
218
+ right: auto;
219
+ width: var(--dile-bottom-nav-sheet-width, 280px);
220
+ max-width: calc(100vw - 25px);
221
+ margin: 0;
222
+ }
223
+ :host([opened]) .overlay {
224
+ opacity: 1;
225
+ visibility: visible;
226
+ transition: opacity 0.3s ease, visibility 0s;
227
+ }
228
+ :host([opened]) .sheet {
229
+ opacity: 1;
230
+ transform: translateY(0);
231
+ visibility: visible;
232
+ transition: transform 0.3s ease, opacity 0.3s ease, visibility 0s;
233
+ }
234
+ .nosheet, .overlay.floating {
235
+ display: none;
236
+ }
237
+
238
+ @media (prefers-reduced-motion: reduce) {
239
+ .trigger, .overlay, .sheet,
240
+ :host([opened]) .overlay, :host([opened]) .sheet {
241
+ transition: none;
242
+ }
243
+ .close-icon {
244
+ animation: none;
245
+ }
246
+ }
247
+ `;
248
+ }
249
+
250
+ // DileOverlay looks for #overlay and #trigger elements, not used in this component.
251
+ firstUpdated() {}
252
+
253
+ disconnectedCallback() {
254
+ super.disconnectedCallback();
255
+ this._removeOpenedListeners();
256
+ this._releaseScrollLock();
257
+ }
258
+
259
+ updated(changedProperties) {
260
+ if (changedProperties.has('opened')) {
261
+ const wasOpened = changedProperties.get('opened');
262
+ if (this.opened) {
263
+ this._onOpened();
264
+ } else if (wasOpened) {
265
+ this._releaseScrollLock();
266
+ this._removeOpenedListeners();
267
+ }
268
+ }
269
+ }
270
+
271
+ _onOpened() {
272
+ if (this._floating) {
273
+ this._positionFloating();
274
+ document.addEventListener('click', this.documentClickHandler);
275
+ window.addEventListener('scroll', this.viewportChangeHandler, { passive: true, capture: true });
276
+ } else {
277
+ const sheet = this.shadowRoot.querySelector('.sheet');
278
+ sheet.style.top = '';
279
+ sheet.style.left = '';
280
+ if (!this._lockingScroll) {
281
+ this._lockingScroll = true;
282
+ openedSheets++;
283
+ updateScrollLock();
284
+ }
285
+ this._computeOffset();
286
+ }
287
+ window.addEventListener('resize', this.viewportChangeHandler, { passive: true });
288
+ }
289
+
290
+ _removeOpenedListeners() {
291
+ document.removeEventListener('click', this.documentClickHandler);
292
+ window.removeEventListener('scroll', this.viewportChangeHandler, { capture: true });
293
+ window.removeEventListener('resize', this.viewportChangeHandler);
294
+ if (this._positionRafId) {
295
+ cancelAnimationFrame(this._positionRafId);
296
+ this._positionRafId = null;
297
+ }
298
+ }
299
+
300
+ _onViewportChange() {
301
+ if (this._positionRafId) return;
302
+ this._positionRafId = requestAnimationFrame(() => {
303
+ this._positionRafId = null;
304
+ if (!this.opened) return;
305
+ if (this._floating) {
306
+ this._positionFloating();
307
+ } else {
308
+ this._computeOffset();
309
+ }
310
+ });
311
+ }
312
+
313
+ // Places the floating sheet over the trigger, centered and inside the viewport.
314
+ _positionFloating() {
315
+ this.overlay = this.shadowRoot.querySelector('.sheet');
316
+ this.trigger = this.shadowRoot.querySelector('.trigger');
317
+ this.updatePosition();
318
+ }
319
+
320
+ _onDocumentClick(e) {
321
+ if (!e.composedPath().includes(this)) {
322
+ this.close();
323
+ }
324
+ }
325
+
326
+ // Distance from the top of the bar to the bottom of the viewport, so the sheet and the overlay do not cover the bar.
327
+ _computeOffset() {
328
+ const bar = this.closest('dile-bottom-nav') || this;
329
+ const offset = window.innerHeight - bar.getBoundingClientRect().top;
330
+ this._offset = Math.min(Math.max(offset, 0), window.innerHeight);
331
+ }
332
+
333
+ _releaseScrollLock() {
334
+ if (this._lockingScroll) {
335
+ this._lockingScroll = false;
336
+ openedSheets--;
337
+ updateScrollLock();
338
+ }
339
+ }
340
+
341
+ render() {
342
+ return html`
343
+ ${this.href && !this._hasSheet ? this.linkTemplate : this.buttonTemplate}
344
+ <div
345
+ class="overlay ${this._hasSheet ? '' : 'nosheet'} ${this._floating ? 'floating' : ''}"
346
+ style="bottom: ${this._offset}px"
347
+ @click="${this.close}"
348
+ ></div>
349
+ <div
350
+ class="sheet ${this._hasSheet ? '' : 'nosheet'} ${this._floating ? 'floating' : ''}"
351
+ style=${styleMap({ bottom: this._floating ? 'auto' : `calc(${this._offset}px + var(--dile-bottom-nav-sheet-gap, 8px))` })}
352
+ role="dialog"
353
+ aria-modal="${!this._floating}"
354
+ aria-label="${this.label}"
355
+ aria-hidden="${!this.opened}"
356
+ tabindex="-1"
357
+ ?inert="${!this.opened}"
358
+ >
359
+ <slot @slotchange="${this._onSlotChange}" @click="${this._onSheetClick}"></slot>
360
+ </div>
361
+ `;
362
+ }
363
+
364
+ get contentTemplate() {
365
+ if (this.opened && this._hasSheet) {
366
+ return html`
367
+ <span class="close-icon" aria-hidden="true"><dile-iconlib icon=${this.closeIcon}></dile-iconlib></span>
368
+ <span class="visually-hidden">${this.label}</span>
369
+ `;
370
+ }
371
+ return html`
372
+ <span class="icon" aria-hidden="true">
373
+ <slot name="icon">${this.icon ? html`<dile-iconlib icon=${this.icon}></dile-iconlib>` : ''}</slot>
374
+ </span>
375
+ <span class="label">${this.label}</span>
376
+ `;
377
+ }
378
+
379
+ get linkTemplate() {
380
+ return html`
381
+ <a
382
+ class="trigger"
383
+ href="${this.href}"
384
+ target="${ifDefined(this.target)}"
385
+ aria-current="${ifDefined(this.selected ? 'page' : undefined)}"
386
+ @click="${this._onTriggerClick}"
387
+ >${this.contentTemplate}</a>
388
+ `;
389
+ }
390
+
391
+ get buttonTemplate() {
392
+ return html`
393
+ <button
394
+ class="trigger"
395
+ type="button"
396
+ aria-current="${ifDefined(this.selected && !this._hasSheet ? 'page' : undefined)}"
397
+ aria-haspopup="${ifDefined(this._hasSheet ? 'dialog' : undefined)}"
398
+ aria-expanded="${ifDefined(this._hasSheet ? String(this.opened) : undefined)}"
399
+ @click="${this._onTriggerClick}"
400
+ >${this.contentTemplate}</button>
401
+ `;
402
+ }
403
+
404
+ _onSlotChange(e) {
405
+ this._hasSheet = e.target.assignedNodes({ flatten: true }).some(node =>
406
+ node.nodeType === Node.ELEMENT_NODE || (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== '')
407
+ );
408
+ if (!this._hasSheet && this.opened) {
409
+ this.close();
410
+ }
411
+ }
412
+
413
+ _onTriggerClick() {
414
+ if (this._hasSheet) {
415
+ if (this.selectable) {
416
+ this.select();
417
+ }
418
+ this.toggle();
419
+ } else {
420
+ this.select();
421
+ }
422
+ this.dispatchEvent(new CustomEvent('dile-bottom-nav-item-click', {
423
+ bubbles: true,
424
+ composed: true,
425
+ detail: { name: this.name, item: this },
426
+ }));
427
+ }
428
+
429
+ _onSheetClick(e) {
430
+ const path = e.composedPath();
431
+ const limit = path.indexOf(e.currentTarget);
432
+ const nodes = limit >= 0 ? path.slice(0, limit) : path;
433
+ const closer = nodes.find(node =>
434
+ node instanceof Element &&
435
+ (node.hasAttribute('data-close') || (this.closeOnClick && (node.tagName === 'A' || node.tagName === 'BUTTON')))
436
+ );
437
+ if (closer) {
438
+ this.close();
439
+ }
440
+ }
441
+
442
+ toggle() {
443
+ if (this.opened) {
444
+ this.close();
445
+ } else {
446
+ this.open();
447
+ }
448
+ }
449
+
450
+ async open() {
451
+ if (this.opened || !this._hasSheet) {
452
+ return;
453
+ }
454
+ // When the bar is static the sheet is a floating card over the item, without overlay.
455
+ this._floating = !!this.closest('dile-bottom-nav')?.static;
456
+ this.opened = true;
457
+ this.dispatchEvent(new CustomEvent('dile-bottom-nav-sheet-opened', {
458
+ bubbles: true,
459
+ composed: true,
460
+ detail: { name: this.name, item: this },
461
+ }));
462
+ await this.updateComplete;
463
+ this.shadowRoot.querySelector('.sheet').focus({ preventScroll: true });
464
+ }
465
+
466
+ async close() {
467
+ if (!this.opened) {
468
+ return;
469
+ }
470
+ const hadFocus = this.matches(':focus-within');
471
+ this.opened = false;
472
+ this.dispatchEvent(new CustomEvent('dile-bottom-nav-sheet-closed', {
473
+ bubbles: true,
474
+ composed: true,
475
+ detail: { name: this.name, item: this },
476
+ }));
477
+ await this.updateComplete;
478
+ if (hadFocus) {
479
+ this.shadowRoot.querySelector('.trigger').focus({ preventScroll: true });
480
+ }
481
+ }
482
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dile/ui",
3
- "version": "3.16.2",
3
+ "version": "3.17.0",
4
4
  "description": "UI Core components from dile-components.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -28,5 +28,5 @@
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
- "gitHead": "0f1a6553d287b706db6f42c7001a25fdebc3bd3a"
31
+ "gitHead": "ea92314b81f3274d6f843b7a926fc528f1a13879"
32
32
  }