feat(select): add required and form association

Fixes #4903

PiperOrigin-RevId: 565260839
This commit is contained in:
Elizabeth Mitchell 2023-09-13 22:44:18 -07:00 committed by Copybara-Service
parent df52d92724
commit 4ad2336b87
4 changed files with 341 additions and 11 deletions

View File

@ -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<Select>) {
// In SSR the options will be ready to query, so try to figure out what
// the value and display text should be.
@ -213,6 +375,12 @@ export abstract class Select extends LitElement {
`;
}
protected override updated(changed: PropertyValues<Select>) {
if (changed.has('required')) {
this.syncValidity();
}
}
protected override async firstUpdated(changed: PropertyValues<Select>) {
await this.menu?.updateComplete;
// If this has been handled on update already due to SSR, try again.
@ -247,12 +415,12 @@ export abstract class Select extends LitElement {
.populated=${!!this.displayText}
.disabled=${this.disabled}
.required=${this.required}
.error=${this.error}
.error=${this.hasError}
?has-start=${this.hasLeadingIcon}
has-end
supporting-text=${this.supportingText}
error-text=${this.errorText}
@keydown =${this.handleKeydown}
error-text=${this.getErrorText()}
@keydown=${this.handleKeydown}
@click=${this.handleClick}
@focus=${this.handleFocus}
@blur=${this.handleBlur}>
@ -444,6 +612,8 @@ export abstract class Select extends LitElement {
this.displayText = '';
}
this.internals.setFormValue(this.value);
this.syncValidity();
return hasSelectedOptionChanged;
}
@ -590,4 +760,34 @@ export abstract class Select extends LitElement {
this.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
this.dispatchEvent(new Event('change', {bubbles: true}));
}
private getErrorText() {
return this.error ? this.errorText : this.nativeErrorText;
}
private syncValidity() {
const valueMissing = this.required && !this.value;
const customError = !!this.customValidationMessage;
const validationMessage = this.customValidationMessage ||
valueMissing && this.getRequiredValidationMessage() || '';
this.internals.setValidity({valueMissing, customError}, validationMessage);
}
// Returns the platform `<select>` 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;
}
}

View File

@ -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('<md-outlined-select>', () => {
expect(changed).toBeTrue();
});
describe('forms', () => {
createFormTests({
queryControl: root => root.querySelector('md-outlined-select'),
valueTests: [
{
name: 'unnamed',
render: () => html`
<md-outlined-select>
<md-select-option value="one"></md-select-option>
<md-select-option value="two" selected></md-select-option>
</md-outlined-select>
`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form without a name')
.toHaveSize(0);
}
},
{
name: 'unselected',
render: () => html`
<md-outlined-select name="select">
<md-select-option value="one"></md-select-option>
<md-select-option value="two"></md-select-option>
</md-outlined-select>
`,
assertValue(formData) {
expect(formData.get('select')).toBe('');
}
},
{
name: 'selected',
render: () => html`
<md-outlined-select name="select">
<md-select-option value="one"></md-select-option>
<md-select-option value="two" selected></md-select-option>
</md-outlined-select>
`,
assertValue(formData) {
expect(formData.get('select')).toBe('two');
}
},
{
name: 'disabled',
render: () => html`
<md-outlined-select name="select" disabled>
<md-select-option value="one"></md-select-option>
<md-select-option value="two" selected></md-select-option>
</md-outlined-select>
`,
assertValue(formData) {
expect(formData)
.withContext('should not add anything to form when disabled')
.toHaveSize(0);
}
}
],
resetTests: [
{
name: 'reset to unselected',
render: () => html`
<md-outlined-select name="select">
<md-select-option value="one"></md-select-option>
<md-select-option value="two"></md-select-option>
</md-outlined-select>
`,
change(select) {
select.value = 'one';
},
assertReset(select) {
expect(select.value)
.withContext('select.value after reset')
.toBe('');
}
},
{
name: 'reset to selected',
render: () => html`
<md-outlined-select name="select">
<md-select-option value="one"></md-select-option>
<md-select-option value="two" selected></md-select-option>
</md-outlined-select>
`,
change(select) {
select.value = 'one';
},
assertReset(select) {
expect(select.value)
.withContext('select.value after reset')
.toBe('two');
}
},
],
restoreTests: [
{
name: 'restore unselected',
render: () => html`
<md-outlined-select name="select">
<md-select-option value="one"></md-select-option>
<md-select-option value="two"></md-select-option>
</md-outlined-select>
`,
assertRestored(select) {
expect(select.value)
.withContext('select.value after restore')
.toBe('');
}
},
{
name: 'restore selected',
render: () => html`
<md-outlined-select name="select">
<md-select-option value="one"></md-select-option>
<md-select-option value="two" selected></md-select-option>
</md-outlined-select>
`,
assertRestored(select) {
expect(select.value)
.withContext('select.value after restore')
.toBe('two');
}
},
]
});
});
});
describe('<md-filled-select>', () => {

View File

@ -221,6 +221,8 @@ export function createFormTests<T extends HTMLElement>(
const newControl = document.createElement(control.tagName) as
ExpectedFormAssociatedElement;
// Include any children for controls like `<select>`
newControl.append(...control.children);
control.remove();
form.appendChild(newControl);
let restoreState: FormState|null|FormData = state ?? value;

View File

@ -54,7 +54,8 @@ export abstract class TextField extends LitElement {
/**
* Gets or sets whether or not the text field 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;
/**
@ -62,8 +63,8 @@ export abstract class TextField 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({attribute: 'error-text'}) errorText = '';
@property() label = '';