@reliability-design/web 1.4.4 → 1.4.6

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.
Files changed (58) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +13 -1
  2. package/dist/components/rel-alert/rel-alert.ts +1 -1
  3. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  4. package/dist/components/rel-badge/rel-badge.ts +6 -1
  5. package/dist/components/rel-button/rel-button.ts +18 -2
  6. package/dist/components/rel-calendar/rel-calendar.ts +86 -18
  7. package/dist/components/rel-carousel/rel-carousel.ts +45 -2
  8. package/dist/components/rel-checkbox/rel-checkbox.ts +4 -1
  9. package/dist/components/rel-chip/rel-chip.ts +59 -19
  10. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  11. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  12. package/dist/components/rel-date-picker/rel-date-picker.ts +675 -93
  13. package/dist/components/rel-drawer/rel-drawer.ts +28 -61
  14. package/dist/components/rel-menu/rel-menu.ts +130 -15
  15. package/dist/components/rel-menubar/rel-menubar.ts +221 -63
  16. package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
  17. package/dist/components/rel-otp/rel-otp.ts +14 -4
  18. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  19. package/dist/components/rel-popover/rel-popover.ts +64 -5
  20. package/dist/components/rel-progress/rel-progress.ts +13 -4
  21. package/dist/components/rel-radio/README.md +6 -3
  22. package/dist/components/rel-radio/rel-radio.ts +236 -60
  23. package/dist/components/rel-range-picker/rel-range-picker.ts +581 -91
  24. package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
  25. package/dist/components/rel-segmented-button/rel-segmented-button.ts +55 -13
  26. package/dist/components/rel-select/rel-select.ts +203 -24
  27. package/dist/components/rel-sheet/rel-sheet.ts +184 -18
  28. package/dist/components/rel-sidebar/rel-sidebar.ts +50 -40
  29. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  30. package/dist/components/rel-slider/rel-slider.ts +6 -3
  31. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  32. package/dist/components/rel-stepper/rel-stepper.ts +47 -2
  33. package/dist/components/rel-switch/rel-switch.ts +3 -1
  34. package/dist/components/rel-tab/rel-tab.ts +32 -7
  35. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  36. package/dist/components/rel-time-picker/rel-time-picker.ts +749 -261
  37. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  38. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  39. package/dist/components/rel-toast/rel-toast.ts +4 -2
  40. package/dist/components/rel-tooltip/README.md +13 -5
  41. package/dist/components/rel-tooltip/rel-tooltip.ts +33 -0
  42. package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
  43. package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
  44. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
  45. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
  46. package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
  47. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
  48. package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
  49. package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
  50. package/dist/components/rel-topbar/rel-topbar.ts +440 -440
  51. package/dist/components/rel-upload/rel-upload.ts +25 -0
  52. package/dist/index.d.mts +461 -23
  53. package/dist/index.d.ts +461 -23
  54. package/dist/index.js +3906 -807
  55. package/dist/index.js.map +1 -1
  56. package/dist/index.mjs +4143 -1055
  57. package/dist/index.mjs.map +1 -1
  58. package/package.json +2 -2
@@ -1,7 +1,8 @@
1
- import { LitElement, html, css } from "lit";
1
+ import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state, query } from "lit/decorators.js";
3
3
  import "@reliability-design/icons/rel-icon";
4
4
  import "./rel-drawer-footer";
5
+ import { getDeepActiveElement, getFocusableElements, trapTabKey } from "../../utils/focus.js";
5
6
 
6
7
  export type DrawerSide = "bottom" | "left" | "right";
7
8
  export type DrawerCloseReason = "backdrop" | "escape" | "drag" | "programmatic";
@@ -327,16 +328,26 @@ export class RelDrawer extends LitElement {
327
328
  }
328
329
 
