Skip to content

Commit

Permalink
feat(radio): add required constraint validation
Browse files Browse the repository at this point in the history
Fixes #4316

PiperOrigin-RevId: 585713253
  • Loading branch information
asyncLiz authored and copybara-github committed Nov 27, 2023
1 parent bedcd65 commit 4358930
Show file tree
Hide file tree
Showing 10 changed files with 356 additions and 196 deletions.
8 changes: 2 additions & 6 deletions labs/behaviors/validators/checkbox-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -46,8 +46,4 @@ export class CheckboxValidator extends Validator<CheckboxState> {
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};
}
}
97 changes: 97 additions & 0 deletions labs/behaviors/validators/radio-validator.ts
Original file line number Diff line number Diff line change
@@ -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
* `<input type="radio">` validation.
*/
export class RadioValidator extends Validator<RadioGroupState> {
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 <input>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;
}
}
77 changes: 77 additions & 0 deletions labs/behaviors/validators/radio-validator_test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
52 changes: 52 additions & 0 deletions labs/behaviors/validators/select-validator.ts
Original file line number Diff line number Diff line change
@@ -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 `<select>`
* validation.
*/
export class SelectValidator extends Validator<SelectState> {
private selectControl?: HTMLSelectElement;

protected override computeValidity(state: SelectState) {
if (!this.selectControl) {
// Lazily create the platform select
this.selectControl = document.createElement('select');
}

render(html`<option value=${state.value}></option>`, this.selectControl);

this.selectControl.value = state.value;
this.selectControl.required = state.required;
return {
validity: this.selectControl.validity,
validationMessage: this.selectControl.validationMessage,
};
}

protected override equals(prev: SelectState, next: SelectState) {
return prev.value === next.value && prev.required === next.required;
}
}
47 changes: 47 additions & 0 deletions labs/behaviors/validators/select-validator_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

// import 'jasmine'; (google3-only)

import {SelectValidator} from './select-validator.js';

describe('SelectValidator', () => {
it('is invalid when required and value is empty', () => {
const state = {
required: true,
value: '',
};

const validator = new SelectValidator(() => state);
const {validity, validationMessage} = validator.getValidity();
expect(validity.valueMissing).withContext('valueMissing').toBeTrue();
expect(validationMessage).withContext('validationMessage').not.toBe('');
});

it('is valid when required and value is provided', () => {
const state = {
required: true,
value: 'Foo',
};

const validator = new SelectValidator(() => state);
const {validity, validationMessage} = validator.getValidity();
expect(validity.valueMissing).withContext('valueMissing').toBeFalse();
expect(validationMessage).withContext('validationMessage').toBe('');
});

it('is valid when not required', () => {
const state = {
required: false,
value: '',
};

const validator = new SelectValidator(() => state);
const {validity, validationMessage} = validator.getValidity();
expect(validity.valueMissing).withContext('valueMissing').toBeFalse();
expect(validationMessage).withContext('validationMessage').toBe('');
});
});
4 changes: 3 additions & 1 deletion labs/behaviors/validators/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ export abstract class Validator<State> {
* @param state The state to copy.
* @return A copy of the state.
*/
protected abstract copy(state: State): State;
protected copy(state: State): State {
return {...state};
}
}

/**
Expand Down
4 changes: 0 additions & 4 deletions labs/behaviors/validators/validator_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ describe('Validator', () => {
equals(prev: CustomState, next: CustomState) {
return prev.value === next.value && prev.required === next.required;
}

copy({value, required}: CustomState) {
return {value, required};
}
}

describe('getValidity()', () => {
Expand Down
34 changes: 31 additions & 3 deletions radio/internal/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ import '../../focus/md-focus-ring.js';
import '../../ripple/ripple.js';

import {html, isServer, LitElement} from 'lit';
import {property} from 'lit/decorators.js';
import {property, query} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';

import {isActivationClick} from '../../internal/controller/events.js';
import {
createValidator,
getValidityAnchor,
mixinConstraintValidation,
} from '../../labs/behaviors/constraint-validation.js';
import {
internals,
mixinElementInternals,
Expand All @@ -22,15 +27,16 @@ import {
getFormValue,
mixinFormAssociated,
} from '../../labs/behaviors/form-associated.js';
import {RadioValidator} from '../../labs/behaviors/validators/radio-validator.js';

import {SingleSelectionController} from './single-selection-controller.js';

const CHECKED = Symbol('checked');
let maskId = 0;

// Separate variable needed for closure.
const radioBaseClass = mixinFormAssociated(
mixinElementInternals(mixinFocusable(LitElement)),
const radioBaseClass = mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(mixinFocusable(LitElement))),
);

/**
Expand Down Expand Up @@ -66,11 +72,17 @@ export class Radio extends radioBaseClass {

[CHECKED] = false;

/**
* Whether or not the radio is required.
*/
@property({type: Boolean}) required = false;

/**
* The element value to use in form submission when checked.
*/
@property() value = 'on';

@query('.container') private readonly container!: HTMLElement;
private readonly selectionController = new SingleSelectionController(this);

constructor() {
Expand Down Expand Up @@ -175,4 +187,20 @@ export class Radio extends radioBaseClass {
override formStateRestoreCallback(state: string) {
this.checked = state === 'true';
}

[createValidator]() {
return new RadioValidator(() => {
if (!this.selectionController) {
// Validation runs on superclass construction, so selection controller
// might not actually be ready until this class constructs.
return [this];
}

return this.selectionController.controls as [Radio, ...Radio[]];
});
}

[getValidityAnchor]() {
return this.container;
}
}
Loading

0 comments on commit 4358930

Please sign in to comment.