@rak200/ui 0.3.0 → 0.5.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.
package/dist/radio.js CHANGED
@@ -1,5 +1,19 @@
1
- import { LitElement, css, html } from 'lit';
1
+ import { LitElement, css, html, nothing } from 'lit';
2
2
  import { reference } from './reference.js';
3
+ // Stryker disable next-line StringLiteral: both ends of this event read the constant — the
4
+ // declaration dispatches it and the group listens for it — so a different name, the empty
5
+ // string included, still meets itself. Nothing outside this module names it, which is what
6
+ // makes the mutant provably equivalent rather than merely uncaught.
7
+ const announcement = 'ui-radio-changed';
8
+ /**
9
+ * The name the rendered controls share, which is what makes them one group.
10
+ *
11
+ * It never leaves this shadow root and never reaches a form: a radio group with no form
12
+ * owner is scoped by its **tree**, so one literal is safe in every instance on a page —
13
+ * two groups carrying it are two groups, measured. What a form submits is the host's own
14
+ * `name`, through `ElementInternals`.
15
+ */
16
+ const grouping = 'option';
3
17
  /**
4
18
  * The size the control is drawn at, and a floor rather than a preference.
5
19
  *
@@ -31,60 +45,206 @@ const whole = css `linear-gradient(#000, #000)`;
31
45
  */
32
46
  const mark = css `radial-gradient(circle closest-side, #000 100%, transparent 100%)`;
33
47
  /**
34
- * One option's control, drawn from the token layer rather than replaced.
48
+ * One choice in a {@link UiRadioGroup}: a value, and the text beside it.
35
49
  *
36
- * **The `<input type="radio">` is yours.** You write it, you set its `name`, `value`,
37
- * `checked` and `disabled`, and it stays in the light DOM — the shape every control in
38
- * this kit has, and forced by the same constraint: an ARIA relationship by IDREF does not
39
- * cross a shadow boundary, so a control rendered in here could not be labelled by the
40
- * `<label>` around it.
41
- *
42
- * **This element carries no state and no name.** It is a drawing, and everything that
43
- * makes a set of radios *a group* — one tab stop, arrow keys that move and select, one
44
- * value out of the set — is the platform's, because they are native radios sharing a
45
- * `name`. Wrapping each one changes none of that: measured in this suite, a group whose
46
- * radios sit inside custom elements is still one group.
50
+ * **It draws nothing.** The group renders the real `<input type="radio">` and the
51
+ * `<label>` around it, because a `role="radiogroup"`, the name pointing at it and the
52
+ * controls it contains have to share one tree scope — `ARCHITECTURE.md`, *A relationship
53
+ * needs one tree scope*. So this element exists to be read rather than to be seen, the way
54
+ * `<ui-option>` does for `<ui-select>`.
47
55
  *
48
56
  * @example
49
57
  * ```html
50
- * <label>
51
- * <ui-radio><input type="radio" name="plan" value="free" /></ui-radio>
52
- * Free
53
- * </label>
58
+ * <ui-radio value="free">Free</ui-radio>
54
59
  * ```
55
60
  */
