diff --git a/labs/behaviors/validators/checkbox-validator.ts b/labs/behaviors/validators/checkbox-validator.ts index c0f9a2d3712..aa63408f5f6 100644 --- a/labs/behaviors/validators/checkbox-validator.ts +++ b/labs/behaviors/validators/checkbox-validator.ts @@ -13,12 +13,12 @@ export interface CheckboxState { /** * Whether the checkbox is checked. */ - checked: boolean; + readonly checked: boolean; /** * Whether the checkbox is required. */ - required: boolean; + readonly required: boolean; } /** @@ -46,8 +46,4 @@ export class CheckboxValidator extends Validator { protected override equals(prev: CheckboxState, next: CheckboxState) { return prev.checked === next.checked && prev.required === next.required; } - - protected override copy({checked, required}: CheckboxState): CheckboxState { - return {checked, required}; - } } diff --git a/labs/behaviors/validators/radio-validator.ts b/labs/behaviors/validators/radio-validator.ts new file mode 100644 index 00000000000..b92144b76d9 --- /dev/null +++ b/labs/behaviors/validators/radio-validator.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Validator} from './validator.js'; + +/** + * Constraint validation properties for a radio. + */ +export interface RadioState { + /** + * Whether the radio is checked. + */ + readonly checked: boolean; + + /** + * Whether the radio is required. + */ + readonly required: boolean; +} + +/** + * Radio constraint validation properties for a single radio and its siblings. + */ +export type RadioGroupState = readonly [RadioState, ...RadioState[]]; + +/** + * A validator that provides constraint validation that emulates + * `` validation. + */ +export class RadioValidator extends Validator { + private radioElement?: HTMLInputElement; + + protected override computeValidity(states: RadioGroupState) { + if (!this.radioElement) { + // Lazily create the radio element + this.radioElement = document.createElement('input'); + this.radioElement.type = 'radio'; + this.radioElement.name = 'a'; // A name is required for validation to run + } + + let isRequired = false; + let hasCheckedSibling = false; + for (const {checked, required} of states) { + if (required) { + isRequired = true; + } + + if (checked) { + hasCheckedSibling = true; + } + } + + // Firefox v119 doesn't compute grouped radio validation correctly while + // they are detached from the DOM, which is why we don't render multiple + // virtual s. Instead, we can check the required/checked states and + // grab the i18n'd validation message if the value is missing. + this.radioElement.checked = hasCheckedSibling; + this.radioElement.required = isRequired; + return { + validity: { + valueMissing: isRequired && !hasCheckedSibling, + }, + validationMessage: this.radioElement.validationMessage, + }; + } + + protected override equals( + prevGroup: RadioGroupState, + nextGroup: RadioGroupState, + ) { + if (prevGroup.length !== nextGroup.length) { + return false; + } + + for (let i = 0; i < prevGroup.length; i++) { + const prev = prevGroup[i]; + const next = nextGroup[i]; + if (prev.checked !== next.checked || prev.required !== next.required) { + return false; + } + } + + return true; + } + + protected override copy(states: RadioGroupState): RadioGroupState { + // Cast as unknown since typescript does not have enough information to + // infer that the array always has at least one element. + return states.map(({checked, required}) => ({ + checked, + required, + })) as unknown as RadioGroupState; + } +} diff --git a/labs/behaviors/validators/radio-validator_test.ts b/labs/behaviors/validators/radio-validator_test.ts new file mode 100644 index 00000000000..ddec3503694 --- /dev/null +++ b/labs/behaviors/validators/radio-validator_test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {RadioValidator} from './radio-validator.js'; + +describe('RadioValidator', () => { + it('is invalid when required and no radios are checked', () => { + const states = [ + { + required: true, + checked: false, + }, + { + required: true, + checked: false, + }, + { + required: true, + checked: false, + }, + ] as const; + + const validator = new RadioValidator(() => states); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeTrue(); + expect(validationMessage).withContext('validationMessage').not.toBe(''); + }); + + it('is valid when required and any radio is checked', () => { + const states = [ + { + required: true, + checked: false, + }, + { + required: true, + checked: true, + }, + { + required: true, + checked: false, + }, + ] as const; + + const validator = new RadioValidator(() => states); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeFalse(); + expect(validationMessage).withContext('validationMessage').toBe(''); + }); + + it('is valid when not required', () => { + const states = [ + { + required: false, + checked: false, + }, + { + required: false, + checked: false, + }, + { + required: false, + checked: false, + }, + ] as const; + + const validator = new RadioValidator(() => states); + const {validity, validationMessage} = validator.getValidity(); + expect(validity.valueMissing).withContext('valueMissing').toBeFalse(); + expect(validationMessage).withContext('validationMessage').toBe(''); + }); +}); diff --git a/labs/behaviors/validators/select-validator.ts b/labs/behaviors/validators/select-validator.ts new file mode 100644 index 00000000000..9dcd524c242 --- /dev/null +++ b/labs/behaviors/validators/select-validator.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {html, render} from 'lit'; + +import {Validator} from './validator.js'; + +/** + * Constraint validation properties for a select dropdown. + */ +export interface SelectState { + /** + * The current selected value. + */ + readonly value: string; + + /** + * Whether the select is required. + */ + readonly required: boolean; +} + +/** + * A validator that provides constraint validation that emulates `) { @@ -402,12 +307,6 @@ export abstract class Select extends selectBaseClass { `; } - protected override updated(changed: PropertyValues) { await this.menu?.updateComplete; // If this has been handled on update already due to SSR, try again. @@ -666,7 +565,6 @@ export abstract class Select extends selectBaseClass { this.displayText = ''; } - this.syncValidity(); return hasSelectedOptionChanged; } @@ -830,49 +728,6 @@ export abstract class Select extends selectBaseClass { 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, - this.field ?? undefined, - ); - } - - // Returns the platform `