diff --git a/select/internal/select.ts b/select/internal/select.ts index 2a22a2817e..7692e6e592 100644 --- a/select/internal/select.ts +++ b/select/internal/select.ts @@ -11,6 +11,7 @@ import {property, query, queryAssignedElements, state} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {html as staticHtml, StaticValue} from 'lit/static-html.js'; +import {Field} from '../../field/internal/field.js'; import {redispatchEvent} from '../../internal/controller/events.js'; import {List} from '../../list/internal/list.js'; import {DEFAULT_TYPEAHEAD_BUFFER_TIME, Menu} from '../../menu/internal/menu.js'; @@ -34,6 +35,9 @@ const VALUE = Symbol('value'); * closed. */ export abstract class Select extends LitElement { + /** @nocollapse */ + static readonly formAssociated = true; + /** * Opens the menu synchronously with no animation. */ @@ -51,8 +55,8 @@ export abstract class Select extends LitElement { * `errorText` is an empty string, then the supporting text will continue to * show. * - * Calling `reportValidity()` will automatically update `errorText` to the - * native `validationMessage`. + * This error message overrides the error message displayed by + * `reportValidity()`. */ @property({type: String, attribute: 'error-text'}) errorText = ''; /** @@ -60,14 +64,15 @@ export abstract class Select extends LitElement { */ @property() label = ''; /** - * Conveys additional information below the text field, such as how it should + * Conveys additional information below the select, such as how it should * be used. */ @property({type: String, attribute: 'supporting-text'}) supportingText = ''; /** - * Gets or sets whether or not the text field is in a visually invalid state. + * Gets or sets whether or not the select is in a visually invalid state. * - * Calling `reportValidity()` will automatically update `error`. + * This error state overrides the error state controlled by + * `reportValidity()`. */ @property({type: Boolean, reflect: true}) error = false; /** @@ -142,6 +147,63 @@ export abstract class Select extends LitElement { return (this.getSelectedOptions() ?? []).map(([option]) => option); } + /** + * The HTML name to use in form submission. + */ + get name() { + return this.getAttribute('name') ?? ''; + } + set name(name: string) { + this.setAttribute('name', name); + } + + /** + * The associated form element with which this element's value will submit. + */ + get form() { + return this.internals.form; + } + + /** + * The labels this element is associated with. + */ + get labels() { + return this.internals.labels; + } + + /** + * Returns a ValidityState object that represents the validity states of the + * checkbox. + * + * Note that selects will only set `valueMissing` if unselected and + * `required`. + */ + get validity() { + this.syncValidity(); + return this.internals.validity; + } + + /** + * Returns the native validation error message. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process + */ + get validationMessage() { + this.syncValidity(); + return this.internals.validationMessage; + } + + /** + * Returns whether an element will successfully validate based on forms + * validation rules and constraints. + * + * https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#constraint_validation_process + */ + get willValidate() { + this.syncValidity(); + return this.internals.willValidate; + } + protected abstract readonly fieldTag: StaticValue; /** @@ -163,12 +225,30 @@ export abstract class Select extends LitElement { // tslint:disable-next-line:enforce-name-casing private lastSelectedOptionRecords: SelectOptionRecord[] = []; + /** + * Whether or not a native error has been reported via `reportValidity()`. + */ + @state() private nativeError = false; + + /** + * The validation message displayed from a native error via + * `reportValidity()`. + */ + @state() private nativeErrorText = ''; + private get hasError() { + return this.error || this.nativeError; + } + @state() private focused = false; @state() private open = false; + @query('.field') private readonly field!: Field|null; @query('md-menu') private readonly menu!: Menu|null; @query('#label') private readonly labelEl!: HTMLElement; @queryAssignedElements({slot: 'leading-icon', flatten: true}) private readonly leadingIcons!: Element[]; + private customValidationMessage = ''; + private readonly internals = + (this as HTMLElement /* needed for closure */).attachInternals(); /** * Selects an option given the value of the option, and updates MdSelect's @@ -192,6 +272,88 @@ export abstract class Select extends LitElement { } } + /** + * Reset the select to its default value. + */ + reset() { + for (const option of this.options) { + option.selected = option.hasAttribute('selected'); + } + + this.updateValueAndDisplayText(); + this.nativeError = false; + this.nativeErrorText = ''; + } + + /** + * Checks the select's native validation and returns whether or not the + * element is valid. + * + * If invalid, this method will dispatch the `invalid` event. + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement/checkValidity + * + * @return true if the select is valid, or false if not. + */ + checkValidity() { + this.syncValidity(); + return this.internals.checkValidity(); + } + + /** + * Checks the select's native validation and returns whether or not the + * element is valid. + * + * If invalid, this method will dispatch the `invalid` event. + * + * This method will display or clear an error text message equal to the + * select's `validationMessage`, unless the invalid event is canceled. + * + * Use `setCustomValidity()` to customize the `validationMessage`. + * + * This method can also be used to re-announce error messages to screen + * readers. + * + * @return true if the select is valid, or false if not. + */ + reportValidity() { + let invalidEvent: Event|undefined; + this.addEventListener('invalid', event => { + invalidEvent = event; + }, {once: true}); + + const valid = this.checkValidity(); + if (invalidEvent?.defaultPrevented) { + return valid; + } + + const prevMessage = this.getErrorText(); + this.nativeError = !valid; + this.nativeErrorText = this.validationMessage; + + if (prevMessage === this.getErrorText()) { + this.field?.reannounceError(); + } + + return valid; + } + + /** + * Sets the select's native validation error message. This is used to + * customize `validationMessage`. + * + * When the error is not an empty string, the select is considered invalid + * and `validity.customError` will be true. + * + * https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement/setCustomValidity + * + * @param error The error message to display. + */ + setCustomValidity(error: string) { + this.customValidationMessage = error; + this.syncValidity(); + } + protected override update(changed: PropertyValues) { + if (changed.has('required')) { + this.syncValidity(); + } + } + protected override async firstUpdated(changed: PropertyValues` validation message for i18n. + private getRequiredValidationMessage() { + const select = document.createElement('select'); + select.required = true; + return select.validationMessage; + } + + /** @private */ + formResetCallback() { + this.reset(); + } + + /** @private */ + formStateRestoreCallback(state: string) { + this.value = state; + } } diff --git a/select/select_test.ts b/select/select_test.ts index e6537ca89b..685ff564c5 100644 --- a/select/select_test.ts +++ b/select/select_test.ts @@ -8,6 +8,7 @@ import {html, render} from 'lit'; +import {createFormTests} from '../testing/forms.js'; import {createTokenTests} from '../testing/tokens.js'; import {MdFilledSelect} from './filled-select.js'; @@ -49,6 +50,132 @@ describe('', () => { expect(changed).toBeTrue(); }); + + describe('forms', () => { + createFormTests({ + queryControl: root => root.querySelector('md-outlined-select'), + valueTests: [ + { + name: 'unnamed', + render: () => html` + + + + + `, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form without a name') + .toHaveSize(0); + } + }, + { + name: 'unselected', + render: () => html` + + + + + `, + assertValue(formData) { + expect(formData.get('select')).toBe(''); + } + }, + { + name: 'selected', + render: () => html` + + + + + `, + assertValue(formData) { + expect(formData.get('select')).toBe('two'); + } + }, + { + name: 'disabled', + render: () => html` + + + + + `, + assertValue(formData) { + expect(formData) + .withContext('should not add anything to form when disabled') + .toHaveSize(0); + } + } + ], + resetTests: [ + { + name: 'reset to unselected', + render: () => html` + + + + + `, + change(select) { + select.value = 'one'; + }, + assertReset(select) { + expect(select.value) + .withContext('select.value after reset') + .toBe(''); + } + }, + { + name: 'reset to selected', + render: () => html` + + + + + `, + change(select) { + select.value = 'one'; + }, + assertReset(select) { + expect(select.value) + .withContext('select.value after reset') + .toBe('two'); + } + }, + ], + restoreTests: [ + { + name: 'restore unselected', + render: () => html` + + + + + `, + assertRestored(select) { + expect(select.value) + .withContext('select.value after restore') + .toBe(''); + } + }, + { + name: 'restore selected', + render: () => html` + + + + + `, + assertRestored(select) { + expect(select.value) + .withContext('select.value after restore') + .toBe('two'); + } + }, + ] + }); + }); }); describe('', () => { diff --git a/testing/forms.ts b/testing/forms.ts index c5df1584d8..50bb1f52e9 100644 --- a/testing/forms.ts +++ b/testing/forms.ts @@ -221,6 +221,8 @@ export function createFormTests( const newControl = document.createElement(control.tagName) as ExpectedFormAssociatedElement; + // Include any children for controls like `