@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/radio/radio.ts
13
- import { html } from "lit";
13
+ import { html, LitElement as LitElement2 } from "lit";
14
14
 
15
15
  // src/base/themeable-element.ts
16
16
  import { LitElement } from "lit";
@@ -61,7 +61,10 @@ var radioStyles = css`
61
61
  flex-direction: row-reverse;
62
62
  }
63
63
 
64
- :host([disabled]) .radio-wrapper {
64
+ /* :host(:disabled) — the browser matches this automatically for a form-associated element inside a
65
+ disabled <fieldset>, no JS required; :host([disabled]) covers the component's own explicit attribute. */
66
+ :host([disabled]) .radio-wrapper,
67
+ :host(:disabled) .radio-wrapper {
65
68
  cursor: not-allowed;
66
69
  opacity: 0.6;
67
70
  }
@@ -118,7 +121,7 @@ var radioStyles = css`
118
121
  :host([size='sm']) .dot {
119
122
  height: 8px;
120
123
  width: 8px;
121
- left: 4px;
124
+ inset-inline-start: 4px;
122
125
  top: 4px;
123
126
  }
124
127
 
@@ -134,7 +137,7 @@ var radioStyles = css`
134
137
  :host([size='md']) .dot {
135
138
  height: 10px;
136
139
  width: 10px;
137
- left: 5px;
140
+ inset-inline-start: 5px;
138
141
  top: 5px;
139
142
  }
140
143
 
@@ -150,7 +153,7 @@ var radioStyles = css`
150
153
  :host([size='lg']) .dot {
151
154
  height: 12px;
152
155
  width: 12px;
153
- left: 6px;
156
+ inset-inline-start: 6px;
154
157
  top: 6px;
155
158
  }
156
159
 
@@ -203,12 +206,102 @@ var radioStyles = css`
203
206
  background: var(--lt-interactive-info-bg-hover);
204
207
  border-color: var(--lt-interactive-info-bg-hover);
205
208
  }
209
+
210
+ /* ── Reduced motion ─────────────────────────────────────────────────────── */
211
+
212
+ @media (prefers-reduced-motion: reduce) {
213
+ input[type='radio'] {
214
+ transition: none;
215
+ }
216
+ }
217
+
218
+ /* ── Forced colors (Windows High Contrast) ─────────────────────────────────
219
+ Under forced-colors, any author background-color (including every variant's token-resolved fill above) is
220
+ flattened to Canvas — the checked indicator is a plain background:white .dot with no border of its own, so
221
+ it disappears into the input's own (also-flattened) background, leaving checked and unchecked visually
222
+ identical. System color keywords are exempt from that flattening and always resolve to the OS's own
223
+ guaranteed-contrasting palette, regardless of which variant was selected — Highlight/HighlightText is the
224
+ canonical "selected state" pairing (the same pair the OS uses for native text selection), unaffected by
225
+ this component's own --lt-* tokens, which forced-colors mode is specifically overriding. !important is
226
+ needed since a variant rule like :host([variant='primary']) input:checked outranks a bare
227
+ input[type='radio']:checked in specificity — this is the standard, accepted use of !important for a
228
+ forced-colors override (overriding the page's own author styling is the entire point). ─────────────────── */
229
+
230
+ @media (forced-colors: active) {
231
+ input[type='radio']:checked {
232
+ background: Highlight !important;
233
+ border-color: Highlight !important;
234
+ }
235
+
236
+ .dot {
237
+ background: HighlightText !important;
238
+ }
239
+ }
206
240
  `;
207
241
 
