@esportsplus/ui 0.56.2 → 0.56.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,608 @@
1
+ import { html, type Attributes, type Renderable } from '@esportsplus/template';
2
+ import { effect, onCleanup, reactive, untrack } from '@esportsplus/reactivity';
3
+ import './scss/index.scss';
4
+
5
+
6
+ type A = Attributes & {
7
+ [NAVIGATION_MENU_POPUP]?: Attributes;
8
+ [NAVIGATION_MENU_TRIGGER]?: Attributes;
9
+ align?: Align;
10
+ items: Item[];
11
+ label?: string;
12
+ state?: State;
13
+ };
14
+
15
+ type Align = 'center' | 'end' | 'start';
16
+
17
+ // With content it's a trigger that opens a panel; without, a plain link in the bar.
18
+ type Item = {
19
+ content?: Renderable<unknown>;
20
+ href?: string;
21
+ label: string;
22
+ };
23
+
24
+ type State = {
25
+ active: number;
26
+ };
27
+
28
+
29
+ // Grace before closing, so a cursor crossing the gap between the bar and the panel doesn't lose it.
30
+ const CLOSE_GRACE = 150;
31
+
32
+ // Keeps the panel this far from the viewport edges.
33
+ const COLLISION_PADDING = 16;
34
+
35
+ const CONTENT_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
36
+
37
+ const EASE_OUT = 'cubic-bezier(0.23, 1, 0.32, 1)';
38
+
39
+ const FOCUSABLE = 'a[href], button:not([disabled])';
40
+
41
+ const NAVIGATION_MENU_POPUP = Symbol.for('@esportsplus/ui/navigation-menu.popup');
42
+
43
+ const NAVIGATION_MENU_TRIGGER = Symbol.for('@esportsplus/ui/navigation-menu.trigger');
44
+
45
+ // Short enough to feel immediate, long enough that sweeping past the bar doesn't flash a panel open.
46
+ const OPEN_INTENT = 50;
47
+
48
+ const SIDE_OFFSET = 8;
49
+
50
+ // How far the old and new content slide when the panel switches, in px.
51
+ const SLIDE = 32;
52
+
53
+
54
+ let uid = 0;
55
+
56
+
57
+ function reduced() {
58
+ return matchMedia('(prefers-reduced-motion: reduce)').matches;
59
+ }
60
+
61
+
62
+ const navigationMenu = ({ align = 'start', items, label = 'Main', state, ...attributes }: A) => {
63
+ let closeTimer: ReturnType<typeof setTimeout> | undefined,
64
+ current = -1,
65
+ hiding: Animation | undefined,
66
+ id = `navigation-menu-${++uid}`,
67
+ // True while the pointer is over the bar; the pill then follows it rather than the open trigger.
68
+ inside = false,
69
+ layers: (HTMLElement | undefined)[] = [],
70
+ list: HTMLElement | undefined,
71
+ nav: HTMLElement | undefined,
72
+ observer: ResizeObserver | undefined,
73
+ openTimer: ReturnType<typeof setTimeout> | undefined,
74
+ pill: HTMLElement | undefined,
75
+ popup: HTMLElement | undefined,
76
+ running = new Map<HTMLElement, Animation>(),
77
+ s = state ?? reactive({ active: -1 }),
78
+ triggers: HTMLElement[] = [];
79
+
80
+ function animate(element: HTMLElement, keyframes: Keyframe[], duration: number, easing = CONTENT_EASE) {
81
+ running.get(element)?.cancel();
82
+
83
+ let animation = element.animate(keyframes, { duration, easing });
84
+
85
+ running.set(element, animation);
86
+
87
+ return animation;
88
+ }
89
+
90
+ function close(refocus = false) {
91
+ clearTimeout(openTimer);
92
+ clearTimeout(closeTimer);
93
+
94
+ let previous = current;
95
+
96
+ if (previous === -1) {
97
+ return;
98
+ }
99
+
100
+ current = -1;
101
+ s.active = -1;
102
+ hide();
103
+
104
+ if (refocus) {
105
+ triggers[previous]?.focus();
106
+ }
107
+
108
+ if (!inside) {
109
+ settle();
110
+ }
111
+ }
112
+
113
+ function focusables() {
114
+ let layer = current === -1 ? undefined : layers[current];
115
+
116
+ return layer ? [...layer.querySelectorAll<HTMLElement>(FOCUSABLE)] : [];
117
+ }
118
+
119
+ function hide() {
120
+ let element = popup;
121
+
122
+ if (!element) {
123
+ return;
124
+ }
125
+
126
+ hiding?.cancel();
127
+
128
+ let animation = element.animate(
129
+ [{}, reduced() ? { opacity: 0 } : { opacity: 0, transform: 'scale(0.96)' }],
130
+ { duration: 160, easing: EASE_OUT, fill: 'forwards' }
131
+ );
132
+
133
+ hiding = animation;
134
+ animation.finished.then(() => {
135
+ element.classList.remove('--active');
136
+ animation.cancel();
137
+
138
+ for (let layer of layers) {
139
+ if (layer) {
140
+ layer.classList.remove('--active', '--leaving');
141
+ layer.inert = true;
142
+ }
143
+ }
144
+ }, () => {});
145
+ }
146
+
147
+ function open(index: number) {
148
+ clearTimeout(openTimer);
149
+ clearTimeout(closeTimer);
150
+
151
+ let previous = current;
152
+
153
+ if (previous === index || !layers[index]) {
154
+ return;
155
+ }
156
+
157
+ current = index;
158
+ s.active = index;
159
+ swap(previous, index);
160
+ place(previous === -1);
161
+
162
+ if (!inside) {
163
+ settle();
164
+ }
165
+ }
166
+
167
+ // Moves the hover pill onto an item. A fresh pill appears in place instead of sliding in from where it last
168
+ // left.
169
+ function pillTo(item: HTMLElement) {
170
+ if (!list || !pill) {
171
+ return;
172
+ }
173
+
174
+ let bounds = list.getBoundingClientRect(),
175
+ box = item.getBoundingClientRect(),
176
+ element = pill,
177
+ style = element.style;
178
+
179
+ style.height = `${box.height}px`;
180
+ style.top = `${box.top - bounds.top}px`;
181
+ style.translate = `${box.left - bounds.left}px 0`;
182
+ style.width = `${box.width}px`;
183
+
184
+ if (!element.classList.contains('--active')) {
185
+ element.getBoundingClientRect();
186
+ element.classList.add('--active');
187
+ }
188
+ }
189
+
190
+ function place(fresh: boolean) {
191
+ if (!nav || !popup || current === -1) {
192
+ return;
193
+ }
194
+
195
+ let layer = layers[current],
196
+ trigger = triggers[current];
197
+
198
+ if (!layer || !trigger) {
199
+ return;
200
+ }
201
+
202
+ let box = trigger.getBoundingClientRect(),
203
+ height = layer.offsetHeight,
204
+ rect = nav.getBoundingClientRect(),
205
+ viewport = document.documentElement.clientWidth,
206
+ width = layer.offsetWidth,
207
+ x = align === 'start'
208
+ ? box.left
209
+ : align === 'end'
210
+ ? box.right - width
211
+ : box.left + (box.width - width) / 2;
212
+
213
+ x = Math.max(COLLISION_PADDING, Math.min(x, viewport - COLLISION_PADDING - width));
214
+
215
+ let element = popup,
216
+ style = element.style,
217
+ update = () => {
218
+ style.height = `${height}px`;
219
+ // Grows out of the trigger it belongs to.
220
+ style.transformOrigin = `${box.left + box.width / 2 - x}px ${-SIDE_OFFSET}px`;
221
+ style.translate = `${x - rect.left}px ${box.bottom - rect.top + SIDE_OFFSET}px`;
222
+ style.width = `${width}px`;
223
+ };
224
+
225
+ if (!fresh) {
226
+ update();
227
+ return;
228
+ }
229
+
230
+ hiding?.cancel();
231
+ hiding = undefined;
232
+
233
+ // Each open starts where it lands, so reopening never slides over from a stale spot.
234
+ element.classList.add('--instant');
235
+ update();
236
+ element.getBoundingClientRect();
237
+ element.classList.remove('--instant');
238
+ element.classList.add('--active');
239
+
240
+ if (reduced()) {
241
+ animate(element, [{ opacity: 0 }, { opacity: 1 }], 160, EASE_OUT);
242
+ return;
243
+ }
244
+
245
+ running.get(element)?.cancel();
246
+ running.set(element, element.animate(
247
+ [
248
+ { opacity: 0, offset: 0, transform: 'perspective(600px) rotateX(-20deg) scale(0.94)' },
249
+ { opacity: 1, offset: 0.53 },
250
+ { opacity: 1, transform: 'none' }
251
+ ],
252
+ { duration: 300, easing: EASE_OUT }
253
+ ));
254
+ }
255
+
256
+ // With the pointer gone, the pill rests on the open trigger, or fades out if nothing is open.
257
+ function settle() {
258
+ let trigger = current === -1 ? undefined : triggers[current];
259
+
260
+ if (trigger) {
261
+ pillTo(trigger);
262
+ }
263
+ else {
264
+ pill?.classList.remove('--active');
265
+ }
266
+ }
267
+
268
+ function swap(previous: number, next: number) {
269
+ let dir = next > previous ? 1 : -1,
270
+ incoming = layers[next],
271
+ outgoing = previous === -1 ? undefined : layers[previous],
272
+ reduce = reduced();
273
+
274
+ for (let i = 0, n = layers.length; i < n; i++) {
275
+ let layer = layers[i];
276
+
277
+ if (!layer || layer === incoming || layer === outgoing) {
278
+ continue;
279
+ }
280
+
281
+ running.get(layer)?.cancel();
282
+ layer.classList.remove('--active', '--leaving');
283
+ layer.inert = true;
284
+ }
285
+
286
+ if (outgoing) {
287
+ let layer = outgoing;
288
+
289
+ layer.classList.remove('--active');
290
+ layer.classList.add('--leaving');
291
+ layer.inert = true;
292
+ animate(layer, [
293
+ { opacity: 1, translate: '0 0' },
294
+ { opacity: 0, translate: `${reduce ? 0 : -dir * SLIDE}px 0` }
295
+ ], 200).onfinish = () => layer.classList.remove('--leaving');
296
+ }
297
+
298
+ if (!incoming) {
299
+ return;
300
+ }
301
+
302
+ incoming.classList.remove('--leaving');
303
+ incoming.classList.add('--active');
304
+ incoming.inert = false;
305
+
306
+ if (!outgoing) {
307
+ running.get(incoming)?.cancel();
308
+ return;
309
+ }
310
+
311
+ animate(incoming, [
312
+ { opacity: 0, translate: `${reduce ? 0 : dir * SLIDE}px 0` },
313
+ { opacity: 1, translate: '0 0' }
314
+ ], 200);
315
+ }
316
+
317
+ function trigger(index: number, e: KeyboardEvent) {
318
+ let key = e.key;
319
+
320
+ if (key === 'ArrowDown' && layers[index]) {
321
+ e.preventDefault();
322
+ open(index);
323
+ focusables()[0]?.focus();
324
+ }
325
+ else if (key === 'ArrowLeft' || key === 'ArrowRight') {
326
+ let n = triggers.length;
327
+
328
+ e.preventDefault();
329
+ triggers[(index + (key === 'ArrowRight' ? 1 : -1) + n) % n]?.focus();
330
+ }
331
+ else if (key === 'Escape' && current !== -1) {
332
+ e.preventDefault();
333
+ close();
334
+ }
335
+ else if (key === 'Tab' && !e.shiftKey && current === index) {
336
+ // The panel sits after the whole list in the DOM; Tab goes into it from its own trigger, as if it
337
+ // followed the trigger directly.
338
+ let first = focusables()[0];
339
+
340
+ if (first) {
341
+ e.preventDefault();
342
+ first.focus();
343
+ }
344
+ }
345
+ }
346
+
347
+ // Returns nothing on purpose: an effect's value is a dependency of whatever renders this menu, so returning the
348
+ // active index would re-render that parent on every open.
349
+ let stop = effect(() => {
350
+ let active = s.active;
351
+
352
+ untrack(() => {
353
+ if (active === current) {
354
+ return;
355
+ }
356
+
357
+ if (active === -1) {
358
+ close();
359
+ }
360
+ else {
361
+ open(active);
362
+ }
363
+ });
364
+ });
365
+
366
+ onCleanup(() => {
367
+ clearTimeout(closeTimer);
368
+ clearTimeout(openTimer);
369
+ hiding?.cancel();
370
+ observer?.disconnect();
371
+ stop();
372
+
373
+ for (let animation of running.values()) {
374
+ animation.cancel();
375
+ }
376
+ });
377
+
378
+ return html`
379
+ <nav
380
+ class='navigation-menu'
381
+ ${attributes}
382
+ ${{
383
+ 'aria-label': label,
384
+ ondocumentpointerdown: (e: PointerEvent) => {
385
+ // Taps outside close it on touch, where there's no pointer to leave.
386
+ if (current !== -1 && nav && !nav.contains(e.target as Node | null)) {
387
+ close();
388
+ }
389
+ },
390
+ onconnect: (element: HTMLElement) => {
391
+ nav = element;
392
+ observer = new ResizeObserver(() => place(false));
393
+ observer.observe(element);
394
+
395
+ for (let layer of layers) {
396
+ if (layer) {
397
+ observer.observe(layer);
398
+ }
399
+ }
400
+ },
401
+ onfocusout: (e: FocusEvent) => {
402
+ if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
403
+ close();
404
+ }
405
+ },
406
+ onpointerenter: (e: PointerEvent) => {
407
+ if (e.pointerType !== 'touch') {
408
+ clearTimeout(closeTimer);
409
+ }
410
+ },
411
+ onpointerleave: (e: PointerEvent) => {
412
+ if (e.pointerType === 'touch') {
413
+ return;
414
+ }
415
+
416
+ clearTimeout(openTimer);
417
+
418
+ if (current !== -1) {
419
+ clearTimeout(closeTimer);
420
+ closeTimer = setTimeout(() => close(), CLOSE_GRACE);
421
+ }
422
+ }
423
+ }}
424
+ >
425
+ <ul
426
+ class='navigation-menu-list'
427
+ ${{
428
+ onpointerleave: () => {
429
+ inside = false;
430
+ settle();
431
+ },
432
+ onrender: (element: HTMLElement) => {
433
+ list = element;
434
+ }
435
+ }}
436
+ >
437
+ <li aria-hidden='true' class='navigation-menu-pill' ${{ onrender: (element: HTMLElement) => { pill = element; } }}></li>
438
+ ${items.map((item, index) => html`
439
+ <li class='navigation-menu-item'>
440
+ ${item.content
441
+ ? html`
442
+ <button
443
+ class='navigation-menu-trigger'
444
+ type='button'
445
+ ${attributes[NAVIGATION_MENU_TRIGGER]}
446
+ ${{
447
+ 'aria-controls': () => s.active === index && `${id}-popup`,
448
+ 'aria-expanded': () => String(s.active === index),
449
+ class: () => s.active === index && '--active',
450
+ onclick: (e: MouseEvent) => {
451
+ // detail is 0 for Enter and Space; those toggle, like a tap.
452
+ if (current === index && (e.detail === 0 || (e as PointerEvent).pointerType === 'touch')) {
453
+ close();
454
+ }
455
+ else {
456
+ open(index);
457
+ }
458
+ },
459
+ onfocus: (e: FocusEvent) => {
460
+ pillTo(e.currentTarget as HTMLElement);
461
+
462
+ // With a panel open, focus moving along the list moves it too.
463
+ if (current !== -1 && current !== index) {
464
+ open(index);
465
+ }
466
+ },
467
+ onkeydown: (e: KeyboardEvent) => trigger(index, e),
468
+ onpointerenter: (e: PointerEvent) => {
469
+ if (e.pointerType === 'touch') {
470
+ return;
471
+ }
472
+
473
+ inside = true;
474
+ pillTo(e.currentTarget as HTMLElement);
475
+ clearTimeout(openTimer);
476
+
477
+ // Already open: follow the cursor at once. Closed: wait a beat to be sure it means it.
478
+ if (current !== -1) {
479
+ open(index);
480
+ }
481
+ else {
482
+ openTimer = setTimeout(() => open(index), OPEN_INTENT);
483
+ }
484
+ },
485
+ onpointerleave: () => {
486
+ if (current === -1) {
487
+ clearTimeout(openTimer);
488
+ }
489
+ },
490
+ onrender: (element: HTMLElement) => {
491
+ triggers[index] = element;
492
+ }
493
+ }}
494
+ >
495
+ ${item.label}
496
+ <svg aria-hidden='true' class='navigation-menu-chevron' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' viewBox='0 0 24 24'>
497
+ <path d='m8 9 4-4 4 4M16 15l-4 4-4-4' />
498
+ </svg>
499
+ </button>
500
+ `
501
+ : html`
502
+ <a
503
+ class='navigation-menu-trigger navigation-menu-trigger--link'
504
+ href='${item.href ?? '#'}'
505
+ ${attributes[NAVIGATION_MENU_TRIGGER]}
506
+ ${{
507
+ onfocus: (e: FocusEvent) => pillTo(e.currentTarget as HTMLElement),
508
+ onkeydown: (e: KeyboardEvent) => trigger(index, e),
509
+ onpointerenter: (e: PointerEvent) => {
510
+ if (e.pointerType === 'touch') {
511
+ return;
512
+ }
513
+
514
+ inside = true;
515
+ pillTo(e.currentTarget as HTMLElement);
516
+ clearTimeout(openTimer);
517
+
518
+ // A plain link has no panel; the open one belongs to someone else.
519
+ if (current !== -1) {
520
+ clearTimeout(closeTimer);
521
+ closeTimer = setTimeout(() => close(), CLOSE_GRACE);
522
+ }
523
+ },
524
+ onrender: (element: HTMLElement) => {
525
+ triggers[index] = element;
526
+ }
527
+ }}
528
+ >
529
+ ${item.label}
530
+ </a>
531
+ `}
532
+ </li>
533
+ `)}
534
+ </ul>
535
+
536
+ <div
537
+ class='navigation-menu-popup'
538
+ id='${id}-popup'
539
+ ${attributes[NAVIGATION_MENU_POPUP]}
540
+ ${{
541
+ onclick: (e: MouseEvent) => {
542
+ if ((e.target as HTMLElement).closest('a')) {
543
+ close();
544
+ }
545
+ },
546
+ onkeydown: (e: KeyboardEvent) => {
547
+ if (current === -1) {
548
+ return;
549
+ }
550
+
551
+ let list = focusables(),
552
+ at = list.indexOf(document.activeElement as HTMLElement),
553
+ key = e.key;
554
+
555
+ if (key === 'Escape') {
556
+ e.preventDefault();
557
+ close(true);
558
+ }
559
+ else if (key === 'ArrowDown' || key === 'ArrowUp') {
560
+ let n = list.length;
561
+
562
+ e.preventDefault();
563
+ list[(at + (key === 'ArrowDown' ? 1 : -1) + n) % n]?.focus();
564
+ }
565
+ else if (key === 'Tab' && e.shiftKey && at === 0) {
566
+ e.preventDefault();
567
+ triggers[current]?.focus();
568
+ }
569
+ else if (key === 'Tab' && !e.shiftKey && at === list.length - 1) {
570
+ let next = triggers[current + 1];
571
+
572
+ // Closed first, so focus landing on the next trigger doesn't open its panel.
573
+ close();
574
+
575
+ if (next) {
576
+ e.preventDefault();
577
+ next.focus();
578
+ }
579
+ }
580
+ },
581
+ onrender: (element: HTMLElement) => {
582
+ popup = element;
583
+ }
584
+ }}
585
+ >
586
+ <div class='navigation-menu-viewport'>
587
+ ${items.map((item, index) => item.content && html`
588
+ <div
589
+ class='navigation-menu-content'
590
+ ${{
591
+ onrender: (element: HTMLElement) => {
592
+ element.inert = true;
593
+ layers[index] = element;
594
+ }
595
+ }}
596
+ >
597
+ ${item.content}
598
+ </div>
599
+ `)}
600
+ </div>
601
+ </div>
602
+ </nav>
603
+ `;
604
+ };
605
+
606
+
607
+ export default Object.assign(navigationMenu, { popup: NAVIGATION_MENU_POPUP, trigger: NAVIGATION_MENU_TRIGGER } as const);
608
+ export type { Align, Item, State };