Skip to content

Commit b0e87c5

Browse files
asynclizcopybara-github
authored andcommitted
feat(radio): change SingleSelectionController to a ReactiveController
PiperOrigin-RevId: 499515557
1 parent 9b9ce0e commit b0e87c5

File tree

2 files changed

+176
-342
lines changed

2 files changed

+176
-342
lines changed

radio/lib/radio.ts

Lines changed: 16 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {property, query, queryAsync, state} from 'lit/decorators.js';
1515
import {classMap} from 'lit/directives/class-map.js';
1616
import {when} from 'lit/directives/when.js';
1717

18-
import {dispatchActivationClick, isActivationClick} from '../../controller/events.js';
18+
import {dispatchActivationClick, isActivationClick, redispatchEvent} from '../../controller/events.js';
1919
import {FormController, getFormValue} from '../../controller/form-controller.js';
2020
import {ariaProperty} from '../../decorators/aria-property.js';
2121
import {pointerPress, shouldShowStrongFocus} from '../../focus/strong-focus.js';
@@ -24,6 +24,8 @@ import {MdRipple} from '../../ripple/ripple.js';
2424

2525
import {SingleSelectionController} from './single-selection-controller.js';
2626

27+
const CHECKED = Symbol('checked');
28+
2729
/**
2830
* @fires checked
2931
* @soyCompatible
@@ -35,45 +37,21 @@ export class Radio extends LitElement {
3537
static formAssociated = true;
3638

3739
@property({type: Boolean, reflect: true})
38-
get checked(): boolean {
39-
return this._checked;
40+
get checked() {
41+
return this[CHECKED];
4042
}
41-
42-
/**
43-
* We define our own getter/setter for `checked` because we need to track
44-
* changes to it synchronously.
45-
*
46-
* The order in which the `checked` property is set across radio buttons
47-
* within the same group is very important. However, we can't rely on
48-
* UpdatingElement's `updated` callback to observe these changes (which is
49-
* also what the `@observer` decorator uses), because it batches changes to
50-
* all properties.
51-
*
52-
* Consider:
53-
*
54-
* radio1.disabled = true;
55-
* radio2.checked = true;
56-
* radio1.checked = true;
57-
*
58-
* In this case we'd first see all changes for radio1, and then for radio2,
59-
* and we couldn't tell that radio1 was the most recently checked.
60-
*/
61-
set checked(isChecked: boolean) {
62-
const oldValue = this._checked;
63-
if (isChecked === oldValue) {
43+
set checked(checked: boolean) {
44+
const wasChecked = this.checked;
45+
if (wasChecked === checked) {
6446
return;
6547
}
66-
this._checked = isChecked;
67-
this.selectionController?.update(this);
68-
69-
this.requestUpdate('checked', oldValue);
7048

71-
// useful when unchecks self and wrapping element needs to synchronize
72-
// TODO(b/168543810): Remove triggering event on programmatic API call.
73-
this.dispatchEvent(new Event('checked', {bubbles: true, composed: true}));
49+
this[CHECKED] = checked;
50+
this.requestUpdate('checked', wasChecked);
51+
this.selectionController.handleCheckedChange();
7452
}
7553

76-
private _checked = false; // tslint:disable-line:enforce-name-casing
54+
[CHECKED] = false;
7755

7856
@property({type: Boolean}) disabled = false;
7957

@@ -94,12 +72,6 @@ export class Radio extends LitElement {
9472
*/
9573
@property({type: Boolean}) reducedTouchTarget = false;
9674

97-
/**
98-
* input's tabindex is updated based on checked status.
99-
* Tab navigation will be removed from unchecked radios.
100-
*/
101-
@property({type: Number}) formElementTabIndex = 0;
102-
10375
@ariaProperty // tslint:disable-line:no-new-decorators
10476
@property({attribute: 'data-aria-label', noAccessor: true})
10577
override ariaLabel!: string;
@@ -114,13 +86,14 @@ export class Radio extends LitElement {
11486
@state() private focused = false;
11587
@query('input') private readonly input!: HTMLInputElement|null;
11688
@queryAsync('md-ripple') private readonly ripple!: Promise<MdRipple|null>;
117-
private selectionController?: SingleSelectionController;
89+
private readonly selectionController = new SingleSelectionController(this);
11890
@state() private showFocusRing = false;
11991
@state() private showRipple = false;
12092

12193
constructor() {
12294
super();
12395
this.addController(new FormController(this));
96+
this.addController(this.selectionController);
12497
this.addEventListener('click', (event: Event) => {
12598
if (!isActivationClick(event)) {
12699
return;
@@ -138,38 +111,6 @@ export class Radio extends LitElement {
138111
this.input?.focus();
139112
}
140113

141-
override connectedCallback() {
142-
super.connectedCallback();
143-
// Note that we must defer creating the selection controller until the
144-
// element has connected, because selection controllers are keyed by the
145-
// radio's shadow root. For example, if we're stamping in a lit map
146-
// or repeat, then we'll be constructed before we're added to a root node.
147-
//
148-
// Also note if we aren't using native shadow DOM, we still need a
149-
// SelectionController, because we should update checked status of other
150-
// radios in the group when selection changes. It also simplifies
151-
// implementation and testing to use one in all cases.
152-
//
153-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
154-
this.selectionController = SingleSelectionController.getController(this);
155-
this.selectionController.register(this);
156-
157-
// Radios maybe checked before connected, update selection as soon it is
158-
// connected to DOM. Last checked radio button in the DOM will be selected.
159-
//
160-
// NOTE: If we update selection only after firstUpdate() we might mistakenly
161-
// update checked status before other radios are rendered.
162-
this.selectionController.update(this);
163-
}
164-
165-
override disconnectedCallback() {
166-
// The controller is initialized in connectedCallback, so if we are in
167-
// disconnectedCallback then it must be initialized.
168-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
169-
this.selectionController!.unregister(this);
170-
this.selectionController = undefined;
171-
}
172-
173114
override updated(changedProperties: PropertyValues) {
174115
if (changedProperties.has('checked') && this.input) {
175116
this.input.checked = this.checked;
@@ -198,7 +139,6 @@ export class Radio extends LitElement {
198139
<div class="md3-radio ${classMap(classes)}">
199140
${this.renderFocusRing()}
200141
<input
201-
tabindex="${this.formElementTabIndex}"
202142
class="md3-radio__native-control"
203143
type="radio"
204144
name="${this.name}"
@@ -232,17 +172,14 @@ export class Radio extends LitElement {
232172
this.showFocusRing = shouldShowStrongFocus();
233173
}
234174

235-
private handleChange() {
175+
private handleChange(event: Event) {
236176
if (this.disabled) {
237177
return;
238178
}
239179

240180
// Per spec, the change event on a radio input always represents checked.
241181
this.checked = true;
242-
this.dispatchEvent(new Event('change', {
243-
bubbles: true,
244-
composed: true,
245-
}));
182+
redispatchEvent(this, event);
246183
}
247184

248185
private handlePointerDown(event: PointerEvent) {

0 commit comments

Comments
 (0)