@latty-ds/web 0.10.4 → 1.0.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.
Files changed (99) hide show
  1. package/custom-elements.json +1969 -228
  2. package/dist/components/accordion/index.js +36 -2
  3. package/dist/components/alert/alert.d.ts +29 -6
  4. package/dist/components/alert/alert.types.d.ts +5 -0
  5. package/dist/components/alert/index.js +124 -17
  6. package/dist/components/avatar/avatar.d.ts +2 -0
  7. package/dist/components/avatar/index.js +10 -3
  8. package/dist/components/breadcrumb/breadcrumb.d.ts +4 -0
  9. package/dist/components/breadcrumb/breadcrumb.types.d.ts +5 -0
  10. package/dist/components/breadcrumb/index.js +20 -3
  11. package/dist/components/button/button.d.ts +33 -0
  12. package/dist/components/button/index.js +135 -12
  13. package/dist/components/calendar/calendar.d.ts +15 -7
  14. package/dist/components/calendar/calendar.types.d.ts +17 -1
  15. package/dist/components/calendar/index.js +78 -25
  16. package/dist/components/checkbox/checkbox.d.ts +14 -2
  17. package/dist/components/checkbox/checkbox.types.d.ts +5 -0
  18. package/dist/components/checkbox/index.js +82 -5
  19. package/dist/components/chip/chip.d.ts +4 -1
  20. package/dist/components/chip/chip.types.d.ts +5 -0
  21. package/dist/components/chip/index.js +29 -1
  22. package/dist/components/color-input/color-input.d.ts +11 -2
  23. package/dist/components/color-input/color-input.types.d.ts +5 -0
  24. package/dist/components/color-input/index.js +92 -8
  25. package/dist/components/combobox/combobox.d.ts +18 -2
  26. package/dist/components/combobox/combobox.types.d.ts +7 -0
  27. package/dist/components/combobox/index.js +164 -23
  28. package/dist/components/date-input/date-input.d.ts +22 -7
  29. package/dist/components/date-input/date-input.types.d.ts +7 -0
  30. package/dist/components/date-input/index.js +216 -53
  31. package/dist/components/datepicker/datepicker.d.ts +9 -1
  32. package/dist/components/datepicker/index.js +108 -15
  33. package/dist/components/dialog/dialog.d.ts +39 -19
  34. package/dist/components/dialog/dialog.types.d.ts +5 -0
  35. package/dist/components/dialog/index.js +237 -90
  36. package/dist/components/divider/index.js +2 -2
  37. package/dist/components/dropdown/dropdown.d.ts +4 -0
  38. package/dist/components/dropdown/index.js +43 -3
  39. package/dist/components/icon-button/icon-button.d.ts +4 -0
  40. package/dist/components/icon-button/index.js +36 -1
  41. package/dist/components/image/image.d.ts +7 -7
  42. package/dist/components/image/index.js +6 -11
  43. package/dist/components/list/index.js +14 -4
  44. package/dist/components/nav/index.js +37 -17
  45. package/dist/components/nav/nav-item.d.ts +8 -8
  46. package/dist/components/pagination/index.js +57 -16
  47. package/dist/components/pagination/pagination.d.ts +8 -1
  48. package/dist/components/pagination/pagination.types.d.ts +15 -0
  49. package/dist/components/progress/index.js +44 -6
  50. package/dist/components/progress/progress.d.ts +12 -0
  51. package/dist/components/radio/index.js +148 -9
  52. package/dist/components/radio/radio.d.ts +34 -2
  53. package/dist/components/radio/radio.types.d.ts +5 -0
  54. package/dist/components/radio-group/index.js +120 -29
  55. package/dist/components/radio-group/radio-group.d.ts +27 -2
  56. package/dist/components/select/index.js +197 -44
  57. package/dist/components/select/select.d.ts +38 -4
  58. package/dist/components/select/select.types.d.ts +5 -0
  59. package/dist/components/shared/aria-forwarding.d.ts +1 -1
  60. package/dist/components/shared/form-default.d.ts +30 -0
  61. package/dist/components/shared/form-disabled.d.ts +40 -0
  62. package/dist/components/shared/form-field.d.ts +7 -0
  63. package/dist/components/shared/form-validity.d.ts +17 -0
  64. package/dist/components/shared/roving.d.ts +11 -0
  65. package/dist/components/shared/scroll-lock.d.ts +2 -0
  66. package/dist/components/sidepanel/index.js +275 -72
  67. package/dist/components/sidepanel/sidepanel.d.ts +32 -10
  68. package/dist/components/sidepanel/sidepanel.types.d.ts +10 -1
  69. package/dist/components/skeleton/index.js +3 -8
  70. package/dist/components/slider/index.js +307 -75
  71. package/dist/components/slider/slider.d.ts +16 -3
  72. package/dist/components/snackbar/index.js +82 -9
  73. package/dist/components/snackbar/snackbar.d.ts +17 -1
  74. package/dist/components/snackbar/snackbar.types.d.ts +5 -0
  75. package/dist/components/surface/index.js +8 -0
  76. package/dist/components/switch/index.js +101 -5
  77. package/dist/components/switch/switch.d.ts +13 -2
  78. package/dist/components/switch/switch.types.d.ts +5 -0
  79. package/dist/components/tab/index.js +15 -14
  80. package/dist/components/tab/tab.d.ts +0 -1
  81. package/dist/components/tab-group/index.js +102 -30
  82. package/dist/components/tab-group/tab-group.d.ts +17 -2
  83. package/dist/components/table/index.js +105 -61
  84. package/dist/components/table/table.d.ts +8 -2
  85. package/dist/components/table/table.types.d.ts +4 -3
  86. package/dist/components/text/index.js +16 -1
  87. package/dist/components/text/text.d.ts +1 -1
  88. package/dist/components/textfield/index.js +243 -35
  89. package/dist/components/textfield/textfield.d.ts +24 -2
  90. package/dist/components/textfield/textfield.types.d.ts +11 -0
  91. package/dist/components/tooltip/index.js +176 -49
  92. package/dist/components/tooltip/tooltip.d.ts +17 -0
  93. package/dist/components/tooltip/tooltip.types.d.ts +5 -1
  94. package/dist/index.cjs +2458 -683
  95. package/dist/index.js +2469 -690
  96. package/dist/manifest.json +202 -32
  97. package/dist/utils/dev-warn.d.ts +5 -0
  98. package/dist/utils/index.d.ts +1 -0
  99. package/package.json +3 -7
