@reliability-design/web 1.4.3 → 1.4.5
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.
- package/dist/components/rel-accordion/rel-accordion.ts +13 -1
- package/dist/components/rel-autocomplete/README.md +1 -0
- package/dist/components/rel-autocomplete/rel-autocomplete.ts +56 -1
- package/dist/components/rel-button/rel-button.ts +18 -2
- package/dist/components/rel-calendar/rel-calendar.ts +23 -1
- package/dist/components/rel-carousel/rel-carousel.ts +20 -0
- package/dist/components/rel-checkbox/README.md +69 -22
- package/dist/components/rel-checkbox/rel-checkbox.ts +119 -9
- package/dist/components/rel-chip/rel-chip.ts +29 -0
- package/dist/components/rel-date-picker/rel-date-picker.ts +156 -13
- package/dist/components/rel-drawer/rel-drawer.ts +22 -58
- package/dist/components/rel-menu/rel-menu.ts +124 -15
- package/dist/components/rel-menubar/rel-menubar.ts +221 -63
- package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
- package/dist/components/rel-otp/rel-otp.ts +3 -3
- package/dist/components/rel-pagination/README.md +14 -0
- package/dist/components/rel-pagination/rel-pagination.ts +30 -14
- package/dist/components/rel-popover/rel-popover.ts +31 -3
- package/dist/components/rel-range-picker/rel-range-picker.ts +251 -3
- package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
- package/dist/components/rel-segmented-button/rel-segmented-button.ts +50 -12
- package/dist/components/rel-select/README.md +1 -0
- package/dist/components/rel-select/rel-select.ts +1385 -1170
- package/dist/components/rel-sheet/rel-sheet.ts +22 -1
- package/dist/components/rel-sidebar/rel-sidebar.ts +43 -39
- package/dist/components/rel-stepper/rel-stepper.ts +44 -1
- package/dist/components/rel-tab/rel-tab.ts +32 -7
- package/dist/components/rel-time-picker/rel-time-picker.ts +338 -206
- package/dist/components/rel-tooltip/rel-tooltip.ts +30 -0
- package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
- package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
- package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
- package/dist/components/rel-topbar/rel-topbar.ts +440 -440
- package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +134 -0
- package/dist/components/rel-upload/rel-upload-actions.ts +163 -0
- package/dist/components/rel-upload/rel-upload.ts +943 -0
- package/dist/index.d.mts +491 -18
- package/dist/index.d.ts +491 -18
- package/dist/index.js +2748 -410
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2953 -627
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { LitElement, html } from "lit";
|
|
2
|
-
import { customElement, state } from "lit/decorators.js";
|
|
1
|
+
import { LitElement, html, nothing } from "lit";
|
|
2
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
3
|
+
import { matchesShortcut, isEditableTarget } from "../../utils/shortcuts.js";
|
|
3
4
|
import { RelMenubarMenu } from "./rel-menubar-menu";
|
|
4
5
|
import "@reliability-design/icons/rel-icon";
|
|
5
6
|
import "../rel-checkbox/rel-checkbox.js";
|
|
@@ -13,15 +14,29 @@ interface MenuState {
|
|
|
13
14
|
|
|
14
15
|
@customElement("rel-menubar")
|
|
15
16
|
export class RelMenubar extends LitElement {
|
|
17
|
+
/**
|
|
18
|
+
* Opt-in: actually BIND the keys declared via data-shortcut (e.g.
|
|
19
|
+
* data-shortcut="Ctrl+Z"), not just display them. "Ctrl" matches Cmd on
|
|
20
|
+
* Apple platforms when written as "Mod". Keystrokes originating in
|
|
21
|
+
* inputs/textareas/contenteditable are ignored.
|
|
22
|
+
*/
|
|
23
|
+
@property({ type: Boolean, attribute: "bind-shortcuts" }) bindShortcuts = false;
|
|
24
|
+
|
|
16
25
|
@state() private menus: MenuState[] = [];
|
|
17
26
|
@state() private openMenuIndex: number = -1;
|
|
18
27
|
@state() private focusedTriggerIndex: number = 0;
|
|
19
28
|
@state() private focusedItemIndex: number = -1;
|
|
29
|
+
/** Virtual-focus index inside the open submenu; -1 = focus is in the panel. */
|
|
30
|
+
@state() private focusedSubItemIndex: number = -1;
|
|
20
31
|
@state() private openSubmenuElement: HTMLElement | null = null;
|
|
21
32
|
|
|
22
33
|
private hoverSwitchEnabled = false;
|
|
23
34
|
private _onScroll?: () => void;
|
|
24
35
|
|
|
36
|
+
/** Stable id prefix for aria-activedescendant targets (light DOM, so ids
|
|
37
|
+
* must be unique per menubar instance on the page). */
|
|
38
|
+
private menubarId = `rel-menubar-${Math.random().toString(36).slice(2)}`;
|
|
39
|
+
|
|
25
40
|
public static readonly _css = `
|
|
26
41
|
/* This component renders into LIGHT DOM (createRenderRoot returns this), so
|
|
27
42
|
:host / <slot> don't work here — the raw <rel-menubar-menu> definitions
|
|
@@ -190,6 +205,7 @@ export class RelMenubar extends LitElement {
|
|
|
190
205
|
super.connectedCallback();
|
|
191
206
|
this.discoverMenus();
|
|
192
207
|
document.addEventListener("click", this.handleOutsideClick);
|
|
208
|
+
document.addEventListener("keydown", this.handleShortcutKeyDown);
|
|
193
209
|
this._onScroll = () => {
|
|
194
210
|
this._repositionOpenMenu();
|
|
195
211
|
this._repositionOpenSubmenu();
|
|
@@ -200,6 +216,7 @@ export class RelMenubar extends LitElement {
|
|
|
200
216
|
disconnectedCallback() {
|
|
201
217
|
super.disconnectedCallback();
|
|
202
218
|
document.removeEventListener("click", this.handleOutsideClick);
|
|
219
|
+
document.removeEventListener("keydown", this.handleShortcutKeyDown);
|
|
203
220
|
if (this._onScroll) {
|
|
204
221
|
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
|
205
222
|
}
|
|
@@ -235,6 +252,24 @@ export class RelMenubar extends LitElement {
|
|
|
235
252
|
this.menus = menuElements.map((menu) => ({ menu, open: false }));
|
|
236
253
|
}
|
|
237
254
|
|
|
255
|
+
private handleShortcutKeyDown = (e: KeyboardEvent) => {
|
|
256
|
+
if (!this.bindShortcuts || e.defaultPrevented) return;
|
|
257
|
+
if (isEditableTarget(e)) return;
|
|
258
|
+
|
|
259
|
+
for (const { menu } of this.menus) {
|
|
260
|
+
for (const item of this.getFocusableItems(menu)) {
|
|
261
|
+
const shortcut = item.getAttribute("data-shortcut");
|
|
262
|
+
if (!shortcut) continue;
|
|
263
|
+
if ((item as HTMLButtonElement).disabled) continue;
|
|
264
|
+
if (matchesShortcut(e, shortcut)) {
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
this.handleItemClick(item as HTMLElement);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
238
273
|
private handleOutsideClick = (e: MouseEvent) => {
|
|
239
274
|
// composedPath (NOT e.target): a document-level listener sees e.target
|
|
240
275
|
// retargeted to the shadow host when the menubar lives inside a shadow root
|
|
@@ -249,13 +284,28 @@ export class RelMenubar extends LitElement {
|
|
|
249
284
|
private closeAllMenus() {
|
|
250
285
|
this.openMenuIndex = -1;
|
|
251
286
|
this.openSubmenuElement = null;
|
|
287
|
+
this.focusedItemIndex = -1;
|
|
288
|
+
this.focusedSubItemIndex = -1;
|
|
252
289
|
this.hoverSwitchEnabled = false;
|
|
253
290
|
this.requestUpdate();
|
|
254
291
|
}
|
|
255
292
|
|
|
293
|
+
/** Close everything and put DOM focus back on the trigger that owned the
|
|
294
|
+
* open menu. (Capturing the index BEFORE closing — closeAllMenus resets
|
|
295
|
+
* openMenuIndex to -1, which used to drop focus to <body>.) */
|
|
296
|
+
private closeAllMenusAndRefocusTrigger() {
|
|
297
|
+
const idx = this.openMenuIndex;
|
|
298
|
+
this.closeAllMenus();
|
|
299
|
+
if (idx !== -1) {
|
|
300
|
+
this.focusedTriggerIndex = idx;
|
|
301
|
+
this.focusTrigger(idx);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
256
305
|
private openMenu(index: number, triggerElement: HTMLElement) {
|
|
257
306
|
this.openMenuIndex = index;
|
|
258
307
|
this.focusedItemIndex = -1;
|
|
308
|
+
this.focusedSubItemIndex = -1;
|
|
259
309
|
this.openSubmenuElement = null;
|
|
260
310
|
this.requestUpdate();
|
|
261
311
|
|
|
@@ -303,27 +353,20 @@ export class RelMenubar extends LitElement {
|
|
|
303
353
|
event.preventDefault();
|
|
304
354
|
this.focusedTriggerIndex = Math.max(0, this.focusedTriggerIndex - 1);
|
|
305
355
|
this.focusTrigger(this.focusedTriggerIndex);
|
|
306
|
-
} else if (key === "Enter" || key === " ") {
|
|
356
|
+
} else if (key === "Enter" || key === " " || key === "ArrowDown") {
|
|
307
357
|
event.preventDefault();
|
|
308
358
|
const trigger = this.querySelector(
|
|
309
359
|
`.menubar-trigger[data-index="${this.focusedTriggerIndex}"]`
|
|
310
360
|
) as HTMLElement;
|
|
311
361
|
if (trigger) {
|
|
312
|
-
this.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
);
|
|
317
|
-
if (focusTarget) {
|
|
318
|
-
focusTarget.focus();
|
|
319
|
-
} else {
|
|
320
|
-
this.focusMenuItem(0);
|
|
321
|
-
}
|
|
322
|
-
});
|
|
362
|
+
if (this.openMenuIndex !== this.focusedTriggerIndex) {
|
|
363
|
+
this.openMenu(this.focusedTriggerIndex, trigger);
|
|
364
|
+
}
|
|
365
|
+
this.updateComplete.then(() => this.focusMenuItem(0));
|
|
323
366
|
}
|
|
324
|
-
} else if (key === "
|
|
367
|
+
} else if (key === "Escape" && this.openMenuIndex !== -1) {
|
|
325
368
|
event.preventDefault();
|
|
326
|
-
this.
|
|
369
|
+
this.closeAllMenusAndRefocusTrigger();
|
|
327
370
|
}
|
|
328
371
|
}
|
|
329
372
|
|
|
@@ -339,6 +382,40 @@ export class RelMenubar extends LitElement {
|
|
|
339
382
|
const menuContent = this.menus[this.openMenuIndex]?.menu;
|
|
340
383
|
if (!menuContent) return;
|
|
341
384
|
|
|
385
|
+
// ── Virtual focus is inside an open submenu ──
|
|
386
|
+
if (this.openSubmenuElement) {
|
|
387
|
+
const subItems = this.getSubmenuItems(this.openSubmenuElement);
|
|
388
|
+
if (key === "ArrowDown") {
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
this.focusedSubItemIndex = Math.min(
|
|
391
|
+
subItems.length - 1,
|
|
392
|
+
this.focusedSubItemIndex + 1
|
|
393
|
+
);
|
|
394
|
+
} else if (key === "ArrowUp") {
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
this.focusedSubItemIndex = Math.max(0, this.focusedSubItemIndex - 1);
|
|
397
|
+
} else if (key === "Home") {
|
|
398
|
+
event.preventDefault();
|
|
399
|
+
this.focusedSubItemIndex = 0;
|
|
400
|
+
} else if (key === "End") {
|
|
401
|
+
event.preventDefault();
|
|
402
|
+
this.focusedSubItemIndex = subItems.length - 1;
|
|
403
|
+
} else if (key === "ArrowLeft" || key === "Escape") {
|
|
404
|
+
// Close only the submenu; keep the parent panel open and focused.
|
|
405
|
+
event.preventDefault();
|
|
406
|
+
event.stopPropagation();
|
|
407
|
+
this.openSubmenuElement = null;
|
|
408
|
+
this.focusedSubItemIndex = -1;
|
|
409
|
+
} else if (key === "Enter" || key === " ") {
|
|
410
|
+
event.preventDefault();
|
|
411
|
+
const subItem = subItems[this.focusedSubItemIndex];
|
|
412
|
+
if (subItem) this.handleSubmenuItemClick(subItem as HTMLElement);
|
|
413
|
+
}
|
|
414
|
+
this.requestUpdate();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Virtual focus is in the panel's main list ──
|
|
342
419
|
const items = this.getFocusableItems(menuContent);
|
|
343
420
|
|
|
344
421
|
if (key === "ArrowDown") {
|
|
@@ -349,31 +426,60 @@ export class RelMenubar extends LitElement {
|
|
|
349
426
|
event.preventDefault();
|
|
350
427
|
this.focusedItemIndex = Math.max(0, this.focusedItemIndex - 1);
|
|
351
428
|
this.requestUpdate();
|
|
429
|
+
} else if (key === "Home") {
|
|
430
|
+
event.preventDefault();
|
|
431
|
+
this.focusedItemIndex = 0;
|
|
432
|
+
this.requestUpdate();
|
|
433
|
+
} else if (key === "End") {
|
|
434
|
+
event.preventDefault();
|
|
435
|
+
this.focusedItemIndex = items.length - 1;
|
|
436
|
+
this.requestUpdate();
|
|
352
437
|
} else if (key === "ArrowRight") {
|
|
353
438
|
event.preventDefault();
|
|
354
439
|
const currentItem = items[this.focusedItemIndex];
|
|
355
440
|
if (currentItem?.hasAttribute("data-submenu")) {
|
|
356
|
-
|
|
441
|
+
// Position against the RENDERED row, not the display:none source
|
|
442
|
+
// element inside <rel-menubar-menu> (whose rect is 0×0).
|
|
443
|
+
const panel = this.querySelector<HTMLElement>(
|
|
444
|
+
`.menubar-panel[data-menu-index="${this.openMenuIndex}"]`
|
|
445
|
+
);
|
|
446
|
+
const renderedTrigger = panel?.querySelector<HTMLElement>(
|
|
447
|
+
`[data-focus-index="${this.focusedItemIndex}"]`
|
|
448
|
+
);
|
|
449
|
+
this.focusedSubItemIndex = 0;
|
|
450
|
+
this.openSubmenu(currentItem as HTMLElement, renderedTrigger ?? undefined);
|
|
357
451
|
}
|
|
358
452
|
} else if (key === "ArrowLeft") {
|
|
359
453
|
event.preventDefault();
|
|
360
|
-
if (this.openSubmenuElement) {
|
|
361
|
-
this.openSubmenuElement = null;
|
|
362
|
-
this.requestUpdate();
|
|
363
|
-
}
|
|
364
454
|
} else if (key === "Escape") {
|
|
365
455
|
event.preventDefault();
|
|
366
|
-
this.
|
|
367
|
-
this.focusTrigger(this.openMenuIndex);
|
|
456
|
+
this.closeAllMenusAndRefocusTrigger();
|
|
368
457
|
} else if (key === "Enter" || key === " ") {
|
|
369
458
|
event.preventDefault();
|
|
370
459
|
const currentItem = items[this.focusedItemIndex];
|
|
371
460
|
if (currentItem) {
|
|
372
|
-
|
|
461
|
+
if (currentItem.hasAttribute("data-submenu")) {
|
|
462
|
+
const panel = this.querySelector<HTMLElement>(
|
|
463
|
+
`.menubar-panel[data-menu-index="${this.openMenuIndex}"]`
|
|
464
|
+
);
|
|
465
|
+
const renderedTrigger = panel?.querySelector<HTMLElement>(
|
|
466
|
+
`[data-focus-index="${this.focusedItemIndex}"]`
|
|
467
|
+
);
|
|
468
|
+
this.focusedSubItemIndex = 0;
|
|
469
|
+
this.openSubmenu(currentItem as HTMLElement, renderedTrigger ?? undefined);
|
|
470
|
+
} else {
|
|
471
|
+
this.handleItemClick(currentItem as HTMLElement);
|
|
472
|
+
}
|
|
373
473
|
}
|
|
374
474
|
}
|
|
375
475
|
}
|
|
376
476
|
|
|
477
|
+
private getSubmenuItems(submenuElement: HTMLElement): Element[] {
|
|
478
|
+
return Array.from(submenuElement.children).filter((el) =>
|
|
479
|
+
el.hasAttribute("data-item")
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
377
483
|
private getFocusableItems(menuContent: RelMenubarMenu): Element[] {
|
|
378
484
|
return Array.from(menuContent.children).filter(
|
|
379
485
|
(el) =>
|
|
@@ -425,32 +531,51 @@ export class RelMenubar extends LitElement {
|
|
|
425
531
|
} else if (item.hasAttribute("data-submenu")) {
|
|
426
532
|
this.openSubmenu(item);
|
|
427
533
|
} else {
|
|
428
|
-
// Regular item
|
|
429
|
-
this.
|
|
534
|
+
// Regular item: notify (clicks, Enter, and bound shortcuts all land
|
|
535
|
+
// here — without this event none of them were observable) and close.
|
|
536
|
+
this.dispatchSelectEvent(item);
|
|
537
|
+
this.closeAllMenusAndRefocusTrigger();
|
|
430
538
|
}
|
|
431
539
|
}
|
|
432
540
|
|
|
433
|
-
private
|
|
541
|
+
private dispatchSelectEvent(item: HTMLElement) {
|
|
542
|
+
this.dispatchEvent(
|
|
543
|
+
new CustomEvent("rel-menubar-select", {
|
|
544
|
+
detail: {
|
|
545
|
+
label: item.textContent?.trim() ?? "",
|
|
546
|
+
value: item.getAttribute("value") ?? undefined,
|
|
547
|
+
shortcut: item.getAttribute("data-shortcut") ?? undefined,
|
|
548
|
+
},
|
|
549
|
+
bubbles: true,
|
|
550
|
+
composed: true,
|
|
551
|
+
})
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private openSubmenu(submenuTrigger: HTMLElement, renderedTrigger?: HTMLElement) {
|
|
434
556
|
this.openSubmenuElement = submenuTrigger;
|
|
435
557
|
this.requestUpdate();
|
|
436
558
|
|
|
437
559
|
this.updateComplete.then(() => {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
) as HTMLElement;
|
|
560
|
+
// Only one submenu is ever open; its rendered trigger row is its
|
|
561
|
+
// previous sibling in the panel template.
|
|
562
|
+
const submenuPanel = this.querySelector(".submenu-panel.open") as HTMLElement;
|
|
442
563
|
if (submenuPanel) {
|
|
443
|
-
const positionTarget =
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
564
|
+
const positionTarget =
|
|
565
|
+
renderedTrigger ?? (submenuPanel.previousElementSibling as HTMLElement);
|
|
566
|
+
const rect = positionTarget?.getBoundingClientRect();
|
|
567
|
+
if (rect) {
|
|
568
|
+
submenuPanel.style.top = `${rect.top}px`;
|
|
569
|
+
submenuPanel.style.left = `${rect.right + 4}px`;
|
|
570
|
+
}
|
|
447
571
|
}
|
|
448
572
|
});
|
|
449
573
|
}
|
|
450
574
|
|
|
451
|
-
private handleSubmenuItemClick(
|
|
452
|
-
// Regular item -
|
|
453
|
-
this.
|
|
575
|
+
private handleSubmenuItemClick(item: HTMLElement) {
|
|
576
|
+
// Regular item - notify, close, and hand focus back to the trigger
|
|
577
|
+
this.dispatchSelectEvent(item);
|
|
578
|
+
this.closeAllMenusAndRefocusTrigger();
|
|
454
579
|
}
|
|
455
580
|
|
|
456
581
|
private dispatchChangeEvent(type: "checkbox" | "radio", labelOrName: string, value: string | boolean) {
|
|
@@ -467,8 +592,13 @@ export class RelMenubar extends LitElement {
|
|
|
467
592
|
);
|
|
468
593
|
}
|
|
469
594
|
|
|
470
|
-
private renderMenuItem(item: Element,
|
|
471
|
-
|
|
595
|
+
private renderMenuItem(item: Element, focusIndex: number, menuIndex: number): any {
|
|
596
|
+
// focusIndex is the index within getFocusableItems() — the same list the
|
|
597
|
+
// keyboard handler walks. Comparing raw child indexes against
|
|
598
|
+
// focusedItemIndex highlighted the wrong row once separators existed.
|
|
599
|
+
const isFocused = focusIndex >= 0 && focusIndex === this.focusedItemIndex;
|
|
600
|
+
const itemId =
|
|
601
|
+
focusIndex >= 0 ? `${this.menubarId}-m${menuIndex}-i${focusIndex}` : nothing;
|
|
472
602
|
|
|
473
603
|
if (item.hasAttribute("data-item")) {
|
|
474
604
|
const button = item as HTMLButtonElement;
|
|
@@ -477,8 +607,12 @@ export class RelMenubar extends LitElement {
|
|
|
477
607
|
|
|
478
608
|
return html`
|
|
479
609
|
<div
|
|
610
|
+
id="${itemId}"
|
|
480
611
|
class="menu-item ${isFocused ? "focused" : ""}"
|
|
612
|
+
role="menuitem"
|
|
613
|
+
data-focus-index="${focusIndex}"
|
|
481
614
|
data-disabled="${disabled}"
|
|
615
|
+
aria-disabled="${disabled ? "true" : nothing}"
|
|
482
616
|
@click="${() => !disabled && this.handleItemClick(item as HTMLElement)}"
|
|
483
617
|
>
|
|
484
618
|
<span>${button.textContent}</span>
|
|
@@ -488,7 +622,7 @@ export class RelMenubar extends LitElement {
|
|
|
488
622
|
}
|
|
489
623
|
|
|
490
624
|
if (item.hasAttribute("data-separator")) {
|
|
491
|
-
return html`<div class="menu-separator"></div>`;
|
|
625
|
+
return html`<div class="menu-separator" role="separator"></div>`;
|
|
492
626
|
}
|
|
493
627
|
|
|
494
628
|
if (item.hasAttribute("data-checkbox")) {
|
|
@@ -502,6 +636,8 @@ export class RelMenubar extends LitElement {
|
|
|
502
636
|
|
|
503
637
|
return html`
|
|
504
638
|
<rel-checkbox
|
|
639
|
+
id="${itemId}"
|
|
640
|
+
data-focus-index="${focusIndex}"
|
|
505
641
|
label=${labelText}
|
|
506
642
|
.status=${checkbox?.checked ? "checked" : "unchecked"}
|
|
507
643
|
@rel-change=${(e: CustomEvent) => {
|
|
@@ -514,18 +650,22 @@ export class RelMenubar extends LitElement {
|
|
|
514
650
|
|
|
515
651
|
if (item.hasAttribute("data-submenu")) {
|
|
516
652
|
const label = item.getAttribute("label") || item.textContent?.trim() || "";
|
|
517
|
-
const submenuId = `submenu-${index}`;
|
|
518
653
|
|
|
519
654
|
return html`
|
|
520
655
|
<div
|
|
656
|
+
id="${itemId}"
|
|
521
657
|
class="menu-item menu-submenu-trigger ${isFocused ? "focused" : ""}"
|
|
522
|
-
|
|
523
|
-
|
|
658
|
+
role="menuitem"
|
|
659
|
+
data-focus-index="${focusIndex}"
|
|
660
|
+
aria-haspopup="menu"
|
|
661
|
+
aria-expanded="${this.openSubmenuElement === item}"
|
|
662
|
+
@click="${(e: Event) => this.openSubmenu(item as HTMLElement, e.currentTarget as HTMLElement)}"
|
|
663
|
+
@mouseenter="${(e: Event) => this.openSubmenu(item as HTMLElement, e.currentTarget as HTMLElement)}"
|
|
524
664
|
>
|
|
525
665
|
<span>${label}</span>
|
|
526
666
|
<rel-icon name="chevron-right"></rel-icon>
|
|
527
667
|
</div>
|
|
528
|
-
${this.renderSubmenu(item as HTMLElement
|
|
668
|
+
${this.renderSubmenu(item as HTMLElement)}
|
|
529
669
|
`;
|
|
530
670
|
}
|
|
531
671
|
|
|
@@ -555,25 +695,24 @@ export class RelMenubar extends LitElement {
|
|
|
555
695
|
return null;
|
|
556
696
|
}
|
|
557
697
|
|
|
558
|
-
private renderSubmenu(submenuElement: HTMLElement
|
|
698
|
+
private renderSubmenu(submenuElement: HTMLElement) {
|
|
559
699
|
const isOpen = this.openSubmenuElement === submenuElement;
|
|
560
|
-
const submenuItems =
|
|
700
|
+
const submenuItems = this.getSubmenuItems(submenuElement);
|
|
561
701
|
|
|
562
702
|
return html`
|
|
563
|
-
<div class="submenu-panel ${isOpen ? "open" : ""}"
|
|
564
|
-
${submenuItems.map((subItem) => {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
</
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
return null;
|
|
703
|
+
<div class="submenu-panel ${isOpen ? "open" : ""}" role="menu">
|
|
704
|
+
${submenuItems.map((subItem, subIdx) => {
|
|
705
|
+
const button = subItem as HTMLButtonElement;
|
|
706
|
+
return html`
|
|
707
|
+
<div
|
|
708
|
+
id="${isOpen ? `${this.menubarId}-sub-${subIdx}` : nothing}"
|
|
709
|
+
class="menu-item ${isOpen && subIdx === this.focusedSubItemIndex ? "focused" : ""}"
|
|
710
|
+
role="menuitem"
|
|
711
|
+
@click="${() => this.handleSubmenuItemClick(subItem as HTMLElement)}"
|
|
712
|
+
>
|
|
713
|
+
<span>${button.textContent}</span>
|
|
714
|
+
</div>
|
|
715
|
+
`;
|
|
577
716
|
})}
|
|
578
717
|
</div>
|
|
579
718
|
`;
|
|
@@ -582,15 +721,30 @@ export class RelMenubar extends LitElement {
|
|
|
582
721
|
private renderMenuPanel(menuState: MenuState, index: number) {
|
|
583
722
|
const isOpen = index === this.openMenuIndex;
|
|
584
723
|
const items = Array.from(menuState.menu.children);
|
|
724
|
+
const focusables = this.getFocusableItems(menuState.menu);
|
|
725
|
+
|
|
726
|
+
// DOM focus stays on the panel; this tells AT which row virtual focus is
|
|
727
|
+
// on as the user arrows around (panel list or open submenu).
|
|
728
|
+
const activeDescendant = !isOpen
|
|
729
|
+
? nothing
|
|
730
|
+
: this.focusedSubItemIndex >= 0
|
|
731
|
+
? `${this.menubarId}-sub-${this.focusedSubItemIndex}`
|
|
732
|
+
: this.focusedItemIndex >= 0
|
|
733
|
+
? `${this.menubarId}-m${index}-i${this.focusedItemIndex}`
|
|
734
|
+
: nothing;
|
|
585
735
|
|
|
586
736
|
return html`
|
|
587
737
|
<div
|
|
588
738
|
class="menubar-panel ${isOpen ? "open" : ""}"
|
|
589
739
|
data-menu-index="${index}"
|
|
590
740
|
tabindex="-1"
|
|
741
|
+
role="menu"
|
|
742
|
+
aria-activedescendant="${activeDescendant}"
|
|
591
743
|
@keydown="${this.handlePanelKeyDown}"
|
|
592
744
|
>
|
|
593
|
-
${items.map((item
|
|
745
|
+
${items.map((item) =>
|
|
746
|
+
this.renderMenuItem(item, focusables.indexOf(item), index)
|
|
747
|
+
)}
|
|
594
748
|
</div>
|
|
595
749
|
`;
|
|
596
750
|
}
|
|
@@ -598,14 +752,18 @@ export class RelMenubar extends LitElement {
|
|
|
598
752
|
render() {
|
|
599
753
|
return html`
|
|
600
754
|
<style>${RelMenubar._css}</style>
|
|
601
|
-
<div class="menubar-container">
|
|
755
|
+
<div class="menubar-container" role="menubar">
|
|
602
756
|
${this.menus.map(
|
|
603
757
|
(menuState, index) => html`
|
|
604
758
|
<button
|
|
605
759
|
class="menubar-trigger ${index === this.openMenuIndex ? "active" : ""}"
|
|
606
760
|
data-index="${index}"
|
|
761
|
+
role="menuitem"
|
|
762
|
+
aria-haspopup="menu"
|
|
763
|
+
aria-expanded="${index === this.openMenuIndex}"
|
|
607
764
|
@click="${(e: Event) => this.handleTriggerClick(index, e)}"
|
|
608
765
|
@mouseenter="${(e: Event) => this.handleTriggerMouseEnter(index, e)}"
|
|
766
|
+
@focus="${() => { this.focusedTriggerIndex = index; }}"
|
|
609
767
|
@keydown="${this.handleTriggerKeyDown}"
|
|
610
768
|
>
|
|
611
769
|
${menuState.menu.label}
|
|
@@ -7,7 +7,7 @@ type DensityType = "comfortable" | "compact";
|
|
|
7
7
|
/**
|
|
8
8
|
* @deprecated rel-nav-menu has no Figma design in RelDS 2.0 and is excluded
|
|
9
9
|
* from the parity contract. Use rel-menu (with the select-option/fixed/fit
|
|
10
|
-
* types) or the
|
|
10
|
+
* types) or the Navbar recipe instead. It remains exported for
|
|
11
11
|
* compatibility but will be removed in a future major version.
|
|
12
12
|
*/
|
|
13
13
|
@customElement("rel-nav-menu")
|
|
@@ -168,6 +168,32 @@ export class RelNavMenu extends LitElement {
|
|
|
168
168
|
|
|
169
169
|
const { key } = event;
|
|
170
170
|
const focusableItems = this.items.filter((item) => !item.disabled);
|
|
171
|
+
const path = event.composedPath();
|
|
172
|
+
const currentItem = focusableItems.find((item) => path.includes(item));
|
|
173
|
+
const inPanel = path.some(
|
|
174
|
+
(el) => el instanceof HTMLElement && el.classList.contains("panel")
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Sync the index to where focus ACTUALLY is — Tab moves focus without
|
|
178
|
+
// going through the arrow handlers, so focusedIndex went stale and
|
|
179
|
+
// Enter/arrows acted on the wrong item.
|
|
180
|
+
if (currentItem && !inPanel) {
|
|
181
|
+
this.focusedIndex = focusableItems.indexOf(currentItem);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Keys originating from inside an open panel: let its links/buttons
|
|
185
|
+
// behave natively; only Escape is ours (close + refocus the trigger).
|
|
186
|
+
if (inPanel) {
|
|
187
|
+
if (key === "Escape" && this.open) {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
const openItemId = this.open;
|
|
190
|
+
this.closePanel();
|
|
191
|
+
const openItem = this.items.find((i) => i.itemId === openItemId);
|
|
192
|
+
const trigger = openItem?.shadowRoot?.querySelector(".trigger") as HTMLElement;
|
|
193
|
+
trigger?.focus();
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
171
197
|
|
|
172
198
|
if (key === "ArrowRight") {
|
|
173
199
|
event.preventDefault();
|
|
@@ -177,10 +203,20 @@ export class RelNavMenu extends LitElement {
|
|
|
177
203
|
event.preventDefault();
|
|
178
204
|
this.focusedIndex = Math.max(0, this.focusedIndex - 1);
|
|
179
205
|
this.focusItem(this.focusedIndex);
|
|
180
|
-
} else if (key === "
|
|
206
|
+
} else if (key === "Home") {
|
|
181
207
|
event.preventDefault();
|
|
182
|
-
|
|
208
|
+
this.focusedIndex = 0;
|
|
209
|
+
this.focusItem(this.focusedIndex);
|
|
210
|
+
} else if (key === "End") {
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
this.focusedIndex = focusableItems.length - 1;
|
|
213
|
+
this.focusItem(this.focusedIndex);
|
|
214
|
+
} else if (key === "Enter" || key === " ") {
|
|
215
|
+
const item = currentItem ?? focusableItems[this.focusedIndex];
|
|
183
216
|
if (item && item.hasContent) {
|
|
217
|
+
// Only intercept panel triggers — plain link items keep their
|
|
218
|
+
// native Enter activation (preventDefault used to suppress it).
|
|
219
|
+
event.preventDefault();
|
|
184
220
|
this.openPanel(item.itemId);
|
|
185
221
|
}
|
|
186
222
|
} else if (key === "Escape") {
|
|
@@ -58,7 +58,7 @@ export class RelOtp extends LitElement {
|
|
|
58
58
|
display: flex;
|
|
59
59
|
align-items: center;
|
|
60
60
|
justify-content: center;
|
|
61
|
-
color: var(--rel-border-default);
|
|
61
|
+
color: var(--rel-border-default);
|
|
62
62
|
user-select: none;
|
|
63
63
|
padding: 0 var(--rel-spacing-3xs);
|
|
64
64
|
font-family: var(--rel-action-xl-font-family);
|
|
@@ -77,7 +77,7 @@ export class RelOtp extends LitElement {
|
|
|
77
77
|
height: 48px;
|
|
78
78
|
border: var(--rel-stroke-sm) solid var(--rel-border-default);
|
|
79
79
|
border-radius: var(--rel-border-radius-sm);
|
|
80
|
-
background: var(--rel-bg-neutral);
|
|
80
|
+
background: var(--rel-bg-neutral);
|
|
81
81
|
text-align: center;
|
|
82
82
|
color: var(--rel-text-default);
|
|
83
83
|
outline: none;
|
|
@@ -471,7 +471,7 @@ export class RelOtp extends LitElement {
|
|
|
471
471
|
data-index="${index}"
|
|
472
472
|
type="${this.mask ? "password" : "text"}"
|
|
473
473
|
inputmode="${this.getInputMode()}"
|
|
474
|
-
autocomplete="
|
|
474
|
+
autocomplete="one-time-code"
|
|
475
475
|
pattern="${this.pattern || ""}"
|
|
476
476
|
placeholder="${this.placeholder}"
|
|
477
477
|
maxlength="1"
|
|
@@ -377,6 +377,20 @@ All interactions disabled:
|
|
|
377
377
|
<rel-pagination totalPages="10" page="5" disabled></rel-pagination>
|
|
378
378
|
```
|
|
379
379
|
|
|
380
|
+
### Very Large Page Counts
|
|
381
|
+
|
|
382
|
+
Page buttons auto-widen to fit their number (min-width 40px). Far first/last
|
|
383
|
+
buttons with numbers ≥ 10,000 are abbreviated so the control stays compact:
|
|
384
|
+
`10000` → `10k`, `368178` → `368k`, `1234567` → `1.2M`. The exact page number
|
|
385
|
+
is preserved in the button's `aria-label` and `title` (hover tooltip). The
|
|
386
|
+
current page and its immediate neighbors always render exact — abbreviating
|
|
387
|
+
them would make adjacent pages indistinguishable.
|
|
388
|
+
|
|
389
|
+
```html
|
|
390
|
+
<rel-pagination totalPages="368178" page="7"></rel-pagination>
|
|
391
|
+
<!-- [<] [1] ... [6] [7] [8] ... [368k] [>] -->
|
|
392
|
+
```
|
|
393
|
+
|
|
380
394
|
## Browser Support
|
|
381
395
|
|
|
382
396
|
Compatible with all modern browsers supporting:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LitElement, html, css } from "lit";
|
|
1
|
+
import { LitElement, html, css, nothing } from "lit";
|
|
2
2
|
import { customElement, property, state } from "lit/decorators.js";
|
|
3
3
|
|
|
4
4
|
@customElement("rel-pagination")
|
|
@@ -133,9 +133,10 @@ export class RelPagination extends LitElement {
|
|
|
133
133
|
display: inline-flex;
|
|
134
134
|
align-items: center;
|
|
135
135
|
justify-content: center;
|
|
136
|
-
width:
|
|
136
|
+
width: auto;
|
|
137
|
+
min-width: 40px;
|
|
137
138
|
height: 40px;
|
|
138
|
-
padding: var(--rel-spacing-xs);
|
|
139
|
+
padding: 0 var(--rel-spacing-xs);
|
|
139
140
|
border-radius: var(--rel-border-radius-sm);
|
|
140
141
|
border: 1px solid var(--rel-border-default);
|
|
141
142
|
background: var(--rel-bg-neutral);
|
|
@@ -376,6 +377,15 @@ export class RelPagination extends LitElement {
|
|
|
376
377
|
return pages;
|
|
377
378
|
}
|
|
378
379
|
|
|
380
|
+
/** Abbreviate large page numbers (368178 → "368k") so buttons stay compact.
|
|
381
|
+
Exact below 10,000; floored to k up to 1M; then M with one decimal. */
|
|
382
|
+
private formatPageLabel(n: number): string {
|
|
383
|
+
if (n < 10_000) return String(n);
|
|
384
|
+
if (n < 1_000_000) return `${Math.floor(n / 1000)}k`;
|
|
385
|
+
const m = Math.floor(n / 100_000) / 10;
|
|
386
|
+
return `${Number.isInteger(m) ? m.toFixed(0) : m}M`;
|
|
387
|
+
}
|
|
388
|
+
|
|
379
389
|
private handlePageChange(newPage: number) {
|
|
380
390
|
const total = this.computedTotalPages;
|
|
381
391
|
if (this.disabled || newPage < 1 || newPage > total || newPage === this.activePage) return;
|
|
@@ -427,17 +437,23 @@ export class RelPagination extends LitElement {
|
|
|
427
437
|
<div class="pag-stack">
|
|
428
438
|
${this.renderNavBtn("prev")}
|
|
429
439
|
<div class="pag-numbers">
|
|
430
|
-
${pages.map((p) =>
|
|
431
|
-
p === -1
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
440
|
+
${pages.map((p) => {
|
|
441
|
+
if (p === -1) return html`<span class="pag-ellipsis">...</span>`;
|
|
442
|
+
/* Only far first/last buttons abbreviate. Anything in the
|
|
443
|
+
visible window stays exact — abbreviating window pages like
|
|
444
|
+
368174/368175 would render identical "368k" buttons. */
|
|
445
|
+
const isNear = Math.abs(p - current) <= 1;
|
|
446
|
+
const isEdge = p === 1 || p === total;
|
|
447
|
+
const label = isEdge && !isNear ? this.formatPageLabel(p) : String(p);
|
|
448
|
+
return html`<button
|
|
449
|
+
type="button"
|
|
450
|
+
class="pag-page-btn ${isNear ? "near" : ""} ${p === 1 || p === total ? "edge" : ""}"
|
|
451
|
+
aria-label="Page ${p}"
|
|
452
|
+
title="${label === String(p) ? nothing : `Page ${p}`}"
|
|
453
|
+
aria-current="${p === current ? "page" : "false"}"
|
|
454
|
+
@click="${() => this.handlePageChange(p)}"
|
|
455
|
+
>${label}</button>`;
|
|
456
|
+
})}
|
|
441
457
|
</div>
|
|
442
458
|
${this.renderNavBtn("next")}
|
|
443
459
|
</div>
|