56
61
  export class UiRadio extends LitElement {
62
+ constructor() {
63
+ super(...arguments);
64
+ /** What the group submits while this choice is the one in force. */
65
+ this.value = '';
66
+ /** Whether this is the choice a group with no `value` starts on, and resets to. */
67
+ this.checked = false;
68
+ /** Whether this one choice refuses interaction, the rest of the set staying live. */
69
+ this.disabled = false;
70
+ this.#announce = () => {
71
+ this.dispatchEvent(new Event(announcement, { bubbles: true }));
72
+ };
73
+ }
74
+ static { this.styles = css `
75
+ :host {
76
+ display: none;
77
+ }
78
+ `; }
79
+ static { this.properties = {
80
+ value: { type: String, reflect: true },
81
+ checked: { type: Boolean, reflect: true },
82
+ disabled: { type: Boolean, reflect: true },
83
+ }; }
84
+ /**
85
+ * Tells the group something changed, on every update and on every slot change.
86
+ *
87
+ * A property write is not something a `MutationObserver` reports, which is what makes a
88
+ * declaration this package owns different from an element a host wrote: the property is
89
+ * reactive here. It bubbles because the choices are the group's own light-DOM children,
90
+ * and it is dispatched from `updated()` rather than from `disconnectedCallback`, which
91
+ * has no path left to bubble along.
92
+ */
93
+ updated() {
94
+ this.#announce();
95
+ }
96
+ #announce;
97
+ render() {
98
+ return html `<slot @slotchange=${this.#announce}></slot>`;
99
+ }
100
+ }
101
+ /**
102
+ * A set of choices, laid out and named as one thing.
103
+ *
104
+ * **It hand-rolls no roving tabindex and it brings no state machine.** Native radios
105
+ * sharing a `name` **are** the APG **Radio Group** pattern — one tab stop, arrow keys that
106
+ * move *and* select, wrapping at the ends, and left/right swapped under `dir="rtl"` — and
107
+ * that holds inside a shadow root, where the group is scoped by its tree because the
108
+ * controls have no form owner. `tests/radio.test.ts` measures it rather than trusting it.
109
+ * `ARCHITECTURE.md`, *Behaviour is delegated*, carries why no machine arrived.
110
+ *
111
+ * **What it owns is what the platform leaves out**: the drawing, the layout, the group's
112
+ * own name, help and message, and form participation through `ElementInternals` — a
113
+ * control in a shadow root has no form owner, so the element answers for the value, the
114
+ * validity and the three form lifecycle callbacks.
115
+ *
116
+ * @example
117
+ * ```html
118
+ * <ui-radio-group label="Plan" name="plan" help="Change it any time.">
119
+ * <ui-radio value="free">Free</ui-radio>
120
+ * <ui-radio value="pro" checked>Pro</ui-radio>
121
+ * </ui-radio-group>
122
+ * ```
123
+ */
124
+ export class UiRadioGroup extends LitElement {
125
+ constructor() {
126
+ super(...arguments);
127
+ /**
128
+ * The accessible name, and the text above the set.
129
+ *
130
+ * A plain field rather than the `accessor` keyword, for the reason `src/button.ts`
131
+ * gives beside its own — and so for every property below.
132
+ */
133
+ this.label = '';
134
+ /** Supporting text under the set, which survives an error rather than yielding. */
135
+ this.help = '';
136
+ /** The message, which paints the boundaries and is announced with the group. */
137
+ this.error = '';
138
+ /**
139
+ * The name the value is submitted under.
140
+ *
141
+ * **Reflected, and that is a requirement rather than a convenience.** A form names a
142
+ * form-associated custom element's entry from the content attribute, so a host who set
143
+ * only the property would submit nothing at all — silently.
144
+ */
145
+ this.name = '';
146
+ /**
147
+ * The choice currently made.
148
+ *
149
+ * **Empty means _the declared default_ rather than _nothing_**, which is what lets a
150
+ * `<ui-radio checked>` mean what `<input checked>` means and a reset return to it.
151
+ * Not reflected, the way a native control's `checked` IDL attribute is not.
152
+ */
153
+ this.value = '';
154
+ /** Which way the options are laid out, and what the group announces it did. */
155
+ this.orientation = 'vertical';
156
+ /** Whether a form is invalid while no choice is made. */
157
+ this.required = false;
158
+ /** Whether the whole set refuses interaction, and submits nothing. */
159
+ this.disabled = false;
160
+ this.#internals = this.attachInternals();
161
+ this.#redraw = () => {
162
+ this.requestUpdate();
163
+ };
164
+ /** A choice made by hand, mirrored back and re-dispatched. */
165
+ this.#changed = (event) => {
166
+ this.value = event.target.value;
167
+ this.dispatchEvent(new Event('change', { bubbles: true }));
168
+ };
169
+ }
57
170
  static { this.styles = css `
58
171
  :host {
59
- display: inline-flex;
172
+ display: block;
173
+ font-family: ${reference('--ui-font')};
174
+ }
175
+
176
+ /* The rhythm ui-field used to own, now owned by the control it was wrapping. */
177
+ .stack {
178
+ display: flex;
179
+ flex-direction: column;
180
+ gap: calc(${reference('--ui-space')} / 2);
60
181
  }
61
182
 
62
- /* Everything below reaches a control the *host* wrote, through ::slotted — the
63
- whole shape of this component, and the same one ui-checkbox has.
183
+ .label {
184
+ color: ${reference('--ui-color-text')};
185
+ }
64
186
 
65
- appearance: none is its whole cost, and it is taken for the reason
187
+ :host([disabled]) .label {
188
+ color: ${reference('--ui-color-text-muted')};
189
+ }
190
+
191
+ .options {
192
+ display: flex;
193
+ flex-direction: column;
194
+ gap: calc(${reference('--ui-space')} / 2);
195
+ }
196
+
197
+ /* Laid out by the attribute rather than by the host's own CSS, so the drawing and
198
+ the announcement cannot disagree: orientation sets both this rule and
199
+ aria-orientation, and a host who reached for flex-direction instead would change
200
+ one of the two. */
201
+ :host([orientation='horizontal']) .options {
202
+ flex-direction: row;
203
+ flex-wrap: wrap;
204
+ column-gap: calc(${reference('--ui-space')} * 2);
205
+ }
206
+
207
+ /* The label CONTAINS its control, so there is no IDREF to resolve and no id to
208
+ generate — the same shape src/checkbox.ts takes, and the reason the click target
209
+ covers the text for free. align-items rather than center: an option whose text
210
+ wraps to two lines should keep its control on the first. */
211
+ .option {
212
+ display: flex;
213
+ align-items: center;
214
+ gap: calc(${reference('--ui-space')} / 2);
215
+ color: ${reference('--ui-color-text')};
216
+ cursor: pointer;
217
+ }
218
+
219
+ .option:has(input:disabled) {
220
+ color: ${reference('--ui-color-text-muted')};
221
+ cursor: not-allowed;
222
+ }
223
+
224
+ /* appearance: none is this component's whole cost, and it is taken for the reason
66
225
  ARCHITECTURE.md states as a debt rather than a free hand: it removes the
67
226
  platform's dot, its target size and its behaviour under forced colors in one
68
227
  declaration, and each of those is answered below. What it buys is a radio that
69
228
  belongs beside the checkbox this kit already draws — a native 13px radio next to
70
229
  a drawn 24px checkbox would argue cosmetically for a difference that is
71
230
  semantic. */
72
- ::slotted(input) {
231
+ input {
73
232
  appearance: none;
74
233
  box-sizing: border-box;
75
- /* The user agent puts 3px around a radio. Left in, it would sit inside the
76
- host box and put the control off centre in any layout that aligns to it. */
234
+ flex: none;
235
+ /* The user agent puts 3px around a radio. Left in, it would put the control
236
+ off centre against the text beside it. */
77
237
  margin: 0;
78
238
  inline-size: ${size};
79
239
  block-size: ${size};
80
- /* A circle, and the one measurement here that is not a token: the shape is
81
- what tells a radio from a checkbox before either is read, so it cannot
82
- follow --ui-radius without a square radio becoming possible. */
240
+ /* A circle, and the one measurement here that is not a token: the shape is what
241
+ tells a radio from a checkbox before either is read, so it cannot follow
242
+ --ui-radius without a square radio becoming possible. */
83
243
  border-radius: 50%;
84
244
  background-color: ${reference('--ui-color-surface')};
85
245
  background-repeat: no-repeat;
86
246
  border: 1px solid ${reference('--ui-color-border')};
87
- cursor: pointer;
247
+ cursor: inherit;
88
248
  /* The focus ring is deliberately not in this list, for the reason
89
249
  src/button.ts gives beside its own: delaying the affordance that says *this
90
250
  is where you are* is the opposite of what it exists to do. */
@@ -94,10 +254,10 @@ export class UiRadio extends LitElement {
94
254
  border-color ${reference('--ui-duration-state')} ${reference('--ui-easing-state')};
95
255
  }
96
256
 
97
- /* The mark is a hole: the whole control, minus the circle, so what shows through
98
- is whatever the control sits on. The docblock on the mask constant says why a
99
- hole rather than a colour. */
100
- ::slotted(input:checked) {
257
+ /* The mark is a hole: the whole control, minus the circle, so what shows through is
258
+ whatever the control sits on. The docblock on the mask constant says why a hole
259
+ rather than a colour. */
260
+ input:checked {
101
261
  background-color: ${reference('--ui-color-accent')};
102
262
  border-color: ${reference('--ui-color-accent')};
103
263
  mask-image: ${whole}, ${mark};
@@ -116,32 +276,52 @@ export class UiRadio extends LitElement {
116
276
 
117
277
  The disabled guard is measured rather than assumed: a disabled control still
118
278
  matches :hover. */
119
- ::slotted(input:hover:not(:disabled)) {
279
+ input:hover:not(:disabled) {
120
280
  border-color: ${reference('--ui-color-text')};
121
281
  }
122
282
 
123
- ::slotted(input:checked:hover:not(:disabled)) {
283
+ input:checked:hover:not(:disabled) {
124
284
  background-color: ${reference('--ui-color-accent-hover')};
125
285
  border-color: ${reference('--ui-color-accent-hover')};
126
286
  }
127
287
 
128
- /* A visible focus ring is not decoration: removing it is the single most common
129
- way a component stops being usable by keyboard — and on a radio group it is the
130
- only thing that says which option an arrow key just moved to. */
131
- ::slotted(input:focus-visible) {
288
+ /* A visible focus ring is not decoration: removing it is the single most common way
289
+ a component stops being usable by keyboard — and on a radio group it is the only
290
+ thing that says which option an arrow key just moved to. */
291
+ input:focus-visible {
132
292
  outline: 2px solid ${reference('--ui-color-focus')};
133
293
  outline-offset: 2px;
134
294
  }
135
295
 
136
- ::slotted(input:disabled) {
296
+ input:disabled {
137
297
  cursor: not-allowed;
138
298
  opacity: 0.5;
139
299
  }
140
300
 
141
- /* There is no error rule here, and its absence is the decision: what a radio group
142
- gets wrong is the *choice*, not one option, so ui-radio-group paints the state
143
- for the whole set. It does it by retargeting the boundary token over its own
144
- subtree, which this rule reads without knowing anything about it. */
301
+ /* The error is the SET's, not one option's: what a radio group gets wrong is the
302
+ choice. Read off the rendered aria-invalid rather than a reflected attribute on
303
+ the host, which is the trap ui-checkbox measured — Lit reflects an empty string
304
+ default as an empty attribute, and an attribute-presence selector matches every
305
+ element.
306
+
307
+ This used to retarget --ui-color-border over the whole subtree, because no
308
+ selector in this sheet could reach a control two elements down in the host's
309
+ tree. The controls are in here now, so the rule names them. */
310
+ .options[aria-invalid='true'] input {
311
+ border-color: ${reference('--ui-color-danger')};
312
+ }
313
+
314
+ .help {
315
+ color: ${reference('--ui-color-text')};
316
+ font-size: ${reference('--ui-text-supporting')};
317
+ }
318
+
319
+ /* Colour is not the only cue — the message says what is wrong, and aria-invalid
320
+ marks the group whatever the styling does. */
321
+ .error {
322
+ color: ${reference('--ui-color-danger')};
323
+ font-size: ${reference('--ui-text-supporting')};
324
+ }
145
325
 
146
326
  /* Forced colors replaces every author colour with one from the user's palette, so
147
327
  the accent that says *selected* becomes the same Canvas as the surface that says
@@ -150,135 +330,186 @@ export class UiRadio extends LitElement {
150
330
  selected state names Highlight and the difference survives; the mark keeps
151
331
  working untouched, because a mask has no colour to force.
152
332
 
153
- Opacity is not a colour and is not forced, so the disabled control would still
154
- be half-transparent against a palette chosen for contrast. GrayText is what that
333
+ Opacity is not a colour and is not forced, so the disabled control would still be
334
+ half-transparent against a palette chosen for contrast. GrayText is what that
155
335
  mode has for *unavailable*, and it is a colour rather than a veil. */
156
336
  @media (forced-colors: active) {
157
- ::slotted(input:checked) {
337
+ input:checked {
158
338
  background-color: Highlight;
159
339
  border-color: Highlight;
160
340
  }
161
341
 
162
- ::slotted(input:disabled) {
342
+ input:disabled {
163
343
  border-color: GrayText;
164
344
  opacity: 1;
165
345
  }
166
346
  }
167
347
  `; }
