Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/radio-invalid-property-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@spectrum-web-components/radio': patch
---

**Fixed**: Removed deprecated `invalid` property from `<sp-radio>` component in favor of group-level validation.

The `invalid` property on individual `<sp-radio>` elements has been removed. Validation state should now be managed exclusively at the `<sp-radio-group>` level using the `invalid` property, which correctly applies the `aria-invalid` attribute to the group container. This change aligns with accessibility best practices where validation feedback should be provided at the radio group level rather than on individual radio buttons.

**Migration guide**: If you were using the `invalid` property on individual `<sp-radio>` elements, move it to the parent `<sp-radio-group>`:

```html
<!-- Before -->
<sp-radio-group>
<sp-radio value="option1" invalid>Option 1</sp-radio>
<sp-radio value="option2">Option 2</sp-radio>
</sp-radio-group>

<!-- After -->
<sp-radio-group invalid>
<sp-radio value="option1">Option 1</sp-radio>
<sp-radio value="option2">Option 2</sp-radio>
</sp-radio-group>
```
5 changes: 5 additions & 0 deletions 1st-gen/packages/radio/src/Radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export class Radio extends SizedMixin(
@property({ type: Boolean, reflect: true })
public emphasized = false;

/**
* @deprecated
* The invalid state of a single radio button is deprecated.
* Please use the invalid state of the radio group instead.
*/
@property({ type: Boolean, reflect: true })
public invalid = false;

Expand Down
66 changes: 66 additions & 0 deletions 1st-gen/packages/radio/src/RadioGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export class RadioGroup extends FocusVisiblePolyfillMixin(FieldGroup) {
@property({ type: String })
public name = '';

@property({ type: Boolean, reflect: true })
public override invalid = false;

@queryAssignedNodes()
public defaultNodes!: Node[];

Expand Down Expand Up @@ -137,7 +140,70 @@ export class RadioGroup extends FocusVisiblePolyfillMixin(FieldGroup) {
}
}

private _childInvalidObserver?: MutationObserver | null;
private _managedInvalid = false;

public override disconnectedCallback(): void {
this.clearInvalidObserver();
super.disconnectedCallback();
}

protected override handleSlotchange(): void {
this.rovingTabindexController.clearElementCache();
this.manageInvalidObserver();
}

private manageInvalidObserver(): void {
if (this._childInvalidObserver) {
this._childInvalidObserver.disconnect();
}
this._childInvalidObserver = new MutationObserver(() => {
this.checkInvalidState();
});
this._childInvalidObserver.observe(this, {
attributes: true,
attributeFilter: ['invalid'],
subtree: true,
});

this.checkInvalidState();
}

private checkInvalidState(): void {
const invalidChild = this.buttons.find((button) => button.invalid);
if (invalidChild) {
if (!this.invalid) {
this.invalid = true;
this._managedInvalid = true;
window.__swc.warn(
this,
'The "invalid" attribute on <sp-radio> is deprecated. Please apply the "invalid" attribute to the parent <sp-radio-group> instead.',
'https://opensource.adobe.com/spectrum-web-components/components/radio',
{ level: 'deprecation' }
);
}
} else if (this.invalid && this._managedInvalid) {
// Only clear invalid state if it was set by us (via child sync)
this.invalid = false;
this._managedInvalid = false;
this.removeAttribute('aria-invalid');
}
}

private clearInvalidObserver(): void {
this._childInvalidObserver?.disconnect();
this._childInvalidObserver = null;
}

protected override updated(changes: PropertyValues<this>): void {
super.updated(changes);

if (changes.has('invalid')) {
if (this.invalid) {
this.setAttribute('aria-invalid', 'true');
} else {
this.removeAttribute('aria-invalid');
}
}
}
}
76 changes: 76 additions & 0 deletions 1st-gen/packages/radio/test/radio-group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,8 @@ describe('Radio Group', () => {
</sp-radio-group>
</div>
`);
// Create __swc if it doesn't exist
window.__swc = window.__swc || { warn: () => {} };
});

it('loads', () => {
Expand Down Expand Up @@ -610,6 +612,80 @@ describe('Radio Group', () => {

expect(changeSpy.calledWith(undefined)).to.be.false;
});

it('updates [aria-invalid] when [invalid] changes', async () => {
const el = await fixture<RadioGroup>(html`
<sp-radio-group invalid>
<sp-radio value="first">Option 1</sp-radio>
<sp-radio value="second">Option 2</sp-radio>
</sp-radio-group>
`);
await elementUpdated(el);
expect(el.hasAttribute('aria-invalid')).to.be.true;
expect(el.getAttribute('aria-invalid')).to.equal('true');
el.invalid = false;
await elementUpdated(el);
expect(el.hasAttribute('aria-invalid')).to.be.false;
el.invalid = true;
await elementUpdated(el);
expect(el.hasAttribute('aria-invalid')).to.be.true;
expect(el.getAttribute('aria-invalid')).to.equal('true');
});

it('warns when [invalid] is used on children and updates group invalid state', async () => {
const swcWarnSpy = spy(window.__swc, 'warn');
const el = await fixture<RadioGroup>(html`
<sp-radio-group>
<sp-radio value="first" invalid>Option 1</sp-radio>
<sp-radio value="second">Option 2</sp-radio>
</sp-radio-group>
`);
await elementUpdated(el);

expect(el.invalid).to.be.true;
expect(el.hasAttribute('aria-invalid')).to.be.true;
expect(swcWarnSpy.called).to.be.true;
expect(swcWarnSpy.args[0][1]).to.include('deprecated');
swcWarnSpy.restore();
});

it('should not have invalid state when children do not have invalid attribute', async () => {
const swcWarnSpy = spy(window.__swc, 'warn');
const el = await fixture<RadioGroup>(html`
<sp-radio-group>
<sp-radio value="first">Option 1</sp-radio>
<sp-radio value="second">Option 2</sp-radio>
</sp-radio-group>
`);
await elementUpdated(el);

expect(el.invalid).to.be.false;
expect(el.hasAttribute('aria-invalid')).to.be.false;
expect(swcWarnSpy.called).to.be.false;
swcWarnSpy.restore();
});

it('should update group invalid state when child radio has invalid attribute and then remove it', async () => {
const el = await fixture<RadioGroup>(html`
<sp-radio-group>
<sp-radio value="first" checked>Option 1</sp-radio>
<sp-radio value="second">Option 2</sp-radio>
</sp-radio-group>
`);
await elementUpdated(el);

const childRadio = el.querySelector('sp-radio') as Radio;
childRadio.setAttribute('invalid', 'true');

await elementUpdated(el);
expect(el.invalid).to.be.true;
expect(el.hasAttribute('aria-invalid')).to.be.true;

childRadio.removeAttribute('invalid');
await elementUpdated(el);
expect(el.invalid).to.be.false;
expect(el.hasAttribute('aria-invalid')).to.be.false;
});
});

describe('Radio Group - late children', () => {
Expand Down
Loading