Skip to content

Commit

Permalink
fix(radio): required validity now works for radio groups
Browse files Browse the repository at this point in the history
  • Loading branch information
Yonatan Kra committed Jan 1, 2025
1 parent ec37d4a commit f33a188
Show file tree
Hide file tree
Showing 3 changed files with 154 additions and 4 deletions.
105 changes: 105 additions & 0 deletions libs/components/src/lib/radio/radio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}></${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);
Expand Down Expand Up @@ -172,6 +181,102 @@ describe('vwc-radio', () => {

expect(element.proxy.getAttribute('name')).toBeNull();
});

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();
it('should sync validity from siblings in same group and name when all unchecked', async () => {
await import('element-internals-polyfill');

mockFormAssociated();
mockElementInternals();

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 () => {
await import('element-internals-polyfill');

mockFormAssociated();
mockElementInternals();

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 () => {
await import('element-internals-polyfill');

mockFormAssociated();
mockElementInternals();

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 () => {
await import('element-internals-polyfill');

mockFormAssociated();
mockElementInternals();

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);
});
});
describe('change', () => {
it('should be fired when a user toggles the radio', async () => {
Expand Down
50 changes: 47 additions & 3 deletions libs/components/src/lib/radio/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,16 @@ export class Radio extends FormAssociatedRadio {
this.proxy.setAttribute('name', this.name);
}


/**
* @internal
*/
override nameChanged(previous: string, next: string): void {
super.nameChanged ? super.nameChanged(previous, next) : null;
next !== null ? this.proxy.setAttribute('name', this.name) : this.proxy.removeAttribute('name');
if (super.nameChanged) {
super.nameChanged(previous, next);
}
next !== null
? this.proxy.setAttribute('name', this.name)
: this.proxy.removeAttribute('name');
}
/**
* @internal
Expand Down Expand Up @@ -159,6 +162,8 @@ export class Radio extends FormAssociatedRadio {
}
}
}

this.#validateValueMissingWithSiblings();
}

private isInsideRadioGroup(): boolean {
Expand Down Expand Up @@ -191,4 +196,43 @@ export class Radio extends FormAssociatedRadio {
this.checked = true;
}
}

/**
* @internal
*/
override checkedChanged = (previous: boolean, next: boolean): void => {
super.checkedChanged(previous, next);
this.#syncSiblingsRequiredValidationStatus();
};

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 [];
}

#syncSiblingsRequiredValidationStatus = (force = false): void => {
if (this.elementInternals && (!this.validity.valueMissing || force)) {
const siblings = this.#radioSiblings;
if (siblings && siblings.length > 1) {
siblings.forEach((x: Radio) => {
x.elementInternals!.setValidity({ valueMissing: false });
});
}
}
};

#validateValueMissingWithSiblings = (): void => {
const siblings = this.#radioSiblings;
if (siblings && siblings.length > 1) {
const isSiblingChecked = siblings.some((x: Radio) => x.checked);
if (isSiblingChecked) {
this.#syncSiblingsRequiredValidationStatus(true);
}
}
};
}
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit f33a188

Please sign in to comment.