242
+ // src/components/shared/form-default.ts
243
+ var FormDefaultController = class {
244
+ constructor(host, get) {
245
+ this._captured = false;
246
+ this._get = get;
247
+ host.addController(this);
248
+ }
249
+ hostConnected() {
250
+ if (this._captured) return;
251
+ this._captured = true;
252
+ this.value = this._get();
253
+ }
254
+ };
255
+
256
+ // src/components/shared/form-disabled.ts
257
+ var FormDisabledController = class {
258
+ constructor(host) {
259
+ this._fieldsetDisabled = false;
260
+ this._host = host;
261
+ host.addController(this);
262
+ }
263
+ // No-op — this controller has no connect-time work of its own (formDisabledCallback drives it instead), but
264
+ // ReactiveController's members are all optional, and a class satisfying an all-optional interface with no
265
+ // members in common is a TS error (TS2559); this gives it one.
266
+ hostConnected() {
267
+ }
268
+ /** Whether an ancestor `<fieldset>` is currently disabled. */
269
+ get fieldsetDisabled() {
270
+ return this._fieldsetDisabled;
271
+ }
272
+ setFieldsetDisabled(disabled) {
273
+ if (this._fieldsetDisabled === disabled) return;
274
+ this._fieldsetDisabled = disabled;
275
+ this._host.requestUpdate();
276
+ }
277
+ };
278
+
208
279
  // src/components/radio/radio.ts