168
- render() {
169
- return html `<slot></slot>`;
170
- }
171
- }
172
- /**
173
- * A set of radios, laid out and named as one thing.
174
- *
175
- * **It hand-rolls no roving tabindex and it brings no state machine**, and that is this
176
- * element's whole claim rather than a gap in it. Issue #15 asked which of the two it would
177
- * be, because the APG **Radio Group** pattern — one tab stop, arrow keys that move *and*
178
- * select, wrapping at the ends — is the first behaviour in the v0 cut that could have
179
- * justified Zag. Neither was needed: native radios sharing a `name` **are** that pattern,
180
- * and this suite measures it here rather than trusting it, wrappers and all.
181
- *
182
- * That is the test `ROADMAP.md` states, applied: Zag arrives where the platform has **no
183
- * element** for the pattern, not merely where the pattern has state. `ui-menu` is where
184
- * that question is open; a radio group is not.
185
- *
186
- * **What is left for this element is what the platform leaves out**: the layout, the
187
- * `role` that makes a set of radios a group to a screen reader, and the group-level name,
188
- * description and error state — which {@link UiField} wires onto *this* element rather
189
- * than onto an option, because a `<label for>` naming one radio names one option and
190
- * leaves the group anonymous.
191
- *
192
- * @example
193
- * ```html
194
- * <ui-field>
195
- * <label slot="label">Plan</label>
196
- * <ui-radio-group>
197
- * <label><ui-radio><input type="radio" name="plan" value="free" /></ui-radio> Free</label>
198
- * <label><ui-radio><input type="radio" name="plan" value="pro" /></ui-radio> Pro</label>
199
- * </ui-radio-group>
200
- * </ui-field>
201
- * ```
202
- */
203
- export class UiRadioGroup extends LitElement {
204
- constructor() {
205
- super(...arguments);
206
- /**
207
- * Which way the options are laid out.
208
- *
209
- * A plain field rather than the `accessor` keyword, for the reason `src/button.ts`
210
- * gives beside its own: the browser this suite runs in does not implement
211
- * auto-accessors, and the module would fail to parse.
212
- */
213
- this.orientation = 'vertical';
214
- }
215
- static { this.styles = css `
216
- :host {
217
- display: flex;
218
- flex-direction: column;
219
- gap: calc(${reference('--ui-space')} / 2);
220
- }
221
-
222
- /* Laid out by the attribute rather than by the host's own CSS, so the drawing and
223
- the announcement cannot disagree: orientation sets both this rule and
224
- aria-orientation, and a host who reached for flex-direction instead would
225
- change one of the two. */
226
- :host([orientation='horizontal']) {
227
- flex-direction: row;
228
- flex-wrap: wrap;
229
- column-gap: calc(${reference('--ui-space')} * 2);
230
- }
231
-
232
- /* The error, painted for the whole set by retargeting the boundary token over this
233
- subtree — which is the override surface the token layer already publishes,
234
- pointed at the group's own descendants instead of at a page.
235
-
236
- A custom property inherits through a shadow boundary and a selector does not, so
237
- this is what reaches a control two elements down that this sheet cannot name:
238
- ::slotted() stops at the group's own children, :host-context() is not
239
- cross-engine, and :host(:has(...)) is invalid in this engine. The value still
240
- arrives as a token reference, so a host who retunes danger retunes this too.
241
-
242
- aria-invalid is not written here: ui-field sets it as part of the wiring it
243
- already owns, so the message under the group and the boundary around its options
244
- cannot disagree about whether there is an error. */
245
- :host([aria-invalid='true']) {
246
- --ui-color-border: ${reference('--ui-color-danger')};
247
- }
248
- `; }
348
+ static { this.formAssociated = true; }
349
+ /**
350
+ * Focus is delegated, so `focus()` and `reportValidity()` reach a control — the host is
351
+ * not a focusable element. Measured with the group: the delegation lands on the first
352
+ * option rather than making all of them tab stops, because the roving one is still the
353
+ * platform's.
354
+ */
355
+ static { this.shadowRootOptions = {
356
+ ...LitElement.shadowRootOptions,
357
+ delegatesFocus: true,
358
+ }; }
249
359
  static { this.properties = {
360
+ label: { type: String, reflect: true },
361
+ help: { type: String, reflect: true },
362
+ error: { type: String, reflect: true },
363
+ name: { type: String, reflect: true },
364
+ // Stryker disable next-line ObjectLiteral: `{}` is what Lit's default converter
365
+ // already does for a string — `type` only switches the converter for Boolean,
366
+ // Number, Object and Array — and `reflect` defaults to false either way. The
367
+ // declaration is written out to say which of the two `value` is, not to change it.
368
+ value: { type: String },
250
369
  orientation: { type: String, reflect: true },
370
+ required: { type: Boolean, reflect: true },
371
+ disabled: { type: Boolean, reflect: true },
251
372
  }; }
373
+ #internals;
374
+ #redraw;
375
+ connectedCallback() {
376
+ super.connectedCallback();
377
+ // The half a slot cannot report: a property write on a choice. It reaches here by
378
+ // bubbling, because the choices are this element's own light-DOM children.
379
+ //
380
+ // There is deliberately no removal in `disconnectedCallback`. A listener an element
381
+ // holds on *itself* cannot outlive it, and `#redraw` is one stable reference, so
382
+ // re-adding it on a reconnect is discarded rather than doubled.
383
+ this.addEventListener(announcement, this.#redraw);
384
+ }
385
+ /** Every choice a host declared, in the order they were written. */
386
+ #choices() {
387
+ return [...this.querySelectorAll('ui-radio')];
388
+ }
252
389
  /**
253
- * Marks the element a group before anything asks what it is.
390
+ * The choice in force: the host's `value` where it names one, the declared default
391
+ * otherwise.
254
392
  *
255
- * In `connectedCallback` rather than at first render, and the timing is the point:
256
- * {@link UiField} reads this attribute to decide that the group — and not the first
257
- * radio inside it — is the thing to name, and it does so on its own first update,
258
- * which lands before this element's. Set a microtask later, the role would arrive
259
- * after the only reader that needs it.
393
+ * The fallback is the platform's own rule read off the declarations — the first choice
394
+ * marked `checked`, and failing that nothing at all, which is what a native set with no
395
+ * checked radio in it does. A radio group differs from a `<select>` exactly here: it
396
+ * has an empty state and a drop-down does not.
397
+ */
398
+ #chosen() {
399
+ const choices = this.#choices();
400
+ if (choices.some((choice) => choice.value === this.value)) {
401
+ return this.value;
402
+ }
403
+ return choices.find((choice) => choice.checked)?.value ?? '';
404
+ }
405
+ /** The validity, taken from a control that computed it. */
406
+ #publish(control) {
407
+ if (this.error !== '') {
408
+ this.#internals.setValidity({ customError: true }, this.error, control);
409
+ return;
410
+ }
411
+ this.#internals.setValidity(control.validity, control.validationMessage, control);
412
+ }
413
+ /**
414
+ * The value and the validity, which move together here or disagree anywhere.
260
415
  *
261
- * A `role` the host wrote is never overwritten, the same courtesy {@link UiField}
262
- * extends to an `id` it finds already there.
416
+ * The first control is the one asked, and it is the one the browser would focus when a
417
+ * form reports an invalid group. Iterated rather than asserted, for the reason
418
+ * `src/input.ts` gives beside the same shape: a null branch here is unreachable, which
419
+ * is a coverage hole and an immortal mutant at once.
263
420
  */
