diff --git a/docs/components/radio-group.md b/docs/components/radio-group.md index 0f784721b..17241de51 100644 --- a/docs/components/radio-group.md +++ b/docs/components/radio-group.md @@ -42,11 +42,62 @@ You can show a fieldset and legend that wraps the radio group using the `fieldse import { SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; const App = () => ( - + Option 1 Option 2 Option 3 ); ``` + +### Using the required attribute + +Adding a `required` attribute to `sl-radio-group` will require at least one option to be selected. + +```html preview + + Option 1 + Option 2 + Option 3 + +
+Validate Group +Reset Group + + +``` + +```jsx react +import { SlRadio, SlRadioGroup } from '@shoelace-style/shoelace/dist/react'; +const validateGroup = () => { + const group = document.querySelector('sl-radio-group.required-radio-group'); + group.reportValidity(); +} + +const resetGroup = () => { + const group = document.querySelector('sl-radio-group.required-radio-group'); + group.value = ""; +} + +const App = () => ( + <> + + Option 1 + Option 2 + Option 3 + +
+ validateGroup()}>Validate Group + resetGroup()}>Reset Group + +); +``` + [component-metadata:sl-radio-group] diff --git a/src/components/radio-group/radio-group.test.ts b/src/components/radio-group/radio-group.test.ts new file mode 100644 index 000000000..60f58ebc0 --- /dev/null +++ b/src/components/radio-group/radio-group.test.ts @@ -0,0 +1,88 @@ +import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; + +import '../../../dist/shoelace.js'; +import type SlRadio from '../radio/radio'; +import type SlRadioGroup from './radio-group'; + +describe('', () => { + it('should toggle selected radio when toggled via keyboard - arrow right key', async () => { + const radioGroup = await fixture(html` + + + + + `); + const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1'); + const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2'); + + expect(radio2.checked).to.be.false; + expect(radio1.checked).to.be.true; + + radio1.focus(); + await sendKeys({ press: 'ArrowRight' }); + + expect(radio2.checked).to.be.true; + expect(radio1.checked).to.be.false; + }); + + it('should toggle selected radio when toggled via keyboard - arrow down key', async () => { + const radioGroup = await fixture(html` + + + + + `); + const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1'); + const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2'); + + expect(radio2.checked).to.be.false; + expect(radio1.checked).to.be.true; + + radio1.focus(); + await sendKeys({ press: 'ArrowDown' }); + + expect(radio2.checked).to.be.true; + expect(radio1.checked).to.be.false; + }); + + it('should toggle selected radio when toggled via keyboard - arrow left key', async () => { + const radioGroup = await fixture(html` + + + + + `); + const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1'); + const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2'); + + expect(radio2.checked).to.be.true; + expect(radio1.checked).to.be.false; + + radio1.focus(); + await sendKeys({ press: 'ArrowLeft' }); + + expect(radio2.checked).to.be.false; + expect(radio1.checked).to.be.true; + }); + + it('should toggle selected radio when toggled via keyboard - arrow up key', async () => { + const radioGroup = await fixture(html` + + + + + `); + const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1'); + const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2'); + + expect(radio2.checked).to.be.true; + expect(radio1.checked).to.be.false; + + radio1.focus(); + await sendKeys({ press: 'ArrowUp' }); + + expect(radio2.checked).to.be.false; + expect(radio1.checked).to.be.true; + }); +}); diff --git a/src/components/radio-group/radio-group.ts b/src/components/radio-group/radio-group.ts index f306ac82f..e3aca1efb 100644 --- a/src/components/radio-group/radio-group.ts +++ b/src/components/radio-group/radio-group.ts @@ -17,15 +17,63 @@ import styles from './radio-group.styles'; @customElement('sl-radio-group') export default class SlRadioGroup extends LitElement { static styles = styles; + private _value: string = ''; @query('slot:not([name])') defaultSlot: HTMLSlotElement; /** The radio group label. Required for proper accessibility. Alternatively, you can use the label slot. */ @property() label = ''; + /** The current value of the radio group. */ + @property() + get value() { + if (!this._value) return this.getCurrentValue(); + + return this._value; + } + + set value(newValue) { + const index = this.getAllRadios().findIndex(el => el.value === newValue); + const oldValue = this._value; + + if (index > -1) { + this.checkRadioByIndex(index); + this._value = newValue; + this.requestUpdate('value', oldValue); + } else { + this._value = ''; + this.deselectAll(); + } + } + /** Shows the fieldset and legend that surrounds the radio group. */ @property({ type: Boolean, attribute: 'fieldset' }) fieldset = false; + /** Indicates that a selection is required. */ + @property({ type: Boolean, reflect: true }) required = false; + + connectedCallback() { + this.addEventListener('sl-change', this.syncRadioButtons); + } + + disconnectedCallback() { + this.removeEventListener('sl-change', this.syncRadioButtons); + } + + syncRadioButtons(event: CustomEvent) { + const currentRadio = event.target; + const radios = this.getAllRadios().filter(el => !el.disabled && el !== currentRadio); + radios.forEach(el => { + el.checked = false; + }); + } + + getCurrentValue() { + const valRadio = this.getAllRadios().filter(el => el.checked); + this._value = valRadio.length === 1 ? valRadio[0].value : ''; + return this._value; + } + handleFocusIn() { // When tabbing into the fieldset, make sure it lands on the checked radio requestAnimationFrame(() => { @@ -39,6 +87,63 @@ export default class SlRadioGroup extends LitElement { }); } + getAllRadios(): SlRadio[] { + return [...this.querySelectorAll('sl-radio')]; + } + + checkRadioByIndex(index: number): SlRadio[] { + const radios = this.deselectAll(); + + radios[index].focus(); + radios[index].checked = true; + this._value = radios[index].value; + + return radios; + } + + deselectAll(): SlRadio[] { + return this.getAllRadios().map(radio => { + radio.checked = false; + return radio; + }); + } + + handleKeyDown(event: KeyboardEvent) { + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + const radios = this.getAllRadios().filter(radio => !radio.disabled); + const currentIndex = radios.findIndex(el => el.checked); + + const incr = ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1; + let index = currentIndex + incr; + if (index < 0) index = radios.length - 1; + if (index > radios.length - 1) index = 0; + + this.checkRadioByIndex(index); + + event.preventDefault(); + } + } + + reportValidity() { + const radios = [...(this.defaultSlot.assignedElements({ flatten: true }) as SlRadio[])]; + let isChecked = true; + + if (this.required && radios.length > 0) { + isChecked = radios.some(el => el.checked); + + if (!isChecked) { + // This is hacky... + radios[0].required = true; + + setTimeout(() => { + radios[0].reportValidity(); + }, 0); + } + } + + return isChecked; + } + render() { return html`
${this.label} diff --git a/src/components/radio/radio.test.ts b/src/components/radio/radio.test.ts index f76c1288b..37dbd3bbd 100644 --- a/src/components/radio/radio.test.ts +++ b/src/components/radio/radio.test.ts @@ -37,23 +37,6 @@ describe('', () => { expect(el.checked).to.be.true; }); - it('should fire sl-change when toggled via keyboard - arrow key', async () => { - const radioGroup = await fixture(html` - - - - - `); - const radio1: SlRadio = radioGroup.querySelector('sl-radio#radio-1'); - const radio2: SlRadio = radioGroup.querySelector('sl-radio#radio-2'); - const input1 = radio1.shadowRoot?.querySelector('input'); - input1.focus(); - setTimeout(() => sendKeys({ press: 'ArrowRight' })); - const event = await oneEvent(radio2, 'sl-change'); - expect(event.target).to.equal(radio2); - expect(radio2.checked).to.be.true; - }); - it('should not fire sl-change when checked is set by javascript', async () => { const el = await fixture(html` `); el.addEventListener('sl-change', () => expect.fail('event fired')); diff --git a/src/components/radio/radio.ts b/src/components/radio/radio.ts index 79690c204..a36d018a4 100644 --- a/src/components/radio/radio.ts +++ b/src/components/radio/radio.ts @@ -47,6 +47,9 @@ export default class SlRadio extends LitElement { /** Draws the radio in a checked state. */ @property({ type: Boolean, reflect: true }) checked = false; + /** Indicates that a selection is required. */ + @property({ type: Boolean, reflect: true }) required = false; + /** * This will be true when the control is in an invalid state. Validity in range inputs is determined by the message * provided by the `setCustomValidity` method. @@ -79,33 +82,11 @@ export default class SlRadio extends LitElement { this.invalid = !this.input.checkValidity(); } - getAllRadios() { - const radioGroup = this.closest('sl-radio-group'); - - // Radios must be part of a radio group - if (!radioGroup) { - return [this]; - } - - return [...radioGroup.querySelectorAll('sl-radio')].filter((radio: this) => radio.name === this.name) as this[]; - } - - getSiblingRadios() { - return this.getAllRadios().filter(radio => radio !== this) as this[]; - } - handleBlur() { this.hasFocus = false; emit(this, 'sl-blur'); } - @watch('checked', { waitUntilFirstUpdate: true }) - handleCheckedChange() { - if (this.checked) { - this.getSiblingRadios().map(radio => (radio.checked = false)); - } - } - handleClick() { this.checked = true; emit(this, 'sl-change'); @@ -125,23 +106,6 @@ export default class SlRadio extends LitElement { emit(this, 'sl-focus'); } - handleKeyDown(event: KeyboardEvent) { - if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { - const radios = this.getAllRadios().filter(radio => !radio.disabled); - const incr = ['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1; - let index = radios.indexOf(this) + incr; - if (index < 0) index = radios.length - 1; - if (index > radios.length - 1) index = 0; - - this.getAllRadios().map(radio => (radio.checked = false)); - radios[index].focus(); - radios[index].checked = true; - emit(radios[index], 'sl-change'); - - event.preventDefault(); - } - } - render() { return html`