@latty-ds/web 0.10.3 → 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 (101) hide show
  1. package/custom-elements.json +1970 -229
  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 +127 -20
  6. package/dist/components/avatar/avatar.d.ts +2 -0
  7. package/dist/components/avatar/index.js +10 -3
  8. package/dist/components/badge/index.js +1 -1
  9. package/dist/components/breadcrumb/breadcrumb.d.ts +4 -0
  10. package/dist/components/breadcrumb/breadcrumb.types.d.ts +5 -0
  11. package/dist/components/breadcrumb/index.js +20 -3
  12. package/dist/components/button/button.d.ts +33 -0
  13. package/dist/components/button/index.js +138 -15
  14. package/dist/components/calendar/calendar.d.ts +15 -7
  15. package/dist/components/calendar/calendar.types.d.ts +17 -1
  16. package/dist/components/calendar/index.js +80 -27
  17. package/dist/components/checkbox/checkbox.d.ts +14 -2
  18. package/dist/components/checkbox/checkbox.types.d.ts +5 -0
  19. package/dist/components/checkbox/index.js +82 -5
  20. package/dist/components/chip/chip.d.ts +4 -1
  21. package/dist/components/chip/chip.types.d.ts +5 -0
  22. package/dist/components/chip/index.js +29 -1
  23. package/dist/components/color-input/color-input.d.ts +11 -2
  24. package/dist/components/color-input/color-input.types.d.ts +5 -0
  25. package/dist/components/color-input/index.js +92 -8
  26. package/dist/components/combobox/combobox.d.ts +18 -2
  27. package/dist/components/combobox/combobox.types.d.ts +7 -0
  28. package/dist/components/combobox/index.js +164 -23
  29. package/dist/components/date-input/date-input.d.ts +22 -7
  30. package/dist/components/date-input/date-input.types.d.ts +7 -0
  31. package/dist/components/date-input/index.js +218 -55
  32. package/dist/components/datepicker/datepicker.d.ts +9 -1
  33. package/dist/components/datepicker/index.js +108 -15
  34. package/dist/components/dialog/dialog.d.ts +39 -19
  35. package/dist/components/dialog/dialog.types.d.ts +5 -0
  36. package/dist/components/dialog/index.js +240 -93
  37. package/dist/components/divider/index.js +2 -2
  38. package/dist/components/dropdown/dropdown-item.d.ts +1 -1
  39. package/dist/components/dropdown/dropdown.d.ts +4 -0
  40. package/dist/components/dropdown/index.js +43 -3
  41. package/dist/components/icon-button/icon-button.d.ts +4 -0
  42. package/dist/components/icon-button/index.js +39 -4
  43. package/dist/components/image/image.d.ts +7 -7
  44. package/dist/components/image/index.js +6 -11
  45. package/dist/components/list/index.js +14 -4
  46. package/dist/components/nav/index.js +37 -17
  47. package/dist/components/nav/nav-item.d.ts +8 -8
  48. package/dist/components/pagination/index.js +57 -16
  49. package/dist/components/pagination/pagination.d.ts +8 -1
  50. package/dist/components/pagination/pagination.types.d.ts +15 -0
  51. package/dist/components/progress/index.js +44 -6
  52. package/dist/components/progress/progress.d.ts +12 -0
  53. package/dist/components/radio/index.js +148 -9
  54. package/dist/components/radio/radio.d.ts +34 -2
  55. package/dist/components/radio/radio.types.d.ts +5 -0
  56. package/dist/components/radio-group/index.js +120 -29
  57. package/dist/components/radio-group/radio-group.d.ts +27 -2
  58. package/dist/components/select/index.js +197 -44
  59. package/dist/components/select/select.d.ts +38 -4
  60. package/dist/components/select/select.types.d.ts +5 -0
  61. package/dist/components/shared/aria-forwarding.d.ts +1 -1
  62. package/dist/components/shared/form-default.d.ts +30 -0
  63. package/dist/components/shared/form-disabled.d.ts +40 -0
  64. package/dist/components/shared/form-field.d.ts +7 -0
  65. package/dist/components/shared/form-validity.d.ts +17 -0
  66. package/dist/components/shared/roving.d.ts +11 -0
  67. package/dist/components/shared/scroll-lock.d.ts +2 -0
  68. package/dist/components/sidepanel/index.js +278 -75
  69. package/dist/components/sidepanel/sidepanel.d.ts +32 -10
  70. package/dist/components/sidepanel/sidepanel.types.d.ts +10 -1
  71. package/dist/components/skeleton/index.js +3 -8
  72. package/dist/components/slider/index.js +307 -75
  73. package/dist/components/slider/slider.d.ts +16 -3
  74. package/dist/components/snackbar/index.js +82 -9
  75. package/dist/components/snackbar/snackbar.d.ts +17 -1
  76. package/dist/components/snackbar/snackbar.types.d.ts +5 -0
  77. package/dist/components/surface/index.js +8 -0
  78. package/dist/components/switch/index.js +101 -5
  79. package/dist/components/switch/switch.d.ts +13 -2
  80. package/dist/components/switch/switch.types.d.ts +5 -0
  81. package/dist/components/tab/index.js +15 -14
  82. package/dist/components/tab/tab.d.ts +0 -1
  83. package/dist/components/tab-group/index.js +102 -30
  84. package/dist/components/tab-group/tab-group.d.ts +17 -2
  85. package/dist/components/table/index.js +105 -61
  86. package/dist/components/table/table.d.ts +8 -2
  87. package/dist/components/table/table.types.d.ts +4 -3
  88. package/dist/components/text/index.js +16 -1
  89. package/dist/components/text/text.d.ts +1 -1
  90. package/dist/components/textfield/index.js +243 -35
  91. package/dist/components/textfield/textfield.d.ts +24 -2
  92. package/dist/components/textfield/textfield.types.d.ts +11 -0
  93. package/dist/components/tooltip/index.js +176 -49
  94. package/dist/components/tooltip/tooltip.d.ts +17 -0
  95. package/dist/components/tooltip/tooltip.types.d.ts +5 -1
  96. package/dist/index.cjs +2467 -692
  97. package/dist/index.js +2478 -699
  98. package/dist/manifest.json +202 -32
  99. package/dist/utils/dev-warn.d.ts +5 -0
  100. package/dist/utils/index.d.ts +1 -0
  101. package/package.json +4 -8
