Merge branch 'break-stuff-radio-group-a11y-fixes' into next

This commit is contained in:
Cory LaViska
2022-08-03 10:00:49 -04:00
10 changed files with 552 additions and 592 deletions

View File

@@ -5,10 +5,10 @@
Radio groups are used to group multiple [radios](/components/radio) or [radio buttons](/components/radio-button) so they function as a single form control.
```html preview
<sl-radio-group label="Select an option">
<sl-radio name="option" value="1" checked>Option 1</sl-radio>
<sl-radio name="option" value="2">Option 2</sl-radio>
<sl-radio name="option" value="3">Option 3</sl-radio>
<sl-radio-group label="Select an option" value="1">
<sl-radio value="1">Option 1</sl-radio>
<sl-radio value="2">Option 2</sl-radio>
<sl-radio value="3">Option 3</sl-radio>
</sl-radio-group>
```
@@ -16,16 +16,10 @@ Radio groups are used to group multiple [radios](/components/radio) or [radio bu
import { SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadio name="option" value="1" checked>
Option 1
</SlRadio>
<SlRadio name="option" value="2">
Option 2
</SlRadio>
<SlRadio name="option" value="3">
Option 3
</SlRadio>
<SlRadioGroup label="Select an option" value="1">
<SlRadio value="1">Option 1</SlRadio>
<SlRadio value="2">Option 2</SlRadio>
<SlRadio value="3">Option 3</SlRadio>
</SlRadioGroup>
);
```
@@ -37,8 +31,8 @@ const App = () => (
You can show the fieldset and legend that wraps the radio group using the `fieldset` attribute. If you don't use this option, you should still provide a label so screen readers announce the control correctly.
```html preview
<sl-radio-group label="Select an option" fieldset>
<sl-radio name="option" value="1" checked>Option 1</sl-radio>
<sl-radio-group label="Select an option" value="1" fieldset>
<sl-radio name="option" value="1">Option 1</sl-radio>
<sl-radio name="option" value="2">Option 2</sl-radio>
<sl-radio name="option" value="3">Option 3</sl-radio>
</sl-radio-group>
@@ -48,8 +42,8 @@ You can show the fieldset and legend that wraps the radio group using the `field
import { SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option" fieldset>
<SlRadio name="option" value="1" checked>
<SlRadioGroup label="Select an option" value="1" fieldset>
<SlRadio name="option" value="1">
Option 1
</SlRadio>
<SlRadio name="option" value="2">
@@ -67,8 +61,8 @@ const App = () => (
[Radio buttons](/components/radio-button) offer an alternate way to display radio controls. In this case, an internal [button group](/components/button-group) is used to group the buttons into a single, cohesive control.
```html preview
<sl-radio-group label="Select an option">
<sl-radio-button name="option" value="1" checked>Option 1</sl-radio-button>
<sl-radio-group label="Select an option" value="1">
<sl-radio-button name="option" value="1">Option 1</sl-radio-button>
<sl-radio-button name="option" value="2">Option 2</sl-radio-button>
<sl-radio-button name="option" value="3">Option 3</sl-radio-button>
</sl-radio-group>
@@ -78,8 +72,8 @@ const App = () => (
import { SlRadioButton, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => (
<SlRadioGroup label="Select an option">
<SlRadioButton name="option" value="1" checked>
<SlRadioGroup label="Select an option" value="1">
<SlRadioButton name="option" value="1">
Option 1
</SlRadioButton>
<SlRadioButton name="option" value="2">
@@ -92,4 +86,141 @@ const App = () => (
);
```
### Validation
Setting the `required` attribute to make selecting an option mandatory. If a value has not been selected, it will prevent the form from submitting and display an error message.
```html preview
<form class="validation">
<sl-radio-group label="Select an option" required>
<sl-radio name="a" value="1">Option 1</sl-radio>
<sl-radio name="a" value="2">Option 2</sl-radio>
<sl-radio name="a" value="3">Option 3</sl-radio>
</sl-radio-group>
<br />
<sl-button type="submit" variant="primary">Submit</sl-button>
</form>
<script>
const form = document.querySelector('.validation');
// Handle form submit
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
});
</script>
```
```jsx react
import { SlButton, SlIcon, SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => {
function handleSubmit(event) {
event.preventDefault();
alert('All fields are valid!');
}
return (
<form class="custom-validity" onSubmit={handleSubmit}>
<SlRadioGroup label="Select an option" onSlChange={handleChange} required>
<SlRadio name="a" value="1">
Option 1
</SlRadio>
<SlRadio name="a" value="2">
Option 2
</SlRadio>
<SlRadio name="a" value="3">
Option 3
</SlRadio>
</SlRadioGroup>
<br />
<SlButton type="submit" variant="primary">
Submit
</SlButton>
</form>
);
};
```
#### Custom Validity
Use the `setCustomValidity()` method to set a custom validation message. This will prevent the form from submitting and make the browser display the error message you provide. To clear the error, call this function with an empty string.
```html preview
<form class="custom-validity">
<sl-radio-group label="Select an option" value="1">
<sl-radio name="a" value="1">Not me</sl-radio>
<sl-radio name="a" value="2">Me neither</sl-radio>
<sl-radio name="a" value="3">Choose me</sl-radio>
</sl-radio-group>
<br />
<sl-button type="submit" variant="primary">Submit</sl-button>
</form>
<script>
const form = document.querySelector('.custom-validity');
const radioGroup = form.querySelector('sl-radio-group');
const errorMessage = 'You must choose the last option';
// Set initial validity as soon as the element is defined
customElements.whenDefined('sl-radio-group').then(() => {
radioGroup.setCustomValidity(errorMessage);
});
// Update validity when a selection is made
form.addEventListener('sl-change', () => {
const isValid = radioGroup.value === '3';
radioGroup.setCustomValidity(isValid ? '' : errorMessage);
});
// Handle form submit
form.addEventListener('submit', event => {
event.preventDefault();
alert('All fields are valid!');
});
</script>
```
```jsx react
import { useEffect, useRef } from 'react';
import { SlButton, SlIcon, SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react';
const App = () => {
const radioGroup = useRef(null);
const errorMessage = 'You must choose this option';
function handleChange() {
radioGroup.current.setCustomValidity(radioGroup.current.value === '3' ? '' : errorMessage);
}
function handleSubmit(event) {
event.preventDefault();
alert('All fields are valid!');
}
useEffect(() => {
radio.current.setCustomValidity(errorMessage);
}, []);
return (
<form class="custom-validity" onSubmit={handleSubmit}>
<SlRadioGroup ref={radioGroup} label="Select an option" value="1" onSlChange={handleChange}>
<SlRadio name="a" value="1">
Not me
</SlRadio>
<SlRadio name="a" value="2">
Me neither
</SlRadio>
<SlRadio name="a" value="3">
Choose me
</SlRadio>
</SlRadioGroup>
<br />
<SlButton type="submit" variant="primary">
Submit
</SlButton>
</form>
);
};
```
[component-metadata:sl-radio-group]

View File

@@ -1,5 +1,5 @@
import { LitElement, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { customElement, property, query, state } from 'lit/decorators.js';
import styles from './button-group.styles';
import type { CSSResultGroup } from 'lit';
@@ -19,6 +19,8 @@ export default class SlButtonGroup extends LitElement {
@query('slot') defaultSlot: HTMLSlotElement;
@state() disableRole = false;
/** A label to use for the button group's `aria-label` attribute. */
@property() label = '';
@@ -65,14 +67,14 @@ export default class SlButtonGroup extends LitElement {
<div
part="base"
class="button-group"
role="group"
role="${this.disableRole ? 'presentation' : 'group'}"
aria-label=${this.label}
@focusout=${this.handleBlur}
@focusin=${this.handleFocus}
@mouseover=${this.handleMouseOver}
@mouseout=${this.handleMouseOut}
>
<slot @slotchange=${this.handleSlotChange}></slot>
<slot @slotchange=${this.handleSlotChange} role="none"></slot>
</div>
`;
}

View File

@@ -1,64 +1,17 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import { expect, fixture, html } from '@open-wc/testing';
import type SlRadioGroup from '../../components/radio-group/radio-group';
import type SlRadioButton from './radio-button';
describe('<sl-radio-button>', () => {
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlRadioButton>(html` <sl-radio-button disabled></sl-radio-button> `);
expect(el.input.disabled).to.be.true;
});
it('should be valid by default', async () => {
const el = await fixture<SlRadioButton>(html` <sl-radio-button></sl-radio-button> `);
expect(el.invalid).to.be.false;
});
it('should fire sl-change when clicked', async () => {
const el = await fixture<SlRadioButton>(html` <sl-radio-button></sl-radio-button> `);
setTimeout(() => el.input.click());
const event = (await oneEvent(el, 'sl-change')) as CustomEvent;
expect(event.target).to.equal(el);
expect(el.checked).to.be.true;
});
it('should fire sl-change when toggled via keyboard - space', async () => {
const el = await fixture<SlRadioButton>(html` <sl-radio-button></sl-radio-button> `);
el.input.focus();
setTimeout(() => sendKeys({ press: ' ' }));
const event = (await oneEvent(el, 'sl-change')) as CustomEvent;
expect(event.target).to.equal(el);
expect(el.checked).to.be.true;
});
it('should fire sl-change when toggled via keyboard - arrow key', async () => {
it('should not get checked when disabled', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio-button id="radio-1"></sl-radio-button>
<sl-radio-button id="radio-2"></sl-radio-button>
<sl-radio-group value="1">
<sl-radio-button id="radio-1" value="1"></sl-radio-button>
<sl-radio-button id="radio-2" value="2" disabled></sl-radio-button>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadioButton>('#radio-1')!;
const radio2 = radioGroup.querySelector<SlRadioButton>('#radio-2')!;
radio1.input.focus();
setTimeout(() => sendKeys({ press: 'ArrowRight' }));
const event = (await oneEvent(radio2, 'sl-change')) as CustomEvent;
expect(event.target).to.equal(radio2);
expect(radio2.checked).to.be.true;
});
it('should not get checked when disabled', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio-button checked></sl-radio-button>
<sl-radio-button disabled></sl-radio-button>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadioButton>('sl-radio-button[checked]')!;
const radio2 = radioGroup.querySelector<SlRadioButton>('sl-radio-button[disabled]')!;
radio2.click();
await Promise.all([radio1.updateComplete, radio2.updateComplete]);
@@ -66,90 +19,4 @@ describe('<sl-radio-button>', () => {
expect(radio1.checked).to.be.true;
expect(radio2.checked).to.be.false;
});
describe('when submitting a form', () => {
it('should submit the correct value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group>
<sl-radio-button id="radio-1" name="a" value="1" checked></sl-radio-button>
<sl-radio-button id="radio-2" name="a" value="2"></sl-radio-button>
<sl-radio-button id="radio-3" name="a" value="3"></sl-radio-button>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio = form.querySelectorAll('sl-radio-button')[1]!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
radio.click();
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('2');
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group>
<sl-radio-button id="radio-1" name="a" value="1" checked></sl-radio-button>
<sl-radio-button id="radio-2" name="a" value="2"></sl-radio-button>
<sl-radio-button id="radio-3" name="a" value="3"></sl-radio-button>
</sl-radio-group>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio1: SlRadioButton = form.querySelector('#radio-1')!;
const radio2: SlRadioButton = form.querySelector('#radio-2')!;
radio2.click();
await radio2.updateComplete;
expect(radio2.checked).to.be.true;
expect(radio1.checked).to.be.false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await radio1.updateComplete;
expect(radio1.checked).to.true;
expect(radio2.checked).to.false;
});
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group>
<sl-radio-button id="radio-1" name="a" value="1" checked></sl-radio-button>
<sl-radio-button id="radio-2" name="a" value="2"></sl-radio-button>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio = form.querySelectorAll('sl-radio-button')[1]!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
// Submitting the form after setting custom validity should not trigger the handler
radio.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
await aTimeout(100);
expect(submitHandler).to.not.have.been.called;
});
});

View File

@@ -3,9 +3,7 @@ import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { html } from 'lit/static-html.js';
import { defaultValue } from '../../internal/default-value';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
import { HasSlotController } from '../../internal/slot';
import { watch } from '../../internal/watch';
import styles from './radio-button.styles';
@@ -18,7 +16,6 @@ import type { CSSResultGroup } from 'lit';
* @slot - The radio's label.
*
* @event sl-blur - Emitted when the button loses focus.
* @event sl-change - Emitted when the button's checked state changes.
* @event sl-focus - Emitted when the button gains focus.
*
* @slot - The button's label.
@@ -38,17 +35,13 @@ export default class SlRadioButton extends LitElement {
@query('.button') input: HTMLInputElement;
@query('.hidden-input') hiddenInput: HTMLInputElement;
protected readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlRadioButton) => (control.checked ? control.value : undefined),
defaultValue: (control: SlRadioButton) => control.defaultChecked,
setValue: (control: SlRadioButton, checked: boolean) => (control.checked = checked)
});
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
@state() protected hasFocus = false;
@state() checked = false;
/** The radio's name attribute. */
@property() name: string;
@property({ reflect: true }) name: string;
/** The radio's value attribute. */
@property() value: string;
@@ -56,47 +49,20 @@ export default class SlRadioButton extends LitElement {
/** Disables the radio. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Draws the radio in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/** The button's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/**
* This will be true when the control is in an invalid state. Validity in radios is determined by the message provided
* by the `setCustomValidity` method.
*/
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue('checked')
defaultChecked = false;
/** Draws a pill-style button with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('role', 'radio');
this.setAttribute('role', 'presentation');
}
/** Simulates a click on the radio. */
click() {
this.input.click();
}
/** Sets focus on the radio. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the radio. */
blur() {
this.input.blur();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
return this.hiddenInput.reportValidity();
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message: string) {
this.hiddenInput.setCustomValidity(message);
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
handleBlur() {
@@ -104,10 +70,14 @@ export default class SlRadioButton extends LitElement {
emit(this, 'sl-blur');
}
handleClick() {
if (!this.disabled) {
this.checked = true;
handleClick(e: MouseEvent) {
if (this.disabled) {
e.preventDefault();
e.stopPropagation();
return;
}
this.checked = true;
}
handleFocus() {
@@ -115,38 +85,13 @@ export default class SlRadioButton extends LitElement {
emit(this, 'sl-focus');
}
@watch('checked')
handleCheckedChange() {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
if (this.hasUpdated) {
emit(this, 'sl-change');
}
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.hasUpdated) {
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
}
}
/** The button's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/** Draws a pill-style button with rounded edges. */
@property({ type: Boolean, reflect: true }) pill = false;
render() {
return html`
<div part="base">
<input class="hidden-input" type="radio" aria-hidden="true" tabindex="-1" />
<div part="base" role="presentation">
<button
part="button"
role="radio"
aria-checked="${this.checked}"
class=${classMap({
button: true,
'button--default': true,
@@ -162,10 +107,11 @@ export default class SlRadioButton extends LitElement {
'button--has-prefix': this.hasSlotController.test('prefix'),
'button--has-suffix': this.hasSlotController.test('suffix')
})}
?disabled=${this.disabled}
aria-disabled=${this.disabled}
type="button"
name=${ifDefined(this.name)}
value=${ifDefined(this.value)}
tabindex="${this.checked ? '0' : '-1'}"
@blur=${this.handleBlur}
@focus=${this.handleFocus}
@click=${this.handleClick}

View File

@@ -49,4 +49,16 @@ export default css`
content: var(--sl-input-required-content);
margin-inline-start: -2px;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`;

View File

@@ -1,4 +1,6 @@
import { expect, fixture, html } from '@open-wc/testing';
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import type SlRadio from '../radio/radio';
import type SlRadioGroup from './radio-group';
@@ -6,15 +8,14 @@ describe('<sl-radio-group>', () => {
describe('validation tests', () => {
it(`should be valid when required and one radio is checked`, async () => {
const el = await fixture<SlRadioGroup>(html`
<sl-radio-group label="Select an option" required>
<sl-radio name="option" value="1" checked>Option 1</sl-radio>
<sl-radio-group label="Select an option" value="1" required>
<sl-radio name="option" value="1">Option 1</sl-radio>
<sl-radio name="option" value="2">Option 2</sl-radio>
<sl-radio name="option" value="3">Option 3</sl-radio>
</sl-radio-group>
`);
const radio = el.querySelector<SlRadio>('sl-radio')!;
expect(radio.reportValidity()).to.be.true;
expect(el.reportValidity()).to.be.true;
});
it(`should be invalid when required and no radios are checked`, async () => {
@@ -25,22 +26,20 @@ describe('<sl-radio-group>', () => {
<sl-radio name="option" value="3">Option 3</sl-radio>
</sl-radio-group>
`);
const radio = el.querySelector<SlRadio>('sl-radio')!;
expect(radio.reportValidity()).to.be.false;
expect(el.reportValidity()).to.be.false;
});
it(`should be valid when required and a different radio is checked`, async () => {
const el = await fixture<SlRadioGroup>(html`
<sl-radio-group label="Select an option" required>
<sl-radio-group label="Select an option" value="3" required>
<sl-radio name="option" value="1">Option 1</sl-radio>
<sl-radio name="option" value="2">Option 2</sl-radio>
<sl-radio name="option" value="3" checked>Option 3</sl-radio>
<sl-radio name="option" value="3">Option 3</sl-radio>
</sl-radio-group>
`);
const radio = el.querySelectorAll('sl-radio')![2];
expect(radio.reportValidity()).to.be.true;
expect(el.reportValidity()).to.be.true;
});
it(`should be invalid when custom validity is set`, async () => {
@@ -51,11 +50,136 @@ describe('<sl-radio-group>', () => {
<sl-radio name="option" value="3">Option 3</sl-radio>
</sl-radio-group>
`);
const radio = el.querySelector<SlRadio>('sl-radio')!;
radio.setCustomValidity('Error');
el.setCustomValidity('Error');
expect(radio.reportValidity()).to.be.false;
expect(el.reportValidity()).to.be.false;
});
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group>
<sl-radio id="radio-1" name="a" value="1" checked></sl-radio>
<sl-radio id="radio-2" name="a" value="2"></sl-radio>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radioGroup = form.querySelector<SlRadioGroup>('sl-radio-group')!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
// Submitting the form after setting custom validity should not trigger the handler
radioGroup.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
await aTimeout(100);
expect(submitHandler).to.not.have.been.called;
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group value="1">
<sl-radio value="1"></sl-radio>
<sl-radio value="2"></sl-radio>
</sl-radio-group>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radioGroup = form.querySelector('sl-radio-group')!;
radioGroup.value = '2';
await radioGroup.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await radioGroup.updateComplete;
expect(radioGroup.value).to.equal('1');
});
});
describe('when submitting a form', () => {
it('should submit the correct value when a value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group name="a" value="1">
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2"></sl-radio>
<sl-radio id="radio-3" value="3"></sl-radio>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio = form.querySelectorAll('sl-radio')[1]!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
radio.click();
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('2');
});
});
describe('when emitting "sl-change" event', () => {
it('should fire sl-change when toggled via keyboard - arrow key', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2"></sl-radio>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadio>('#radio-1')!;
radio1.focus();
setTimeout(() => sendKeys({ press: 'ArrowRight' }));
await oneEvent(radioGroup, 'sl-change');
expect(radioGroup.value).to.equal('2');
});
it('should fire sl-change when clicked', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2"></sl-radio>
</sl-radio-group>
`);
const radio = radioGroup.querySelector<SlRadio>('#radio-1')!;
setTimeout(() => radio.click());
const event = (await oneEvent(radioGroup, 'sl-change')) as CustomEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});
it('should fire sl-change when toggled via keyboard - space', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2"></sl-radio>
</sl-radio-group>
`);
const radio = radioGroup.querySelector<SlRadio>('#radio-1')!;
radio.focus();
setTimeout(() => sendKeys({ press: ' ' }));
const event = (await oneEvent(radioGroup, 'sl-change')) as CustomEvent;
expect(event.target).to.equal(radioGroup);
expect(radioGroup.value).to.equal('1');
});
});

View File

@@ -1,9 +1,13 @@
import { html, LitElement } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { emit } from 'src/internal/event';
import { FormSubmitController } from 'src/internal/form';
import { watch } from 'src/internal/watch';
import '../../components/button-group/button-group';
import styles from './radio-group.styles';
import type SlRadio from '../../components/radio/radio';
import type SlRadioButton from '../radio-button/radio-button';
import type { CSSResultGroup } from 'lit';
const RADIO_CHILDREN = ['sl-radio', 'sl-radio-button'];
@@ -17,6 +21,8 @@ const RADIO_CHILDREN = ['sl-radio', 'sl-radio-button'];
* @slot - The default slot where radio controls are placed.
* @slot label - The radio group label. Required for proper accessibility. Alternatively, you can use the label prop.
*
* @event sl-change - Emitted when the radio group's selected value changes.
*
* @csspart base - The component's internal wrapper.
* @csspart label - The radio group's label.
* @csspart button-group - The button group that wraps radio buttons.
@@ -26,95 +32,202 @@ const RADIO_CHILDREN = ['sl-radio', 'sl-radio-button'];
export default class SlRadioGroup extends LitElement {
static styles: CSSResultGroup = styles;
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
protected readonly formSubmitController = new FormSubmitController(this, {
defaultValue: (control: SlRadioGroup) => control.defaultValue
});
@state() hasButtonGroup = false;
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.radio-group__validation-input') input: HTMLInputElement;
@state() private hasButtonGroup = false;
@state() private isInvalid = false;
@state() private errorMessage = '';
@state() private customErrorMessage = '';
@state() private defaultValue = '';
/** The radio group label. Required for proper accessibility. Alternatively, you can use the label slot. */
@property() label = '';
/** The selected value of the control. */
@property({ reflect: true }) value = '';
/** The name assigned to the radio controls. */
@property() name = 'option';
/** Shows the fieldset and legend that surrounds the radio group. */
@property({ type: Boolean, attribute: 'fieldset', reflect: true }) fieldset = false;
/** Ensures a child radio is checked before allowing the containing form to submit. */
@property({ type: Boolean, reflect: true }) required = false;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'radiogroup');
@watch('value')
handleValueChange() {
if (this.hasUpdated) {
emit(this, 'sl-change');
this.updateCheckedRadio();
}
}
getAllRadios() {
connectedCallback() {
super.connectedCallback();
this.defaultValue = this.value;
}
setCustomValidity(message = '') {
this.customErrorMessage = message;
this.errorMessage = message;
if (!message) {
this.isInvalid = false;
} else {
this.isInvalid = true;
this.input.setCustomValidity(message);
this.showNativeErrorMessage();
}
}
get validity(): ValidityState {
const hasMissingData = !((this.value && this.required) || !this.required);
const hasCustomError = this.customErrorMessage !== '';
return {
badInput: false,
customError: hasCustomError,
patternMismatch: false,
rangeOverflow: false,
rangeUnderflow: false,
stepMismatch: false,
tooLong: false,
tooShort: false,
typeMismatch: false,
valid: hasMissingData || hasCustomError ? false : true,
valueMissing: !hasMissingData
};
}
reportValidity(): boolean {
const validity = this.validity;
this.errorMessage = this.customErrorMessage || validity.valid ? '' : this.input.validationMessage;
this.isInvalid = !validity.valid;
if (!validity.valid) {
this.showNativeErrorMessage();
}
return !this.isInvalid;
}
private showNativeErrorMessage() {
this.input.hidden = false;
this.input.reportValidity();
setTimeout(() => (this.input.hidden = true), 10000);
}
private getAllRadios() {
return [...this.querySelectorAll(RADIO_CHILDREN.join(','))].filter(el =>
RADIO_CHILDREN.includes(el.tagName.toLowerCase())
) as SlRadio[];
}
handleRadioClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const checkedRadio = target.closest(RADIO_CHILDREN.map(selector => `${selector}:not([disabled])`).join(','));
private handleRadioClick(event: MouseEvent) {
const target = event.target as SlRadio | SlRadioButton;
if (checkedRadio) {
const radios = this.getAllRadios();
radios.forEach(radio => {
radio.checked = radio === checkedRadio;
radio.input.tabIndex = radio === checkedRadio ? 0 : -1;
});
if (target.disabled) {
return;
}
}
handleKeyDown(event: KeyboardEvent) {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
const radios = this.getAllRadios().filter(radio => !radio.disabled);
const checkedRadio = radios.find(radio => radio.checked) ?? radios[0];
const incr = ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
let index = radios.indexOf(checkedRadio) + incr;
if (index < 0) {
index = radios.length - 1;
}
if (index > radios.length - 1) {
index = 0;
}
this.getAllRadios().forEach(radio => {
radio.checked = false;
radio.input.tabIndex = -1;
});
radios[index].focus();
radios[index].checked = true;
radios[index].input.tabIndex = 0;
event.preventDefault();
}
}
handleSlotChange() {
this.value = target.value;
const radios = this.getAllRadios();
const checkedRadio = radios.find(radio => radio.checked);
radios.forEach(radio => (radio.checked = radio === target));
}
private handleKeyDown(event: KeyboardEvent) {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) {
return;
}
const radios = this.getAllRadios().filter(radio => !radio.disabled);
const checkedRadio = radios.find(radio => radio.checked) ?? radios[0];
const incr = event.key === ' ' ? 0 : ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1;
let index = radios.indexOf(checkedRadio) + incr;
if (index < 0) {
index = radios.length - 1;
}
if (index > radios.length - 1) {
index = 0;
}
this.getAllRadios().forEach(radio => {
radio.checked = false;
if (!this.hasButtonGroup) {
radio.tabIndex = -1;
}
});
this.value = radios[index].value;
radios[index].checked = true;
if (!this.hasButtonGroup) {
radios[index].tabIndex = 0;
radios[index].focus();
} else {
radios[index].shadowRoot!.querySelector('button')!.focus();
}
event.preventDefault();
}
private handleSlotChange() {
const radios = this.getAllRadios();
radios.forEach(radio => {
radio.name = this.name;
radio.checked = radio.value === this.value;
});
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'sl-radio-button');
radios.forEach(radio => {
radio.setAttribute('role', 'radio');
radio.input.tabIndex = -1;
});
if (checkedRadio) {
checkedRadio.input.tabIndex = 0;
} else if (radios.length > 0) {
radios[0].input.tabIndex = 0;
if (!radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
const buttonRadio = radios[0].shadowRoot!.querySelector('button')!;
buttonRadio.tabIndex = 0;
} else {
radios[0].tabIndex = 0;
}
}
if (this.hasButtonGroup) {
const buttonGroup = this.shadowRoot?.querySelector('sl-button-group');
if (buttonGroup) {
buttonGroup.disableRole = true;
}
}
}
private updateCheckedRadio() {
const radios = this.getAllRadios();
radios.forEach(radio => (radio.checked = radio.value === this.value));
}
render() {
const defaultSlot = html`
<slot @click=${this.handleRadioClick} @keydown=${this.handleKeyDown} @slotchange=${this.handleSlotChange}></slot>
<slot
@click=${this.handleRadioClick}
@keydown=${this.handleKeyDown}
@slotchange=${this.handleSlotChange}
role="presentation"
></slot>
`;
return html`
<fieldset
part="base"
role="radiogroup"
aria-errormessage="radio-error-message"
aria-invalid="${this.isInvalid}"
class=${classMap({
'radio-group': true,
'radio-group--has-fieldset': this.fieldset,
@@ -124,6 +237,12 @@ export default class SlRadioGroup extends LitElement {
<legend part="label" class="radio-group__label">
<slot name="label">${this.label}</slot>
</legend>
<div class="visually-hidden">
<div id="radio-error-message" aria-live="assertive">${this.errorMessage}</div>
<label class="radio-group__validation visually-hidden">
<input type="text" class="radio-group__validation-input" ?required=${this.required} tabindex="-1" hidden />
</label>
</div>
${this.hasButtonGroup
? html`<sl-button-group part="button-group">${defaultSlot}</sl-button-group>`
: defaultSlot}

View File

@@ -8,6 +8,10 @@ export default css`
display: inline-block;
}
:host(:focus-visible) {
outline: 0px;
}
.radio {
display: inline-flex;
align-items: top;
@@ -80,7 +84,7 @@ export default css`
}
/* Checked + focus */
.radio.radio--checked:not(.radio--disabled) .radio__input:focus-visible ~ .radio__control {
.radio.radio--focused .radio__control {
outline: var(--sl-focus-ring);
outline-offset: var(--sl-focus-ring-offset);
}

View File

@@ -1,65 +1,17 @@
import { aTimeout, expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import sinon from 'sinon';
import { expect, fixture, html } from '@open-wc/testing';
import type SlRadioGroup from '../../components/radio-group/radio-group';
import type SlRadio from './radio';
describe('<sl-radio>', () => {
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<SlRadio>(html` <sl-radio disabled></sl-radio> `);
const radio = el.input;
expect(radio.disabled).to.be.true;
});
it('should be valid by default', async () => {
const el = await fixture<SlRadio>(html` <sl-radio></sl-radio> `);
expect(el.invalid).to.be.false;
});
it('should fire sl-change when clicked', async () => {
const el = await fixture<SlRadio>(html` <sl-radio></sl-radio> `);
setTimeout(() => el.input.click());
const event = (await oneEvent(el, 'sl-change')) as CustomEvent;
expect(event.target).to.equal(el);
expect(el.checked).to.be.true;
});
it('should fire sl-change when toggled via keyboard - space', async () => {
const el = await fixture<SlRadio>(html` <sl-radio></sl-radio> `);
el.input.focus();
setTimeout(() => sendKeys({ press: ' ' }));
const event = (await oneEvent(el, 'sl-change')) as CustomEvent;
expect(event.target).to.equal(el);
expect(el.checked).to.be.true;
});
it('should fire sl-change when toggled via keyboard - arrow key', async () => {
it('should not get checked when disabled', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio id="radio-1"></sl-radio>
<sl-radio id="radio-2"></sl-radio>
<sl-radio-group value="1">
<sl-radio id="radio-1" value="1"></sl-radio>
<sl-radio id="radio-2" value="2" disabled></sl-radio>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadio>('#radio-1')!;
const radio2 = radioGroup.querySelector<SlRadio>('#radio-2')!;
radio1.input.focus();
setTimeout(() => sendKeys({ press: 'ArrowRight' }));
const event = (await oneEvent(radio2, 'sl-change')) as CustomEvent;
expect(event.target).to.equal(radio2);
expect(radio2.checked).to.be.true;
});
it('should not get checked when disabled', async () => {
const radioGroup = await fixture<SlRadioGroup>(html`
<sl-radio-group>
<sl-radio checked></sl-radio>
<sl-radio disabled></sl-radio>
</sl-radio-group>
`);
const radio1 = radioGroup.querySelector<SlRadio>('sl-radio[checked]')!;
const radio2 = radioGroup.querySelector<SlRadio>('sl-radio[disabled]')!;
radio2.click();
await Promise.all([radio1.updateComplete, radio2.updateComplete]);
@@ -67,116 +19,4 @@ describe('<sl-radio>', () => {
expect(radio1.checked).to.be.true;
expect(radio2.checked).to.be.false;
});
describe('when submitting a form', () => {
it('should submit the correct value when a value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group>
<sl-radio id="radio-1" name="a" value="1" checked></sl-radio>
<sl-radio id="radio-2" name="a" value="2"></sl-radio>
<sl-radio id="radio-3" name="a" value="3"></sl-radio>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio = form.querySelectorAll('sl-radio')[1]!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
radio.click();
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('2');
});
});
describe('when resetting a form', () => {
it('should reset the element to its initial value', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio name="a" value="1" checked></sl-radio>
<sl-button type="reset">Reset</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio = form.querySelector('sl-radio')!;
radio.checked = false;
await radio.updateComplete;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await radio.updateComplete;
expect(radio.checked).to.true;
radio.defaultChecked = false;
setTimeout(() => button.click());
await oneEvent(form, 'reset');
await radio.updateComplete;
expect(radio.checked).to.false;
});
});
it('should submit "on" when no value is provided', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group>
<sl-radio id="radio-1" name="a" checked></sl-radio>
<sl-radio id="radio-2" name="a"></sl-radio>
<sl-radio id="radio-3" name="a"></sl-radio>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio = form.querySelectorAll('sl-radio')[1]!;
const submitHandler = sinon.spy((event: SubmitEvent) => {
formData = new FormData(form);
event.preventDefault();
});
let formData: FormData;
form.addEventListener('submit', submitHandler);
radio.click();
button.click();
await waitUntil(() => submitHandler.calledOnce);
expect(formData!.get('a')).to.equal('on');
});
it('should show a constraint validation error when setCustomValidity() is called', async () => {
const form = await fixture<HTMLFormElement>(html`
<form>
<sl-radio-group>
<sl-radio id="radio-1" name="a" value="1" checked></sl-radio>
<sl-radio id="radio-2" name="a" value="2"></sl-radio>
</sl-radio-group>
<sl-button type="submit">Submit</sl-button>
</form>
`);
const button = form.querySelector('sl-button')!;
const radio = form.querySelectorAll('sl-radio')[1]!;
const submitHandler = sinon.spy((event: SubmitEvent) => event.preventDefault());
// Submitting the form after setting custom validity should not trigger the handler
radio.setCustomValidity('Invalid selection');
form.addEventListener('submit', submitHandler);
button.click();
await aTimeout(100);
expect(submitHandler).to.not.have.been.called;
});
});

View File

@@ -1,11 +1,7 @@
import { html, LitElement } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { defaultValue } from '../../internal/default-value';
import { emit } from '../../internal/event';
import { FormSubmitController } from '../../internal/form';
import { emit } from 'src/internal/event';
import { watch } from '../../internal/watch';
import styles from './radio.styles';
import type { CSSResultGroup } from 'lit';
@@ -17,7 +13,6 @@ import type { CSSResultGroup } from 'lit';
* @slot - The radio's label.
*
* @event sl-blur - Emitted when the control loses focus.
* @event sl-change - Emitted when the control's checked state changes.
* @event sl-focus - Emitted when the control gains focus.
*
* @csspart base - The component's internal wrapper.
@@ -31,16 +26,11 @@ export default class SlRadio extends LitElement {
@query('.radio__input') input: HTMLInputElement;
protected readonly formSubmitController = new FormSubmitController(this, {
value: (control: SlRadio) => (control.checked ? control.value || 'on' : undefined),
defaultValue: (control: SlRadio) => control.defaultChecked,
setValue: (control: SlRadio, checked: boolean) => (control.checked = checked)
});
@state() checked = false;
@state() protected hasFocus = false;
/** The radio's name attribute. */
@property() name: string;
@property({ reflect: true }) name: string;
/** The radio's value attribute. */
@property() value: string;
@@ -48,118 +38,54 @@ export default class SlRadio extends LitElement {
/** Disables the radio. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Draws the radio in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/**
* This will be true when the control is in an invalid state. Validity in radios is determined by the message provided
* by the `setCustomValidity` method.
*/
@property({ type: Boolean, reflect: true }) invalid = false;
/** Gets or sets the default value used to reset this element. The initial value corresponds to the one originally specified in the HTML that created this element. */
@defaultValue('checked')
defaultChecked = false;
connectedCallback(): void {
super.connectedCallback();
this.setAttribute('role', 'radio');
}
/** Simulates a click on the radio. */
click() {
this.input.click();
}
/** Sets focus on the radio. */
focus(options?: FocusOptions) {
this.input.focus(options);
}
/** Removes focus from the radio. */
blur() {
this.input.blur();
}
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity(): boolean {
const group = this.closest('sl-radio-group');
const allRadios = group?.getAllRadios().filter(radio => !radio.disabled);
const isRequired = group?.required;
const isChecked = allRadios?.some(radio => radio.checked);
const internalRadio = (radio: SlRadio): HTMLInputElement =>
radio.shadowRoot!.querySelector<HTMLInputElement>('input[type="radio"]')!;
// If no radio group or radios are found, skip validation
if (!group || !allRadios) {
return true;
}
// If the radio group is required but no radios are checked, mark the first internal radio required and report it
if (isRequired && !isChecked) {
const radio = internalRadio(allRadios[0]);
radio.required = true;
return radio.reportValidity();
}
// Reset the required state of all internal radios so we can accurately report custom validation messages
allRadios.forEach(radio => (internalRadio(radio).required = false));
// Report custom validation errors
for (const radio of allRadios) {
if (!internalRadio(radio).reportValidity()) {
return false;
}
}
return true;
}
/** Sets a custom validation message. If `message` is not empty, the field will be considered invalid. */
setCustomValidity(message: string) {
this.input.setCustomValidity(message);
this.invalid = !this.input.checkValidity();
}
handleBlur() {
this.hasFocus = false;
emit(this, 'sl-blur');
}
handleClick() {
if (!this.disabled) {
this.checked = true;
}
}
handleFocus() {
this.hasFocus = true;
emit(this, 'sl-focus');
this.setInitialAttributes();
this.addEventListeners();
}
@watch('checked')
handleCheckedChange() {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
if (this.hasUpdated) {
emit(this, 'sl-change');
}
this.setAttribute('tabindex', this.checked ? '0' : '-1');
}
@watch('disabled', { waitUntilFirstUpdate: true })
handleDisabledChange() {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
// Disabled form controls are always valid, so we need to recheck validity when the state changes
if (this.hasUpdated) {
this.input.disabled = this.disabled;
this.invalid = !this.input.checkValidity();
private handleBlur() {
this.hasFocus = false;
emit(this, 'sl-blur');
}
private handleClick() {
if (!this.disabled) {
this.checked = true;
}
}
private handleFocus() {
this.hasFocus = true;
emit(this, 'sl-focus');
}
private addEventListeners() {
this.addEventListener('blur', () => this.handleBlur());
this.addEventListener('click', () => this.handleClick());
this.addEventListener('focus', () => this.handleFocus());
}
private setInitialAttributes() {
this.setAttribute('role', 'radio');
this.setAttribute('tabindex', '-1');
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
render() {
return html`
<label
<span
part="base"
class=${classMap({
radio: true,
@@ -168,17 +94,6 @@ export default class SlRadio extends LitElement {
'radio--focused': this.hasFocus
})}
>
<input
class="radio__input"
type="radio"
name=${ifDefined(this.name)}
value=${ifDefined(this.value)}
.checked=${live(this.checked)}
.disabled=${this.disabled}
@click=${this.handleClick}
@blur=${this.handleBlur}
@focus=${this.handleFocus}
/>
<span part="control" class="radio__control">
<svg part="checked-icon" class="radio__icon" viewBox="0 0 16 16">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
@@ -192,7 +107,7 @@ export default class SlRadio extends LitElement {
<span part="label" class="radio__label">
<slot></slot>
</span>
</label>
</span>
`;
}
}