From 97ef53d8a9583ec9b5161fe4165b43d667303631 Mon Sep 17 00:00:00 2001 From: Elliott Marquez Date: Fri, 28 Feb 2020 16:14:53 -0800 Subject: [PATCH] fix(select): Fix mwc-select for IE and add tests PiperOrigin-RevId: 297958805 --- CHANGELOG.md | 7 + packages/list/src/mwc-check-list-item-base.ts | 2 +- packages/list/src/mwc-list-base.ts | 19 +- packages/list/src/mwc-list-item-base.ts | 4 +- packages/list/src/mwc-radio-list-item-base.ts | 2 +- packages/select/src/mwc-select-base.ts | 8 +- packages/select/src/test/mwc-select.test.ts | 537 ++++++++++++++++++ 7 files changed, 568 insertions(+), 11 deletions(-) create mode 100644 packages/select/src/test/mwc-select.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c825872b..332414578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - CSS custom properties for typography - Added `autoValidate` property on textfield - `mwc-button` now has a slot for `icon` and `trailingIcon` +- **BREAKING** setting `mwc-list-item.selected` will update selection in the parent list ### Changed @@ -22,12 +23,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - **BREAKING:VISUAL** `mwc-tab`'s default slot now has name `icon` - `mdcFoundation` and `mdcFoundationClass` are now optional in BaseElement. - Remove `export *` from BaseElement and FormElement. +- **BREAKING:A11Y** mwc-list will no longer update items on slotchange but on first render and on list item connect meaning list dividers will only add role="separator" in those cases ### Fixed - Setting `scrollTarget` on `mwc-top-app-bar` will update listeners - Fixed sass imports of `_index.scss` files - Fixed issue with caret jumping to end of input on textfield +- mwc-list-item now works on IE +- mwc-select's `updateComplete` will now properly await child custom elements' `updateComplete`s +- **BREAKING** Disabled icon buttons no longer have pointer events +- `mwc-textfield` will not set `value` on the internal input tag on `input` event causing caret jumping in Safari +- `mwc-select`'s `--mdc-select-ink-color` actually does something now ## [0.13.0] - 2020-02-03 diff --git a/packages/list/src/mwc-check-list-item-base.ts b/packages/list/src/mwc-check-list-item-base.ts index de1592a41..e335106ba 100644 --- a/packages/list/src/mwc-check-list-item-base.ts +++ b/packages/list/src/mwc-check-list-item-base.ts @@ -64,6 +64,6 @@ export class CheckListItemBase extends ListItemBase { super.connectedCallback(); this.addEventListener('click', this.boundOnClick); - this.toggleAttribute('mwc-list-item', true); + this.setAttribute('mwc-list-item', ''); } } diff --git a/packages/list/src/mwc-list-base.ts b/packages/list/src/mwc-list-base.ts index 9ea658155..23c7cbd6a 100644 --- a/packages/list/src/mwc-list-base.ts +++ b/packages/list/src/mwc-list-base.ts @@ -201,15 +201,24 @@ export abstract class ListBase extends BaseElement { @keydown=${this.onKeydown} @focusin=${this.onFocusIn} @focusout=${this.onFocusOut} - @request-selected=${this.onRequestSelected}> - - + @request-selected=${this.onRequestSelected} + @list-item-rendered=${this.onListItemConnected}> + `; } + firstUpdated() { + super.firstUpdated(); + + if (!this.items.length) { + // required because this is called before observers + this.mdcFoundation.setMulti(this.multi); + // for when children upgrade before list + this.layout(); + } + } + protected onFocusIn(evt: FocusEvent) { if (this.mdcFoundation && this.mdcRoot) { const index = this.getIndexOfTarget(evt); diff --git a/packages/list/src/mwc-list-item-base.ts b/packages/list/src/mwc-list-item-base.ts index d77ba8c62..f021fb0a2 100644 --- a/packages/list/src/mwc-list-item-base.ts +++ b/packages/list/src/mwc-list-item-base.ts @@ -58,7 +58,7 @@ export class ListItemBase extends LitElement { this.activated = false; this.tabIndex = -1; } else { - this.toggleAttribute('mwc-list-item', true); + this.setAttribute('mwc-list-item', ''); } }) noninteractive = false; @@ -151,7 +151,7 @@ export class ListItemBase extends LitElement { super.connectedCallback(); if (!this.noninteractive) { - this.toggleAttribute('mwc-list-item', true); + this.setAttribute('mwc-list-item', ''); } this.addEventListener('click', this.boundOnClick); } diff --git a/packages/list/src/mwc-radio-list-item-base.ts b/packages/list/src/mwc-radio-list-item-base.ts index aafaa6f20..c45e28f40 100644 --- a/packages/list/src/mwc-radio-list-item-base.ts +++ b/packages/list/src/mwc-radio-list-item-base.ts @@ -79,6 +79,6 @@ export class RadioListItemBase extends ListItemBase { super.connectedCallback(); this.addEventListener('click', this.boundOnClick); - this.toggleAttribute('mwc-list-item', true); + this.setAttribute('mwc-list-item', ''); } } diff --git a/packages/select/src/mwc-select-base.ts b/packages/select/src/mwc-select-base.ts index 315cc4212..f50dec753 100644 --- a/packages/select/src/mwc-select-base.ts +++ b/packages/select/src/mwc-select-base.ts @@ -290,7 +290,7 @@ export abstract class SelectBase extends FormElement { @selected=${this.onSelected} @opened=${this.onOpened} @closed=${this.onClosed}> - + `; } @@ -409,6 +409,10 @@ export abstract class SelectBase extends FormElement { } }, notifyChange: async (value) => { + if (value === this.value) { + return; + } + this.value = value; await this.updateComplete; const ev = new Event('change', {bubbles: true}); @@ -621,11 +625,11 @@ export abstract class SelectBase extends FormElement { } protected async _getUpdateComplete() { - await super._getUpdateComplete(); await Promise.all([ this._outlineUpdateComplete, this._menuUpdateComplete, ]); + await super._getUpdateComplete(); } protected async firstUpdated() { diff --git a/packages/select/src/test/mwc-select.test.ts b/packages/select/src/test/mwc-select.test.ts new file mode 100644 index 000000000..45e71444f --- /dev/null +++ b/packages/select/src/test/mwc-select.test.ts @@ -0,0 +1,537 @@ +/** + * @license + * Copyright 2019 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// taze: chai from //third_party/javascript/chai:closure_chai + +import '@material/mwc-list/mwc-list-item'; + +import {ListItem} from '@material/mwc-list/mwc-list-item'; +import {Select} from '@material/mwc-select'; +import {html} from 'lit-html'; + +import {fixture, TestFixture} from '../../../../test/src/util/helpers'; + +interface WithSelectedText { + selectedText: string; +} + +const basic = (outlined = false) => html` + + + Apple + Banana + Cucumber + +`; + +const validationRequired = (outlined = false) => html` + + + Apple + Banana + Cucumber + +`; + +const reqInitialVal = (outlined = false) => html` + + + Apple + Banana + Cucumber + +`; + +const itemsTemplate = html` + + Apple + Banana + Cucumber`; + +const lazy = (template = html``) => html` + + ${template} + +`; + +const isUiInvalid = (element: Select) => { + return !!element.shadowRoot!.querySelector('.mdc-select--invalid'); +}; + +suite('mwc-select:', () => { + let fixt: TestFixture; + + suite('basic', () => { + let element: Select; + setup(async () => { + fixt = await fixture(basic()); + + element = fixt.root.querySelector('mwc-select')!; + }); + + test('initializes as an mwc-select', () => { + assert.instanceOf(element, Select); + }); + + test('setting value sets on input', async () => { + element.value = 'my test value'; + + const inputElement = element.shadowRoot!.querySelector('input'); + assert(inputElement, 'my test value'); + }); + + teardown(() => { + if (fixt) { + fixt.remove(); + } + }); + }); + + suite('validation', () => { + suite('standard', () => { + test('required invalidates on blur', async () => { + fixt = await fixture(validationRequired()); + const element = fixt.root.querySelector('mwc-select')!; + + assert.isFalse(isUiInvalid(element), 'ui initially valid'); + element.focus(); + await element.updateComplete; + assert.isFalse(isUiInvalid(element), 'no validation on focus'); + element.blur(); + await element.updateComplete; + assert.isTrue(isUiInvalid(element), 'invalid after blur'); + }); + + test('validity & checkValidity do not trigger ui', async () => { + fixt = await fixture(validationRequired()); + const element = fixt.root.querySelector('mwc-select')!; + assert.isFalse(isUiInvalid(element), 'ui initially valid'); + + let invalidCalled = false; + element.addEventListener('invalid', () => invalidCalled = true); + + const validity = element.validity; + + assert.isTrue(validity.valueMissing, 'validation fails - required'); + assert.isFalse(validity.valid, 'element is invalid'); + assert.isFalse( + invalidCalled, 'invalid event not fired because of .validity'); + assert.isFalse( + isUiInvalid(element), 'ui is not invalid because of .validity'); + + const checkValidity = element.checkValidity(); + + assert.isFalse( + checkValidity, 'check validity returns false when invalid'); + assert.isTrue(invalidCalled, 'invalid event called'); + assert.isFalse(isUiInvalid(element), 'ui is invalid'); + }); + + test('setCustomValidity', async () => { + fixt = await fixture(basic()); + const element = fixt.root.querySelector('mwc-select')!; + + assert.isFalse(isUiInvalid(element), 'ui initially valid'); + assert.equal(element.validationMessage, ''); + + const validationMsgProp = 'set on prop'; + element.validationMessage = validationMsgProp; + assert.isFalse( + isUiInvalid(element), + 'ui not false due to setting validationMessage'); + assert.equal( + element.validationMessage, + validationMsgProp, + 'setting validationMessage works'); + + const validationMsgFn = 'set by setCustomValidity'; + element.setCustomValidity(validationMsgFn); + + assert.equal( + element.validationMessage, + validationMsgFn, + 'validationMessage prop is set with setCustomValidity'); + + const validity = element.validity; + assert.isTrue( + validity.customError, 'customError is reason for valitity failure'); + assert.isFalse(validity.valid, 'element is not valid'); + }); + + test('validity transform', async () => { + fixt = await fixture(basic()); + const element = fixt.root.querySelector('mwc-select')! as Select; + + assert.isTrue(element.checkValidity(), 'element is initially valid'); + + const transformFn = + (value: string, vState: ValidityState): Partial => { + if (value === 'a') { + return { + valid: true, + }; + } else if (vState.valid) { + const isOutOfRange = value === 'c'; + if (isOutOfRange) { + return { + valid: false, + rangeOverflow: true, + }; + } + } + + return {}; + }; + + element.validityTransform = transformFn; + + let validity = element.validity; + assert.isTrue(validity.valid, 'unhandled case is valid'); + assert.isTrue( + element.checkValidity(), + 'checkValidity is true for unhandled case'); + + element.select(1); + await element.updateComplete; + validity = element.validity; + assert.isTrue(validity.valid, 'explicitly handled value is true'); + assert.isTrue( + element.reportValidity(), + 'checkValidity is true for explicit case'); + + assert.isFalse( + isUiInvalid(element), 'reportValidity makes ui valid when valid'); + + element.select(3); + await element.updateComplete; + validity = element.validity; + + assert.isFalse(validity.valid, 'explicitly false case returns false'); + assert.isTrue( + validity.rangeOverflow, 'explicit reason for invalid set'); + assert.isFalse( + element.reportValidity(), + 'checkValidity is true for explicitly invalid case'); + + assert.isTrue( + isUiInvalid(element), + 'reportValidity makes ui invalid when invalid'); + + element.select(2); + await element.updateComplete; + validity = element.validity; + + assert.isTrue(validity.valid, 'validity can be set back to true'); + assert.isFalse( + validity.rangeOverflow, 'explicit reason for invalid unset'); + assert.isTrue( + element.reportValidity(), + 'checkValidity is set back true for valid case'); + + assert.isFalse(isUiInvalid(element), 'ui can be made valid again'); + }); + + test('initial validation', async () => { + fixt = await fixture(reqInitialVal()); + let element = fixt.root.querySelector('mwc-select')!; + await element.updateComplete; + assert.isTrue(isUiInvalid(element), 'initial render is invalid'); + + fixt.remove(); + + fixt = await fixture(validationRequired()); + element = fixt.root.querySelector('mwc-select')!; + await element.updateComplete; + assert.isFalse(isUiInvalid(element), 'without flag is valid'); + }); + + teardown(() => { + if (fixt) { + fixt.remove(); + } + }); + }); + + suite('outlined', () => { + test('required invalidates on blur', async () => { + fixt = await fixture(validationRequired(true)); + const element = fixt.root.querySelector('mwc-select')!; + + assert.isFalse(isUiInvalid(element), 'ui initially valid'); + element.focus(); + await element.updateComplete; + assert.isFalse(isUiInvalid(element), 'no validation on focus'); + element.blur(); + await element.updateComplete; + assert.isTrue(isUiInvalid(element), 'invalid after blur'); + }); + + test('validity & checkValidity do not trigger ui', async () => { + fixt = await fixture(validationRequired(true)); + const element = fixt.root.querySelector('mwc-select')!; + assert.isFalse(isUiInvalid(element), 'ui initially valid'); + + let invalidCalled = false; + element.addEventListener('invalid', () => invalidCalled = true); + + const validity = element.validity; + + assert.isTrue(validity.valueMissing, 'validation fails - required'); + assert.isFalse(validity.valid, 'element is invalid'); + assert.isFalse( + invalidCalled, 'invalid event not fired because of .validity'); + assert.isFalse( + isUiInvalid(element), 'ui is not invalid because of .validity'); + + const checkValidity = element.checkValidity(); + + assert.isFalse( + checkValidity, 'check validity returns false when invalid'); + assert.isTrue(invalidCalled, 'invalid event called'); + assert.isFalse(isUiInvalid(element), 'ui is invalid'); + }); + + test('setCustomValidity', async () => { + fixt = await fixture(basic(true)); + const element = fixt.root.querySelector('mwc-select')!; + await element.updateComplete; + + assert.isFalse(isUiInvalid(element)); + assert.equal(element.validationMessage, ''); + + const validationMsgProp = 'set on prop'; + element.validationMessage = validationMsgProp; + assert.isFalse(isUiInvalid(element)); + assert.equal(element.validationMessage, validationMsgProp); + + const validationMsgFn = 'set by setCustomValidity'; + element.setCustomValidity(validationMsgFn); + + assert.equal(element.validationMessage, validationMsgFn); + + const validity = element.validity; + assert.isTrue(validity.customError); + assert.isFalse(validity.valid); + }); + + test('validity transform', async () => { + fixt = await fixture(basic(true)); + const element = fixt.root.querySelector('mwc-select')! as Select; + + assert.isTrue(element.checkValidity(), 'element is initially valid'); + + const transformFn = + (value: string, vState: ValidityState): Partial => { + if (value === 'a') { + return { + valid: true, + }; + } else if (vState.valid) { + const isOutOfRange = value === 'c'; + if (isOutOfRange) { + return { + valid: false, + rangeOverflow: true, + }; + } + } + + return {}; + }; + + element.validityTransform = transformFn; + + let validity = element.validity; + assert.isTrue(validity.valid, 'unhandled case is valid'); + assert.isTrue( + element.checkValidity(), + 'checkValidity is true for unhandled case'); + + element.select(1); + await element.updateComplete; + validity = element.validity; + assert.isTrue(validity.valid, 'explicitly handled value is true'); + assert.isTrue( + element.reportValidity(), + 'checkValidity is true for explicit case'); + + assert.isFalse( + isUiInvalid(element), 'reportValidity makes ui valid when valid'); + + element.select(3); + await element.updateComplete; + validity = element.validity; + + assert.isFalse(validity.valid, 'explicitly false case returns false'); + assert.isTrue( + validity.rangeOverflow, 'explicit reason for invalid set'); + assert.isFalse( + element.reportValidity(), + 'checkValidity is true for explicitly invalid case'); + + assert.isTrue( + isUiInvalid(element), + 'reportValidity makes ui invalid when invalid'); + + element.select(2); + await element.updateComplete; + validity = element.validity; + + assert.isTrue(validity.valid, 'validity can be set back to true'); + assert.isFalse( + validity.rangeOverflow, 'explicit reason for invalid unset'); + assert.isTrue( + element.reportValidity(), + 'checkValidity is set back true for valid case'); + + assert.isFalse(isUiInvalid(element), 'ui can be made valid again'); + }); + + test('initial validation', async () => { + fixt = await fixture(reqInitialVal(true)); + let element = fixt.root.querySelector('mwc-select')!; + await element.updateComplete; + assert.isTrue(isUiInvalid(element), 'initial render is invalid'); + + fixt.remove(); + + fixt = await fixture(validationRequired(true)); + element = fixt.root.querySelector('mwc-select')!; + await element.updateComplete; + assert.isFalse(isUiInvalid(element), 'without flag is valid'); + }); + + teardown(() => { + if (fixt) { + fixt.remove(); + } + }); + }); + }); + + + suite('selection', () => { + let element: Select; + let changeCalls = 0; + const changeListener = () => { + changeCalls++; + }; + + setup(async () => { + fixt = await fixture(basic()); + + element = fixt.root.querySelector('mwc-select')!; + element.addEventListener('change', changeListener); + + await element.updateComplete; + }); + + test('selection via index', async () => { + assert.equal(changeCalls, 0, 'change evt not called on startup'); + assert.equal(element.value, '', 'initial value is blank'); + assert.equal( + (element as unknown as WithSelectedText).selectedText, + '', + 'selectedText is blank'); + assert.isTrue(!!element.selected, 'there is a selected element'); + + const firstElement = element.querySelector('mwc-list-item')!; + assert.isTrue(firstElement.selected, 'the element has selected prop'); + + element.select(1); + await element.updateComplete; + assert.equal(changeCalls, 1, 'change event called once on selection'); + changeCalls = 0; + + assert.equal(element.value, 'a', 'select method updates value'); + assert.isTrue( + (element as unknown as WithSelectedText).selectedText === 'Apple', + 'selectedText is updated'); + assert.isTrue( + !!element.selected, 'there is a selected element after select'); + + const aElement = element.querySelector('[value="a"]') as ListItem; + assert.isFalse(firstElement.selected, 'the previous has be deselected'); + assert.isTrue(aElement.selected, 'the element has selected prop'); + assert.isTrue( + aElement === element.selected, + 'element with selected prop is the same as selected on mwc-select'); + }); + + test('selection via element', async () => { + assert.equal(changeCalls, 0, 'change evt not called on startup'); + assert.equal(element.value, '', 'initial value is blank'); + assert.isTrue( + (element as unknown as WithSelectedText).selectedText === '', + 'selectedText is blank'); + assert.isTrue(!!element.selected, 'there is a selected element'); + + const firstElement = element.querySelector('mwc-list-item')!; + assert.isTrue(firstElement.selected, 'the element has selected prop'); + + const aElement = element.querySelector('[value="a"]') as ListItem; + aElement.selected = true; + await aElement.updateComplete; + await element.updateComplete; + assert.equal(changeCalls, 1, 'change event called once on selection'); + changeCalls = 0; + + assert.equal(element.value, 'a', 'select method updates value'); + assert.isTrue( + (element as unknown as WithSelectedText).selectedText === 'Apple', + 'selectedText is updated'); + assert.isTrue( + !!element.selected, 'there is a selected element after select'); + + assert.isFalse(firstElement.selected, 'the previous has be deselected'); + assert.isTrue(aElement.selected, 'the element has selected prop'); + assert.isTrue( + aElement === element.selected, + 'element with selected prop is the same as selected on mwc-select'); + }); + + test('lazy selection', async () => { + fixt.remove(); + fixt = await fixture(lazy()); + element = fixt.root.querySelector('mwc-select')!; + + assert.equal(element.index, -1, 'unselected index when no children'); + + fixt.template = lazy(itemsTemplate); + await fixt.updateComplete; + await element.updateComplete; + + assert.equal(element.index, 3, 'index updates when lazily slotted'); + assert.equal(element.value, 'c', 'value updates when lazily slotted'); + }); + + teardown(() => { + if (fixt) { + fixt.remove(); + } + + element.removeEventListener('change', changeListener); + changeCalls = 0; + }); + }); +});