329
330
  private handleOpen() {
330
- this.previousFocus = document.activeElement as HTMLElement;
331
+ // Deep-resolved: document.activeElement retargets to a component's host
332
+ // (e.g. <rel-button>), whose .focus() is a no-op — restore would drop
333
+ // focus to <body>.
334
+ this.previousFocus = getDeepActiveElement();
331
335
  this.lockScroll();
332
336
 
333
337
  if (this.modal) {
338
+ // The panel lives in the shadow root, so plain this.querySelector never
339
+ // found it (focus was never moved in). The shared util walks the
340
+ // composed tree, seeing both shadow and slotted content.
334
341
  this.updateComplete.then(() => {
335
- const focusTarget =
336
- this.querySelector<HTMLElement>("[autofocus]") ??
337
- this.querySelector<HTMLElement>(".close-button") ??
338
- this.querySelector<HTMLElement>(".panel");
339
- focusTarget?.focus();
342
+ const panel = this.panelElement;
343
+ if (!panel) return;
344
+ const focusTarget = getFocusableElements(panel)[0];
345
+ if (focusTarget) {
346
+ focusTarget.focus();
347
+ } else {
348
+ panel.tabIndex = -1;
349
+ panel.focus();
350
+ }
340
351
  });
341
352
  }
342
353
 
@@ -414,35 +425,6 @@ export class RelDrawer extends LitElement {
414
425
  document.body.style.paddingRight = this.originalBodyPaddingRight;
415
426
  }
416
427
 
417
- private getFocusableElements(): HTMLElement[] {
418
- if (!this.panelElement) return [];
419
-
420
- const selectors = [
421
- 'a[href]',
422
- 'button:not([disabled])',
423
- 'textarea:not([disabled])',
424
- 'input:not([disabled])',
425
- 'select:not([disabled])',
426
- '[tabindex]:not([tabindex="-1"])',
427
- ].join(",");
428
-
429
- const elements = Array.from(
430
- this.panelElement.querySelectorAll(selectors)
431
- ) as HTMLElement[];
432
-
433
- // Also check slotted content
434
- const slots = this.panelElement.querySelectorAll("slot");
435
- slots.forEach((slot) => {
436
- const assigned = slot.assignedElements({ flatten: true });
437
- assigned.forEach((el) => {
438
- const focusableInSlot = el.querySelectorAll(selectors);
439
- elements.push(...(Array.from(focusableInSlot) as HTMLElement[]));
440
- });
441
- });
442
-
443
- return elements;
444
- }
445
-
446
428
  private handleKeydown = (e: KeyboardEvent) => {
447
429
  if (!this.open) return;
448
430
 
@@ -452,29 +434,11 @@ export class RelDrawer extends LitElement {
452
434
  return;
453
435
  }
454
436
 
455
- // Focus trap
456
- if (e.key === "Tab" && this.modal) {
457
- const focusable = this.getFocusableElements();
458
- if (focusable.length === 0) {
459
- e.preventDefault();
460
- return;
461
- }
462
-
463
- const firstFocusable = focusable[0];
464
- const lastFocusable = focusable[focusable.length - 1];
465
- const activeElement = document.activeElement;
466
-
467
- if (e.shiftKey) {
468
- if (activeElement === firstFocusable) {
469
- e.preventDefault();
470
- lastFocusable.focus();
471
- }
472
- } else {
473
- if (activeElement === lastFocusable) {
474
- e.preventDefault();
475
- firstFocusable.focus();
476
- }
477
- }
437
+ // Focus trap — the shared helper resolves the deep active element, which
438
+ // the old document.activeElement comparison never could (it retargets to
439
+ // the <rel-drawer> host, so Tab was never wrapped).
440
+ if (e.key === "Tab" && this.modal && this.panelElement) {
441
+ trapTabKey(e, this.panelElement);
478
442
  }
479
443
  };
480
444
 
@@ -586,6 +550,9 @@ export class RelDrawer extends LitElement {
586
550
  style="${this.width ? `--drawer-width: ${this.width};` : ''}${this.maxHeight ? `--drawer-max-height: ${this.maxHeight};` : ''}${this.getPanelStyle()}"
587
551
  role="${this.modal ? "dialog" : "region"}"
588
552
  aria-modal="${this.modal}"
553
+ aria-labelledby="${hasTitle ? "drawer-title" : nothing}"
554
+ aria-describedby="${hasDescription ? "drawer-description" : nothing}"
555
+ aria-label="${hasTitle ? nothing : "Drawer"}"
589
556
  tabindex="-1">
590
557
  ${showHandleBar
591
558
  ? html`<div
@@ -600,10 +567,10 @@ export class RelDrawer extends LitElement {
600
567
  <div class="header-top">
601
568
  <div>
602
569
  ${hasTitle
603
- ? html`<h2 class="title">${this.title}</h2>`
570
+ ? html`<h2 class="title" id="drawer-title">${this.title}</h2>`
604
571
  : ""}
605
572
  ${hasDescription
606
- ? html`<p class="description">${this.description}</p>`
573
+ ? html`<p class="description" id="drawer-description">${this.description}</p>`
607
574
  : ""}
608
575
  </div>
609
576
  ${showCloseButton
@@ -1,4 +1,4 @@
1
- import { LitElement, html } from "lit";
1
+ import { LitElement, html, nothing } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
3
  import "@reliability-design/icons/rel-icon";
4
4
  import "../rel-checkbox/rel-checkbox.js";
@@ -38,8 +38,13 @@ export class RelMenu extends LitElement {
38
38
  @property({ type: Array }) items: MenuItem[] = [];
39
39
 
40
40
  @state() private focusedIndex: number = 0;
41
+ /** Index inside the open submenu that virtual focus sits on; -1 = main list. */
42
+ @state() private subFocusedIndex: number = -1;
41
43
  @state() private _items: MenuItem[] = [];
42
44
 
45
+ /** Stable id prefix for aria-activedescendant targets. */
46
+ private menuId = `rel-menu-${Math.random().toString(36).slice(2)}`;
47
+
43
48
  public static readonly _css = `
44
49
  :host {
45
50
  color: var(--rel-text-default);
@@ -122,7 +127,10 @@ export class RelMenu extends LitElement {
122
127
  cursor: not-allowed;
123
128
  }
124
129
 
125
- .rel-menu-item[data-focused="true"] {
130
+ /* [data-focused] (presence, not ="true"): the ?data-focused boolean
131
+ binding renders the attribute with an EMPTY value, so a ="true"
132
+ selector never matches. */
133
+ .rel-menu-item[data-focused] {
126
134
  outline: 2px solid var(--rel-border-focus);
127
135
  outline-offset: -2px;
128
136
  }
@@ -235,6 +243,7 @@ export class RelMenu extends LitElement {
235
243
  // click that opened it. composedPath pierces shadow boundaries.
236
244
  if (!e.composedPath().includes(this)) {
237
245
  this.lockedIndex = -1;
246
+ this.subFocusedIndex = -1;
238
247
  this._items = this._items.map(i => ({ ...i, open: false }));
239
248
  }
240
249
  };
@@ -319,38 +328,119 @@ export class RelMenu extends LitElement {
319
328
  );
320
329
  }
321
330
 
331
+ /** Keyboard-open locks the submenu (no hover-out to close it) and moves
332
+ * virtual focus onto its first entry. */
333
+ private openSubmenuAt(index: number) {
334
+ this.lockedIndex = index;
335
+ this.subFocusedIndex = 0;
336
+ this._items = this._items.map((i, idx) => ({ ...i, open: idx === index }));
337
+ }
338
+
339
+ private closeSubmenus() {
340
+ this.lockedIndex = -1;
341
+ this.subFocusedIndex = -1;
342
+ this._items = this._items.map(i => ({ ...i, open: false }));
343
+ }
344
+
322
345
  private onKeydown(ev: KeyboardEvent) {
323
346
  const last = this._items.length - 1;
347
+ const focusedItem = this._items[this.focusedIndex];
348
+
349
+ // ── Virtual focus is inside an open submenu ──
350
+ if (
351
+ this.subFocusedIndex >= 0 &&
352
+ this.type === "arrow" &&
353
+ focusedItem?.open &&
354
+ focusedItem.submenu?.length
355
+ ) {
356
+ const subLast = focusedItem.submenu.length - 1;
357
+ switch (ev.key) {
358
+ case "ArrowDown":
359
+ this.subFocusedIndex = Math.min(subLast, this.subFocusedIndex + 1);
360
+ ev.preventDefault();
361
+ return;
362
+ case "ArrowUp":
363
+ this.subFocusedIndex = Math.max(0, this.subFocusedIndex - 1);
364
+ ev.preventDefault();
365
+ return;
366
+ case "Home":
367
+ this.subFocusedIndex = 0;
368
+ ev.preventDefault();
369
+ return;
370
+ case "End":
371
+ this.subFocusedIndex = subLast;
372
+ ev.preventDefault();
373
+ return;
374
+ case "ArrowLeft":
375
+ case "Escape":
376
+ // Close only the submenu; don't let Escape also dismiss an
377
+ // enclosing popover in the same keystroke.
378
+ this.closeSubmenus();
379
+ ev.preventDefault();
380
+ ev.stopPropagation();
381
+ return;
382
+ case "Enter":
383
+ case " ":
384
+ this.onSubClick(this.focusedIndex, this.subFocusedIndex);
385
+ ev.preventDefault();
386
+ return;
387
+ default:
388
+ return;
389
+ }
390
+ }
324
391
 
392
+ // ── Virtual focus is in the main list ──
325
393
  if (ev.key === "ArrowDown") {
326
394
  this.focusedIndex = Math.min(last, this.focusedIndex + 1);
327
395
  ev.preventDefault();
328
396
  } else if (ev.key === "ArrowUp") {
329
397
  this.focusedIndex = Math.max(0, this.focusedIndex - 1);
330
398
  ev.preventDefault();
399
+ } else if (ev.key === "Home") {
400
+ this.focusedIndex = 0;
401
+ ev.preventDefault();
402
+ } else if (ev.key === "End") {
403
+ this.focusedIndex = last;
404
+ ev.preventDefault();
331
405
  } else if (ev.key === "ArrowRight") {
332
406
  const item = this._items[this.focusedIndex];
333
- if (this.type === "arrow" && item?.submenu?.length) {
334
- // keyboard-open locks (no hover-out to close it)
335
- this.lockedIndex = this.focusedIndex;
336
- this._items = this._items.map((i, idx) => ({
337
- ...i,
338
- open: idx === this.focusedIndex ? true : i.open,
339
- }));
407
+ if (this.type === "arrow" && item?.submenu?.length && !item.disabled) {
408
+ this.openSubmenuAt(this.focusedIndex);
409
+ ev.preventDefault();
340
410
  }
341
411
  } else if (ev.key === "ArrowLeft" || ev.key === "Escape") {
342
- this.lockedIndex = -1;
343
- this._items = this._items.map(i => ({ ...i, open: false }));
412
+ const anyOpen = this._items.some(i => i.open);
413
+ if (anyOpen) {
414
+ this.closeSubmenus();
415
+ ev.preventDefault();
416
+ if (ev.key === "Escape") ev.stopPropagation();
417
+ }
344
418
  } else if (ev.key === "Enter" || ev.key === " ") {
345
419
  const item = this._items[this.focusedIndex];
346
- this.onItemClick(item, this.focusedIndex);
420
+ if (this.type === "arrow" && item?.submenu?.length && !item.disabled) {
421
+ this.openSubmenuAt(this.focusedIndex);
422
+ } else {
423
+ this.onItemClick(item, this.focusedIndex);
424
+ }
347
425
  ev.preventDefault();
348
426
  }
349
427
  }
350
428
 
351
429
  private renderMenuItem(item: MenuItem, index: number) {
352
- const isFocused = this.focusedIndex === index;
430
+ const isFocused = this.focusedIndex === index && this.subFocusedIndex < 0;
353
431
  const hasSubmenu = this.type === "arrow" && !!item.submenu?.length;
432
+ const role =
433
+ this.type === "checkbox"
434
+ ? "menuitemcheckbox"
435
+ : this.type === "select-option"
436
+ ? "menuitemradio"
437
+ : "menuitem";
438
+ const ariaChecked =
439
+ this.type === "checkbox"
440
+ ? String(!!item.checked)
441
+ : this.type === "select-option"
442
+ ? String(!!item.active)
443
+ : nothing;
354
444
 
355
445
  // Checkbox / select-option rows delegate their control to the real
356
446
  // rel-checkbox / rel-radio components (pointer-events:none so the whole ROW
@@ -360,6 +450,8 @@ export class RelMenu extends LitElement {
360
450
  this.type === "checkbox"
361
451
  ? html`<rel-checkbox
362
452
  class="menu-control"
453
+ inert
454
+ aria-hidden="true"
363
455
  label="${item.label}"
364
456
  status="${item.checked ? "checked" : "unchecked"}"
365
457
  state="${item.disabled ? "disabled" : "default"}"
@@ -367,6 +459,8 @@ export class RelMenu extends LitElement {
367
459
  : this.type === "select-option"
368
460
  ? html`<rel-radio
369
461
  class="menu-control"
462
+ inert
463
+ aria-hidden="true"
370
464
  .options="${[{ value: item.label, label: item.label, disabled: !!item.disabled }]}"
371
465
  .selectedValue="${item.active ? item.label : null}"
372
466
  ></rel-radio>`
@@ -381,8 +475,14 @@ export class RelMenu extends LitElement {
381
475
 
382
476
  return html`
383
477
  <div
478
+ id="${this.menuId}-item-${index}"
384
479
  class="rel-menu-item ${item.active ? "active" : ""} ${item.disabled ? "disabled" : ""}"
385
- role="menuitem"
480
+ role="${role}"
481
+ aria-checked="${ariaChecked}"
482
+ aria-label="${this.type === "checkbox" || this.type === "select-option" ? item.label : nothing}"
483
+ aria-disabled="${item.disabled ? "true" : nothing}"
484
+ aria-haspopup="${hasSubmenu ? "menu" : nothing}"
485
+ aria-expanded="${hasSubmenu ? String(!!item.open) : nothing}"
386
486
  ?data-focused="${isFocused}"
387
487
  @mouseenter="${() => this.onItemEnter(item, index)}"
388
488
  @mouseleave="${() => this.onItemLeave(item, index)}"
@@ -392,12 +492,16 @@ export class RelMenu extends LitElement {
392
492
  ? html`
393
493
  <div
394
494
  part="panel" class="rel-submenu ${item.open ? "visible" : ""}"
495
+ role="menu"
395
496
  @click="${(e: Event) => e.stopPropagation()}">
396
497
  ${item.submenu!.map(
397
498
  (sub, subIndex) => html`
398
499
  <div
399
- class="rel-menu-item ${sub.active ? "active" : ""}"
500
+ id="${this.menuId}-item-${index}-sub-${subIndex}"
501
+ class="rel-menu-item ${sub.active ? "active" : ""} ${sub.disabled ? "disabled" : ""}"
400
502
  role="menuitem"
503
+ aria-disabled="${sub.disabled ? "true" : nothing}"
504
+ ?data-focused="${this.focusedIndex === index && this.subFocusedIndex === subIndex}"
401
505
  @click="${(e: Event) => this.onSubClick(index, subIndex, e)}">
402
506
  <span class="menu-label">${sub.label}</span>
403
507
  </div>
@@ -411,12 +515,23 @@ export class RelMenu extends LitElement {
411
515
  }
412
516
 
413
517
  render() {
518
+ // DOM focus stays on the container; this tells AT which item virtual
519
+ // focus is on as the user arrows around (main list or open submenu).
520
+ const activeDescendant =
521
+ this._items.length === 0
522
+ ? nothing
523
+ : this.subFocusedIndex >= 0
524
+ ? `${this.menuId}-item-${this.focusedIndex}-sub-${this.subFocusedIndex}`
525
+ : `${this.menuId}-item-${this.focusedIndex}`;
526
+
414
527
  return html`
415
528
  <style>${RelMenu._css}</style>
416
529
  <div
417
530
  part="list" class="rel-menu ${this.type === "fixed" || this.type === "fit" ? this.type : ""}"
418
531
  role="menu"
532
+ aria-label="Menu"
419
533
  tabindex="0"
534
+ aria-activedescendant="${activeDescendant}"
420
535
  @keydown="${this.onKeydown}"
421
536
  @click="${(e: Event) => e.stopPropagation()}">
422
537
  ${this._items.map((item, index) => this.renderMenuItem(item, index))}