@@ -10,7 +10,7 @@ var __decorateClass = (decorators, target, key, kind) => {
10
10
  };
11
11
 
12
12
  // src/components/sidepanel/sidepanel.ts
13
- import { html as html3 } from "lit";
13
+ import { html as html3, nothing as nothing2 } from "lit";
14
14
 
15
15
  // src/base/themeable-element.ts
16
16
  import { LitElement } from "lit";
@@ -35,80 +35,145 @@ __decorateClass([
35
35
  ], ThemeableElement.prototype, "theme", 2);
36
36
 
37
37
  // src/components/sidepanel/sidepanel.ts
38
- import { customElement as customElement3, property as property4 } from "lit/decorators.js";
38
+ import { customElement as customElement3, property as property4, query } from "lit/decorators.js";
39
39
 
40
40
  // src/components/sidepanel/sidepanel.styles.ts
41
41
  import { css } from "lit";
42
42
  var sidepanelStyles = css`
43
43
  :host {
44
44
  display: contents;
45
+ /* translateX/box-shadow offsets have no logical-property equivalent, unlike the inset-inline-*
46
+ positioning below (which resolves automatically) — these carry the direction-dependent sign instead,
47
+ flipped under :dir(rtl), so the off-screen slide direction and shadow side both stay correct without
48
+ any JS reading computed direction at all. */
49
+ --_start-x: -100%;
50
+ --_end-x: 100%;
51
+ --_dir-sign: 1;
45
52
  }
46
53
 
47
- /* ── Overlay ────────────────────────────────────────────────────────────── */
54
+ :host(:dir(rtl)) {
55
+ --_start-x: 100%;
56
+ --_end-x: -100%;
57
+ --_dir-sign: -1;
58
+ }
59
+
60
+ /* ── Panel — the native <dialog> itself now provides the overlay too, via ::backdrop; a closed dialog is
61
+ display:none automatically, so unlike the old transform-only-hidden div, a closed panel is genuinely
62
+ non-interactive and out of the accessibility tree, with no extra JS needed (SPL-1). ────────────────────── */
48
63
 
49
- [part='overlay'] {
64
+ dialog {
65
+ background: var(--lt-bg-default);
66
+ border: none;
67
+ color: var(--lt-text-default);
68
+ margin: 0;
69
+ max-height: none;
70
+ max-width: none;
71
+ padding: 0;
50
72
  position: fixed;
51
- inset: 0;
52
- background: rgba(0, 0, 0, var(--lt-sidepanel-overlay-opacity, 0.45));
53
- z-index: 400;
54
- opacity: 0;
55
- pointer-events: none;
56
- transition: opacity 200ms ease;
73
+ /* display is deliberately NOT set here — a closed dialog must stay display:none (the native default) for
74
+ SPL-1's fix to actually hold (genuinely non-interactive, out of the accessibility tree); it's set to flex
75
+ only for the open state, below. display/overlay need transition-behavior: allow-discrete so close()
76
+ doesn't remove the panel (and its ::backdrop) from rendering instantly, cutting the slide-out transform
77
+ transition off partway through — same reasoning as lt-dialog. */
78
+ transition:
79
+ transform 250ms cubic-bezier(0.4, 0, 0.2, 1),
80
+ display 250ms allow-discrete,
81
+ overlay 250ms allow-discrete;
57
82
  }
58
83
 
59
- /* ── Panel ──────────────────────────────────────────────────────────────── */
84
+ dialog::backdrop {
85
+ background: rgba(0, 0, 0, 0);
86
+ transition:
87
+ background-color 200ms ease,
88
+ display 250ms allow-discrete,
89
+ overlay 250ms allow-discrete;
90
+ }
60
91
 
61
- [part='panel'] {
62
- position: fixed;
63
- background: var(--lt-bg-default);
64
- display: flex;
65
- flex-direction: column;
66
- z-index: 401;
67
- pointer-events: none;
68
- transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
92
+ dialog[open]::backdrop {
93
+ background: rgba(0, 0, 0, var(--lt-sidepanel-overlay-opacity, 0.45));
69
94
  }
70
95
 
71
- /* ── Anchors — initial off-screen positions ─────────────────────────────── */
96
+ /* ── Anchors — off-screen positions; dialog[open] (below) is the only thing that moves it on-screen. Fully
97
+ explicit inset-inline-start/end/top/bottom (not inset-block/inline shorthand, and not omitting the
98
+ opposite side to fall back to "auto") and an explicit height/width — a native <dialog>, even
99
+ position:fixed, does not stretch to fill top:0/bottom:0 the way a plain block box would; confirmed
100
+ empirically, it needs an explicit dimension on the axis inset alone doesn't constrain. ────────────────── */
72
101
 
73
- :host([anchor='left']) [part='panel'],
74
- :host(:not([anchor])) [part='panel'] {
75
- inset-block: 0;
76
- left: 0;
77
- transform: translateX(-100%);
78
- box-shadow: var(--lt-sidepanel-shadow, 4px 0 24px rgba(0, 0, 0, 0.12));
102
+ :host([anchor='start']) dialog,
103
+ :host(:not([anchor])) dialog {
104
+ top: 0;
105
+ inset-inline-end: auto;
106
+ bottom: auto;
107
+ inset-inline-start: 0;
108
+ height: 100vh;
109
+ transform: translateX(var(--_start-x));
110
+ box-shadow: var(--lt-sidepanel-shadow, calc(4px * var(--_dir-sign)) 0 24px rgba(0, 0, 0, 0.12));
79
111
  }
80
112
 
81
- :host([anchor='right']) [part='panel'] {
82
- inset-block: 0;
83
- right: 0;
84
- transform: translateX(100%);
85
- box-shadow: var(--lt-sidepanel-shadow, -4px 0 24px rgba(0, 0, 0, 0.12));
113
+ :host([anchor='end']) dialog {
114
+ top: 0;
115
+ inset-inline-end: 0;
116
+ bottom: auto;
117
+ inset-inline-start: auto;
118
+ height: 100vh;
119
+ transform: translateX(var(--_end-x));
120
+ box-shadow: var(--lt-sidepanel-shadow, calc(-4px * var(--_dir-sign)) 0 24px rgba(0, 0, 0, 0.12));
86
121
  }
87
122
 
88
- :host([anchor='top']) [part='panel'] {
89
- inset-inline: 0;
123
+ :host([anchor='top']) dialog {
90
124
  top: 0;
125
+ inset-inline-end: auto;
126
+ bottom: auto;
127
+ inset-inline-start: 0;
128
+ width: 100vw;
91
129
  transform: translateY(-100%);
92
130
  box-shadow: var(--lt-sidepanel-shadow, 0 4px 24px rgba(0, 0, 0, 0.12));
93
131
  }
94
132
 
95
- :host([anchor='bottom']) [part='panel'] {
96
- inset-inline: 0;
133
+ :host([anchor='bottom']) dialog {
134
+ top: auto;
135
+ inset-inline-end: auto;
97
136
  bottom: 0;
137
+ inset-inline-start: 0;
138
+ width: 100vw;
98
139
  transform: translateY(100%);
99
140
  box-shadow: var(--lt-sidepanel-shadow, 0 -4px 24px rgba(0, 0, 0, 0.12));
100
141
  }
101
142
 
102
- /* ── Open state — must come after anchors to win the cascade ────────────── */
103
-
104
- :host([open]) [part='overlay'] {
105
- opacity: 1;
106
- pointer-events: auto;
143
+ dialog[open] {
144
+ display: flex;
145
+ flex-direction: column;
107
146
  }
108
147
 
109
- :host([open]) [part='panel'] {
148
+ /* The transform override has to be scoped per anchor, not a bare dialog[open] — :host([anchor='end'])
149
+ dialog (above) has HIGHER specificity than a plain dialog[open] (one more attribute selector, from the
150
+ :host() argument), so a bare "dialog[open] { transform: translate(0,0); }" would lose the cascade to the
151
+ anchor rule's own closed-position transform regardless of source order, leaving the panel permanently
152
+ stuck off-screen. Matching each anchor rule's own specificity here guarantees the open state wins. */
153
+ :host([anchor='start']) dialog[open],
154
+ :host(:not([anchor])) dialog[open],
155
+ :host([anchor='end']) dialog[open],
156
+ :host([anchor='top']) dialog[open],
157
+ :host([anchor='bottom']) dialog[open] {
110
158
  transform: translate(0, 0);
111
- pointer-events: auto;
159
+ }
160
+
161
+ /* A newly-shown dialog has no earlier rendered frame to transition from — @starting-style supplies one,
162
+ per anchor, so the slide-in actually animates instead of snapping straight to the open position. */
163
+ @starting-style {
164
+ :host([anchor='start']) dialog[open],
165
+ :host(:not([anchor])) dialog[open] {
166
+ transform: translateX(var(--_start-x));
167
+ }
168
+ :host([anchor='end']) dialog[open] {
169
+ transform: translateX(var(--_end-x));
170
+ }
171
+ :host([anchor='top']) dialog[open] {
172
+ transform: translateY(-100%);
173
+ }
174
+ :host([anchor='bottom']) dialog[open] {
175
+ transform: translateY(100%);
176
+ }
112
177
  }
113
178
 
114
179
  /* ── Header ─────────────────────────────────────────────────────────────── */
@@ -129,7 +194,7 @@ var sidepanelStyles = css`
129
194
 