264
- connectedCallback() {
265
- super.connectedCallback();
266
- if (!this.hasAttribute('role')) {
267
- this.setAttribute('role', 'radiogroup');
421
+ updated() {
422
+ this.#internals.setFormValue(this.#chosen());
423
+ for (const control of this.renderRoot.querySelectorAll('input')) {
424
+ this.#publish(control);
425
+ return;
268
426
  }
427
+ // A group with no choices declared has nothing to validate against and nothing to
428
+ // submit, which is a state a host can reach by writing an empty element.
429
+ this.#internals.setValidity({});
430
+ }
431
+ /** A choice made by hand, mirrored back and re-dispatched. */
432
+ #changed;
433
+ /** A form reset, which returns to the choice the declarations name. */
434
+ formResetCallback() {
435
+ this.value = '';
436
+ }
437
+ /** A `<fieldset disabled>` above this element, which reaches it and nothing below. */
438
+ formDisabledCallback(disabled) {
439
+ this.disabled = disabled;
440
+ }
441
+ /** Restoring after a back-navigation, where the browser hands the value back. */
442
+ formStateRestoreCallback(state) {
443
+ this.value = state ?? '';
444
+ }
445
+ /** The form this element participates in, for a host that needs it. */
446
+ get form() {
447
+ return this.#internals.form;
448
+ }
449
+ /** Whether the group currently satisfies its constraints. */
450
+ get validity() {
451
+ return this.#internals.validity;
452
+ }
453
+ /** The message a form would report for it, empty while the group is valid. */
454
+ get validationMessage() {
455
+ return this.#internals.validationMessage;
456
+ }
457
+ /** The ids describing the group, in the order a reader needs them. */
458
+ #described() {
459
+ const ids = [this.error === '' ? '' : 'error', this.help === '' ? '' : 'help'].filter((id) => id !== '');
460
+ return ids.length === 0 ? nothing : ids.join(' ');
269
461
  }
270
462
  /**
271
- * Keeps the announced orientation on the drawn one.
463
+ * One declaration, as the label and control the platform needs.
272
464
  *
273
- * Written unconditionally rather than only for the horizontal case: the default a
274
- * screen reader assumes for a `radiogroup` is not something a component should have an
275
- * opinion about, and the attribute costs nothing to state.
465
+ * **`.checked` is a property binding and `?checked` would be wrong**, which is not a
466
+ * style choice: on a radio the content attribute is `defaultChecked`, so once the
467
+ * control has been interacted with — or once anything has set the property — the
468
+ * attribute stops driving the live state. Measured, as a reset that redrew the
469
+ * declared default and left the control on the choice the reader had made.
276
470
  */
277
- willUpdate() {
278
- this.setAttribute('aria-orientation', this.orientation);
471
+ #draw(choice, chosen) {
472
+ return html `<label class="option" part="option">
473
+ <input
474
+ type="radio"
475
+ part="control"
476
+ name=${grouping}
477
+ value=${choice.value}
478
+ .checked=${choice.value === chosen}
479
+ ?disabled=${this.disabled || choice.disabled}
480
+ ?required=${this.required}
481
+ @change=${this.#changed}
482
+ />
483
+ <span part="option-label">${choice.textContent}</span>
484
+ </label>`;
279
485
  }
280
486
  render() {
281
- return html `<slot></slot>`;
487
+ const chosen = this.#chosen();
488
+ return html `
489
+ <div class="stack" part="stack">
490
+ <span class="label" id="label" part="label">${this.label}</span>
491
+ <div
492
+ class="options"
493
+ part="options"
494
+ role="radiogroup"
495
+ aria-labelledby="label"
496
+ aria-orientation=${this.orientation}
497
+ aria-invalid=${this.error === '' ? nothing : 'true'}
498
+ aria-describedby=${this.#described()}
499
+ >
500
+ ${this.#choices().map((choice) => this.#draw(choice, chosen))}
501
+ </div>
502
+ ${this.help === ''
503
+ ? nothing
504
+ : html `<span class="help" id="help" part="help">${this.help}</span>`}
505
+ ${this.error === ''
506
+ ? nothing
507
+ : html `<span class="error" id="error" part="error">${this.error}</span>`}
508
+ <!-- The structural half, which slotchange reports and no observer has to.
509
+ What it holds draws nothing: a declaration is display: none. -->
510
+ <slot @slotchange=${this.#redraw}></slot>
511
+ </div>
512
+ `;
282
513
  }
283
514
  }
