Skip to content

Commit

Permalink
Fix mwc-radio group synchronization when not created/connected simult…
Browse files Browse the repository at this point in the history
…aneously

Selection groups are keyed by root (document or shadow root). Before, we created our selection group controller at construction. This is fine when creating and connecting at the same time (e.g. in HTML, or in a simple lit template), but fails otherwise, because we get the wrong root. Now we defer creating our controller until connectedCallback, so that we always get the correct root.

Fixes #282

Supersedes #279

(Also small fix to mwc-textarea.test.ts to avoid use of any)

PiperOrigin-RevId: 274220064
  • Loading branch information
aomarks authored and Copybara-Service committed Oct 11, 2019
1 parent 8f2dc36 commit f583e61
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 15 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Expand Up @@ -29,6 +29,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed

- Fixed `mwc-dialog`'s issues with working on older browsers.
- `<mwc-radio>` groups are now correctly synchronized when stamped using a
lit-html `map` or `repeat`, and any other time the radio is not created and
connected at the same time ([#282](https://github.com/material-components/material-components-web-components/issues/282)).

## [0.9.0] - 2019-09-26

Expand Down
44 changes: 31 additions & 13 deletions packages/radio/src/mwc-radio-base.ts
Expand Up @@ -62,7 +62,9 @@ export class RadioBase extends FormElement {
if (this.formElement) {
this.formElement.checked = checked;
}
this._selectionController.update(this);
if (this._selectionController !== undefined) {
this._selectionController.update(this);
}
this.requestUpdate('checked', oldValue);
}

Expand All @@ -84,23 +86,35 @@ export class RadioBase extends FormElement {

protected mdcFoundation!: MDCRadioFoundation;

/* eslint-disable @typescript-eslint/no-use-before-define */

// Note if we aren't using native shadow DOM, then we don't technically need a
// SelectionController, because our inputs will share document-scoped native
// selection groups. However, it simplifies implementation and testing to use
// one in all cases. In particular, it means we correctly manage groups before
// the first update stamps the native input.
private _selectionController = SelectionController.getController(this);
/* eslint-enable @typescript-eslint/no-use-before-define */
private _selectionController?: SelectionController;

connectedCallback() {
super.connectedCallback();
// Note that we must defer creating the selection controller until the
// element has connected, because selection controllers are keyed by the
// radio's shadow root. For example, if we're stamping in a lit-html map
// or repeat, then we'll be constructed before we're added to a root node.
//
// Also note if we aren't using native shadow DOM, then we don't technically
// need a SelectionController, because our inputs will share document-scoped
// native selection groups. However, it simplifies implementation and
// testing to use one in all cases. In particular, it means we correctly
// manage groups before the first update stamps the native input.
//
// eslint-disable-next-line @typescript-eslint/no-use-before-define
this._selectionController = SelectionController.getController(this);
this._selectionController.register(this);
// With native <input type="radio">, when a checked radio is added to the
// root, then it wins. Immediately update to emulate this behavior.
this._selectionController.update(this);
}

disconnectedCallback() {
this._selectionController.unregister(this);
// The controller is initialized in connectedCallback, so if we are in
// disconnectedCallback then it must be initialized.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this._selectionController!.unregister(this);
this._selectionController = undefined;
}

focusNative() {
Expand All @@ -125,7 +139,9 @@ export class RadioBase extends FormElement {
}

private _focusHandler() {
this._selectionController.focus(this);
if (this._selectionController !== undefined) {
this._selectionController.focus(this);
}
}

private _clickHandler() {
Expand Down Expand Up @@ -158,7 +174,9 @@ export class RadioBase extends FormElement {
// We might not have been able to synchronize this from the checked setter
// earlier, if checked was set before the input was stamped.
this.formElement.checked = this.checked;
this._selectionController.update(this);
if (this._selectionController !== undefined) {
this._selectionController.update(this);
}
}
}

Expand Down
73 changes: 73 additions & 0 deletions packages/radio/src/test/mwc-radio.test.ts
Expand Up @@ -139,5 +139,78 @@ suite('mwc-radio', () => {
assert.isFalse(a2.checked);
assert.isFalse(b1.checked);
});

test('when checked before connected', () => {
const r1 = document.createElement('mwc-radio');
r1.name = 'a';
const r2 = document.createElement('mwc-radio');
r2.name = 'a';
const r3 = document.createElement('mwc-radio');
r3.name = 'a';

// r1 and r2 should both be checked, because even though they have the
// same name, they aren't yet connected to a root. Groups are scoped to
// roots, and we can't know which root a radio belongs to until it is
// connected to one. This matches native <input type="radio"> behavior.
r1.checked = true;
r2.checked = true;
assert.isTrue(r1.checked);
assert.isTrue(r2.checked);
assert.isFalse(r3.checked);

// Connecting r1 shouldn't change anything, since it's the only one in the
// group.
container.appendChild(r1);
assert.isTrue(r1.checked);
assert.isTrue(r2.checked);
assert.isFalse(r3.checked);

// Appending r2 should disable r1, because when a new checked radio is
// connected, it wins (this matches native input behavior).
container.appendChild(r2);
assert.isFalse(r1.checked);
assert.isTrue(r2.checked);
assert.isFalse(r3.checked);

// Appending r3 shouldn't change anything, because it's not checked.
container.appendChild(r3);
assert.isFalse(r1.checked);
assert.isTrue(r2.checked);
assert.isFalse(r3.checked);

// Checking r3 should uncheck r2 because it's now in the same group.
r3.checked = true;
assert.isFalse(r1.checked);
assert.isFalse(r2.checked);
assert.isTrue(r3.checked);
});

test('in a lit repeat', () => {
const values = ['a1', 'a2'];
render(
html`${
values.map(
(value) =>
html`<mwc-radio value=${value} name="a"></mwc-radio>`)}`,
container);
const [a1, a2] = container.querySelectorAll('mwc-radio');

assert.isFalse(a1.checked);
assert.isFalse(a2.checked);
assert.equal(a1.value, values[0]);
assert.equal(a2.value, values[1]);

a1.checked = true;
assert.isTrue(a1.checked);
assert.isFalse(a2.checked);

a2.checked = true;
assert.isFalse(a1.checked);
assert.isTrue(a2.checked);

a2.checked = false;
assert.isFalse(a1.checked);
assert.isFalse(a2.checked);
});
});
});
8 changes: 6 additions & 2 deletions packages/textarea/src/test/mwc-textarea.test.ts
Expand Up @@ -20,6 +20,9 @@ import {html} from 'lit-html';

import {fixture, TestFixture} from '../../../../test/src/util/helpers';

interface TextareaInternals {
createFoundation: () => void;
}

const basic = html`
<mwc-textarea></mwc-textarea>
Expand Down Expand Up @@ -65,13 +68,14 @@ suite('mwc-textarea:', () => {
'createFoundation called an appropriate amount of times & render interactions',
async () => {
const element = fixt.root.querySelector('mwc-textarea')!;
const internals = element as unknown as TextareaInternals;
element.helperPersistent = true;

const oldCreateFoundation =
(element as any).createFoundation.bind(element) as () => void;
internals.createFoundation.bind(element) as () => void;
let numTimesCreateFoundationCalled = 0;

((element as any).createFoundation as () => void) = () => {
internals.createFoundation = () => {
numTimesCreateFoundationCalled = numTimesCreateFoundationCalled + 1;
oldCreateFoundation();
};
Expand Down

0 comments on commit f583e61

Please sign in to comment.