feat(checkbox): replace RovingTabIndexController with focusgroup - #3566
feat(checkbox): replace RovingTabIndexController with focusgroup#3566jpzwarte wants to merge 7 commits into
RovingTabIndexController with focusgroup#3566Conversation
Replaces the custom RovingTabindexController keyboard navigation with the native focusgroup HTML attribute (via @microsoft/focusgroup-polyfill) in the checkbox-group component, as part of the investigation for issue #3187. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🕸 Preview deploys |
There was a problem hiding this comment.
Pull request overview
This PR migrates sl-checkbox-group away from the custom RovingTabindexController toward the emerging platform focusgroup behavior, using @microsoft/focusgroup-polyfill to keep behavior working in non-supporting environments.
Changes:
- Added
@microsoft/focusgroup-polyfilland loaded it in Vitest and Storybook environments. - Refactored
sl-checkbox-groupto use afocusgroupwrapper instead ofRovingTabindexController, plus updated styling and stories. - Updated checkbox-group tests for the new behavior (but currently includes focused-test/debug artifacts).
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
vitest.setup.ts |
Loads focusGroup polyfill during tests when needed. |
.storybook/preview.ts |
Loads focusGroup polyfill for Storybook rendering. |
packages/components/checkbox/src/checkbox-group.ts |
Replaces roving tabindex controller usage with focusgroup wrapper and refactors event wiring. |
packages/components/checkbox/src/checkbox-group.scss |
Moves layout styling to the new wrapper part. |
packages/components/checkbox/src/checkbox-group.stories.ts |
Adds a tooltip-focused story to exercise navigation with extra focusable content. |
packages/components/checkbox/src/checkbox-group.spec.ts |
Adjusts assertions and navigation test for the new focus behavior. |
package.json |
Adds @microsoft/focusgroup-polyfill dependency. |
Suppressed comments (1)
packages/components/checkbox/src/checkbox-group.spec.ts:237
- Remove debug
console.logstatements from tests to keep test output clean and avoid noise in CI logs.
console.log(el.renderRoot.querySelector('[part="wrapper"]')?.getAttribute('focusgroup')); // toolbar block wrap
// console.log(el.renderRoot.querySelector('[part="wrapper"]')?.focusGroup); // undefined
| this.#observer.observe(this, OBSERVER_OPTIONS); | ||
|
|
||
| this.internals.role = 'group'; | ||
| this.setFormControlElement(this); |
| requestAnimationFrame(() => | ||
| this.setProxyTarget(this.renderRoot.querySelector('[part="wrapper"]')!) | ||
| ); |
| * | ||
| * @default false | ||
| */ | ||
| @property({ type: Boolean }) override disabled?: boolean; |
| * | ||
| * @default false | ||
| */ | ||
| @property({ type: Boolean }) override required?: boolean; |
🦋 Changeset detectedLatest commit: 5806141 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (9)
packages/components/checkbox/src/checkbox-group.ts:88
requiredno longer reflects to an attribute. This diverges from the established pattern for form controls in this repo (e.g.radio-group/src/radio-group.ts:140,checkbox/src/checkbox.ts:113,select/src/select.ts:223) and is a breaking API change for attribute-based styling/testing.
@property({ type: Boolean }) override required?: boolean;
.storybook/preview.ts:28
- The
console.loginside the Storybook preview polyfill block is leftover debug output and will spam the browser console for all stories.
if (!('focusGroup' in HTMLElement.prototype)) {
const { polyfillBodyAndObserve } = await import('@microsoft/focusgroup-polyfill');
console.log('Applying focusGroup polyfill', polyfillBodyAndObserve);
polyfillBodyAndObserve();
}
packages/components/checkbox/src/checkbox-group.ts:128
requestAnimationFrame(() => this.setProxyTarget(this.renderRoot.querySelector(... )!))can run before the wrapper is rendered; ifquerySelectorreturns null this will throw at runtime.ForwardAriaMixinalso documents callingsetProxyTargettypically infirstUpdated.
requestAnimationFrame(() =>
this.setProxyTarget(this.renderRoot.querySelector('[part="wrapper"]')!)
);
packages/components/checkbox/src/checkbox-group.ts:114
internals.roleis no longer set. Other form-associated group controls (e.g.sl-radio-groupsetsinternals.role = 'radiogroup') use ElementInternals to expose the correct ARIA role on the host; relying only on a shadow childrole="group"can reduce AT interoperability.
this.#observer.observe(this, OBSERVER_OPTIONS);
this.setFormControlElement(this);
packages/components/checkbox/src/checkbox-group.ts:81
disabledno longer reflects to an attribute. In this codebase, form controldisabledis consistentlyreflect: true(e.g.radio-group/src/radio-group.ts:130,checkbox/src/checkbox.ts:96,select/src/select.ts:184), and removing reflection is a breaking change for users relying on attribute selectors and SSR.
@property({ type: Boolean }) override disabled?: boolean;
packages/components/checkbox/src/checkbox-group.ts:200
sl-checkbox-groupno longer overridesfocus(). A number of existing tests/usages callel.focus()expecting focus to move into the first focusable checkbox; without an override,HTMLElement.focus()on a custom element is typically a no-op unless it is made focusable viatabindex. This is a behavioral breaking change.
override reportValidity(): boolean {
this.boxes?.forEach(box => box.reportValidity());
return super.reportValidity();
}
packages/components/checkbox/src/checkbox-group.spec.ts:232
it.only(...)will cause Vitest to run only this single test and skip the rest of the suite, which can mask failures in other tests.
it.only('should handle navigating between options correctly', async () => {
packages/components/checkbox/src/checkbox-group.spec.ts:237
- Leftover debug logging in a test will add noise to CI output and can slow down runs. This should be removed before merging.
console.log(el.renderRoot.querySelector('[part="wrapper"]')?.getAttribute('focusgroup')); // toolbar block wrap
// console.log(el.renderRoot.querySelector('[part="wrapper"]')?.focusGroup); // undefined
packages/components/checkbox/src/checkbox-group.ts:209
- Clicking the host (e.g. via an associated
<label>) currently focusesboxes?.at(0), even if the first checkbox is disabled. This can leave focus on a non-interactive control; the old roving logic focused the first enabled option.
#onClick = (event: Event): void => {
if (event.target === this) {
this.boxes?.at(0)?.focus();
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.
Suppressed comments (8)
packages/components/checkbox/src/checkbox-group.ts:210
sl-checkbox-groupno longer overridesfocus(), so callingel.focus()won’t move focus into the group (the existing tests expect focus to land on the first checkbox input). Also, clicking an associated<label>currently focusesboxes.at(0)even if it’s disabled; the previous behavior focused the first enabled option.
#onClick = (event: Event): void => {
if (event.target === this) {
this.boxes?.at(0)?.focus();
}
};
packages/components/checkbox/src/checkbox-group.ts:128
setProxyTarget()is invoked viarequestAnimationFrame()inconnectedCallback()with a non-null assertion. If the first render hasn’t happened yet,querySelector('[part="wrapper"]')can be null and throw.ForwardAriaMixinalso documents callingsetProxyTarget()infirstUpdated(or after the initial render).
requestAnimationFrame(() =>
this.setProxyTarget(this.renderRoot.querySelector('[part="wrapper"]')!)
);
packages/components/checkbox/src/checkbox-group.ts:81
disabledno longer reflects to an attribute. Most form controls in this repo reflectdisabled(e.g.radio-group.ts:131), and removing reflection can be a breaking API change for styling and consumers that observe attributes.
@property({ type: Boolean }) override disabled?: boolean;
packages/components/checkbox/src/checkbox-group.ts:88
requiredno longer reflects to an attribute. Most form controls in this repo reflectrequired(e.g.radio-group.ts:140), and removing reflection can be a breaking API change for styling and consumers that observe attributes.
@property({ type: Boolean }) override required?: boolean;
packages/components/checkbox/src/checkbox-group.spec.ts:244
- After
{ArrowDown}, focus will be inside the second checkbox’s internal<input>(or its proxy), not on thesl-checkboxhost itself. Also, the:state(checked)assertions won’t match becausesl-checkboxdoesn’t use custom states. Use.checkedand assert focus against the corresponding input element.
expect(document.activeElement).to.equal(el.boxes?.at(1));
packages/components/checkbox/src/checkbox-group.ts:184
focusgroupkeyboard navigation requires either native support or the polyfill, but this component doesn’t load the polyfill at runtime (it’s only loaded in Storybook/Vitest setup). In browsers without nativefocusgroup, arrow-key roving behavior will regress unless consumers are instructed to include the polyfill.
<div focusgroup="toolbar block wrap" part="wrapper" role="group">
vitest.setup.ts:13
- Feature-detecting
focusgroupsupport via'focusGroup' in HTMLElement.prototypeis unreliable for this polyfill (it does not guarantee adding afocusGroupproperty). This can cause the polyfill to be loaded unnecessarily or repeatedly. Consider guarding with a one-time global flag instead.
if (!('focusGroup' in HTMLElement.prototype)) {
const { polyfillBodyAndObserve } = await import('@microsoft/focusgroup-polyfill');
polyfillBodyAndObserve();
}
.storybook/preview.ts:27
- Feature-detecting
focusgroupsupport via'focusGroup' in HTMLElement.prototypeis unreliable for this polyfill (it does not guarantee adding afocusGroupproperty). This can cause the polyfill to be loaded unnecessarily or repeatedly. Consider guarding with a one-time global flag instead.
if (!('focusGroup' in HTMLElement.prototype)) {
const { polyfillBodyAndObserve } = await import('@microsoft/focusgroup-polyfill');
polyfillBodyAndObserve();
}
| expect(boxes[0]).to.match(':state(checked)'); | ||
| expect(boxes[1]).to.match(':state(checked)'); | ||
| expect(boxes[2]).not.to.match(':state(checked)'); |
… from focusgroup Adds a `checked` custom state to `sl-checkbox` so `:state(checked)` can be used, and opts the infotip slot out of the focusgroup when the checkbox is disabled. Removes the checkbox group tests that relied on the removed `focus()` override. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (6)
packages/components/checkbox/src/checkbox-group.ts:196
sl-checkbox-groupno longer overridesfocus(). Since the host isn’t focusable (no tabindex), calling.focus()won’t move focus into the group, which is inconsistent with other composite controls (e.g.radio-groupoverridesfocus()to focus the first item).
}
override reportValidity(): boolean {
packages/components/checkbox/src/checkbox-group.ts:81
disabledis no longer reflected to an attribute (@property({ reflect: true })), soel.disabled = truewon’t produce a[disabled]attribute. Most other form controls in this repo reflectdisabled(e.g.radio-group/src/radio-group.ts:131), and consumers often rely on[disabled]for styling and state introspection.
@property({ type: Boolean }) override disabled?: boolean;
packages/components/checkbox/src/checkbox-group.ts:88
requiredis no longer reflected to an attribute, soel.required = truewon’t produce a[required]attribute. Other comparable controls reflectrequired(e.g.radio-group/src/radio-group.ts:140), and dropping reflection is a breaking DOM/API behavior change for consumers using attribute selectors.
@property({ type: Boolean }) override required?: boolean;
packages/components/checkbox/src/checkbox-group.ts:184
role="group"is applied to the internal[part="wrapper"], but the group’s required state is set on the host viathis.internals.ariaRequired(see willUpdate). That means the element withrole="group"won’t reflectaria-required, so assistive tech may not announce the group as required.
<div focusgroup="toolbar block wrap" part="wrapper" role="group">
packages/components/checkbox/src/checkbox.ts:258
toggle()(and therefore click/keyboard activation) never clearsindeterminate. Withrole="checkbox", a user activation should move frommixedto a determinatechecked/uncheckedstate; otherwisearia-checkedstaysmixedindefinitely and the checkbox can’t be cleared from indeterminate by user interaction.
toggle(force?: boolean): void {
// Changing `checked` schedules an update, and `willUpdate` syncs the form value and validity;
// doing that here as well would emit `sl-validate` twice per toggle.
this.checked = force ?? !this.checked;
packages/components/checkbox/src/checkbox-group.ts:209
- When the host is clicked (e.g. via an associated
<label>), the group focusesboxes.at(0)even if that checkbox is disabled. Previously the roving tabindex logic skipped disabled items; this should focus the first enabled checkbox.
#onClick = (event: Event): void => {
if (event.target === this) {
this.boxes?.at(0)?.focus();
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (4)
packages/components/checkbox/src/checkbox-group.ts:128
setProxyTarget()is scheduled viarequestAnimationFrame()inconnectedCallback(). This is a bit racy because it assumes the first Lit render has completed by the next frame; the ForwardAriaMixin docs/tests consistently set the proxy target infirstUpdated()after the shadow DOM exists.
Using updateComplete here avoids timing issues without relying on an extra frame.
requestAnimationFrame(() =>
this.setProxyTarget(this.renderRoot.querySelector('[part="wrapper"]')!)
);
vitest.setup.ts:13
- The focusgroup polyfill feature-detection checks for
focusGrouponHTMLElement.prototype, but focusgroup is an HTML attribute and this check is unlikely to ever become true. This will effectively always load the polyfill and could also conflict with future native implementations if the detection never flips.
Consider either loading the polyfill unconditionally in the test environment (for consistency), or switching to the polyfill package’s recommended detection mechanism instead of focusGroup.
// Load the polyfill for the focusGroup API if needed
if (!('focusGroup' in HTMLElement.prototype)) {
const { polyfillBodyAndObserve } = await import('@microsoft/focusgroup-polyfill');
polyfillBodyAndObserve();
}
.storybook/preview.ts:27
- The focusgroup polyfill feature-detection checks for
focusGrouponHTMLElement.prototype, but focusgroup is an HTML attribute and this check is unlikely to ever become true. This will effectively always load the polyfill and could also conflict with future native implementations if the detection never flips.
Consider either loading the polyfill unconditionally in Storybook (for consistency), or switching to the polyfill package’s recommended detection mechanism instead of focusGroup.
// Load the polyfill for the focusGroup API if needed
if (!('focusGroup' in HTMLElement.prototype)) {
const { polyfillBodyAndObserve } = await import('@microsoft/focusgroup-polyfill');
polyfillBodyAndObserve();
}
packages/components/checkbox/src/checkbox.ts:269
toggle(force?)currently emitssl-changeand marks the control dirty even ifforcematches the current checked state (e.g.toggle(true)when already checked). Other components’toggle(force)helpers (e.g.sl-accordion-item) no-op in that case to avoid redundant events/state churn.
toggle(force?: boolean): void {
// Changing `checked` schedules an update, and `willUpdate` syncs the form value and validity;
// doing that here as well would emit `sl-validate` twice per toggle.
this.checked = force ?? !this.checked;
Closes #3187