@agent-ui-kit/components 0.0.3

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 (271) hide show
  1. package/dist/base-styles.css +8 -0
  2. package/dist/component-styles.css +147 -0
  3. package/dist/controls/_base/index.d.ts +3 -0
  4. package/dist/controls/_base/index.js +9 -0
  5. package/dist/controls/_base/indicator-element.d.ts +132 -0
  6. package/dist/controls/_base/indicator-element.js +158 -0
  7. package/dist/controls/_base/listbox-element.d.ts +70 -0
  8. package/dist/controls/_base/listbox-element.js +74 -0
  9. package/dist/controls/_base/range-element.d.ts +120 -0
  10. package/dist/controls/_base/range-element.js +158 -0
  11. package/dist/controls/_surface/container-box.css +301 -0
  12. package/dist/controls/_surface/container.css +156 -0
  13. package/dist/controls/_token-surface/token-surface.d.ts +35 -0
  14. package/dist/controls/_token-surface/token-surface.js +97 -0
  15. package/dist/controls/attachment/attachment-meta.d.ts +8 -0
  16. package/dist/controls/attachment/attachment-meta.js +84 -0
  17. package/dist/controls/attachment/attachment.css +91 -0
  18. package/dist/controls/attachment/attachment.d.ts +40 -0
  19. package/dist/controls/attachment/attachment.js +70 -0
  20. package/dist/controls/avatar/avatar-initials.d.ts +4 -0
  21. package/dist/controls/avatar/avatar-initials.js +30 -0
  22. package/dist/controls/avatar/avatar.css +87 -0
  23. package/dist/controls/avatar/avatar.d.ts +40 -0
  24. package/dist/controls/avatar/avatar.js +77 -0
  25. package/dist/controls/badge/badge.css +175 -0
  26. package/dist/controls/badge/badge.d.ts +36 -0
  27. package/dist/controls/badge/badge.js +71 -0
  28. package/dist/controls/bar-chart/bar-chart.css +110 -0
  29. package/dist/controls/bar-chart/bar-chart.d.ts +26 -0
  30. package/dist/controls/bar-chart/bar-chart.js +80 -0
  31. package/dist/controls/bar-chart/bar-math.d.ts +31 -0
  32. package/dist/controls/bar-chart/bar-math.js +106 -0
  33. package/dist/controls/button/button.css +263 -0
  34. package/dist/controls/button/button.d.ts +61 -0
  35. package/dist/controls/button/button.js +115 -0
  36. package/dist/controls/calendar/calendar.css +495 -0
  37. package/dist/controls/calendar/calendar.d.ts +161 -0
  38. package/dist/controls/calendar/calendar.js +1004 -0
  39. package/dist/controls/card/card-content.d.ts +24 -0
  40. package/dist/controls/card/card-content.js +133 -0
  41. package/dist/controls/card/card-footer.d.ts +3 -0
  42. package/dist/controls/card/card-footer.js +16 -0
  43. package/dist/controls/card/card-header.d.ts +3 -0
  44. package/dist/controls/card/card-header.js +18 -0
  45. package/dist/controls/card/card.css +445 -0
  46. package/dist/controls/card/card.d.ts +51 -0
  47. package/dist/controls/card/card.js +61 -0
  48. package/dist/controls/checkbox/checkbox.css +204 -0
  49. package/dist/controls/checkbox/checkbox.d.ts +114 -0
  50. package/dist/controls/checkbox/checkbox.js +130 -0
  51. package/dist/controls/code/code.css +72 -0
  52. package/dist/controls/code/code.d.ts +23 -0
  53. package/dist/controls/code/code.js +40 -0
  54. package/dist/controls/color-picker/color-picker.css +209 -0
  55. package/dist/controls/color-picker/color-picker.d.ts +84 -0
  56. package/dist/controls/color-picker/color-picker.js +545 -0
  57. package/dist/controls/color-picker/color.d.ts +40 -0
  58. package/dist/controls/color-picker/color.js +159 -0
  59. package/dist/controls/column/column.css +151 -0
  60. package/dist/controls/column/column.d.ts +107 -0
  61. package/dist/controls/column/column.js +57 -0
  62. package/dist/controls/combo-box/combo-box.css +397 -0
  63. package/dist/controls/combo-box/combo-box.d.ts +133 -0
  64. package/dist/controls/combo-box/combo-box.js +680 -0
  65. package/dist/controls/command-modal/command-modal.css +226 -0
  66. package/dist/controls/command-modal/command-modal.d.ts +42 -0
  67. package/dist/controls/command-modal/command-modal.js +315 -0
  68. package/dist/controls/disclosure/disclosure.css +162 -0
  69. package/dist/controls/disclosure/disclosure.d.ts +37 -0
  70. package/dist/controls/disclosure/disclosure.js +207 -0
  71. package/dist/controls/field/field.css +140 -0
  72. package/dist/controls/field/field.d.ts +27 -0
  73. package/dist/controls/field/field.js +228 -0
  74. package/dist/controls/form-provider/form-provider.css +40 -0
  75. package/dist/controls/form-provider/form-provider.d.ts +42 -0
  76. package/dist/controls/form-provider/form-provider.js +105 -0
  77. package/dist/controls/grid/grid.css +72 -0
  78. package/dist/controls/grid/grid.d.ts +60 -0
  79. package/dist/controls/grid/grid.js +56 -0
  80. package/dist/controls/icon/icon.css +37 -0
  81. package/dist/controls/icon/icon.d.ts +25 -0
  82. package/dist/controls/icon/icon.js +55 -0
  83. package/dist/controls/index.d.ts +57 -0
  84. package/dist/controls/index.js +136 -0
  85. package/dist/controls/ladder/ladder.css +89 -0
  86. package/dist/controls/ladder/ladder.d.ts +27 -0
  87. package/dist/controls/ladder/ladder.js +78 -0
  88. package/dist/controls/list/list.css +118 -0
  89. package/dist/controls/list/list.d.ts +84 -0
  90. package/dist/controls/list/list.js +48 -0
  91. package/dist/controls/menu/menu.css +195 -0
  92. package/dist/controls/menu/menu.d.ts +44 -0
  93. package/dist/controls/menu/menu.js +248 -0
  94. package/dist/controls/modal/modal.css +99 -0
  95. package/dist/controls/modal/modal.d.ts +61 -0
  96. package/dist/controls/modal/modal.js +155 -0
  97. package/dist/controls/popover/popover.css +83 -0
  98. package/dist/controls/popover/popover.d.ts +44 -0
  99. package/dist/controls/popover/popover.js +164 -0
  100. package/dist/controls/progress/progress.css +106 -0
  101. package/dist/controls/progress/progress.d.ts +28 -0
  102. package/dist/controls/progress/progress.js +80 -0
  103. package/dist/controls/radio/radio-group.css +84 -0
  104. package/dist/controls/radio/radio-group.d.ts +152 -0
  105. package/dist/controls/radio/radio-group.js +350 -0
  106. package/dist/controls/radio/radio.css +219 -0
  107. package/dist/controls/radio/radio.d.ts +19 -0
  108. package/dist/controls/radio/radio.js +75 -0
  109. package/dist/controls/ramp/ramp.css +75 -0
  110. package/dist/controls/ramp/ramp.d.ts +29 -0
  111. package/dist/controls/ramp/ramp.js +72 -0
  112. package/dist/controls/row/row.css +152 -0
  113. package/dist/controls/row/row.d.ts +95 -0
  114. package/dist/controls/row/row.js +45 -0
  115. package/dist/controls/segment/segment.css +66 -0
  116. package/dist/controls/segment/segment.d.ts +3 -0
  117. package/dist/controls/segment/segment.js +26 -0
  118. package/dist/controls/segmented-control/segmented-control.css +189 -0
  119. package/dist/controls/segmented-control/segmented-control.d.ts +20 -0
  120. package/dist/controls/segmented-control/segmented-control.js +64 -0
  121. package/dist/controls/select/select.css +467 -0
  122. package/dist/controls/select/select.d.ts +147 -0
  123. package/dist/controls/select/select.js +538 -0
  124. package/dist/controls/slider/slider.css +231 -0
  125. package/dist/controls/slider/slider.d.ts +111 -0
  126. package/dist/controls/slider/slider.js +60 -0
  127. package/dist/controls/slider-multi/slider-multi.css +197 -0
  128. package/dist/controls/slider-multi/slider-multi.d.ts +149 -0
  129. package/dist/controls/slider-multi/slider-multi.js +380 -0
  130. package/dist/controls/sparkline/sparkline-math.d.ts +23 -0
  131. package/dist/controls/sparkline/sparkline-math.js +79 -0
  132. package/dist/controls/sparkline/sparkline.css +61 -0
  133. package/dist/controls/sparkline/sparkline.d.ts +27 -0
  134. package/dist/controls/sparkline/sparkline.js +74 -0
  135. package/dist/controls/split/constrain.d.ts +44 -0
  136. package/dist/controls/split/constrain.js +126 -0
  137. package/dist/controls/split/split-pane.css +63 -0
  138. package/dist/controls/split/split-pane.d.ts +49 -0
  139. package/dist/controls/split/split-pane.js +42 -0
  140. package/dist/controls/split/split.css +127 -0
  141. package/dist/controls/split/split.d.ts +42 -0
  142. package/dist/controls/split/split.js +364 -0
  143. package/dist/controls/stat/stat-model.d.ts +22 -0
  144. package/dist/controls/stat/stat-model.js +69 -0
  145. package/dist/controls/stat/stat.css +101 -0
  146. package/dist/controls/stat/stat.d.ts +30 -0
  147. package/dist/controls/stat/stat.js +73 -0
  148. package/dist/controls/status-stream/status-stream.css +151 -0
  149. package/dist/controls/status-stream/status-stream.d.ts +108 -0
  150. package/dist/controls/status-stream/status-stream.js +417 -0
  151. package/dist/controls/swatch/swatch.css +66 -0
  152. package/dist/controls/swatch/swatch.d.ts +28 -0
  153. package/dist/controls/swatch/swatch.js +74 -0
  154. package/dist/controls/swiper/swiper-item.css +17 -0
  155. package/dist/controls/swiper/swiper-item.d.ts +31 -0
  156. package/dist/controls/swiper/swiper-item.js +47 -0
  157. package/dist/controls/swiper/swiper-label.css +10 -0
  158. package/dist/controls/swiper/swiper-label.d.ts +4 -0
  159. package/dist/controls/swiper/swiper-label.js +20 -0
  160. package/dist/controls/swiper/swiper-paddles.css +26 -0
  161. package/dist/controls/swiper/swiper-paddles.d.ts +10 -0
  162. package/dist/controls/swiper/swiper-paddles.js +62 -0
  163. package/dist/controls/swiper/swiper-pagination.css +60 -0
  164. package/dist/controls/swiper/swiper-pagination.d.ts +31 -0
  165. package/dist/controls/swiper/swiper-pagination.js +78 -0
  166. package/dist/controls/swiper/swiper.css +153 -0
  167. package/dist/controls/swiper/swiper.d.ts +158 -0
  168. package/dist/controls/swiper/swiper.js +659 -0
  169. package/dist/controls/switch/switch.css +194 -0
  170. package/dist/controls/switch/switch.d.ts +5 -0
  171. package/dist/controls/switch/switch.js +18 -0
  172. package/dist/controls/table/table-model.d.ts +43 -0
  173. package/dist/controls/table/table-model.js +124 -0
  174. package/dist/controls/table/table.css +90 -0
  175. package/dist/controls/table/table.d.ts +29 -0
  176. package/dist/controls/table/table.js +178 -0
  177. package/dist/controls/tabs/tab-panel.d.ts +10 -0
  178. package/dist/controls/tabs/tab-panel.js +89 -0
  179. package/dist/controls/tabs/tab.d.ts +26 -0
  180. package/dist/controls/tabs/tab.js +67 -0
  181. package/dist/controls/tabs/tabs.css +181 -0
  182. package/dist/controls/tabs/tabs.d.ts +61 -0
  183. package/dist/controls/tabs/tabs.js +176 -0
  184. package/dist/controls/text/href.d.ts +26 -0
  185. package/dist/controls/text/href.js +48 -0
  186. package/dist/controls/text/text.css +328 -0
  187. package/dist/controls/text/text.d.ts +85 -0
  188. package/dist/controls/text/text.js +273 -0
  189. package/dist/controls/text-field/text-field.css +498 -0
  190. package/dist/controls/text-field/text-field.d.ts +226 -0
  191. package/dist/controls/text-field/text-field.js +905 -0
  192. package/dist/controls/textarea/textarea.css +251 -0
  193. package/dist/controls/textarea/textarea.d.ts +136 -0
  194. package/dist/controls/textarea/textarea.js +312 -0
  195. package/dist/controls/theme-provider/theme-provider.css +48 -0
  196. package/dist/controls/theme-provider/theme-provider.d.ts +59 -0
  197. package/dist/controls/theme-provider/theme-provider.js +40 -0
  198. package/dist/controls/timeline/timeline.css +33 -0
  199. package/dist/controls/timeline/timeline.d.ts +39 -0
  200. package/dist/controls/timeline/timeline.js +52 -0
  201. package/dist/controls/timeline-item/timeline-item.css +495 -0
  202. package/dist/controls/timeline-item/timeline-item.d.ts +79 -0
  203. package/dist/controls/timeline-item/timeline-item.js +360 -0
  204. package/dist/controls/toast/toast-region.css +56 -0
  205. package/dist/controls/toast/toast-region.d.ts +20 -0
  206. package/dist/controls/toast/toast-region.js +113 -0
  207. package/dist/controls/toast/toast.css +96 -0
  208. package/dist/controls/toast/toast.d.ts +28 -0
  209. package/dist/controls/toast/toast.js +191 -0
  210. package/dist/controls/toolbar/toolbar.css +125 -0
  211. package/dist/controls/toolbar/toolbar.d.ts +108 -0
  212. package/dist/controls/toolbar/toolbar.js +68 -0
  213. package/dist/controls/tooltip/tooltip.css +80 -0
  214. package/dist/controls/tooltip/tooltip.d.ts +57 -0
  215. package/dist/controls/tooltip/tooltip.js +205 -0
  216. package/dist/descriptor/component-descriptor.d.ts +112 -0
  217. package/dist/descriptor/component-descriptor.js +492 -0
  218. package/dist/descriptor/index.d.ts +1 -0
  219. package/dist/descriptor/index.js +5 -0
  220. package/dist/dom/container.d.ts +45 -0
  221. package/dist/dom/container.js +60 -0
  222. package/dist/dom/element.d.ts +98 -0
  223. package/dist/dom/element.js +221 -0
  224. package/dist/dom/form.d.ts +240 -0
  225. package/dist/dom/form.js +399 -0
  226. package/dist/dom/index.d.ts +10 -0
  227. package/dist/dom/index.js +31 -0
  228. package/dist/dom/props.d.ts +56 -0
  229. package/dist/dom/props.js +231 -0
  230. package/dist/dom/repeat.d.ts +10 -0
  231. package/dist/dom/repeat.js +202 -0
  232. package/dist/dom/template.d.ts +161 -0
  233. package/dist/dom/template.js +634 -0
  234. package/dist/dom/watch.d.ts +11 -0
  235. package/dist/dom/watch.js +82 -0
  236. package/dist/foundation-styles.css +9 -0
  237. package/dist/index.d.ts +2 -0
  238. package/dist/index.js +3 -0
  239. package/dist/reactive/graph.d.ts +65 -0
  240. package/dist/reactive/graph.js +310 -0
  241. package/dist/reactive/index.d.ts +3 -0
  242. package/dist/reactive/index.js +4 -0
  243. package/dist/reactive/scheduler.d.ts +13 -0
  244. package/dist/reactive/scheduler.js +49 -0
  245. package/dist/traits/area-drag.d.ts +12 -0
  246. package/dist/traits/area-drag.js +70 -0
  247. package/dist/traits/form-registry.d.ts +36 -0
  248. package/dist/traits/form-registry.js +108 -0
  249. package/dist/traits/index.d.ts +24 -0
  250. package/dist/traits/index.js +19 -0
  251. package/dist/traits/overlay.d.ts +50 -0
  252. package/dist/traits/overlay.js +277 -0
  253. package/dist/traits/pane-resize.d.ts +26 -0
  254. package/dist/traits/pane-resize.js +103 -0
  255. package/dist/traits/press-activation.d.ts +11 -0
  256. package/dist/traits/press-activation.js +55 -0
  257. package/dist/traits/roving-focus.d.ts +52 -0
  258. package/dist/traits/roving-focus.js +214 -0
  259. package/dist/traits/scroll-fade.d.ts +28 -0
  260. package/dist/traits/scroll-fade.js +120 -0
  261. package/dist/traits/selection-commit.d.ts +30 -0
  262. package/dist/traits/selection-commit.js +146 -0
  263. package/dist/traits/tabbable.d.ts +12 -0
  264. package/dist/traits/tabbable.js +59 -0
  265. package/dist/traits/track-user-invalid.d.ts +36 -0
  266. package/dist/traits/track-user-invalid.js +52 -0
  267. package/dist/traits/value-codec.d.ts +120 -0
  268. package/dist/traits/value-codec.js +497 -0
  269. package/dist/traits/value-drag.d.ts +18 -0
  270. package/dist/traits/value-drag.js +84 -0
  271. package/package.json +263 -0