130
195
  .close-button {
131
196
  flex-shrink: 0;
132
- margin-left: auto;
197
+ margin-inline-start: auto;
133
198
  }
134
199
 
135
200
  /* ── Body ───────────────────────────────────────────────────────────────── */
@@ -143,8 +208,8 @@ var sidepanelStyles = css`
143
208
  /* ── Reduced motion ─────────────────────────────────────────────────────── */
144
209
 
145
210
  @media (prefers-reduced-motion: reduce) {
146
- [part='overlay'],
147
- [part='panel'] {
211
+ dialog,
212
+ dialog::backdrop {
148
213
  transition: none;
149
214
  }
150
215
  }
@@ -153,8 +218,7 @@ var sidepanelStyles = css`
153
218
  // src/components/shared/backdrop.styles.ts
154
219
  import { css as css2 } from "lit";
155
220
  var backdropFeatureStyles = css2`
156
- :host([backdrop-blur]) .backdrop,
157
- :host([backdrop-blur]) [part='overlay'] {
221
+ :host([backdrop-blur]) dialog::backdrop {
158
222
  backdrop-filter: blur(6px);
159
223
  -webkit-backdrop-filter: blur(6px);
160
224
  }
@@ -163,6 +227,32 @@ var backdropFeatureStyles = css2`
163
227
  // src/utils/dispatch.ts