@@ -34,6 +34,16 @@ __decorateClass([
34
34
  property({ reflect: true })
35
35
  ], ThemeableElement.prototype, "theme", 2);
36
36
 
37
+ // src/utils/color.ts
38
+ var resolveColorValue = (value) => value.startsWith("--") ? `var(${value})` : value;
39
+
40
+ // src/utils/dev-warn.ts
41
+ var devWarn = (tag, message) => {
42
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
43
+ console.warn(`[${tag}] ${message}`);
44
+ }
45
+ };
46
+
37
47
  // src/components/dialog/dialog.ts
38
48
  import { customElement as customElement4, property as property5, query } from "lit/decorators.js";
39
49
 
@@ -44,80 +54,83 @@ var dialogStyles = css`
44
54
  font-family: var(--lt-typography-fontFamilyPrimary, 'Hanken Grotesk', sans-serif);
45
55
  }
46
56
 
47
- /* Backdrop */
48
- .backdrop {
49
- align-items: center;
50
- background: rgba(0, 0, 0, var(--lt-dialog-overlay-opacity, 0.5));
51
- display: flex;
52
- height: 100vh;
53
- justify-content: center;
54
- left: 0;
55
- opacity: 0;
56
- padding: var(--lt-spacing-4);
57
- position: fixed;
58
- top: 0;
59
- transition: opacity 200ms ease;
60
- width: 100vw;
61
- z-index: 1000;
62
- }
63
-
64
- :host([open]) .backdrop {
65
- opacity: 1;
66
- }
67
-
68
- :host(:not([open])) .backdrop {
69
- pointer-events: none;
70
- }
71
-
72
- /* lt-surface host — flex item inside .backdrop, sized per size variant */
73
- lt-surface {
74
- width: 100%;
57
+ /* The native <dialog> is the modal surface now — its own showModal()/close() give the top-layer stacking,
58
+ focus trap and inert background for free (DLG-1). Reset its UA-default border/padding/background (a native
59
+ <dialog> renders with a visible border and white background by default) so lt-surface, not the browser's
60
+ own chrome, controls how it looks. Width/height are viewport-relative with a spacing-4 inset on each side
61
+ (matching the old .backdrop's padding), capped per size variant below. */
62
+ dialog {
63
+ background: transparent;
64
+ border: none;
65
+ max-height: calc(100vh - var(--lt-spacing-8));
66
+ overflow: visible;
67
+ padding: 0;
68
+ width: calc(100vw - var(--lt-spacing-8));
69
+ /* display/overlay (top-layer membership) are the only things animated on <dialog> itself — the entrance/exit
70
+ motion (opacity + translateY) lives on lt-surface below, unaffected by any of this. Without these,
71
+ close() would remove the dialog (and its ::backdrop) from rendering instantly, cutting that motion off
72
+ partway through; allow-discrete keeps it rendered for the transition's duration instead, then removes it. */
73
+ transition:
74
+ display 220ms allow-discrete,
75
+ overlay 220ms allow-discrete;
75
76
  }
76
77
 
77
- :host([size='sm']) lt-surface {
78
+ :host([size='sm']) dialog {
78
79
  max-width: 400px;
79
80
  }
80
- :host([size='md']) lt-surface {
81
+ :host([size='md']) dialog {
81
82
  max-width: 600px;
82
83
  }
83
- :host([size='lg']) lt-surface {
84
+ :host([size='lg']) dialog {
84
85
  max-width: 800px;
85
86
  }
86
- :host([size='xl']) lt-surface {
87
+ :host([size='xl']) dialog {
87
88
  max-width: 1000px;
88
89
  }
89
-
90
- :host([size='fullscreen']) .backdrop {
91
- padding: 0;
92
- }
93
-
94
- :host([size='fullscreen']) lt-surface {
90
+ :host([size='fullscreen']) dialog {
95
91
  height: 100vh;
96
92
  max-height: 100vh;
97
93
  max-width: 100vw;
94
+ width: 100vw;
98
95
  }
99
96
 
100
- /* lt-surface inner div — layout and animation */
101
- lt-surface::part(surface) {
97
+ /* ::backdrop replaces the old .backdrop div — it's a real paintable box (just not a real DOM node), so its
98
+ own background-color transition plays independently of lt-surface's. */
99
+ dialog::backdrop {
100
+ background: rgba(0, 0, 0, 0);
101
+ transition:
102
+ background-color 200ms ease,
103
+ display 220ms allow-discrete,
104
+ overlay 220ms allow-discrete;
105
+ }
106
+
107
+ dialog[open]::backdrop {
108
+ background: rgba(0, 0, 0, var(--lt-dialog-overlay-opacity, 0.5));
109
+ }
110
+
111
+ /* lt-surface — fills the <dialog>, sized by it (max-width/height above); the entrance/exit animation */
112
+ lt-surface {
102
113
  display: flex;
103
114
  flex-direction: column;
104
- max-height: calc(100vh - var(--lt-spacing-8));
115
+ height: 100%;
105
116
  opacity: 0;
106
- position: relative;
107
117
  transform: translateY(16px);
108
118
  transition:
109
119
  opacity 220ms ease,
110
120
  transform 220ms cubic-bezier(0.34, 1.2, 0.64, 1);
121
+ width: 100%;
111
122
  }
112
123
 
113
- :host([open]) lt-surface::part(surface) {
124
+ dialog[open] lt-surface {
114
125
  opacity: 1;
115
126
  transform: translateY(0);
116
127
  }
117
128
 
118
- :host([size='fullscreen']) lt-surface::part(surface) {
119
- border-radius: 0;
120
- max-height: 100vh;
129
+ lt-surface::part(surface) {
130
+ display: flex;
131
+ flex: 1;
132
+ flex-direction: column;
133
+ min-height: 0;
121
134
  }
122
135
 
123
136
  /* Header */
@@ -144,7 +157,7 @@ var dialogStyles = css`
144
157
 
