From f11ed4e356dcee260c6c465382f2c8a2b4f27385 Mon Sep 17 00:00:00 2001 From: Alexandre Esteves Date: Fri, 11 Oct 2024 14:22:03 +0200 Subject: [PATCH] feat(radio): add validityState --- .../ods/src/components/radio/package.json | 4 +- .../src/components/ods-radio/ods-radio.scss | 4 + .../src/components/ods-radio/ods-radio.tsx | 53 ++++- .../radio/tests/rendering/ods-radio.e2e.ts | 41 ++++ .../radio/tests/validity/ods-radio.e2e.ts | 223 ++++++++++++++++++ packages/ods/src/style/_radio.scss | 14 +- .../stories/components/radio/radio.stories.ts | 72 +++++- .../radio/technical-information.mdx | 4 + 8 files changed, 399 insertions(+), 16 deletions(-) create mode 100644 packages/ods/src/components/radio/tests/validity/ods-radio.e2e.ts diff --git a/packages/ods/src/components/radio/package.json b/packages/ods/src/components/radio/package.json index 7b5f436fa7..90c7621cf9 100644 --- a/packages/ods/src/components/radio/package.json +++ b/packages/ods/src/components/radio/package.json @@ -13,7 +13,7 @@ "start": "stencil build --dev --watch --serve", "test:e2e": "stencil test --e2e --config stencil.config.ts --max-workers=2", "test:e2e:ci": "tsc --noEmit && stencil test --e2e --ci --runInBand --config stencil.config.ts", - "test:spec": "stencil test --spec --config stencil.config.ts --coverage", - "test:spec:ci": "tsc --noEmit && stencil test --config stencil.config.ts --spec --ci" + "test:spec": "stencil test --spec --config stencil.config.ts --coverage --passWithNoTests", + "test:spec:ci": "tsc --noEmit && stencil test --config stencil.config.ts --spec --ci --passWithNoTests" } } diff --git a/packages/ods/src/components/radio/src/components/ods-radio/ods-radio.scss b/packages/ods/src/components/radio/src/components/ods-radio/ods-radio.scss index 5f8e978b8c..0ede05b6f1 100644 --- a/packages/ods/src/components/radio/src/components/ods-radio/ods-radio.scss +++ b/packages/ods/src/components/radio/src/components/ods-radio/ods-radio.scss @@ -7,5 +7,9 @@ &__radio { @include radio.ods-radio(); + + &--error { + border-color: var(--ods-color-form-element-border-critical); + } } } diff --git a/packages/ods/src/components/radio/src/components/ods-radio/ods-radio.tsx b/packages/ods/src/components/radio/src/components/ods-radio/ods-radio.tsx index d9d3cacf1b..d96f259da7 100644 --- a/packages/ods/src/components/radio/src/components/ods-radio/ods-radio.tsx +++ b/packages/ods/src/components/radio/src/components/ods-radio/ods-radio.tsx @@ -1,4 +1,4 @@ -import { AttachInternals, Component, Element, Event, type EventEmitter, type FunctionalComponent, Host, Method, Prop, h } from '@stencil/core'; +import { Component, Element, Event, type EventEmitter, type FunctionalComponent, Host, Method, Prop, State, h } from '@stencil/core'; import { submitFormOnEnter } from '../../../../../utils/dom'; import { type OdsRadioChangeEventDetail } from '../../interfaces/events'; @@ -13,7 +13,7 @@ export class OdsRadio { @Element() el!: HTMLElement; - @AttachInternals() private internals!: ElementInternals; + @State() private isInvalid: boolean = false; @Prop({ reflect: true }) public ariaLabel: HTMLElement['ariaLabel'] = null; @Prop({ reflect: true }) public ariaLabelledby?: string; @@ -30,6 +30,12 @@ export class OdsRadio { @Event() odsFocus!: EventEmitter; @Event() odsReset!: EventEmitter; + @Method() + public async checkValidity(): Promise { + this.isInvalid = !this.inputEl?.validity.valid; + return this.inputEl?.checkValidity(); + } + @Method() public async clear(): Promise { if (this.inputEl) { @@ -45,6 +51,11 @@ export class OdsRadio { this.inputEl?.focus(); } + @Method() + public async getValidationMessage(): Promise { + return this.inputEl?.validationMessage; + } + @Method() public async getValidity(): Promise { return this.inputEl?.validity; @@ -72,11 +83,22 @@ export class OdsRadio { }); } + @Method() + public async reportValidity(): Promise { + this.isInvalid = !this.inputEl?.validity.valid; + return this.inputEl?.reportValidity(); + } + @Method() public async select(): Promise { this.inputEl?.click(); } + @Method() + public async willValidate(): Promise { + return this.inputEl?.willValidate; + } + async formResetCallback(): Promise { await this.reset(); } @@ -94,6 +116,11 @@ export class OdsRadio { return document.querySelectorAll(`ods-radio[name="${this.name}"]`); } + private onBlur(): void { + this.isInvalid = !this.inputEl?.validity.valid; + this.odsBlur.emit(); + } + private onInput(event: Event): void { this.emitChange({ checked: (event.target as HTMLInputElement)?.checked, @@ -103,21 +130,35 @@ export class OdsRadio { }); } + private onInvalidEvent(event: Event): void { + // Remove the native validation message popup + event.preventDefault(); + event.stopPropagation(); + + // Enforce the state here as we may still be in pristine state (if the form is submitted before any changes occurs) + this.isInvalid = true; + } + render(): FunctionalComponent { return ( - + => this.odsBlur.emit() } + onBlur={ (): void => this.onBlur() } onFocus={ (): CustomEvent => this.odsFocus.emit() } onInput={ (event: InputEvent): void => this.onInput(event) } - onKeyUp={ (event: KeyboardEvent): void => submitFormOnEnter(event, this.internals.form) } + onInvalid={ (e): void => this.onInvalidEvent(e) } + onKeyUp={ (event: KeyboardEvent): void => this.inputEl && submitFormOnEnter(event, this.inputEl.form) } ref={ (el): HTMLInputElement => this.inputEl = el as HTMLInputElement } required={ this.isRequired } type="radio" diff --git a/packages/ods/src/components/radio/tests/rendering/ods-radio.e2e.ts b/packages/ods/src/components/radio/tests/rendering/ods-radio.e2e.ts index daf10af7cb..add84bde9f 100644 --- a/packages/ods/src/components/radio/tests/rendering/ods-radio.e2e.ts +++ b/packages/ods/src/components/radio/tests/rendering/ods-radio.e2e.ts @@ -2,6 +2,7 @@ import type { E2EElement, E2EPage } from '@stencil/core/testing'; import { newE2EPage } from '@stencil/core/testing'; describe('ods-radio rendering', () => { + let el: E2EElement; let page: E2EPage; let inputRadio: E2EElement; @@ -15,6 +16,7 @@ describe('ods-radio rendering', () => { await page.addStyleTag({ content: customStyle }); } + el = await page.find('ods-radio'); inputRadio = await page.find('ods-radio > input[type="radio"]'); } @@ -25,4 +27,43 @@ describe('ods-radio rendering', () => { expect(inputRadioStyle.getPropertyValue('background-color')).toBe('rgb(255, 0, 0)'); }); }); + + describe('error state', () => { + it('should render in error on form submit, before any changes, if invalid', async() => { + await setup('
'); + + await page.evaluate(() => { + document.querySelector('form')?.requestSubmit(); + }); + await page.waitForChanges(); + + const hasErrorClass = await page.evaluate(() => { + return document.querySelector('ods-radio')?.querySelector('input')?.classList.contains('ods-radio__radio--error'); + }); + expect(hasErrorClass).toBe(true); + }); + + it('should toggle the error state on value change', async() => { + await setup('
'); + + await el.click(); + await page.waitForChanges(); + + const hasErrorClass = await page.evaluate(() => { + return document.querySelector('ods-radio')?.querySelector('input')?.classList.contains('ods-radio__radio--error'); + }); + expect(hasErrorClass).toBe(false); + + await el.callMethod('clear'); + await page.click('body', { offset: { x: 200, y: 200 } }); // Blur + await page.waitForChanges(); + + const hasErrorClass2 = await page.evaluate(() => { + return document.querySelector('ods-radio')?.querySelector('input')?.classList.contains('ods-radio__radio--error'); + }); + await page.waitForChanges(); + + expect(hasErrorClass2).toBe(true); + }); + }); }); diff --git a/packages/ods/src/components/radio/tests/validity/ods-radio.e2e.ts b/packages/ods/src/components/radio/tests/validity/ods-radio.e2e.ts new file mode 100644 index 0000000000..4b68b54735 --- /dev/null +++ b/packages/ods/src/components/radio/tests/validity/ods-radio.e2e.ts @@ -0,0 +1,223 @@ +import { type E2EElement, type E2EPage, newE2EPage } from '@stencil/core/testing'; +import { type OdsRadio } from '../../src'; + +describe('ods-radio validity', () => { + let el: E2EElement; + let page: E2EPage; + + async function setup(content: string): Promise { + page = await newE2EPage(); + + await page.setContent(content); + await page.evaluate(() => document.body.style.setProperty('margin', '0px')); + + el = await page.find('ods-radio'); + } + + beforeEach(jest.clearAllMocks); + + describe('initialization', () => { + describe('with no value attribute defined', () => { + it('should return validity true if not required', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should return validity false if required', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(false); + }); + }); + + describe('with empty string value', () => { + it('should return validity true if not required', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should return validity false if required', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(false); + }); + }); + + describe('with defined value', () => { + it('should return validity true if not required', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should return validity true if required and checked', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(true); + }); + }); + }); + + describe('methods', () => { + describe('checkValidity', () => { + it('should return true if internals validity is true', async() => { + await setup(''); + expect(await el.callMethod('checkValidity')).toBe(true); + + await setup(''); + expect(await el.callMethod('checkValidity')).toBe(true); + }); + + it('should return false if internals validity is false', async() => { + await setup(''); + expect(await el.callMethod('checkValidity')).toBe(false); + + await setup(''); + expect(await el.callMethod('checkValidity')).toBe(false); + }); + }); + + describe('clear', () => { + it('should update the validity state accordingly, given value', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(true); + + await el.callMethod('clear'); + await page.waitForChanges(); + + expect(await el.callMethod('checkValidity')).toBe(false); + }); + }); + + describe('getValidationMessage', () => { + it('should return empty element validation message if valid', async() => { + await setup(''); + expect(await el.callMethod('getValidationMessage')).toBe(''); + + await setup(''); + expect(await el.callMethod('getValidationMessage')).toBe(''); + }); + + it('should return the element validation message if not valid', async() => { + await setup(''); + expect(await el.callMethod('getValidationMessage')).not.toBe(''); + + await setup(''); + expect(await el.callMethod('getValidationMessage')).not.toBe(''); + }); + }); + + describe('getValidity', () => { + // el.callMethod('getValidity') does not return the ValidityState instance, we have to use an evaluate + // and return a single prop, otherwise it'll return an empty object + async function getValidityProp(prop: keyof ValidityState): Promise { + return await page.evaluate(async(validityProp): Promise => { + const validityState = await (document.querySelector('ods-radio') as unknown as OdsRadio)?.getValidity(); + + if (validityState) { + return validityState[validityProp]; + } + return null; + }, prop); + } + + it('should return valid validity state if valid', async() => { + await setup(''); + expect(await getValidityProp('valid')).toBe(true); + + await setup(''); + expect(await getValidityProp('valid')).toBe(true); + }); + + it('should return invalid validity state if not valid', async() => { + await setup(''); + expect(await getValidityProp('valid')).toBe(false); + expect(await getValidityProp('valueMissing')).toBe(true); + + await setup(''); + expect(await getValidityProp('valid')).toBe(false); + expect(await getValidityProp('valueMissing')).toBe(true); + }); + }); + + describe('reportValidity', () => { + it('should return true if internals validity is true', async() => { + await setup(''); + expect(await el.callMethod('reportValidity')).toBe(true); + + await setup(''); + expect(await el.callMethod('reportValidity')).toBe(true); + }); + + it('should return false if internals validity is false', async() => { + await setup(''); + expect(await el.callMethod('reportValidity')).toBe(false); + + await setup(''); + expect(await el.callMethod('reportValidity')).toBe(false); + }); + }); + + describe('reset', () => { + it('should update the validity state accordingly, given value', async() => { + await setup(''); + + expect(await el.callMethod('checkValidity')).toBe(true); + + await el.callMethod('clear'); + await page.waitForChanges(); + + expect(await el.callMethod('checkValidity')).toBe(false); + + await el.callMethod('reset'); + await page.waitForChanges(); + + expect(await el.callMethod('checkValidity')).toBe(true); + }); + }); + + describe('willValidate', () => { + it('should return true if element is submittable', async() => { + await setup(''); + expect(await el.callMethod('willValidate')).toBe(true); + }); + + it('should return false if element is not submittable', async() => { + await setup(''); + expect(await el.callMethod('willValidate')).toBe(false); + }); + }); + }); + + describe('in a form', () => { + it('should not submit the form on submit before any changes if radio is invalid', async() => { + await setup('
'); + + const formValidity = await page.evaluate(() => { + const form = document.querySelector('form'); + form?.requestSubmit(); + return form?.reportValidity(); + }); + + expect(await el.callMethod('checkValidity')).toBe(false); + expect(formValidity).toBe(false); + }); + + it('should submit the form if radio is valid', async() => { + await setup('
'); + + await el.click(); + const formValidity = await page.evaluate(() => { + const form = document.querySelector('form'); + form?.requestSubmit(); + return form?.reportValidity(); + }); + + expect(await el.callMethod('checkValidity')).toBe(true); + expect(formValidity).toBe(true); + }); + }); +}); diff --git a/packages/ods/src/style/_radio.scss b/packages/ods/src/style/_radio.scss index 458e3d17ff..a99063b263 100644 --- a/packages/ods/src/style/_radio.scss +++ b/packages/ods/src/style/_radio.scss @@ -22,14 +22,16 @@ content: ""; } - &:focus { - @include focus.ods-focus(); + &:not(&--error) { + &:focus { + @include focus.ods-focus(); - border-color: var(--ods-color-form-element-border-focus-default); - } + border-color: var(--ods-color-form-element-border-focus-default); + } - &:hover { - border-color: var(--ods-color-form-element-border-hover-default); + &:hover { + border-color: var(--ods-color-form-element-border-hover-default); + } } &:checked::before { diff --git a/packages/storybook/stories/components/radio/radio.stories.ts b/packages/storybook/stories/components/radio/radio.stories.ts index db20a39c4d..7ddd5f7b74 100644 --- a/packages/storybook/stories/components/radio/radio.stories.ts +++ b/packages/storybook/stories/components/radio/radio.stories.ts @@ -11,18 +11,42 @@ const meta: Meta = { export default meta; export const Demo: StoryObj = { - render: (args) => html` + render: (args) => { + const validityStateTemplate = html`
+
+ `; + return html` + ${args.validityState ? validityStateTemplate : ''} `, + `; + }, argTypes: orderControls({ ariaLabel: { table: { @@ -57,9 +81,27 @@ export const Demo: StoryObj = { }, control: 'boolean', }, + isRequired: { + control: 'boolean', + table: { + category: CONTROL_CATEGORY.general, + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + validityState: { + table: { + category: CONTROL_CATEGORY.accessibility, + defaultValue: { summary: false }, + type: { summary: 'boolean' }, + }, + control: 'boolean', + }, }), args: { isDisabled: false, + isRequired: false, + validityState: false, }, }; @@ -241,3 +283,29 @@ export const Alignment: StoryObj = { `, }; + +export const ValidityState: StoryObj = { + tags: ['isHidden'], + render: () => html` + + +
+ +`, +}; diff --git a/packages/storybook/stories/components/radio/technical-information.mdx b/packages/storybook/stories/components/radio/technical-information.mdx index 995dab0955..9d580bb200 100644 --- a/packages/storybook/stories/components/radio/technical-information.mdx +++ b/packages/storybook/stories/components/radio/technical-information.mdx @@ -73,6 +73,10 @@ The `id` is encapsulated by the `input-id` attribute. This way you can link a la + + + +