284
515
  // Stryker disable next-line StringLiteral: the registration runs once, at import, inside
package/dist/radio.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"radio.js","sourceRoot":"","sources":["../src/radio.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,IAAI,EAAuB,MAAM,KAAK,CAAC;AACjE,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAK3C;;;;;;;;;;;;;;;GAeG;AACH,MAAM,IAAI,GAAG,GAAG,CAAA,kBAAkB,SAAS,CAAC,YAAY,CAAC,QAAQ,CAAC;AAElE,uFAAuF;AACvF,MAAM,KAAK,GAAG,GAAG,CAAA,6BAA6B,CAAC;AAE/C;;;;;;;;;GASG;AACH,MAAM,IAAI,GAAG,GAAG,CAAA,mEAAmE,CAAC;AAEpF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,OAAO,OAAQ,SAAQ,UAAU;aACV,WAAM,GAAG,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;2BAqBd,IAAI;0BACL,IAAI;;;;;gCAKE,SAAS,CAAC,oBAAoB,CAAC;;gCAE/B,SAAS,CAAC,mBAAmB,CAAC;;;;;;mCAM3B,SAAS,CAAC,qBAAqB,CAAC;sBAC7C,SAAS,CAAC,mBAAmB,CAAC;+BACrB,SAAS,CAAC,qBAAqB,CAAC,IAAI,SAAS,CAAC,mBAAmB,CAAC;;;;;;;gCAOjE,SAAS,CAAC,mBAAmB,CAAC;4BAClC,SAAS,CAAC,mBAAmB,CAAC;0BAChC,KAAK,KAAK,IAAI;;;;uBAIjB,IAAI;;;;;;;;;;;;;4BAaC,SAAS,CAAC,iBAAiB,CAAC;;;;gCAIxB,SAAS,CAAC,yBAAyB,CAAC;4BACxC,SAAS,CAAC,yBAAyB,CAAC;;;;;;;iCAO/B,SAAS,CAAC,kBAAkB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAmCzD,CAAC;IAEO,MAAM;QACX,OAAO,IAAI,CAAA,eAAe,CAAC;IAC/B,CAAC;;AAGL;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,OAAO,YAAa,SAAQ,UAAU;IAA5C;;QAwCI;;;;;;WAMG;QACH,gBAAW,GAAqB,UAAU,CAAC;IAoC/C,CAAC;aAlF4B,WAAM,GAAG,GAAG,CAAA;;;;wBAIjB,SAAS,CAAC,YAAY,CAAC;;;;;;;;;;+BAUhB,SAAS,CAAC,YAAY,CAAC;;;;;;;;;;;;;;;;;iCAiBrB,SAAS,CAAC,mBAAmB,CAAC;;KAE1D,AAjC8B,CAiC7B;aAEuB,eAAU,GAAG;QAClC,WAAW,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE;KAC/C,AAFkC,CAEjC;IAWF;;;;;;;;;;;OAWG;IACM,iBAAiB;QACtB,KAAK,CAAC,iBAAiB,EAAE,CAAC;QAE1B,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,CAAC;YAC7B,IAAI,CAAC,YAAY,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;QAC5C,CAAC;IACL,CAAC;IAED;;;;;;OAMG;IACM,UAAU;QACf,IAAI,CAAC,YAAY,CAAC,kBAAkB,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;IAC5D,CAAC;IAEQ,MAAM;QACX,OAAO,IAAI,CAAA,eAAe,CAAC;IAC/B,CAAC;;AAGL,yFAAyF;AACzF,yFAAyF;AACzF,yFAAyF;AACzF,oEAAoE;AACpE,cAAc,CAAC,MAAM,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;AAE3C,0EAA0E;AAC1E,cAAc,CAAC,MAAM,CAAC,gBAAgB,EAAE,YAAY,CAAC,CAAC"}
1
+ {"version":3,"file":"radio.js","sourceRoot":"","sources":["../src/radio.ts"],"names":[],"mappings":"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"}