@@ -0,0 +1,350 @@
1
+ // radio-group.ts — UIRadioGroupElement, the radio-group container (Wave 1 Indicator, S3).
2
+ //
3
+ // The CONTAINER owns everything the radio itself cannot own: single-selection exclusivity, roving-focus
4
+ // keyboard navigation (Arrow/Home/End), the group form value (the selected radio's `value`), and the
5
+ // required → valueMissing validity verdict. Extends UIFormElement DIRECTLY — NOT UIIndicatorElement; the
6
+ // group itself is not an indicator, it is a form-value-owning container whose children are the indicators.
7
+ //
8
+ // ADR-0051 growth: the group wires its own `trackUserInvalid` controller + `formUserInvalid()` override
9
+ // (the text-field precedent) — the group, not any individual radio, is the constraint owner, so the
10
+ // user-invalid TIMING and the :state(user-invalid)/aria-invalid reflection live here. The group has no
11
+ // visual surface of its own (radio-group.css); the CSS danger leg reaches into each `ui-radio` child's
12
+ // own `::before` border. `UISegmentedControlElement` inherits this unchanged — it never overrides
13
+ // `connected()`/`formReset()`, so the wiring applies to its own internals directly.
14
+ //
15
+ // ARIA: `role='radiogroup'` via internals (FACE — never a host attribute). Each `ui-radio` child carries
16
+ // `role='radio'` via its own internals. The group provides no `tabindex` of its own; the rovingFocus trait
17
+ // manages tabindexes on the radio children (exactly one is tabindex=0; others are -1), matching the ARIA
18
+ // APG radio-group keyboard pattern.
19
+ //
20
+ // Selection model:
21
+ // · Arrow keys (Up/Down) + Home/End: move focus AND selection simultaneously (selection-follows-focus,
22
+ // the ARIA APG radio-group contract; ADR-0022 roving tier).
23
+ // · Click / Space: check the targeted radio → base toggle fires `change` → group's delegated change
24
+ // listener calls #commit() for exclusivity + form-value update.
25
+ // · #commit(index): the ONE user-driven commit path — checks radios[index], clears all others, updates
26
+ // #selectedValue, emits `change` on the group when the selection is new. Built on #applySelection,
27
+ // the shared (silent, non-emitting) selection-transition primitive it shares with the public `value`
28
+ // accessor below (a programmatic value write selects/clears without emitting `change` — the
29
+ // `UICheckboxElement.checked` / `UISelectElement.value` fleet convention for programmatic sets).
30
+ //
31
+ // Form value: #selectedValue (a signal) drives the UIFormElement effects for setFormValue + setValidity.
32
+ // The signal is updated inside #applySelection, which every path (Arrow key via onMove, click via change
33
+ // delegation, AND the public `value` setter) converges on. Reading this.#selectedValue.value inside
34
+ // formValue() + formValidity() keeps the effects reactive — they re-publish to internals automatically
35
+ // when the signal changes. The public `value` GETTER is the same signal read, exposed for external
36
+ // callers (e.g. the A2UI catalog's two-way `value:{prop:'value',event:'change'}` bind).
37
+ //
38
+ // ADR-0095 (supersedes ADR-0086's `variant="segmented"`): the segmented presentation is now the standalone
39
+ // `UISegmentedControlElement` (controls/segmented-control/), NOT a variant of this class. `variant` does not
40
+ // exist here — the group is back to its dot-only presentation surface. ADR-0103: the group OWNS its interior
41
+ // layout (radio-group.css's `@scope` flex column/row + `--ui-radio-group-gap`) — direct-children discovery
42
+ // (`#radios()` below) means the layout cannot be left to page-author composition, unlike a coordination
43
+ // wrapper. Two PROTECTED seams exist purely so that subclass can reuse this class's exclusivity/roving/value/
44
+ // validity machinery without forking it:
45
+ // · `defaultOrientation()` — the class-derived roving-axis default (this base returns 'vertical'; the
46
+ // resolve-once-and-reflect-at-connect mechanism below calls it only when no explicit `orientation`
47
+ // attribute is authored). ADR-0095 clause 1.
48
+ // · `selectionChanged(radios, index)` — a no-op hook fired on every selection-defining event (connect
49
+ // seed, every `#applySelection`, and `formReset()`) — the SAME three call sites ADR-0086 wired its
50
+ // (now-retired) `#writeIndexCount` state seam to. `UISegmentedControlElement` overrides it to write its
51
+ // own `--ui-segmented-control-index`/`-count` moving-indicator state. ADR-0095 clause 2.
52
+ //
53
+ // Layer: controls/ — imports reactive + dom + traits + controls/radio (inward-only ✓).
54
+ import { signal } from "../../reactive/index.js";
55
+ import { UIFormElement } from "../../dom/form.js";
56
+ import { prop } from "../../dom/props.js";
57
+ import { rovingFocus } from "../../traits/roving-focus.js";
58
+ import { trackUserInvalid } from "../../traits/track-user-invalid.js";
59
+ import { UIRadioElement } from "./radio.js";
60
+ const groupProps = {
61
+ // Universal form attributes (name / disabled / required) — spread so the group participates
62
+ // in forms as the single form value owner for the radio family (ADR-0013 formProps spread pattern).
63
+ ...UIFormElement.formProps,
64
+ // ADR-0086 clause 1 (orientation SURVIVES the ADR-0095 supersession — clause 1 there: "orientation stays
65
+ // on the group"). The roving axis. Default 'vertical' (today's shipped roving). The EFFECTIVE orientation
66
+ // (author-set vs the class-derived default, `defaultOrientation()` below) is resolved once at connect —
67
+ // see `connected()` below — and reflected back here so CSS and the roving trait read ONE source.
68
+ orientation: { ...prop.enum(['horizontal', 'vertical'], 'vertical'), reflect: true },
69
+ };
70
+ export class UIRadioGroupElement extends UIFormElement {
71
+ static props = groupProps;
72
+ // The committed selection — the group's form value. A signal so the UIFormElement base's scope-owned
73
+ // `formValue()` + `formValidity()` effects re-run (and re-publish to internals) when it changes.
74
+ // null = no radio selected; a string = the selected radio's `value` prop.
75
+ #selectedValue = signal(null);
76
+ // The user-invalid TIMING controller (ADR-0051), created per connection (re-arms on reconnect;
77
+ // released on disconnect) — the text-field/select precedent.
78
+ #userInvalid = null;
79
+ connected() {
80
+ // ARIA: radiogroup role via internals, never a host role/aria-* attribute (FACE).
81
+ this.internals.role = 'radiogroup';
82
+ // ADR-0051 — the user-invalid TIMING controller. `blur` never bubbles, but the capture phase
83
+ // reaches this ancestor before whichever `ui-radio` child held focus (the track-user-invalid.ts
84
+ // precedent); `change` is the group's OWN re-emitted event (#commit(), below) — not the child
85
+ // radio's original (that one is stopImmediatePropagation'd) — so this listener always sees the
86
+ // group's real commits regardless of listener registration order. Reflects :state(user-invalid)
87
+ // + internals.ariaInvalid — the group carries no visual surface of its own (radio-group.css); the
88
+ // CSS leg reaches into each ui-radio child's own ::before border.
89
+ const invalidController = trackUserInvalid(this, { invalid: () => !this.formValidity().valid });
90
+ this.#userInvalid = invalidController;
91
+ this.effect(() => {
92
+ if (invalidController.userInvalid()) {
93
+ this.internals.states?.add('user-invalid');
94
+ this.internals.ariaInvalid = 'true';
95
+ }
96
+ else {
97
+ this.internals.states?.delete('user-invalid');
98
+ this.internals.ariaInvalid = null;
99
+ }
100
+ });
101
+ // Well-known data attribute marker: lets UIRadioElement.grouped() detect any UIRadioGroupElement
102
+ // subclass (e.g. probe subclasses in tests) via a CSS attribute selector without a circular import.
103
+ this.dataset['radioGroup'] = '';
104
+ // Seed from any initially-checked radio (HTML-parsed content: radios connect before the group's
105
+ // connected() runs, so they are already present and may carry a `checked` attribute from markup).
106
+ const radios = this.#radios();
107
+ const initial = radios.find((r) => r.checked);
108
+ if (initial)
109
+ this.#selectedValue.value = initial.value;
110
+ // ADR-0095 clause 1 (was ADR-0086 clause 1, variant-derived) — resolve the EFFECTIVE orientation
111
+ // ONCE, here, BEFORE the rovingFocus call below. `rovingFocus` captures `orientation` as a static
112
+ // value at call time (roving-focus.ts:148 reads the closed-over param directly, not a callback like
113
+ // `items`/`syncIndex`), so resolving this AFTER the call would silently keep the trait on the
114
+ // default. Precedence: an author-set `orientation` attribute wins; otherwise `defaultOrientation()`
115
+ // supplies the CLASS-derived default (this base: `'vertical'`, today's shipped roving, unchanged;
116
+ // `UISegmentedControlElement` overrides it to `'horizontal'`). Reflecting the resolved value back to
117
+ // the host attribute gives CSS (`[orientation]`) and the roving trait one single source of truth.
118
+ const resolvedOrientation = this.hasAttribute('orientation')
119
+ ? this.orientation
120
+ : this.defaultOrientation();
121
+ this.orientation = resolvedOrientation;
122
+ // ADR-0095 clause 2 (was ADR-0086 clauses 2/3, `#writeIndexCount`) — the protected post-selection
123
+ // hook, seeded here at connect. A no-op in this base; `UISegmentedControlElement` overrides it to
124
+ // write its own moving-indicator state.
125
+ this.selectionChanged(radios, this.#checkedIndex());
126
+ // ── roving-focus (Arrow/Home/End: selection-follows-focus) ──────────────────────────────────
127
+ // The ARIA APG radio-group keyboard contract: Arrow keys move focus AND selection simultaneously.
128
+ // `initialIndex` seeds the roving cursor at the currently-checked radio (or first if none), so
129
+ // reconnect restores the correct tabindex=0 position. `syncIndex` re-reads the checked position
130
+ // before each keydown so a preceding click (processed via the change-delegation path below) is
131
+ // visible to the next Arrow move.
132
+ rovingFocus(this, {
133
+ items: () => this.#radios(),
134
+ orientation: resolvedOrientation,
135
+ loop: true,
136
+ typeAhead: false,
137
+ initialIndex: () => {
138
+ const i = this.#checkedIndex();
139
+ return i >= 0 ? i : 0; // default to first when nothing is checked
140
+ },
141
+ syncIndex: () => this.#checkedIndex(),
142
+ onMove: (index) => { if (!this.effectiveDisabled())
143
+ this.#commit(index); },
144
+ });
145
+ // ── delegated change listener (click / Space commit path) ───────────────────────────────────
146
+ // The base indicator emits `change` (bubbling) when it toggles unchecked → checked via a click or
147
+ // Space keyup. The group's capture guard in UIRadioElement.grouped() prevents the already-checked →
148
+ // unchecked direction from firing (stopImmediatePropagation before the base toggle), so only the
149
+ // relevant direction (unchecked → checked) bubbles here. This handler:
150
+ // · Identifies the originating UIRadioElement inside this group.
151
+ // · Stops the radio's change event from propagating further (the group re-emits its own).
152
+ // · Calls #commit to enforce exclusivity and update the group's form value.
153
+ this.listen(this, 'change', (event) => {
154
+ if (this.effectiveDisabled()) {
155
+ // A disabled group swallows child change events entirely: stopImmediatePropagation prevents
156
+ // any other listener on this element (or ancestors) from seeing the radio's change (C7).
157
+ event.stopImmediatePropagation();
158
+ return;
159
+ }
160
+ const target = event.target;
161
+ if (!(target instanceof UIRadioElement))
162
+ return;
163
+ if (!this.contains(target))
164
+ return;
165
+ // Stop immediate propagation: prevent the radio's internal change event from reaching
166
+ // other listeners registered on the group (e.g. external consumers). The group re-emits
167
+ // its own semantic change event below via #commit, so external code sees exactly one change.
168
+ event.stopImmediatePropagation();
169
+ const index = this.#radios().indexOf(target);
170
+ if (index !== -1)
171
+ this.#commit(index);
172
+ });
173
+ }
174
+ // ── public value accessor ───────────────────────────────────────────────────────────────────────
175
+ /**
176
+ * The group's selected value — the checked `ui-radio` child's `value`, or `null` when none is
177
+ * selected. A public getter/setter pair delegating to the private `#selectedValue` signal (the
178
+ * `UICheckboxElement.indeterminate` precedent, checkbox.ts:39 — a plain accessor over a private
179
+ * signal, not a `prop.*`-declared attribute: the value is DERIVED from child radio state, so it
180
+ * has nothing of its own to reflect).
181
+ *
182
+ * The getter reads the signal directly — a reactive caller (e.g. inside `this.effect`) tracks it
183
+ * exactly like any other signal read.
184
+ *
185
+ * The setter is the PROGRAMMATIC path (e.g. a two-way data-bind write): it selects the child
186
+ * `ui-radio` whose `value` matches (unchecking all others) via `#applySelection` — the SAME state
187
+ * transition `#commit` drives for a user click/keyboard commit — but it does NOT emit `change`.
188
+ * This matches the fleet convention for a programmatic prop write on a value-bearing control
189
+ * (`UICheckboxElement.checked` / `UISelectElement.value` are directly settable and never
190
+ * self-emit on assignment; only the interaction-driven commit path — `#commit` here — emits).
191
+ *
192
+ * `null` clears the selection (unchecks every radio). A value matching NO child radio also CLEARS
193
+ * the selection, rather than a silent no-op — the native `HTMLSelectElement.value` precedent:
194
+ * assigning a `<select>`'s `.value` to a string with no matching `<option>` resolves
195
+ * `selectedIndex` to `-1` (the value reads back as `''`) instead of leaving the prior selection
196
+ * in place.
197
+ */
198
+ get value() {
199
+ return this.#selectedValue.value;
200
+ }
201
+ set value(v) {
202
+ const radios = this.#radios();
203
+ const index = v === null ? -1 : radios.findIndex((r) => r.value === v);
204
+ this.#applySelection(radios, index);
205
+ }
206
+ // ── private helpers ─────────────────────────────────────────────────────────────────────────────
207
+ /**
208
+ * Live ordered set of `UIRadioElement` children (direct children only; re-read on each call).
209
+ * Uses `instanceof UIRadioElement` so subclasses (e.g. probe subclasses in tests) are also found,
210
+ * rather than relying on the `ui-radio` tag name which would miss subclasses.
211
+ */
212
+ #radios() {
213
+ return [...this.children].filter((el) => el instanceof UIRadioElement);
214
+ }
215
+ /** Index of the currently checked radio (first match), or -1 when nothing is checked. */
216
+ #checkedIndex() {
217
+ return this.#radios().findIndex((r) => r.checked);
218
+ }
219
+ /**
220
+ * ADR-0095 clause 1 — the CLASS-derived default roving-axis orientation, consulted by `connected()`'s
221
+ * resolve-once-and-reflect ONLY when no explicit `orientation` attribute is authored. This base returns
222
+ * `'vertical'` (today's shipped roving, unchanged). `UISegmentedControlElement` overrides this to
223
+ * `'horizontal'` — the ADR-0086 "variant=segmented ⇒ horizontal" default, now class-derived instead of
224
+ * variant-derived.
225
+ */
226
+ defaultOrientation() {
227
+ return 'vertical';
228
+ }
229
+ /**
230
+ * ADR-0095 clause 2 — a protected, no-op-in-the-base post-selection hook, fired from every
231
+ * selection-DEFINING event: the connect-time seed, every `#applySelection` (both `#commit`'s user-driven
232
+ * path AND the public `value` setter's programmatic path), and `formReset()`. This is the SAME seam
233
+ * ADR-0086's (now-retired) private `#writeIndexCount` fired from — moved here, as a protected hook,
234
+ * so `UISegmentedControlElement` can write its own `--ui-segmented-control-index`/`-count` moving-
235
+ * indicator state WITHOUT this base class knowing anything about segments or indicators. A no-op here
236
+ * costs nothing for the plain dot-group presentation.
237
+ */
238
+ selectionChanged(_radios, _index) { }
239
+ /**
240
+ * The shared selection-transition path: checks `radios[index]`, unchecks all others (direct prop
241
+ * writes — no click events, no change events on siblings, avoids re-entrancy into the group's own
242
+ * change listener via the UIRadioElement guard), updates `#selectedValue`, and fires the
243
+ * `selectionChanged` hook. `index === -1` clears the selection (no radio checked, `#selectedValue` →
244
+ * `null`) — the public `value` setter's "no match" path. Does NOT emit `change`; that is exclusively
245
+ * `#commit`'s concern (the user-driven path) — this is the silent primitive both `#commit` and the
246
+ * public `value` setter build on. Returns whether the selection actually changed.
247
+ */
248
+ #applySelection(radios, index) {
249
+ const radio = index >= 0 ? radios[index] : undefined;
250
+ const newValue = radio ? radio.value : null;
251
+ const changed = newValue !== this.#selectedValue.value;
252
+ // Exclusivity: check the target (if any), uncheck all others (direct writes — no click, no re-entrancy).
253
+ radios.forEach((r, i) => {
254
+ r.checked = i === index;
255
+ });
256
+ // ADR-0095 clause 2 — fire the post-selection hook on every transition (both the Arrow-key/click/Space
257
+ // commit paths AND the public value setter converge here); a no-op in this base.
258
+ this.selectionChanged(radios, index);
259
+ if (changed)
260
+ this.#selectedValue.value = newValue;
261
+ return changed;
262
+ }
263
+ /**
264
+ * The ONE user-driven commit path — invoked from both the rovingFocus `onMove` callback (Arrow keys)
265
+ * and the delegated change listener (click/Space). Applies the transition via `#applySelection` and
266
+ * emits `change` on the group when the selection is new. An out-of-range `index` (no radio at that
267
+ * position) is a no-op — unlike the public `value` setter's "no match" path, this never clears an
268
+ * existing selection (there is no user gesture that should silently blank the group).
269
+ */
270
+ #commit(index) {
271
+ const radios = this.#radios();
272
+ if (!radios[index])
273
+ return;
274
+ if (this.#applySelection(radios, index))
275
+ this.emit('change');
276
+ }
277
+ // ── form hooks ──────────────────────────────────────────────────────────────────────────────────
278
+ /**
279
+ * The group's form value: the selected radio's `value`, or `null` when nothing is checked.
280
+ * Reads `#selectedValue.value` (a signal) so the UIFormElement base's scope-owned setFormValue
281
+ * effect re-runs automatically when selection changes.
282
+ */
283
+ formValue() {
284
+ return this.#selectedValue.value;
285
+ }
286
+ /**
287
+ * Validity: `required` + no selection → `valueMissing`. Reads `#selectedValue.value` so the
288
+ * UIFormElement base's setValidity effect re-runs when selection changes. An empty, required group
289
+ * stays invalid until a radio is committed; a non-required empty group is always valid.
290
+ */
291
+ formValidity() {
292
+ if (this.required && this.#selectedValue.value === null) {
293
+ return {
294
+ valid: false,
295
+ flags: { valueMissing: true },
296
+ message: 'Please select one of these options.',
297
+ };
298
+ }
299
+ return { valid: true };
300
+ }
301
+ /**
302
+ * Form reset — group-level coordination (bug-A fix). Each child radio is its OWN `UIFormElement`
303
+ * participant with its OWN `formResetCallback` (now fixed, indicator-element.ts) that silently restores
304
+ * ITS `checked` to ITS `defaultChecked` — but the GROUP owns a SEPARATE `#selectedValue` signal
305
+ * (`formValue()`/`formValidity()` above), which no radio's own reset can reach. Recompute it here from
306
+ * every child's `defaultChecked` (a stable, native-parity PUBLIC getter — see UIIndicatorElement) rather
307
+ * than each child's live `checked`: the platform resets the group and its radios as INDEPENDENT
308
+ * `UIFormElement`s, in an order this class must not assume (tree order suggests ancestor-first, i.e.
309
+ * this runs BEFORE the radios' own resets — reading live `checked` here would see PRE-reset values).
310
+ * `defaultChecked` sidesteps the ordering question entirely: it is a fixed snapshot from each radio's
311
+ * first connect, unaffected by reset order on either side. Silent — no `change` emitted (a reset is not
312
+ * a user commit; matches `#commit`'s own emit-on-user-action-only discipline).
313
+ *
314
+ * Bug fix (component-reviewer B4, blocking; ADR-0086, preserved verbatim by ADR-0095 clause 2): a reset
315
+ * must ALSO fire the post-selection hook (`selectionChanged`). The hook was previously (ADR-0086) a
316
+ * private `#writeIndexCount` written only at connect and in `#commit` — but a radio's own reset
317
+ * (`indicator-element.ts`) restores its `checked` OUTSIDE `#commit`, so without this the seam desyncs
318
+ * in a subclass that consumes it (e.g. `UISegmentedControlElement`'s moving indicator): the CSS
319
+ * `:not(:has(ui-radio[checked]))`-style gate correctly re-shows the indicator (the DOM `[checked]` is
320
+ * genuinely restored), but a STALE index would still park the moving fill on the pre-reset selection —
321
+ * a visible indicator/ink mismatch. Recomputed from `defaultChecked` for the same reset-order-
322
+ * independence reason as `#selectedValue` above (not live `checked`, which may not have reset yet on
323
+ * this radio's siblings). `findIndex` returns `-1` when no radio was ever default-checked; a consuming
324
+ * subclass's hook is expected to clamp that to `0` the same way ADR-0086's seam did — harmless, since a
325
+ * segmented indicator hides itself whenever nothing is `[checked]`.
326
+ */
327
+ formReset() {
328
+ const radios = this.#radios();
329
+ const defaultIndex = radios.findIndex((r) => r.defaultChecked);
330
+ const defaultRadio = defaultIndex >= 0 ? radios[defaultIndex] : undefined;
331
+ this.#selectedValue.value = defaultRadio ? defaultRadio.value : null;
332
+ this.selectionChanged(radios, defaultIndex);
333
+ // ADR-0051 — a reset must not leave a required-empty group showing :state(user-invalid) until
334
+ // the user re-interacts (the text-field formReset() precedent).
335
+ this.#userInvalid?.reset();
336
+ }
337
+ disconnected() {
338
+ this.#userInvalid?.release(); // idempotent — the listeners already die with the connection scope
339
+ this.#userInvalid = null;
340
+ }
341
+ /** Feeds `FormConnectDetail.userInvalid` (ADR-0050) — the `trackUserInvalid` tracker IS the one
342
+ * timing source; this override just exposes its gate. Inherited by `UISegmentedControlElement`
343
+ * unchanged (it never overrides connected()/formReset(), so this base's wiring applies to its
344
+ * own internals directly). */
345
+ formUserInvalid() {
346
+ return this.#userInvalid?.userInvalid() ?? false;
347
+ }
348
+ }
349
+ if (!customElements.get('ui-radio-group'))
350
+ customElements.define('ui-radio-group', UIRadioGroupElement);
@@ -0,0 +1,219 @@
1
+ /* radio.css — ui-radio, the radio-button indicator (Wave 1 S3, ADR-0042 / ADR-0041).
2
+ *
3
+ * Two SECTIONED blocks:
4
+ * [1] TOKEN BLOCK — `:where(ui-radio)` declares `--ui-radio-*` from the compact ramp + colour roles.
5
+ * [size] repoints the box from the --md-sys-compact-{sm/md/lg} widget ramp (ADR-0041). No --md-sys-scale
6
+ * multiplier: the compact ramp is an EXPLICIT per-[scale] lookup re-tabled in dimensions.css, so the
7
+ * radio box tracks scale automatically (ADR-0038 pattern). [checked] reflects so the dot selector
8
+ * works as a CSS attribute hook (the custom state :state(checked) is the browser-only alternative;
9
+ * both selectors appear for coverage).
10
+ * [2] STYLES BLOCK — `@scope (ui-radio)` consumes only `--ui-radio-*`.
11
+ * Anatomy: the host is an inline-flex row; `::before` is the circular indicator box (border-radius
12
+ * 50%); `::after` is the checked DOT, a separate absolutely-positioned circle centred on `::before`
13
+ * (switch/slider precedent: ring/track = `::before`, glyph/thumb = `::after`); the default slot
14
+ * content flows after both as the optional label. Focus ring: `:focus-visible` on `:scope` (the
15
+ * whole host), not only on the circle — standard fleet focus treatment (ADR-0009).
16
+ * Forced-colors: the ring + dot survive via `ButtonText`/`Highlight`; the focus ring via
17
+ * `--md-sys-color-focus-ring`.
18
+ *
19
+ * 2026-07-07 fix (Kim-filed visual bug): the checked ring AND dot both read `--ui-radio-ink` (the SAME
20
+ * primary blue) — the box-shadow-inset dot rendered as a solid blue disc indistinguishable from its own
21
+ * ring, unlike ui-switch's checked thumb (`--md-sys-color-primary-on-primary`, bright on a primary track).
22
+ * Fix: the checked ring FILLS solid primary (`background: var(--ui-radio-ink)`, matching the switch's
23
+ * filled-track pattern) and the NEW `::after` dot reads the bright `--md-sys-color-primary-on-primary` role —
24
+ * the switch's own high-contrast pair, verified 4.79:1 light / 3.32:1 dark against the primary fill
25
+ * (`tokens.test.ts`'s AA-selected block independently reproduces the light leg; both clear the SC 1.4.11
26
+ * 3:1 non-text bar). Radio's geometry (a single filled disc, no rail/page-surface straddle) has none of
27
+ * ui-slider's competing-backdrop constraint, so the switch's flat on-primary pair ports directly.
28
+ */
29
+
30
+ /* ════════════════ [1] TOKEN BLOCK — :where(ui-radio) declares --ui-radio-* ════════════════ */
31
+
32
+ :where(ui-radio) {
33
+ /* Box size from the widget ramp (ADR-0041). Default = md. */
34
+ --ui-radio-box: var(--md-sys-compact-md);
35
+
36
+ /* Dot geometry: the SAME 22%-inset law as before (unchanged diameter maths), now sizing a real
37
+ `::after` circle instead of an inset box-shadow annulus — the fix touches COLOUR, not proportion. */
38
+ --ui-radio-dot-inset: calc(var(--ui-radio-box) * 0.22);
39
+ --ui-radio-dot-size: calc(var(--ui-radio-box) - 2 * var(--ui-radio-dot-inset));
40
+
41
+ /* Border: idle ring colour. */
42
+ --ui-radio-border: var(--md-sys-color-neutral-on-surface-variant);
43
+
44
+ /* Ink: the checked ring + FILL repoint (matches ui-switch's filled-track pattern). */
45
+ --ui-radio-ink: var(--md-sys-color-primary);
46
+
47
+ /* Dot: the checked glyph — BRIGHT on-primary, the ui-switch checked-thumb role (2026-07-07 fix). Was
48
+ `--ui-radio-ink` reused (same blue as the ring/fill it sits on) — a same-hue dot never reads as a
49
+ distinct indicator. Clears SC 1.4.11 (3:1) against the primary fill: 4.79:1 light / 3.32:1 dark. */
50
+ --ui-radio-dot: var(--md-sys-color-primary-on-primary);
51
+
52
+ /* Gap: between the circle and the label slot (font/2, the anatomy gap law — density-bearing). */
53
+ --ui-radio-gap: var(--md-sys-gap-md);
54
+ }
55
+
56
+ /* [size] → compact ramp repoint (ADR-0041). Gap follows the font-size tier (density gap = font/2). */
57
+ :where(ui-radio[size='sm']) {
58
+ --ui-radio-box: var(--md-sys-compact-sm);
59
+ --ui-radio-gap: var(--md-sys-gap-sm);
60
+ }
61
+ :where(ui-radio[size='lg']) {
62
+ --ui-radio-box: var(--md-sys-compact-lg);
63
+ --ui-radio-gap: var(--md-sys-gap-lg);
64
+ }
65
+
66
+ /* disabled → muted neutral roles. Holds at idle (pointer-inert below). Disabled is SC 1.4.11-EXEMPT
67
+ (ADR-0059 precedent: an inactive component doesn't need the checked ring/dot to stay distinguishable),
68
+ so ring, fill, AND dot collapse onto the SAME muted neutral — a flat disabled disc. */
69
+ :where(ui-radio[disabled]) {
70
+ --ui-radio-border: var(--md-sys-color-neutral-on-surface-variant);
71
+ --ui-radio-ink: var(--md-sys-color-neutral-on-surface-variant);
72
+ --ui-radio-dot: var(--md-sys-color-neutral-on-surface-variant);
73
+ }
74
+
75
+ /* ════════════════ [2] STYLES BLOCK — @scope (ui-radio) consumes ONLY --ui-radio-* ════════════════ */
76
+
77
+ @scope (ui-radio) {
78
+ /* Host: inline-flex row — circle (::before) + optional label slot side by side.
79
+ position: relative is the containing block for the absolutely-positioned ::after dot (switch/slider
80
+ precedent — the thumb/dot is centred on the ring via calc + translate, not flow layout). */
81
+ :scope {
82
+ display: inline-flex;
83
+ align-items: center;
84
+ gap: var(--ui-radio-gap);
85
+ position: relative;
86
+
87
+ /* Control-standard single-line line-height (ADR-0036): 1 keeps the text box = the font box. */
88
+ line-height: var(--md-sys-control-line-height);
89
+
90
+ cursor: pointer;
91
+ -webkit-user-select: none; /* WebKit/Safari: prefix needed */
92
+ user-select: none;
93
+ }
94
+
95
+ /* ── Circular indicator box ──────────────────────────────────────────────────────────────────── */
96
+
97
+ /* ::before = the circle. Sized to --ui-radio-box; circular via border-radius 50%; ring via border.
98
+ background: transparent at idle (the interior stays empty — just the 2px ring). */
99
+ :scope::before {
100
+ content: '';
101
+ display: block;
102
+ flex-shrink: 0; /* never crushed by label text */
103
+
104
+ inline-size: var(--ui-radio-box);
105
+ block-size: var(--ui-radio-box);
106
+ border-radius: 50%;
107
+
108
+ /* 2px border (same weight as the focus ring and --md-sys-widget-inset's 2px law; ADR-0041). */
109
+ border: 2px solid var(--ui-radio-border);
110
+ box-sizing: border-box;
111
+ background: transparent;
112
+ }
113
+
114
+ /* ── Checked: ring FILLS solid primary; the dot (::after) carries the bright glyph ─────────────── */
115
+
116
+ /* [checked] is the reflected attribute (boolean prop, reflect=true). :state(checked) is the
117
+ custom-state equivalent (absent in jsdom; browser-only). Both selectors ensure coverage.
118
+ The circle fills solid primary (ring border + interior both --ui-radio-ink) — the same "filled
119
+ track" treatment ui-switch gives its checked track; the ::after dot below is what reads as the
120
+ distinct indicator, not a same-hue box-shadow annulus (the 2026-07-07 fix). */
121
+ :scope:is([checked])::before,
122
+ :scope:state(checked)::before {
123
+ border-color: var(--ui-radio-ink);
124
+ background: var(--ui-radio-ink);
125
+ }
126
+
127
+ /* ── Dot (::after) — the bright checked glyph, centred on the ::before circle ────────────────────
128
+ * ::before is the FIRST flex item at the host's inline-start (align-items: center vertically centres
129
+ * it); its horizontal centre is therefore `--ui-radio-box / 2` from the host's inline-start. The dot
130
+ * is absolutely positioned at that point (inset-inline-start + inset-block-start: 50%) and pulled back
131
+ * by half its own size (translate(-50%, -50%)) so its CENTRE — not its corner — lands there. Sized to
132
+ * --ui-radio-dot-size (the same 22%-inset diameter maths the old box-shadow used). Transparent at idle
133
+ * (no dot); the bright --ui-radio-dot fill is repointed in on checked. */
134
+ :scope::after {
135
+ content: '';
136
+ position: absolute;
137
+ box-sizing: border-box;
138
+
139
+ inline-size: var(--ui-radio-dot-size);
140
+ block-size: var(--ui-radio-dot-size);
141
+ border-radius: 50%;
142
+
143
+ inset-block-start: 50%;
144
+ inset-inline-start: calc(var(--ui-radio-box) / 2);
145
+ transform: translate(-50%, -50%);
146
+
147
+ background: transparent;
148
+ transition: background-color var(--md-sys-motion-duration-fast) var(--md-sys-motion-easing-standard);
149
+ }
150
+
151
+ :scope:is([checked])::after,
152
+ :scope:state(checked)::after {
153
+ background: var(--ui-radio-dot);
154
+ }
155
+
156
+ /* ── Interaction states ──────────────────────────────────────────────────────────────────────── */
157
+
158
+ /* hover: subtle border lift — repoint border to the ink colour (matches native <input type=radio>
159
+ hover treatment: the ring reinforces intent before click). No background change (no filled bg). */
160
+ :scope:hover::before {
161
+ border-color: var(--ui-radio-ink);
162
+ }
163
+
164
+ /* Focus ring (ADR-0009): fleet-wide shared `:focus-visible` outline on the HOST (covers the circle
165
+ AND the label text — standard for labelled controls). Not on ::before alone, so screen-reader
166
+ users see a full enclosing ring. layout-neutral (outline outside the box model). */
167
+ :scope:focus-visible {
168
+ outline: var(--md-sys-state-focus-ring-width) solid var(--md-sys-color-focus-ring);
169
+ outline-offset: var(--md-sys-state-focus-ring-offset);
170
+ }
171
+
172
+ /* Motion (interaction-states standard) — transition state-PAINT properties on ::before (the ring/fill)
173
+ and ::after (the dot, transition declared alongside its rule above). Geometry (box size) is never
174
+ transitioned. Unconditional — no :state(ready) gate (the base sets no such state). */
175
+ :scope::before {
176
+ transition:
177
+ border-color var(--md-sys-motion-duration-fast) var(--md-sys-motion-easing-standard),
178
+ background-color var(--md-sys-motion-duration-fast) var(--md-sys-motion-easing-standard);
179
+ }
180
+ @media (prefers-reduced-motion: reduce) {
181
+ :scope::before,
182
+ :scope::after {
183
+ transition: none;
184
+ }
185
+ }
186
+
187
+ /* Disabled: pointer-inert + cursor reset (the reflected [disabled] attribute makes the host inert).
188
+ TKT-0047 — the redundant `opacity: 0.5` this rule carried is removed: the TOKEN block's own
189
+ `:where(ui-radio[disabled])` already repoints border/ink/dot to the muted neutral role, converging
190
+ on the fleet's dominant disabled mechanism (token-repoint, not opacity) — the doubled dimming was
191
+ never load-bearing, just an acknowledged v1 leftover. */
192
+ :scope:is([disabled]) {
193
+ pointer-events: none;
194
+ cursor: default;
195
+ }
196
+
197
+ /* ── Forced colors (WHCM) ────────────────────────────────────────────────────────────────────── */
198
+
199
+ @media (forced-colors: active) {
200
+ /* Ensure the idle ring is visible. */
201
+ :scope::before {
202
+ border-color: ButtonText;
203
+ }
204
+ /* Checked: ring + fill map to Highlight (matches the switch/slider checked-fill forced-colors
205
+ pattern); the dot (::after) maps to HighlightText — the system's own paired "on-fill" colour,
206
+ guaranteed distinct from Highlight by the platform (same pairing as the switch's checked thumb). */
207
+ :scope:is([checked])::before,
208
+ :scope:state(checked)::before {
209
+ forced-color-adjust: none;
210
+ border-color: Highlight;
211
+ background: Highlight;
212
+ }
213
+ :scope:is([checked])::after,
214
+ :scope:state(checked)::after {
215
+ forced-color-adjust: none;
216
+ background: HighlightText;
217
+ }
218
+ }
219
+ }
@@ -0,0 +1,19 @@
1
+ import { UIIndicatorElement } from '../_base/indicator-element.js';
2
+ export declare class UIRadioElement extends UIIndicatorElement {
3
+ /**
4
+ * LLD-C2: ARIA role for this leaf — `internals.role` is set by the UIIndicatorElement base
5
+ * from this declaration in `connected()`.
6
+ */
7
+ static role: string;
8
+ /**
9
+ * LLD-C5: group wiring hook — called from `connected()` after all base wiring is in place.
10
+ * When this radio is nested inside a `ui-radio-group`, registers a capture-phase click guard:
11
+ * if the radio is already checked, the click is stopped (via stopImmediatePropagation) before it
12
+ * reaches the base's bubble-phase toggle handler. This enforces the radio-button invariant that a
13
+ * selected radio cannot be deselected by clicking — only by selecting another radio in the group.
14
+ *
15
+ * Without a group parent, this is a no-op and the base toggle runs normally (full checkbox semantics:
16
+ * click toggles unchecked ↔ checked).
17
+ */
18
+ protected grouped(): void;
19
+ }