145
158
  /* Body */
146
159
  .body {
147
- color: var(--lt-color-neutral-700);
160
+ color: var(--lt-text-neutral);
148
161
  flex: 1;
149
162
  overflow-y: auto;
150
163
  padding: var(--lt-spacing-5);
@@ -164,18 +177,46 @@ var dialogStyles = css`
164
177
  letter-spacing: 0.05rem;
165
178
  text-transform: lowercase;
166
179
  }
180
+
181
+ /* ── Reduced motion ─────────────────────────────────────────────────────── */
182
+
183
+ @media (prefers-reduced-motion: reduce) {
184
+ dialog,
185
+ dialog::backdrop,
186
+ lt-surface {
187
+ transition: none;
188
+ }
189
+ }
167
190
  `;
168
191
 
169
192
  // src/components/shared/backdrop.styles.ts
170
193
  import { css as css2 } from "lit";
171
194
  var backdropFeatureStyles = css2`
172
- :host([backdrop-blur]) .backdrop,
173
- :host([backdrop-blur]) [part='overlay'] {
195
+ :host([backdrop-blur]) dialog::backdrop {
174
196
  backdrop-filter: blur(6px);
175
197
  -webkit-backdrop-filter: blur(6px);
176
198
  }
177
199
  `;
178
200
 
201
+ // src/components/shared/scroll-lock.ts
202
+ var lockCount = 0;
203
+ var previousOverflow = null;
204
+ function lockBodyScroll() {
205
+ if (lockCount === 0) {
206
+ previousOverflow = document.body.style.overflow;
207
+ document.body.style.overflow = "hidden";
208
+ }
209
+ lockCount++;
210
+ }
211
+ function unlockBodyScroll() {
212
+ if (lockCount === 0) return;
213
+ lockCount--;
214
+ if (lockCount === 0) {
215
+ document.body.style.overflow = previousOverflow ?? "";
216
+ previousOverflow = null;
217
+ }
218
+ }
219
+
179
220
  // src/components/icon-button/icon-button.ts
180
221
  import { html, nothing } from "lit";
181
222
  import { customElement, property as property2 } from "lit/decorators.js";
@@ -313,11 +354,11 @@ var iconButtonStyles = css3`
313
354
  --_active-bg: var(--lt-interactive-secondary-bg-active);
314
355
  }
315
356
  :host([appearance='filled'][variant='neutral']) {
316
- --_bg: var(--lt-color-neutral-500);
357
+ --_bg: var(--lt-interactive-neutral-bg);
317
358
  --_color: var(--lt-color-neutral-50);
318
- --_hover-bg: var(--lt-color-neutral-700);
359
+ --_hover-bg: var(--lt-interactive-neutral-bg-hover);
319
360
  --_hover-color: var(--lt-color-neutral-50);
320
- --_active-bg: var(--lt-color-neutral-800);
361
+ --_active-bg: var(--lt-interactive-neutral-bg-active);
321
362
  }
322
363
  :host([appearance='filled'][variant='success']) {
323
364
  --_bg: var(--lt-interactive-success-bg);
@@ -427,6 +468,14 @@ var iconButtonStyles = css3`
427
468
  :host([round]) [part='base'] {
428
469
  border-radius: 50%;
429
470
  }
471
+
472
+ /* ── Reduced motion ─────────────────────────────────────────────────────── */
473
+
474
+ @media (prefers-reduced-motion: reduce) {
475
+ [part='base'] {
476
+ transition: none;
477
+ }
478
+ }
430
479
  `;
431
480
 
432
481
  // src/components/shared/aria-forwarding.ts
@@ -435,7 +484,8 @@ var FORWARDED_ARIA_ATTRIBUTES = [
435
484
  "aria-expanded",
436
485
  "aria-haspopup",
437
486
  "aria-controls",
438
- "aria-current"
487
+ "aria-current",
488
+ "aria-description"
439
489
  ];
440
490
  var AriaForwardingController = class {
441
491
  constructor(host, getTarget) {
@@ -475,11 +525,30 @@ var IconButton = class extends ThemeableElement {
475
525
  this.bordered = false;
476
526
  this.size = "md";
477
527
  this.disabled = false;
528
+ this._blockClickWhileDisabled = (e) => {
529
+ if (this.disabled) {
530
+ e.preventDefault();
531
+ e.stopImmediatePropagation();
532
+ }
533
+ };
478
534
  this.href = "";
479
535
  this.target = "";
480
536
  this.rel = "";
481
537
  this.round = false;
482
538
  }
539
+ firstUpdated() {
540
+ if (!this.label && !this.textContent?.trim()) {
541
+ devWarn("lt-icon-button", "no label or visible text \u2014 set `label` so screen readers can name the button");
542
+ }
543
+ }
544
+ connectedCallback() {
545
+ super.connectedCallback();
546
+ this.addEventListener("click", this._blockClickWhileDisabled, { capture: true });
547
+ }
548
+ disconnectedCallback() {
549
+ super.disconnectedCallback();
550
+ this.removeEventListener("click", this._blockClickWhileDisabled, { capture: true });
551
+ }
483
552
  _inner() {
484
553
  return this.icon ? html`<lt-icon name=${this.icon}></lt-icon>` : html`<slot></slot>`;
485
554
  }
@@ -599,10 +668,15 @@ var surfaceStyles = css4`
599
668
  :host([appearance='outlined']:hover) .surface {
600
669
  border-color: var(--lt-border-strong);
601
670
  }
602
- `;
603
671
 
604
- // src/utils/color.ts
605
- var resolveColorValue = (value) => value.startsWith("--") ? `var(${value})` : value;
672
+ /* ── Reduced motion ─────────────────────────────────────────────────────── */
673
+
674
+ @media (prefers-reduced-motion: reduce) {
675
+ .surface {
676
+ transition: none;
677
+ }
678
+ }
679
+ `;
606
680
 
607
681
  // src/components/surface/surface.ts
608
682
  var Surface = class extends ThemeableElement {
@@ -804,6 +878,20 @@ var textStyles = css5`
804
878
  `;
805
879
 
806
880
  // src/components/text/text.ts
881
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set([
882
+ "h1",
883
+ "h2",
884
+ "h3",
885
+ "h4",
886
+ "h5",
887
+ "h6",
888
+ "p",
889
+ "span",
890
+ "div",
891
+ "label",
892
+ "strong",
893
+ "em"
894
+ ]);
807
895
  var VARIANT_TAG = {
808
896
  "display-2xl": "p",
809
897
  "display-xl": "p",
@@ -828,7 +916,8 @@ var Text = class extends ThemeableElement {
828
916
  this.as = "";
829
917
  }
830
918
  render() {
831
- const tag = unsafeStatic(this.as || VARIANT_TAG[this.variant] || "p");
919
+ const requested = this.as && ALLOWED_TAGS.has(this.as) ? this.as : "";
920
+ const tag = unsafeStatic(requested || VARIANT_TAG[this.variant] || "p");
832
921
  return html3`<${tag} part="base"><slot></slot></${tag}>`;
833
922
  }
834
923
  };
@@ -844,12 +933,15 @@ Text = __decorateClass([
844
933
  ], Text);
845
934
 
846
935
  // src/components/dialog/dialog.ts
936
+ var DEFAULT_LABELS = {
937
+ close: "Close dialog"
938
+ };
847
939
  var Dialog = class extends ThemeableElement {
848
940
  constructor() {
849
941
  super(...arguments);
850
942
  this.size = "md";
851
943
  this.open = false;
852
- this.title = "";
944
+ this.heading = "";
853
945
  this.noCloseButton = false;
854
946
  this.closeOnBackdropClick = true;
855
947
  this.closeOnEscape = true;
@@ -857,13 +949,53 @@ var Dialog = class extends ThemeableElement {
857
949
  this.backdropBlur = false;
858
950
  this.elevation = "4";
859
951
  this.appearance = "filled";
860
- this.handleKeydown = (event) => {
861
- if (this.open && this.closeOnEscape && event.key === "Escape") {
862
- event.preventDefault();
952
+ this.labels = {};
953
+ this._scrollLocked = false;
954
+ // `updated()` also runs after the very first render, where `changed.has('open')` is true even though nothing
955
+ // was ever shown — `open` starts false, so the else branch ran, firing a spurious `close` and clobbering
956
+ // `document.body.style.overflow` on page load (DLG-2). Same fix as lt-dropdown's `_skipNextOpenChange` (DDN-1)
957
+ // and lt-snackbar's (SNK-1).
958
+ this._skipNextOpenChange = true;
959
+ // Native <dialog> fires 'cancel' (cancelable) on Escape before it closes itself — preventDefault() here is
960
+ // how close-on-escape="false" is implemented now, instead of an old keydown listener racing the platform's
961
+ // own handling (confirmed working in the spike across all three engines).
962
+ this._handleCancel = (e) => {
963
+ if (!this.closeOnEscape) {
964
+ e.preventDefault();
965
+ }
966
+ };
967
+ // The native element's own 'close' event fires whenever IT closes, for any reason — our own close() call
968
+ // above, but also Escape (which closes it directly; nothing else tells us). Sync `open` back so the two never
969
+ // drift apart. If we're the ones who just called close() (inside handleClose(), above), `open` is already
970
+ // false and this is a no-op — Lit ignores a property set to its current value.
971
+ this._handleNativeClose = () => {
972
+ this.open = false;
973
+ };
974
+ // The clicked-outside-the-content area of a shown <dialog> resolves e.target to the <dialog> element itself
975
+ // (::backdrop isn't a real node and can't be an event target) — clicking any actual descendant instead
976
+ // targets that descendant, so this only ever fires for a genuine backdrop click (confirmed in the spike).
977
+ this._handleDialogClick = (e) => {
978
+ if (this.closeOnBackdropClick && e.target === this.dialogElement) {
863
979
  this.hide();
864
980
  }
865
981
  };
866
982
  }
983
+ /**
984
+ * @deprecated Use `heading` instead. `title` collides with the browser's own native tooltip attribute — every
985
+ * HTML element has one regardless of custom-element status, so `<lt-dialog title="…">` made hovering anywhere
986
+ * over the dialog show a browser tooltip, and assistive tech could read it as a duplicate description (DLG-6).
987
+ * This alias keeps existing markup/JS working; the native `title` attribute is always stripped as soon as
988
+ * it's read, so the tooltip never appears.
989
+ */
990
+ get title() {
991
+ return this.heading;
992
+ }
993
+ set title(value) {
994
+ this.heading = value;
995
+ }
996
+ get _labels() {
997
+ return { ...DEFAULT_LABELS, ...this.labels };
998
+ }
867
999
  /**
868
1000
  * Opens the dialog.
869
1001
  */
@@ -882,18 +1014,31 @@ var Dialog = class extends ThemeableElement {
882
1014
  toggle() {
883
1015
  this.open = !this.open;
884
1016
  }
1017
+ firstUpdated() {
1018
+ if (!this.heading && !this.hasAttribute("aria-label") && !this.hasAttribute("aria-labelledby")) {
1019
+ devWarn("lt-dialog", "no `heading` \u2014 the dialog has no accessible name; set `heading` or `aria-label`");
1020
+ }
1021
+ }
885
1022
  connectedCallback() {
886
1023
  super.connectedCallback();
887
- this.addEventListener("keydown", this.handleKeydown);
1024
+ if (this.hasAttribute("title")) {
1025
+ if (!this.heading) this.heading = this.getAttribute("title");
1026
+ this.removeAttribute("title");
1027
+ }
888
1028
  }
889
1029
  disconnectedCallback() {
890
1030
  super.disconnectedCallback();
891
- this.removeEventListener("keydown", this.handleKeydown);
1031
+ if (this._scrollLocked) {
1032
+ unlockBodyScroll();
1033
+ this._scrollLocked = false;
1034
+ }
892
1035
  this.restoreFocus();
893
1036
  }
894
1037
  updated(changedProperties) {
895
1038
  if (changedProperties.has("open")) {
896
- if (this.open) {
1039
+ if (this._skipNextOpenChange) {
1040
+ this._skipNextOpenChange = false;
1041
+ } else if (this.open) {
897
1042
  this.handleOpen();
898
1043
  } else {
899
1044
  this.handleClose();
@@ -902,19 +1047,25 @@ var Dialog = class extends ThemeableElement {
902
1047
  }
903
1048
  handleOpen() {
904
1049
  this.previouslyFocusedElement = document.activeElement;
905
- document.body.style.overflow = "hidden";
906
- this.updateComplete.then(() => {
907
- this.dialogElement?.focus();
908
- });
909
- const openEvent = new CustomEvent("open", { bubbles: true, composed: true });
910
- this.dispatchEvent(openEvent);
911
- this.dispatchEvent(new CustomEvent("dialog-open", { bubbles: true, composed: true }));
1050
+ if (!this._scrollLocked) {
1051
+ lockBodyScroll();
1052
+ this._scrollLocked = true;
1053
+ }
1054
+ if (this.dialogElement && !this.dialogElement.open) {
1055
+ this.dialogElement.showModal();
1056
+ }
1057
+ this.dispatchEvent(new CustomEvent("open", { bubbles: true, composed: true }));
912
1058
  }
913
1059
  handleClose() {
914
- document.body.style.overflow = "";
1060
+ if (this._scrollLocked) {
1061
+ unlockBodyScroll();
1062
+ this._scrollLocked = false;
1063
+ }
1064
+ if (this.dialogElement?.open) {
1065
+ this.dialogElement.close();
1066
+ }
915
1067
  this.restoreFocus();
916
1068
  this.dispatchEvent(new CustomEvent("close", { bubbles: true, composed: true }));
917
- this.dispatchEvent(new CustomEvent("dialog-close", { bubbles: true, composed: true }));
918
1069
  }
919
1070
  restoreFocus() {
920
1071
  if (this.previouslyFocusedElement) {
@@ -922,30 +1073,23 @@ var Dialog = class extends ThemeableElement {
922
1073
  this.previouslyFocusedElement = void 0;
923
1074
  }
924
1075
  }
925
- handleBackdropClick(event) {
926
- if (this.closeOnBackdropClick && event.target === event.currentTarget) {
927
- this.hide();
928
- }
929
- }
930
1076
  handleCloseClick() {
931
1077
  this.hide();
932
1078
  }
933
1079
  render() {
934
1080
  return html4`
935
- <div class="backdrop" ?inert=${!this.open} @click=${this.handleBackdropClick} part="backdrop">
936
- <lt-surface
937
- elevation=${this.elevation}
938
- appearance=${this.appearance}
939
- role="dialog"
940
- aria-modal="true"
941
- aria-labelledby="dialog-title"
942
- tabindex="-1"
943
- part="dialog"
944
- >
1081
+ <dialog
1082
+ part="dialog"
1083
+ aria-labelledby="dialog-title"
1084
+ @cancel=${this._handleCancel}
1085
+ @close=${this._handleNativeClose}
1086
+ @click=${this._handleDialogClick}
1087
+ >
1088
+ <lt-surface elevation=${this.elevation} appearance=${this.appearance} part="surface">
945
1089
  <div class="header" part="header">
946
1090
  <div class="header-content">
947
1091
  <slot name="header">
948
- ${this.title ? html4`<lt-text variant="h4" as="h2" id="dialog-title" class="title">${this.title}</lt-text>` : ""}
1092
+ ${this.heading ? html4`<lt-text variant="h4" as="h2" id="dialog-title" class="title">${this.heading}</lt-text>` : ""}
949
1093
  </slot>
950
1094
  </div>
951
1095
  ${!this.noCloseButton ? html4`
@@ -953,7 +1097,7 @@ var Dialog = class extends ThemeableElement {
953
1097
  class="close-button"
954
1098
  part="close-button"
955
1099
  icon="xmark"
956
- label="Close dialog"
1100
+ label=${this._labels.close}
957
1101
  size="sm"
958
1102
  @click=${this.handleCloseClick}
959
1103
  ></lt-icon-button>
@@ -970,7 +1114,7 @@ var Dialog = class extends ThemeableElement {
970
1114
  </div>
971
1115
  ` : ""}
972
1116
  </lt-surface>
973
- </div>
1117
+ </dialog>
974
1118
  `;
975
1119
  }
976
1120
  hasSlot(name) {
@@ -986,7 +1130,7 @@ __decorateClass([
986
1130
  ], Dialog.prototype, "open", 2);
987
1131
  __decorateClass([
988
1132
  property5()
989
- ], Dialog.prototype, "title", 2);
1133
+ ], Dialog.prototype, "heading", 2);
990
1134
  __decorateClass([
991
1135
  property5({ type: Boolean, attribute: "no-close-button" })
992
1136
  ], Dialog.prototype, "noCloseButton", 2);
@@ -1009,7 +1153,10 @@ __decorateClass([
1009
1153
  property5({ reflect: true })
1010
1154
  ], Dialog.prototype, "appearance", 2);
1011
1155
  __decorateClass([
1012
- query("lt-surface")
1156
+ property5({ type: Object })
1157
+ ], Dialog.prototype, "labels", 2);
1158
+ __decorateClass([
1159
+ query("dialog")
1013
1160
  ], Dialog.prototype, "dialogElement", 2);
1014
1161
  Dialog = __decorateClass([
1015
1162
  customElement4("lt-dialog")
@@ -80,8 +80,8 @@ var dividerStyles = css`
80
80
 
81
81
  :host([orientation='vertical']) [part='base']::before,
82
82
  :host([orientation='vertical']) [part='base']::after {
83
- border-left: 1px solid var(--lt-border-default);
84
- border-left-style: var(--_divider-style, solid);
83
+ border-inline-start: 1px solid var(--lt-border-default);
84
+ border-inline-start-style: var(--_divider-style, solid);
85
85
  content: '';
86
86
  flex: 1;
87
87
  }
@@ -11,7 +11,7 @@ export declare class DropdownItem extends ThemeableElement {
11
11
  static shadowRootOptions: {
12
12
  delegatesFocus: boolean;
13
13
  clonable?: boolean;
14
- customElementRegistry?: CustomElementRegistry;
14
+ customElementRegistry?: CustomElementRegistry | null;
15
15
  mode: ShadowRootMode;
16
16
  serializable?: boolean;
17
17
  slotAssignment?: SlotAssignmentMode;
@@ -12,6 +12,7 @@ import '../surface/surface';
12
12
  * @fires {CustomEvent} close - Fired when the menu closes
13
13
  *
14
14
  * @cssprop [--lt-dropdown-menu-padding] - Overrides the menu surface's default vertical padding.
15
+ * @cssprop [--lt-dropdown-max-height=min(24rem, 60vh)] - Tallest the menu grows before it scrolls.
15
16
  */
16
17
  export declare class Dropdown extends ThemeableElement {
17
18
  static styles: import("lit").CSSResult[];
@@ -19,8 +20,11 @@ export declare class Dropdown extends ThemeableElement {
19
20
  placement: DropdownPlacement;
20
21
  private _cleanupClickOutside;
21
22
  private _floatingCleanup;
23
+ private _skipNextOpenChange;
22
24
  connectedCallback(): void;
23
25
  disconnectedCallback(): void;
26
+ private _syncTriggerAria;
27
+ private _onFocusOut;
24
28
  updated(changed: Map<string, unknown>): void;
25
29
  show(): void;
26
30
  hide(): void;
@@ -1446,7 +1446,9 @@ var dropdownStyles = [
1446
1446
  }
1447
1447
 
1448
1448
  lt-surface.menu::part(surface) {
1449
- overflow: hidden;
1449
+ max-height: var(--lt-dropdown-max-height, min(24rem, 60vh));
1450
+ overflow-x: hidden;
1451
+ overflow-y: auto;
1450
1452
  padding-block: var(--lt-dropdown-menu-padding, var(--lt-spacing-2));
1451
1453
  }
1452
1454
  `
@@ -1492,7 +1494,7 @@ var dropdownItemStyles = css3`
1492
1494
  font-size: 0.875rem;
1493
1495
  gap: var(--lt-spacing-2);
1494
1496
  padding: var(--lt-spacing-2) var(--lt-spacing-4);
1495
- text-align: left;
1497
+ text-align: start;
1496
1498
  text-decoration: none;
1497
1499
  width: 100%;
1498
1500
  }
@@ -1633,6 +1635,14 @@ var surfaceStyles = css4`
1633
1635
  :host([appearance='outlined']:hover) .surface {
1634
1636
  border-color: var(--lt-border-strong);
1635
1637
  }
1638
+
1639
+ /* ── Reduced motion ─────────────────────────────────────────────────────── */
1640
+
1641
+ @media (prefers-reduced-motion: reduce) {
1642
+ .surface {
1643
+ transition: none;
1644
+ }
1645
+ }
1636
1646
  `;
1637
1647
 
1638
1648
  // src/components/surface/surface.ts
@@ -1674,6 +1684,22 @@ var Dropdown = class extends ThemeableElement {
1674
1684
  this.placement = "bottom-start";
1675
1685
  this._cleanupClickOutside = null;
1676
1686
  this._floatingCleanup = null;
1687
+ // `updated()` also runs after the very first render, where `changed.has('open')` is true even though nothing
1688
+ // ever opened. Without this, mounting a closed dropdown fired `close` and moved focus to the trigger on page
1689
+ // load (verified: activeElement jumped to a native <button> trigger). Ships with lt-button's `delegatesFocus`
1690
+ // (BTN-4) — before that, the same first-update run was a no-op for an `lt-button` trigger anyway, since its
1691
+ // `.focus()` did nothing.
1692
+ this._skipNextOpenChange = true;
1693
+ // Tab away from the trigger/menu (as opposed to Escape, or a click outside, which are already handled) left
1694
+ // the menu open — nothing closed it once focus moved on. relatedTarget is the element focus is moving *to*;
1695
+ // if that's still somewhere inside this dropdown (the trigger, or an item — both light DOM children of this
1696
+ // element), there's nothing to do.
1697
+ this._onFocusOut = (e) => {
1698
+ if (!this.open) return;
1699
+ const next = e.relatedTarget;
1700
+ if (next && this.contains(next)) return;
1701
+ this.hide();
1702
+ };
1677
1703
  this._onKeydown = (e) => {
1678
1704
  if (!this.open) return;
1679
1705
  const items = this._items();
@@ -1706,18 +1732,32 @@ var Dropdown = class extends ThemeableElement {
1706
1732
  super.connectedCallback();
1707
1733
  this.addEventListener("keydown", this._onKeydown);
1708
1734
  this.addEventListener("select", this.hide);
1735
+ this.addEventListener("focusout", this._onFocusOut);
1709
1736
  }
1710
1737
  disconnectedCallback() {
1711
1738
  super.disconnectedCallback();
1712
1739
  this.removeEventListener("keydown", this._onKeydown);
1713
1740
  this.removeEventListener("select", this.hide);
1741
+ this.removeEventListener("focusout", this._onFocusOut);
1714
1742
  this._cleanupClickOutside?.();
1715
1743
  this._cleanupClickOutside = null;
1716
1744
  this._floatingCleanup?.();
1717
1745
  this._floatingCleanup = null;
1718
1746
  }
1747
+ // The slotted trigger otherwise has no indication it opens a menu, or whether that menu is currently open.
1748
+ _syncTriggerAria() {
1749
+ const trigger = this._triggerEl();
1750
+ if (!trigger) return;
1751
+ trigger.setAttribute("aria-haspopup", "menu");
1752
+ trigger.setAttribute("aria-expanded", this.open ? "true" : "false");
1753
+ }
1719
1754
  updated(changed) {
1755
+ this._syncTriggerAria();
1720
1756
  if (!changed.has("open")) return;
1757
+ if (this._skipNextOpenChange) {
1758
+ this._skipNextOpenChange = false;
1759
+ return;
1760
+ }
1721
1761
  if (this.open) {
1722
1762
  this._cleanupClickOutside = createClickOutsideHandler(this, () => this.hide(), { event: "click", capture: true });
1723
1763
  const triggerEl = this._triggerEl() ?? this;
@@ -1761,7 +1801,7 @@ var Dropdown = class extends ThemeableElement {
1761
1801
  render() {
1762
1802
  return html3`
1763
1803
  <div class="trigger-wrap" @click=${this.toggle}>
1764
- <slot name="trigger"></slot>
1804
+ <slot name="trigger" @slotchange=${this._syncTriggerAria}></slot>
1765
1805
  </div>
1766
1806
  <lt-surface
1767
1807
  class="menu"