From 5fb9b5294dc6d4bd4f6586daece53cbde5f2df07 Mon Sep 17 00:00:00 2001 From: Yonatan Kra Date: Thu, 2 Jan 2025 15:24:41 +0200 Subject: [PATCH] fix(radio): radio required validation sync with spec (VIV-2265) (#2070) * fix(radio): radio singular required validation * fix(radio): required validity now works for radio groups --------- Co-authored-by: Yonatan Kra --- .../src/lib/radio/radio.form-associated.ts | 40 ++++ libs/components/src/lib/radio/radio.spec.ts | 188 ++++++++++++++++++ libs/components/src/lib/radio/radio.ts | 19 +- package-lock.json | 3 +- 4 files changed, 247 insertions(+), 3 deletions(-) diff --git a/libs/components/src/lib/radio/radio.form-associated.ts b/libs/components/src/lib/radio/radio.form-associated.ts index 10fae33d68..21b3e1efc9 100644 --- a/libs/components/src/lib/radio/radio.form-associated.ts +++ b/libs/components/src/lib/radio/radio.form-associated.ts @@ -7,4 +7,44 @@ interface _Radio extends CheckableFormAssociated {} export class FormAssociatedRadio extends CheckableFormAssociated(_Radio) { proxy = document.createElement('input'); + + get #radioSiblings(): _Radio[] { + const siblings = this.parentElement?.querySelectorAll( + `${this.tagName.toLocaleLowerCase()}[name="${this.name}"]` + ); + if (siblings) { + return Array.from(siblings) as unknown as _Radio[]; + } + return []; + } + + #validateValueMissingWithSiblings = (): void => { + const siblings = this.#radioSiblings; + if (siblings && siblings.length > 1) { + const isSiblingChecked = siblings.some((x: _Radio) => x.checked); + if (isSiblingChecked) { + this.setValidity({ valueMissing: false }); + } + } + }; + + #syncSiblingsRequiredValidationStatus = (): void => { + if (this.elementInternals && !this.validity.valueMissing) { + const siblings = this.#radioSiblings; + if (siblings && siblings.length > 1) { + siblings.forEach((x: _Radio) => { + x.elementInternals!.setValidity({ valueMissing: false }); + }); + } + } + }; + + override validate = (anchor?: HTMLElement): void => { + super.validate(anchor); + if (this.validity.valueMissing) { + this.#validateValueMissingWithSiblings(); + } else { + this.#syncSiblingsRequiredValidationStatus(); + } + }; } diff --git a/libs/components/src/lib/radio/radio.spec.ts b/libs/components/src/lib/radio/radio.spec.ts index ed5f8c99af..32d7f71b67 100644 --- a/libs/components/src/lib/radio/radio.spec.ts +++ b/libs/components/src/lib/radio/radio.spec.ts @@ -23,11 +23,20 @@ async function setBoolAttributeOn( describe('vwc-radio', () => { let element: Radio; + let internalsMock: jest.SpyInstance | null = null; + let formAssociatedMock: jest.SpyInstance | null = null; beforeEach(async () => { element = fixture(`<${COMPONENT_TAG}>`) as Radio; }); + afterEach(async () => { + internalsMock?.mockRestore(); + formAssociatedMock?.mockRestore(); + internalsMock = null; + formAssociatedMock = null; + }); + describe('basic', () => { it('should be initialized as a vwc-radio', async () => { expect(element).toBeInstanceOf(Radio); @@ -143,6 +152,185 @@ describe('vwc-radio', () => { }); }); + describe('required', () => { + it('should reflect required attribute', async () => { + element.required = true; + await elementUpdated(element); + expect(element.hasAttribute('required')).toBe(true); + }); + + it('should invalidate the element when required and not selected', async () => { + element.required = true; + await elementUpdated(element); + element.checkValidity(); + expect(element.validity.valueMissing).toBe(true); + }); + + it('should set the name attribute on the proxy element so that elementals validation works', async () => { + element.name = 'test'; + await elementUpdated(element); + expect(element.proxy.name).toBe('test'); + }); + + it('should remove the proxy name attribute if removed from the element', async () => { + element.name = 'test'; + await elementUpdated(element); + + element.removeAttribute('name'); + await elementUpdated(element); + + expect(element.proxy.getAttribute('name')).toBeNull(); + }); + + describe('with internals', () => { + function mockElementInternals() { + function addElementInternals(this: Radio) { + let internals = InternalsMap.get(this); + + if (!internals) { + internals = (this as any).attachInternals(); + InternalsMap.set(this, internals); + } + + return internals; + } + + return (internalsMock = jest + .spyOn(Radio.prototype, 'elementInternals', 'get') + .mockImplementation(addElementInternals)); + } + + function mockFormAssociated() { + return (formAssociatedMock = jest + .spyOn(Radio as any, 'formAssociated', 'get') + .mockReturnValue(true)); + } + + const InternalsMap = new WeakMap(); + + beforeEach(async () => { + await import('element-internals-polyfill'); + mockFormAssociated(); + mockElementInternals(); + }); + + it('should sync validity from siblings in same group and name when all unchecked', async () => { + const sibling = document.createElement(COMPONENT_TAG) as Radio; + sibling.name = element.name = 'test'; + sibling.required = element.required = true; + + element.parentElement?.appendChild(sibling); + await elementUpdated(element); + + expect(element.validity.valueMissing).toBe(true); + }); + + it('should sync validity with siblings in same group and name when checked', async () => { + const sibling = document.createElement(COMPONENT_TAG) as Radio; + sibling.name = element.name = 'test'; + sibling.required = element.required = true; + + element.parentElement?.appendChild(sibling); + await elementUpdated(element); + sibling.checked = true; + await elementUpdated(element); + + expect(element.validity.valueMissing).toBe(false); + }); + + it('should set the correct valueMissing validity when added to the DOM with valid group', async () => { + const sibling = document.createElement(COMPONENT_TAG) as Radio; + sibling.name = element.name = 'test'; + sibling.required = element.required = true; + element.checked = true; + + await elementUpdated(element); + element.parentElement?.appendChild(sibling); + + await elementUpdated(element); + + expect(sibling.validity.valueMissing).toBe(false); + }); + + it("should sync siblings' validity.valueMissing when added as checked", async () => { + const sibling = document.createElement(COMPONENT_TAG) as Radio; + sibling.name = element.name = 'test'; + sibling.required = element.required = true; + sibling.checked = true; + + await elementUpdated(element); + element.parentElement?.appendChild(sibling); + + await elementUpdated(element); + + expect(element.validity.valueMissing).toBe(false); + }); + + it('should sync values when name changes to that of siblings', async () => { + const sibling = document.createElement(COMPONENT_TAG) as Radio; + sibling.name = 'not-test'; + element.name = 'test'; + sibling.required = element.required = true; + sibling.checked = true; + + await elementUpdated(element); + element.parentElement?.appendChild(sibling); + + await elementUpdated(element); + + const valueWhenNameIsDifferent = element.validity.valueMissing; + + element.name = sibling.name; + await elementUpdated(element); + + expect(valueWhenNameIsDifferent).toBe(true); + expect(element.validity.valueMissing).toBe(false); + }); + + it('should validate valueMissing on a single radio only when required is set', async () => { + element.name = 'test'; + element.required = false; + + await elementUpdated(element); + + expect(element.validity.valueMissing).toBe(false); + }); + + it('should validate valueMissing on radio-group only when required is set', async () => { + const sibling = document.createElement(COMPONENT_TAG) as Radio; + sibling.name = 'test'; + element.name = 'test'; + sibling.required = element.required = false; + + await elementUpdated(element); + element.parentElement?.appendChild(sibling); + + await elementUpdated(element); + + expect(element.validity.valueMissing).toBe(false); + expect(sibling.validity.valueMissing).toBe(false); + }); + + it('should validate valueMissing when required is set on at least one sibling', async () => { + const sibling = document.createElement(COMPONENT_TAG) as Radio; + sibling.name = element.name = 'test'; + + sibling.required = false; + element.required = true; + await elementUpdated(element); + + element.parentElement?.appendChild(sibling); + await elementUpdated(element); + + sibling.checked = true; + await elementUpdated(element); + + expect(element.validity.valueMissing).toBe(false); + expect(sibling.validity.valueMissing).toBe(false); + }); + }); + }); + describe('change', () => { it('should be fired when a user toggles the radio', async () => { const spy = jest.fn(); diff --git a/libs/components/src/lib/radio/radio.ts b/libs/components/src/lib/radio/radio.ts index 48698f847e..93988c2eb5 100644 --- a/libs/components/src/lib/radio/radio.ts +++ b/libs/components/src/lib/radio/radio.ts @@ -1,5 +1,6 @@ import { attr, + DOM, observable, type SyntheticViewTemplate, } from '@microsoft/fast-element'; @@ -86,7 +87,7 @@ export class Radio extends FormAssociatedRadio { /** * The name of the radio. See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname | name attribute} for more info. */ - @observable + @attr override name!: string; /** @@ -121,14 +122,28 @@ export class Radio extends FormAssociatedRadio { constructor() { super(); this.proxy.setAttribute('type', 'radio'); + this.proxy.setAttribute('name', this.name); } + /** + * @internal + */ + override nameChanged(previous: string, next: string): void { + if (super.nameChanged) { + super.nameChanged(previous, next); + } + next !== null + ? this.proxy.setAttribute('name', this.name) + : this.proxy.removeAttribute('name'); + + DOM.queueUpdate(this.validate); + } /** * @internal */ override connectedCallback(): void { super.connectedCallback(); - this.validate(); + DOM.queueUpdate(this.validate); if ( this.parentElement!.getAttribute('role') !== 'radiogroup' && diff --git a/package-lock.json b/package-lock.json index 2037daebc1..96e5d6bdfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19373,7 +19373,8 @@ "version": "1.3.12", "resolved": "https://registry.npmjs.org/element-internals-polyfill/-/element-internals-polyfill-1.3.12.tgz", "integrity": "sha512-KW1k+cMGwXlx3X9nqhgmuElAfR/c/ccFt0pG4KpwK++Mx9Y+mPExxJW+jgQnqux/NQrJejgOxxg4Naf3f6y67Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/eleventy-plugin-nesting-toc": { "version": "1.3.0",