From 5a3d46ae00d9f1bd7c0910f741173536088b988c Mon Sep 17 00:00:00 2001 From: Christian Schilling Date: Thu, 10 Oct 2024 20:00:14 +0200 Subject: [PATCH] Add ability to focus sl-radio-group dynamically (#2192) * Add ability to focus sl-radio-group dynamically * Adjusted to review findings * add changelog entry * prettier * add extra timeout for safari * prettier --------- Co-authored-by: konnorrogers --- docs/pages/resources/changelog.md | 1 + .../radio-group/radio-group.component.ts | 23 +++-- .../radio-group/radio-group.test.ts | 96 +++++++++++++++++++ src/components/select/select.test.ts | 1 + 4 files changed, 113 insertions(+), 8 deletions(-) diff --git a/docs/pages/resources/changelog.md b/docs/pages/resources/changelog.md index 785a1826..f7b06af2 100644 --- a/docs/pages/resources/changelog.md +++ b/docs/pages/resources/changelog.md @@ -15,6 +15,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti ## Next - Added Finnish translations [#2211] +- Added the `.focus` function to `` [#2192] - Fixed a bug with with `` not respecting its initial value. [#2204] - Fixed a bug with certain bundlers when using dynamic imports [#2210] - Fixed a bug in `` causing scroll jumping when using `resize="auto"` [#2182] diff --git a/src/components/radio-group/radio-group.component.ts b/src/components/radio-group/radio-group.component.ts index aecf78b5..c1b4dc4b 100644 --- a/src/components/radio-group/radio-group.component.ts +++ b/src/components/radio-group/radio-group.component.ts @@ -192,14 +192,7 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor } private handleLabelClick() { - const radios = this.getAllRadios(); - const checked = radios.find(radio => radio.checked); - const radioToFocus = checked || radios[0]; - - // Move focus to the checked radio (or the first one if none are checked) when clicking the label - if (radioToFocus) { - radioToFocus.focus(); - } + this.focus(); } private handleInvalid(event: Event) { @@ -325,6 +318,20 @@ export default class SlRadioGroup extends ShoelaceElement implements ShoelaceFor this.formControlController.updateValidity(); } + /** Sets focus on the radio-group. */ + public focus(options?: FocusOptions) { + const radios = this.getAllRadios(); + const checked = radios.find(radio => radio.checked); + const firstEnabledRadio = radios.find(radio => !radio.disabled); + const radioToFocus = checked || firstEnabledRadio; + + // Call focus for the checked radio + // If no radio is checked, focus the first one that is not disabled + if (radioToFocus) { + radioToFocus.focus(options); + } + } + render() { const hasLabelSlot = this.hasSlotController.test('label'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); diff --git a/src/components/radio-group/radio-group.test.ts b/src/components/radio-group/radio-group.test.ts index ba9c9a7e..4fa2e532 100644 --- a/src/components/radio-group/radio-group.test.ts +++ b/src/components/radio-group/radio-group.test.ts @@ -300,6 +300,102 @@ describe('when a size is applied', () => { }); }); +describe('when handling focus', () => { + const doAction = async (instance: SlRadioGroup, type: string) => { + if (type === 'focus') { + instance.focus(); + await instance.updateComplete; + return; + } + + const label = instance.shadowRoot!.querySelector('#label')!; + label.click(); + await instance.updateComplete; + }; + + // Tests for focus and label actions with radio buttons + ['focus', 'label'].forEach(actionType => { + describe(`when using ${actionType}`, () => { + it('should do nothing if all elements are disabled', async () => { + const el = await fixture(html` + + + + + + + `); + + const validFocusHandler = sinon.spy(); + + Array.from(el.querySelectorAll('sl-radio')).forEach(radio => + radio.addEventListener('sl-focus', validFocusHandler) + ); + + expect(validFocusHandler).to.not.have.been.called; + await doAction(el, actionType); + expect(validFocusHandler).to.not.have.been.called; + }); + + it('should focus the first radio that is enabled when the group receives focus', async () => { + const el = await fixture(html` + + + + + + + `); + + const invalidFocusHandler = sinon.spy(); + const validFocusHandler = sinon.spy(); + + const disabledRadio = el.querySelector('#radio-0')!; + const validRadio = el.querySelector('#radio-1')!; + + disabledRadio.addEventListener('sl-focus', invalidFocusHandler); + validRadio.addEventListener('sl-focus', validFocusHandler); + + expect(invalidFocusHandler).to.not.have.been.called; + expect(validFocusHandler).to.not.have.been.called; + + await doAction(el, actionType); + + expect(invalidFocusHandler).to.not.have.been.called; + expect(validFocusHandler).to.have.been.called; + }); + + it('should focus the currently enabled radio when the group receives focus', async () => { + const el = await fixture(html` + + + + + + + `); + + const invalidFocusHandler = sinon.spy(); + const validFocusHandler = sinon.spy(); + + const disabledRadio = el.querySelector('#radio-0')!; + const validRadio = el.querySelector('#radio-2')!; + + disabledRadio.addEventListener('sl-focus', invalidFocusHandler); + validRadio.addEventListener('sl-focus', validFocusHandler); + + expect(invalidFocusHandler).to.not.have.been.called; + expect(validFocusHandler).to.not.have.been.called; + + await doAction(el, actionType); + + expect(invalidFocusHandler).to.not.have.been.called; + expect(validFocusHandler).to.have.been.called; + }); + }); + }); +}); + describe('when the value changes', () => { it('should emit sl-change when toggled with the arrow keys', async () => { const radioGroup = await fixture(html` diff --git a/src/components/select/select.test.ts b/src/components/select/select.test.ts index 1de3eced..a1967ec1 100644 --- a/src/components/select/select.test.ts +++ b/src/components/select/select.test.ts @@ -601,6 +601,7 @@ describe('', () => { ); const el = form.querySelector('sl-select')!; + await aTimeout(10); expect(el.value).to.equal(''); expect(new FormData(form).get('select')).equal('');