164
228
  var dispatch = (host, name, detail, init) => host.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true, ...init }));
165
229
 
230
+ // src/utils/dev-warn.ts
231
+ var devWarn = (tag, message) => {
232
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
233
+ console.warn(`[${tag}] ${message}`);
234
+ }
235
+ };
236
+
237
+ // src/components/shared/scroll-lock.ts
238
+ var lockCount = 0;
239
+ var previousOverflow = null;
240
+ function lockBodyScroll() {
241
+ if (lockCount === 0) {
242
+ previousOverflow = document.body.style.overflow;
243
+ document.body.style.overflow = "hidden";
244
+ }
245
+ lockCount++;
246
+ }
247
+ function unlockBodyScroll() {
248
+ if (lockCount === 0) return;
249
+ lockCount--;
250
+ if (lockCount === 0) {
251
+ document.body.style.overflow = previousOverflow ?? "";
252
+ previousOverflow = null;
253
+ }
254
+ }
255
+
166
256
  // src/components/icon-button/icon-button.ts
167
257
  import { html, nothing } from "lit";
168
258
  import { customElement, property as property2 } from "lit/decorators.js";
@@ -414,6 +504,14 @@ var iconButtonStyles = css3`
414
504
  :host([round]) [part='base'] {
415
505
  border-radius: 50%;
416
506
  }
507
+
508
+ /* ── Reduced motion ─────────────────────────────────────────────────────── */
509
+
510
+ @media (prefers-reduced-motion: reduce) {
511
+ [part='base'] {
512
+ transition: none;
513
+ }
514
+ }
417
515
  `;
