Skip to content

Commit

Permalink
Merge branch 'main' into VIV-2248-dialog-scroll
Browse files Browse the repository at this point in the history
  • Loading branch information
rachelbt authored Jan 2, 2025
2 parents 068e759 + 5fb9b52 commit 2e85c22
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 3 deletions.
40 changes: 40 additions & 0 deletions libs/components/src/lib/radio/radio.form-associated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
};
}
188 changes: 188 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 @@ -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();
Expand Down
19 changes: 17 additions & 2 deletions libs/components/src/lib/radio/radio.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
attr,
DOM,
observable,
type SyntheticViewTemplate,
} from '@microsoft/fast-element';
Expand Down Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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' &&
Expand Down
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 2e85c22

Please sign in to comment.