280
+ var DEFAULT_LABELS = {
281
+ validationMessage: "Please select this option"
282
+ };
283
+ var radioRegistries = /* @__PURE__ */ new WeakMap();
284
+ var radioGroupFor = (radio) => {
285
+ if (!radio.name) return null;
286
+ const scope = radio.closest("form") ?? radio.getRootNode();
287
+ let byName = radioRegistries.get(scope);
288
+ if (!byName) {
289
+ byName = /* @__PURE__ */ new Map();
290
+ radioRegistries.set(scope, byName);
291
+ }
292
+ let group = byName.get(radio.name);
293
+ if (!group) {
294
+ group = /* @__PURE__ */ new Set();
295
+ byName.set(radio.name, group);
296
+ }
297
+ return group;
298
+ };
209
299
  var Radio = class extends ThemeableElement {
210
300
  constructor() {
211
301
  super();
302
+ this._group = null;
303
+ this._default = new FormDefaultController(this, () => this.checked);
304
+ this._fieldsetDisabled = new FormDisabledController(this);
212
305
  this.variant = "primary";
213
306
  this.size = "md";
214
307
  this.checked = false;
@@ -218,19 +311,49 @@ var Radio = class extends ThemeableElement {
218
311
  this.labelPosition = "right";
219
312
  this.name = "";
220
313
  this.value = "";
314
+ this.tabbable = true;
315
+ this.labels = {};
221
316
  this._internals = this.attachInternals();
222
317
  }
318
+ connectedCallback() {
319
+ super.connectedCallback();
320
+ this._joinGroup();
321
+ }
322
+ disconnectedCallback() {
323
+ super.disconnectedCallback();
324
+ this._group?.delete(this);
325
+ this._group = null;
326
+ }
327
+ willUpdate(changed) {
328
+ super.willUpdate(changed);
329
+ if (changed.has("name")) this._joinGroup();
330
+ }
331
+ _joinGroup() {
332
+ this._group?.delete(this);
333
+ this._group = radioGroupFor(this);
334
+ this._group?.add(this);
335
+ }
336
+ get _labels() {
337
+ return { ...DEFAULT_LABELS, ...this.labels };
338
+ }
223
339
  updated() {
224
340
  this._internals.setFormValue(this.checked ? this.value : null);
225
341
  const input = this.shadowRoot?.querySelector("input");
226
342
  if (this.required && !this.checked && input) {
227
- this._internals.setValidity({ valueMissing: true }, "Please select this option", input);
343
+ this._internals.setValidity({ valueMissing: true }, this._labels.validationMessage, input);
228
344
  } else {
229
345
  this._internals.setValidity({});
230
346
  }
231
347
  }
232
348
  formResetCallback() {
233
- this.checked = false;
349
+ this.checked = this._default.value;
350
+ }
351
+ formDisabledCallback(disabled) {
352
+ this._fieldsetDisabled.setFieldsetDisabled(disabled);
353
+ }
354
+ /** `this.disabled` combined with an ancestor `<fieldset>`'s own disabled state (see `FormDisabledController`). */
355
+ get _isDisabled() {
356
+ return this.disabled || this._fieldsetDisabled.fieldsetDisabled;
234
357
  }
235
358
  checkValidity() {
236
359
  return this._internals.checkValidity();
@@ -249,6 +372,11 @@ var Radio = class extends ThemeableElement {
249
372
  e.stopPropagation();
250
373
  const target = e.target;
251
374
  this.checked = target.checked;
375
+ if (this.checked) {
376
+ this._group?.forEach((other) => {
377
+ if (other !== this) other.checked = false;
378
+ });
379
+ }
252
380
  this.dispatchEvent(
253
381
  new CustomEvent("change", {
254
382
  detail: { value: this.value, checked: this.checked },
@@ -264,10 +392,11 @@ var Radio = class extends ThemeableElement {
264
392
  <input
265
393
  type="radio"
266
394
  .checked=${this.checked}
267
- ?disabled=${this.disabled}
395
+ ?disabled=${this._isDisabled}
268
396
  ?required=${this.required}
269
397
  name=${this.name}
270
398
  value=${this.value}
399
+ tabindex=${this.tabbable ? "0" : "-1"}
271
400
  @change=${this.handleChange}
272
401
  aria-label=${this.label || "radio"}
273
402
  />
@@ -280,6 +409,10 @@ var Radio = class extends ThemeableElement {
280
409
  };
281
410
  Radio.styles = radioStyles;
282
411
  Radio.formAssociated = true;
412
+ // Makes host.focus() move focus into the shadow root (the inner <input>) — lt-radio-group needs this to move
413
+ // focus programmatically for arrow-key navigation (RAD-2), the same way lt-button's delegatesFocus (BTN-4)
414
+ // lets lt-dropdown do the same for its trigger.
415
+ Radio.shadowRootOptions = { ...LitElement2.shadowRootOptions, delegatesFocus: true };
283
416
  __decorateClass([
284
417
  property2({ reflect: true })
285
418
  ], Radio.prototype, "variant", 2);
@@ -287,7 +420,7 @@ __decorateClass([
287
420
  property2({ reflect: true })
288
421
  ], Radio.prototype, "size", 2);
289
422
  __decorateClass([
290
- property2({ type: Boolean, reflect: true })
423
+ property2({ type: Boolean })
291
424
  ], Radio.prototype, "checked", 2);
292
425
  __decorateClass([
293
426
  property2({ type: Boolean, reflect: true })
@@ -307,6 +440,12 @@ __decorateClass([
307
440
  __decorateClass([
308
441
  property2({ reflect: true })
309
442
  ], Radio.prototype, "value", 2);
443
+ __decorateClass([
444
+ property2({ type: Boolean })
445
+ ], Radio.prototype, "tabbable", 2);
446
+ __decorateClass([
447
+ property2({ type: Object })
448
+ ], Radio.prototype, "labels", 2);
310
449
  Radio = __decorateClass([
311
450
  customElement("lt-radio")
312
451
  ], Radio);
@@ -1,5 +1,6 @@
1
+ import { type PropertyValues } from 'lit';
1
2
  import { ThemeableElement } from '../../base';
2
- import { RadioSize, RadioVariant, RadioLabelPosition } from './radio.types';
3
+ import { RadioSize, RadioVariant, RadioLabelPosition, type RadioLabels } from './radio.types';
3
4
  /**
4
5
  * A customizable radio button component with support for multiple variants and sizes.
5
6
  *
@@ -39,8 +40,23 @@ import { RadioSize, RadioVariant, RadioLabelPosition } from './radio.types';
39
40
  export declare class Radio extends ThemeableElement {
40
41
  static styles: import("lit").CSSResult;
41
42
  static formAssociated: boolean;
43
+ static shadowRootOptions: {
44
+ delegatesFocus: boolean;
45
+ clonable?: boolean;
46
+ customElementRegistry?: CustomElementRegistry | null;
47
+ mode: ShadowRootMode;
48
+ serializable?: boolean;
49
+ slotAssignment?: SlotAssignmentMode;
50
+ };
42
51
  private _internals;
52
+ private _group;
53
+ private _default;
54
+ private _fieldsetDisabled;
43
55
  constructor();
56
+ connectedCallback(): void;
57
+ disconnectedCallback(): void;
58
+ willUpdate(changed: PropertyValues<this>): void;
59
+ private _joinGroup;
44
60
  /**
45
61
  * Visual variant that determines the color when checked.
46
62
  * @default 'primary'
@@ -52,7 +68,10 @@ export declare class Radio extends ThemeableElement {
52
68
  */
53
69
  size: RadioSize;
54
70
  /**
55
- * Whether the radio is checked.
71
+ * Whether the radio is checked. Not reflected as an attribute — a native radio's `checked` _content_
72
+ * attribute stays fixed at the markup default and never tracks interaction; reflecting it live meant
73
+ * `formResetCallback` could no longer tell what the original default was. Read the property, or the `change`
74
+ * event's `detail.checked`, instead of `[checked]`/`hasAttribute('checked')`.
56
75
  * @default false
57
76
  */
58
77
  checked: boolean;
@@ -87,8 +106,21 @@ export declare class Radio extends ThemeableElement {
87
106
  * @default ''
88
107
  */
89
108
  value: string;
109
+ /**
110
+ * Whether this radio is reachable with Tab. Defaults to true for a standalone radio; `lt-radio-group` drives
111
+ * this itself for its managed radios — a roving tabindex (only the checked radio, or the first enabled one,
112
+ * is a Tab stop), matching the native radiogroup pattern.
113
+ * @default true
114
+ */
115
+ tabbable: boolean;
116
+ /** Overridable UI text (the required-field validation message), for i18n. English defaults — override only the keys you need. */
117
+ labels: Partial<RadioLabels>;
118
+ private get _labels();
90
119
  updated(): void;
91
120
  formResetCallback(): void;
121
+ formDisabledCallback(disabled: boolean): void;
122
+ /** `this.disabled` combined with an ancestor `<fieldset>`'s own disabled state (see `FormDisabledController`). */
123
+ private get _isDisabled();
92
124
  checkValidity(): boolean;
93
125
  reportValidity(): boolean;
94
126
  /**
@@ -19,3 +19,8 @@ export type RadioSize = 'sm' | 'md' | 'lg';
19
19
  */
20
20
  export type RadioVariant = 'primary' | 'secondary' | 'success' | 'error' | 'info';
21
21
  export type RadioLabelPosition = 'left' | 'right';
22
+ /** Overridable UI text for `lt-radio`, for i18n. */
23
+ export interface RadioLabels {
24
+ /** Validation message shown when a required radio group has no option selected. */
25
+ validationMessage: string;
26
+ }
@@ -9,6 +9,15 @@ var __decorateClass = (decorators, target, key, kind) => {
9
9
  return result;
10
10
  };
11
11
 
12
+ // src/components/shared/roving.ts
13
+ var enabledItems = (items) => items.filter((item) => !item.disabled);
14
+ var rovingTargetOf = (enabled, value) => enabled.find((item) => item.value === value) ?? enabled[0];
15
+ var stepIndex = (index, length, delta) => (Math.max(index, 0) + delta + length) % length;
16
+
17
+ // src/components/shared/form-field.ts
18
+ var fieldCount = 0;
19
+ var createFieldIds = () => ({ helper: `lt-field-${fieldCount++}-helper` });
20
+
12
21
  // src/components/radio-group/radio-group.ts
13
22
  import { html as html2, nothing } from "lit";
14
23
 
@@ -57,8 +66,8 @@ var radioGroupStyles = css`
57
66
  }
58
67
 
59
68
  .required-indicator {
60
- color: var(--lt-color-error-500);
61
- margin-left: var(--lt-spacing-1);
69
+ color: var(--lt-text-error);
70
+ margin-inline-start: var(--lt-spacing-1);
62
71
  }
63
72
 
64
73
  .radios-container {
@@ -258,6 +267,20 @@ var textStyles = css2`
258
267
  `;
259
268
 
260
269
  // src/components/text/text.ts
270
+ var ALLOWED_TAGS = /* @__PURE__ */ new Set([
271
+ "h1",
272
+ "h2",
273
+ "h3",
274
+ "h4",
275
+ "h5",
276
+ "h6",
277
+ "p",
278
+ "span",
279
+ "div",
280
+ "label",
281
+ "strong",
282
+ "em"
283
+ ]);
261
284
  var VARIANT_TAG = {
262
285
  "display-2xl": "p",
263
286
  "display-xl": "p",
@@ -282,7 +305,8 @@ var Text = class extends ThemeableElement {
282
305
  this.as = "";
283
306
  }
284
307
  render() {
285
- const tag = unsafeStatic(this.as || VARIANT_TAG[this.variant] || "p");
308
+ const requested = this.as && ALLOWED_TAGS.has(this.as) ? this.as : "";
309
+ const tag = unsafeStatic(requested || VARIANT_TAG[this.variant] || "p");
286
310
  return html`<${tag} part="base"><slot></slot></${tag}>`;
287
311
  }
288
312
  };
@@ -305,53 +329,103 @@ var RadioGroup = class extends ThemeableElement {
305
329
  this.name = "";
306
330
  this.value = "";
307
331
  this.orientation = "vertical";
332
+ /**
333
+ * Helper text displayed below the radio group.
334
+ * @default ''
335
+ */
336
+ this._fieldIds = createFieldIds();
308
337
  this.helperText = "";
309
338
  this.error = false;
310
339
  this.required = false;
311
- this._attached = /* @__PURE__ */ new WeakSet();
340
+ // Reacts to a slotted radio's `disabled` attribute changing after the fact (e.g. a consumer disabling one
341
+ // dynamically, with no accompanying change to this group's own value/name), so the roving tabindex and
342
+ // keyboard navigation immediately stop landing on it.
343
+ this._radioObserver = new MutationObserver(() => this._syncRadios());
312
344
  /**
313
345
  * Handles change events from individual radio buttons.
314
- * Updates the group value and ensures only one radio is checked.
315
346
  *
316
347
  * @param e - The change event from a radio button
317
348
  * @private
318
349
  */
319
350
  this.handleRadioChange = (e) => {
320
351
  const radio = e.target;
321
- if (radio.checked) {
322
- this.value = radio.value;
323
- this.radios.forEach((r) => {
324
- if (r !== radio) {
325
- r.checked = false;
326
- }
327
- });
328
- this.dispatchEvent(
329
- new CustomEvent("change", {
330
- detail: { value: this.value },
331
- bubbles: true,
332
- composed: true
333
- })
334
- );
352
+ if (!radio.checked) return;
353
+ e.stopPropagation();
354
+ this._select(radio);
355
+ };
356
+ /**
357
+ * Arrow keys move the roving tab stop and, per the native radiogroup's automatic-activation model, select the
358
+ * radio moved to — matching a real `<input type="radio">` group, where arrowing both moves focus and checks
359
+ * the newly-focused button. All four directions move (Right/Down forward, Left/Up back), disabled radios are
360
+ * skipped entirely, and it wraps at both ends.
361
+ * @private
362
+ */
363
+ this.handleRadioKeydown = (e) => {
364
+ const enabled = enabledItems(this.radios ?? []);
365
+ if (enabled.length === 0) return;
366
+ const index = enabled.indexOf(e.currentTarget);
367
+ let next;
368
+ switch (e.key) {
369
+ case "ArrowRight":
370
+ case "ArrowDown":
371
+ next = enabled[stepIndex(index, enabled.length, 1)];
372
+ break;
373
+ case "ArrowLeft":
374
+ case "ArrowUp":
375
+ next = enabled[stepIndex(index, enabled.length, -1)];
376
+ break;
335
377
  }
378
+ if (!next) return;
379
+ e.preventDefault();
380
+ this._select(next);
381
+ next.focus();
336
382
  };
337
383
  }
338
- firstUpdated() {
384
+ connectedCallback() {
385
+ super.connectedCallback();
386
+ this._radioObserver.observe(this, { attributes: true, attributeFilter: ["disabled"], subtree: true });
339
387
  this._syncRadios();
340
- const checkedRadio = this.radios?.find((r) => r.checked);
341
- if (checkedRadio && !this.value) {
342
- this.value = checkedRadio.value;
388
+ }
389
+ firstUpdated() {
390
+ if (!this.value) {
391
+ const checkedRadio = this.radios?.find((r) => r.checked);
392
+ if (checkedRadio) this.value = checkedRadio.value;
343
393
  }
394
+ this._syncRadios();
344
395
  }
396
+ /**
397
+ * Wires each radio's `name`, roving tabindex, and listeners, and drives `checked` from this group's `value` —
398
+ * the direction a consumer setting `group.value = '…'` needs, since that's a plain property assignment on the
399
+ * group, not a native input event any individual radio would otherwise react to.
400
+ * @private
401
+ */
345
402
  _syncRadios() {
346
403
  if (!this.radios) return;
404
+ const enabled = enabledItems(this.radios);
405
+ const rovingTarget = rovingTargetOf(enabled, this.value);
347
406
  this.radios.forEach((radio) => {
348
407
  if (this.name) radio.name = this.name;
349
- if (!this._attached.has(radio)) {
350
- radio.addEventListener("change", this.handleRadioChange);
351
- this._attached.add(radio);
352
- }
408
+ radio.checked = radio.value === this.value;
409
+ radio.tabbable = radio === rovingTarget;
410
+ radio.addEventListener("change", this.handleRadioChange);
411
+ radio.addEventListener("keydown", this.handleRadioKeydown);
353
412
  });
354
413
  }
414
+ /**
415
+ * Selects a radio (used by both the native-change and keyboard-navigation handlers below).
416
+ * @private
417
+ */
418
+ _select(radio) {
419
+ if (radio.disabled || radio.value === this.value) return;
420
+ this.value = radio.value;
421
+ this.dispatchEvent(
422
+ new CustomEvent("change", {
423
+ detail: { value: this.value },
424
+ bubbles: true,
425
+ composed: true
426
+ })
427
+ );
428
+ }
355
429
  /**
356
430
  * Handles slot changes to update radios when content changes.
357
431
  * @private
@@ -359,20 +433,35 @@ var RadioGroup = class extends ThemeableElement {
359
433
  handleSlotChange() {
360
434
  this._syncRadios();
361
435
  }
436
+ /**
437
+ * A group `value` set by the consumer (`group.value = '…'`) is a plain property assignment — it doesn't pass
438
+ * through any radio's own change handling, so this is what drives `checked` on the radios to match it.
439
+ */
440
+ updated(changed) {
441
+ super.updated(changed);
442
+ if (changed.has("value") || changed.has("name")) this._syncRadios();
443
+ }
362
444
  /**
363
445
  * Cleanup event listeners when component is removed.
364
446
  */
365
447
  disconnectedCallback() {
366
448
  super.disconnectedCallback();
449
+ this._radioObserver.disconnect();
367
450
  if (this.radios) {
368
451
  this.radios.forEach((radio) => {
369
452
  radio.removeEventListener("change", this.handleRadioChange);
453
+ radio.removeEventListener("keydown", this.handleRadioKeydown);
370
454
  });
371
455
  }
372
456
  }
373
457
  render() {
374
458
  return html2`
375
- <div class="radio-group" role="radiogroup" aria-label=${this.label || nothing}>
459
+ <div
460
+ class="radio-group"
461
+ role="radiogroup"
462
+ aria-label=${this.label || nothing}
463
+ aria-describedby=${this.helperText ? this._fieldIds.helper : nothing}
464
+ >
376
465
  ${this.label ? html2`
377
466
  <div class="label">
378
467
  <lt-text variant="label" as="span">${this.label}</lt-text>
@@ -382,7 +471,9 @@ var RadioGroup = class extends ThemeableElement {
382
471
  <div class="radios-container">
383
472
  <slot @slotchange=${this.handleSlotChange}></slot>
384
473
  </div>
385
- ${this.helperText ? html2`<lt-text variant="caption" as="span" class="helper-text">${this.helperText}</lt-text>` : ""}
474
+ ${this.helperText ? html2`<lt-text id=${this._fieldIds.helper} variant="caption" as="span" class="helper-text"
475
+ >${this.helperText}</lt-text
476
+ >` : ""}
386
477
  </div>
387
478
  `;
388
479
  }
@@ -60,6 +60,7 @@ export declare class RadioGroup extends ThemeableElement {
60
60
  * Helper text displayed below the radio group.
61
61
  * @default ''
62
62
  */
63
+ private readonly _fieldIds;
63
64
  helperText: string;
64
65
  /**
65
66
  * Whether the radio group is in an error state.
@@ -76,22 +77,46 @@ export declare class RadioGroup extends ThemeableElement {
76
77
  * @private
77
78
  */
78
79
  private radios;
79
- private _attached;
80
+ private readonly _radioObserver;
81
+ connectedCallback(): void;
80
82
  firstUpdated(): void;
83
+ /**
84
+ * Wires each radio's `name`, roving tabindex, and listeners, and drives `checked` from this group's `value` —
85
+ * the direction a consumer setting `group.value = '…'` needs, since that's a plain property assignment on the
86
+ * group, not a native input event any individual radio would otherwise react to.
87
+ * @private
88
+ */
81
89
  private _syncRadios;
90
+ /**
91
+ * Selects a radio (used by both the native-change and keyboard-navigation handlers below).
92
+ * @private
93
+ */
94
+ private _select;
82
95
  /**
83
96
  * Handles change events from individual radio buttons.
84
- * Updates the group value and ensures only one radio is checked.
85
97
  *
86
98
  * @param e - The change event from a radio button
87
99
  * @private
88
100
  */
89
101
  private handleRadioChange;
102
+ /**
103
+ * Arrow keys move the roving tab stop and, per the native radiogroup's automatic-activation model, select the
104
+ * radio moved to — matching a real `<input type="radio">` group, where arrowing both moves focus and checks
105
+ * the newly-focused button. All four directions move (Right/Down forward, Left/Up back), disabled radios are
106
+ * skipped entirely, and it wraps at both ends.
107
+ * @private
108
+ */
109
+ private handleRadioKeydown;
90
110
  /**
91
111
  * Handles slot changes to update radios when content changes.
92
112
  * @private
93
113
  */
94
114
  private handleSlotChange;
115
+ /**
116
+ * A group `value` set by the consumer (`group.value = '…'`) is a plain property assignment — it doesn't pass
117
+ * through any radio's own change handling, so this is what drives `checked` on the radios to match it.
118
+ */
119
+ protected updated(changed: Map<string, unknown>): void;
95
120
  /**
96
121
  * Cleanup event listeners when component is removed.
97
122
  */