418
516
 
419
517
  // src/components/shared/aria-forwarding.ts
@@ -422,7 +520,8 @@ var FORWARDED_ARIA_ATTRIBUTES = [
422
520
  "aria-expanded",
423
521
  "aria-haspopup",
424
522
  "aria-controls",
425
- "aria-current"
523
+ "aria-current",
524
+ "aria-description"
426
525
  ];
427
526
  var AriaForwardingController = class {
428
527
  constructor(host, getTarget) {
@@ -462,11 +561,30 @@ var IconButton = class extends ThemeableElement {
462
561
  this.bordered = false;
463
562
  this.size = "md";
464
563
  this.disabled = false;
564
+ this._blockClickWhileDisabled = (e) => {
565
+ if (this.disabled) {
566
+ e.preventDefault();
567
+ e.stopImmediatePropagation();
568
+ }
569
+ };
465
570
  this.href = "";
466
571
  this.target = "";
467
572
  this.rel = "";
468
573
  this.round = false;
469
574
  }
575
+ firstUpdated() {
576
+ if (!this.label && !this.textContent?.trim()) {
577
+ devWarn("lt-icon-button", "no label or visible text \u2014 set `label` so screen readers can name the button");
578
+ }
579
+ }
580
+ connectedCallback() {
581
+ super.connectedCallback();
582
+ this.addEventListener("click", this._blockClickWhileDisabled, { capture: true });
583
+ }
584
+ disconnectedCallback() {
585
+ super.disconnectedCallback();
586
+ this.removeEventListener("click", this._blockClickWhileDisabled, { capture: true });
587
+ }
470
588
  _inner() {
471
589
  return this.icon ? html`<lt-icon name=${this.icon}></lt-icon>` : html`<slot></slot>`;
472
590
  }
@@ -700,6 +818,20 @@ var textStyles = css4`
700
818
  `;
701
819
 
702
820
  // src/components/text/text.ts
821
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set([
822
+ "h1",
823
+ "h2",
824
+ "h3",
825
+ "h4",
826
+ "h5",
827
+ "h6",
828
+ "p",
829
+ "span",
830
+ "div",
831
+ "label",
832
+ "strong",
833
+ "em"
834
+ ]);
703
835
  var VARIANT_TAG = {
704
836
  "display-2xl": "p",
705
837
  "display-xl": "p",
@@ -724,7 +856,8 @@ var Text = class extends ThemeableElement {
724
856
  this.as = "";
725
857
  }
726
858
  render() {
727
- const tag = unsafeStatic(this.as || VARIANT_TAG[this.variant] || "p");
859
+ const requested = this.as && ALLOWED_TAGS.has(this.as) ? this.as : "";
860
+ const tag = unsafeStatic(requested || VARIANT_TAG[this.variant] || "p");
728
861
  return html2`<${tag} part="base"><slot></slot></${tag}>`;
729
862
  }
730
863
  };
@@ -740,19 +873,45 @@ Text = __decorateClass([
740
873
  ], Text);
741
874
 
742
875
  // src/components/sidepanel/sidepanel.ts
876
+ var DEFAULT_LABELS = {
877
+ close: "Close panel"
878
+ };
743
879
  var SidePanel = class extends ThemeableElement {
744
880
  constructor() {
745
881
  super(...arguments);
746
882
  this.open = false;
747
- this.anchor = "left";
883
+ this.anchor = "start";
748
884
  this.size = "320px";
749
885
  this.label = "";
750
886
  this.noCloseButton = false;
751
887
  this.backdropBlur = false;
752
888
  this.overlayOpacity = 0.45;
753
- this._handleKeyDown = (e) => {
754
- if (e.key === "Escape" && this.open) this.hide();
889
+ this.labels = {};
890
+ this._scrollLocked = false;
891
+ // Same first-update guard, for the same reason, as lt-dialog (DLG-2), lt-dropdown (DDN-1) and lt-snackbar
892
+ // (SNK-1): `updated()` also runs after the very first render, where `changed.has('open')` is true even though
893
+ // nothing was ever shown.
894
+ this._skipNextOpenChange = true;
895
+ // Unlike lt-dialog, this has no close-on-escape toggle, so there's nothing to preventDefault() on 'cancel' —
896
+ // Escape is left entirely to the native element, now scoped to whichever panel actually has focus, not a
897
+ // window-level listener that closed every open panel on one keypress regardless of which was focused (SPL-3).
898
+ // The native element's own 'close' event fires whenever IT closes, for any reason — sync `open` back so the
899
+ // two never drift apart. If we're the ones who just called close() (inside _handleClose(), above), `open` is
900
+ // already false and this is a no-op.
901
+ this._handleNativeClose = () => {
902
+ this.open = false;
755
903
  };
904
+ // The clicked-outside-the-content area of a shown <dialog> resolves e.target to the <dialog> element itself
905
+ // (::backdrop isn't a real node and can't be an event target) — clicking any actual descendant instead
906
+ // targets that descendant (same mechanism as lt-dialog's backdrop-click detection, confirmed in the spike).
907
+ this._handleDialogClick = (e) => {
908
+ if (e.target === this.dialogElement) {
909
+ this.hide();
910
+ }
911
+ };
912
+ }
913
+ get _labels() {
914
+ return { ...DEFAULT_LABELS, ...this.labels };
756
915
  }
757
916
  /** Open the panel. */
758
917
  show() {
@@ -761,24 +920,30 @@ var SidePanel = class extends ThemeableElement {
761
920
  /** Close the panel. */
762
921
  hide() {
763
922
  this.open = false;
764
- dispatch(this, "close");
765
923
  }
766
924
  /** Toggle the panel open/closed. */
767
925
  toggle() {
768
- if (this.open) this.hide();
769
- else this.show();
770
- }
771
- connectedCallback() {
772
- super.connectedCallback();
773
- window.addEventListener("keydown", this._handleKeyDown);
926
+ this.open = !this.open;
774
927
  }
775
928
  disconnectedCallback() {
776
929
  super.disconnectedCallback();
777
- window.removeEventListener("keydown", this._handleKeyDown);
930
+ if (this._scrollLocked) {
931
+ unlockBodyScroll();
932
+ this._scrollLocked = false;
933
+ }
778
934
  }
779
935
  updated(changed) {
936
+ if (changed.has("open")) {
937
+ if (this._skipNextOpenChange) {
938
+ this._skipNextOpenChange = false;
939
+ } else if (this.open) {
940
+ this._handleOpen();
941
+ } else {
942
+ this._handleClose();
943
+ }
944
+ }
780
945
  if (!changed.has("anchor")) return;
781
- const panel = this.shadowRoot?.querySelector('[part="panel"]');
946
+ const panel = this.dialogElement;
782
947
  if (!panel) return;
783
948
  panel.style.transition = "none";
784
949
  if (this.open) {
@@ -790,33 +955,60 @@ var SidePanel = class extends ThemeableElement {
790
955
  panel.style.transform = "";
791
956
  });
792
957
  }
958
+ _handleOpen() {
959
+ if (!this._scrollLocked) {
960
+ lockBodyScroll();
961
+ this._scrollLocked = true;
962
+ }
963
+ if (this.dialogElement && !this.dialogElement.open) {
964
+ this.dialogElement.showModal();
965
+ }
966
+ dispatch(this, "open");
967
+ }
968
+ _handleClose() {
969
+ if (this._scrollLocked) {
970
+ unlockBodyScroll();
971
+ this._scrollLocked = false;
972
+ }
973
+ if (this.dialogElement?.open) {
974
+ this.dialogElement.close();
975
+ }
976
+ dispatch(this, "close");
977
+ }
978
+ // translateX has no logical/direction-aware equivalent in CSS, unlike the positioning below (inset-inline-*
979
+ // resolves automatically) — var(--_start-x)/var(--_end-x) carry the direction-dependent sign instead (defined
980
+ // in the stylesheet, flipped under :host(:dir(rtl))), so this stays correct under RTL without reading
981
+ // getComputedStyle(this).direction here at all.
793
982
  get _offScreenTransform() {
794
983
  switch (this.anchor) {
795
- case "right":
796
- return "translateX(100%)";
984
+ case "end":
985
+ return "translateX(var(--_end-x, 100%))";
797
986
  case "top":
798
987
  return "translateY(-100%)";
799
988
  case "bottom":
800
989
  return "translateY(100%)";
801
990
  default:
802
- return "translateX(-100%)";
991
+ return "translateX(var(--_start-x, -100%))";
803
992
  }
804
993
  }
805
994
  get _panelStyle() {
806
- return this.anchor === "left" || this.anchor === "right" ? `width: ${this.size}` : `height: ${this.size}`;
995
+ const sizeStyle = this.anchor === "start" || this.anchor === "end" ? `width: ${this.size}` : `height: ${this.size}`;
996
+ return `${sizeStyle}; --lt-sidepanel-overlay-opacity: ${this.overlayOpacity}`;
997
+ }
998
+ get _hasLabel() {
999
+ return this.label.trim().length > 0;
807
1000
  }
808
1001
  _renderHeader() {
809
- const hasLabel = this.label.trim().length > 0;
810
- if (!hasLabel && this.noCloseButton) return "";
1002
+ if (!this._hasLabel && this.noCloseButton) return "";
811
1003
  return html3`
812
1004
  <div part="header" class="header">
813
- ${hasLabel ? html3`<lt-text variant="h6" class="panel-label">${this.label}</lt-text>` : ""}
1005
+ ${this._hasLabel ? html3`<lt-text variant="h6" class="panel-label" id="sidepanel-label">${this.label}</lt-text>` : ""}
814
1006
  ${!this.noCloseButton ? html3`
815
1007
  <lt-icon-button
816
1008
  part="close-button"
817
1009
  class="close-button"
818
1010
  icon="xmark"
819
- label="Close panel"
1011
+ label=${this._labels.close}
820
1012
  size="sm"
821
1013
  @click=${this.hide}
822
1014
  ></lt-icon-button>
@@ -826,13 +1018,18 @@ var SidePanel = class extends ThemeableElement {
826
1018
  }
827
1019
  render() {
828
1020
  return html3`
829
- <div part="overlay" style="--_overlay-opacity: ${this.overlayOpacity}" @click=${this.hide}></div>
830
- <div part="panel" style=${this._panelStyle} role="dialog" aria-modal="true">
1021
+ <dialog
1022
+ part="panel"
1023
+ style=${this._panelStyle}
1024
+ aria-labelledby=${this._hasLabel ? "sidepanel-label" : nothing2}
1025
+ @close=${this._handleNativeClose}
1026
+ @click=${this._handleDialogClick}
1027
+ >
831
1028
  ${this._renderHeader()}
832
1029
  <div part="body" class="body">
833
1030
  <slot></slot>
834
1031
  </div>
835
- </div>
1032
+ </dialog>
836
1033
  `;
837
1034
  }
838
1035
  };
@@ -858,6 +1055,12 @@ __decorateClass([
858
1055
  __decorateClass([
859
1056
  property4({ type: Number, attribute: "overlay-opacity" })
860
1057
  ], SidePanel.prototype, "overlayOpacity", 2);
1058
+ __decorateClass([
1059
+ property4({ type: Object })
1060
+ ], SidePanel.prototype, "labels", 2);
1061
+ __decorateClass([
1062
+ query("dialog")
1063
+ ], SidePanel.prototype, "dialogElement", 2);
861
1064
  SidePanel = __decorateClass([
862
1065
  customElement3("lt-sidepanel")
863
1066
  ], SidePanel);
@@ -1,29 +1,42 @@
1
1
  import { type PropertyValues } from 'lit';
2
2
  import { ThemeableElement } from '../../base';
3
- import type { SidePanelAnchor } from './sidepanel.types';
3
+ import type { SidePanelAnchor, SidePanelLabels } from './sidepanel.types';
4
4
  import '../icon-button/icon-button';
5
5
  import '../text/text';
6
6
  /**
7
- * A slide-in panel anchored to any edge of the viewport, with a backdrop overlay.
7
+ * A slide-in panel anchored to any edge of the viewport, built on the native `<dialog>` element — the same
8
+ * migration `lt-dialog` went through (DLG-1..4), applied here to fix the equivalent gaps (SPL-1/2/3): a closed
9
+ * panel is no longer just transformed off-screen while staying focusable and exposed to assistive tech
10
+ * (`display: none` when closed is native, automatic, and absolute); focus trap and restore on close both come
11
+ * from `showModal()`/`close()` for free — no manual focus-tracking code needed here at all, unlike `lt-dialog`,
12
+ * which still carries its own from before that component's migration; Escape closes only the panel that was
13
+ * actually focused, not every open panel on the page (no more `window`-level keydown listener); and `close`
14
+ * fires consistently from `updated()` whenever `open` becomes `false`, however it happened — not only when
15
+ * `hide()` was called directly.
8
16
  *
9
17
  * @element lt-sidepanel
10
18
  * @slot - Content rendered inside the panel body
11
- * @fires close - Fired when the user requests close (backdrop click, Escape key, or close button)
12
- * @part overlay - The backdrop scrim behind the panel
13
- * @part panel - The sliding panel container
19
+ * @fires open - Fired when the panel opens
20
+ * @fires close - Fired when the panel closes (however it closed — the close button, Escape, a backdrop click,
21
+ * or setting `open` to `false` directly)
22
+ * @part overlay - The backdrop scrim behind the panel (the native `<dialog>`'s own `::backdrop`, exposed as a
23
+ * part so consumers can still target it, even though it's not a real light-DOM-visible element)
24
+ * @part panel - The sliding panel container (the `<dialog>` element itself)
14
25
  * @part header - The header bar (contains label and close button)
15
26
  * @part close-button - The close button inside the header
16
27
  * @part body - The scrollable content area wrapping the default slot
17
28
  *
18
29
  * @cssprop [--lt-sidepanel-shadow] - Overrides the panel's box-shadow, for all anchor positions.
30
+ * @cssprop [--lt-sidepanel-overlay-opacity] - Backdrop darkness. Set via the `overlay-opacity` property/attribute
31
+ * instead of this custom property directly, unless you're overriding it from outside the component.
19
32
  */
20
33
  export declare class SidePanel extends ThemeableElement {
21
34
  static styles: import("lit").CSSResult[];
22
35
  /** Whether the panel is visible. */
23
36
  open: boolean;
24
- /** Edge from which the panel slides in. */
37
+ /** Edge from which the panel slides in. `'start'`/`'end'` flip with reading direction. */
25
38
  anchor: SidePanelAnchor;
26
- /** Panel width (left/right anchors) or height (top/bottom anchors). */
39
+ /** Panel width (start/end anchors) or height (top/bottom anchors). */
27
40
  size: string;
28
41
  /** Title shown in the panel header. When empty, the header is hidden. */
29
42
  label: string;
@@ -33,18 +46,27 @@ export declare class SidePanel extends ThemeableElement {
33
46
  backdropBlur: boolean;
34
47
  /** Backdrop darkness as an alpha value from 0 (transparent) to 1 (opaque). */
35
48
  overlayOpacity: number;
49
+ /** Overridable UI text (the close button's accessible name), for i18n. English defaults — override only the keys you need. */
50
+ labels: Partial<SidePanelLabels>;
51
+ private get _labels();
52
+ private dialogElement?;
53
+ private _scrollLocked;
54
+ private _skipNextOpenChange;
36
55
  /** Open the panel. */
37
56
  show(): void;
38
57
  /** Close the panel. */
39
58
  hide(): void;
40
59
  /** Toggle the panel open/closed. */
41
60
  toggle(): void;
42
- connectedCallback(): void;
43
61
  disconnectedCallback(): void;
44
- private _handleKeyDown;
45
- protected updated(changed: PropertyValues): void;
62
+ updated(changed: PropertyValues<this>): void;
63
+ private _handleOpen;
64
+ private _handleClose;
65
+ private _handleNativeClose;
66
+ private _handleDialogClick;
46
67
  private get _offScreenTransform();
47
68
  private get _panelStyle();
69
+ private get _hasLabel();
48
70
  private _renderHeader;
49
71
  render(): import("lit").TemplateResult<1>;
50
72
  }
@@ -1 +1,10 @@
1
- export type SidePanelAnchor = 'left' | 'right' | 'top' | 'bottom';
1
+ /**
2
+ * Edge the panel slides in from. `'start'`/`'end'` are logical — they resolve to the left/right edge based on
3
+ * the page's reading direction (left/right in LTR, right/left in RTL), not a fixed physical side.
4
+ */
5
+ export type SidePanelAnchor = 'start' | 'end' | 'top' | 'bottom';
6
+ /** Overridable UI text, for i18n — English defaults; override only the keys you need. */
7
+ export interface SidePanelLabels {
8
+ /** Accessible label for the close button. */
9
+ close: string;
10
+ }
@@ -46,7 +46,7 @@ var skeletonStyles = css`
46
46
  }
47
47
 
48
48
  [part='base'] {
49
- background: var(--lt-color-neutral-200);
49
+ background: var(--lt-bg-overlay);
50
50
  border-radius: var(--lt-border-radius);
51
51
  height: 1.25rem;
52
52
  overflow: hidden;
@@ -66,12 +66,7 @@ var skeletonStyles = css`
66
66
 
67
67
  :host([animated]) [part='base'] {
68
68
  animation: shimmer 1.5s ease-in-out infinite;
69
- background: linear-gradient(
70
- 90deg,
71
- var(--lt-color-neutral-100) 25%,
72
- var(--lt-color-neutral-200) 50%,
73
- var(--lt-color-neutral-100) 75%
74
- );
69
+ background: linear-gradient(90deg, var(--lt-bg-surface) 25%, var(--lt-bg-overlay) 50%, var(--lt-bg-surface) 75%);
75
70
  background-size: 200% 100%;
76
71
  }
77
72
 
@@ -87,7 +82,7 @@ var skeletonStyles = css`
87
82
  @media (prefers-reduced-motion: reduce) {
88
83
  :host([animated]) [part='base'] {
89
84
  animation: none;
90
- background: var(--lt-color-neutral-200);
85
+ background: var(--lt-bg-overlay);
91
86
  }
92
87
  }
93
88
  `;