feat(switch): refactor switch - #3604
Open
jpzwarte wants to merge 7 commits into
Open
Conversation
The switch no longer adds an `<input>`, a `<label>` and a `<style>` element to its light DOM. It is now the form associated element itself, using `ElementInternals` for the form value and validity, and renders an `<input type="checkbox" role="switch">` in its shadow DOM as the control for the keyboard and assistive technology, following the WAI-ARIA Authoring Practices. ARIA attributes set on the switch are forwarded to that input by `ForwardAriaMixin`, so `aria-disabled` now works: the switch looks disabled and cannot be toggled, but stays focusable. Also adds a `description` slot, `checked`/`has-description`/`has-label` custom states and new CSS parts, and a `getSlottedText()` utility to `@sl-design-system/shared` for reading the text assigned to a slot. BREAKING CHANGE: see the changeset for the full list of breaking changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The switch can show a tooltip, the same way `<sl-button>` does, without having to add an `<sl-tooltip>` yourself. When you hover anywhere on the switch, the tooltip is centered on the switch as a whole; when the toggle has focus, the tooltip is centered on the toggle. The tooltip describes the switch, unless the switch has no label, in which case it labels the switch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 2cb8f6e The changes in this PR will be included in the next version bump. This PR includes changesets to release 37 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 |
Contributor
🕸 Preview deploys |
Contributor
There was a problem hiding this comment.
Pull request overview
Refactors <sl-switch> into a form-associated custom element and adds accessible descriptions, ARIA forwarding, tooltips, and programmatic toggling.
Changes:
- Moves the checkbox control into shadow DOM using
ElementInternals. - Adds tooltip, description, CSS parts/states, and
toggle(). - Extends shared slot and ARIA-forwarding utilities with tests.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
yarn.lock |
Locks the tooltip dependency. |
packages/components/switch/src/switch.ts |
Implements the switch refactor and new APIs. |
packages/components/switch/src/switch.stories.ts |
Adds updated usage examples. |
packages/components/switch/src/switch.spec.ts |
Tests interaction, forms, ARIA, and tooltips. |
packages/components/switch/src/switch.scss |
Styles the new shadow-DOM structure and states. |
packages/components/switch/package.json |
Adds the tooltip dependency. |
packages/components/shared/src/slot.ts |
Adds slot-content utilities. |
packages/components/shared/src/slot.spec.ts |
Tests slot utilities. |
packages/components/shared/src/mixins/forward-aria-mixin.ts |
Adds data-label-id forwarding. |
packages/components/shared/src/mixins/forward-aria-mixin.spec.ts |
Tests label-reference forwarding. |
packages/components/shared/package.json |
Exports the slot utility module. |
.changeset/tame-spiders-invite.md |
Documents switch changes. |
.changeset/gentle-slots-listen.md |
Documents shared slot utilities. |
Suppressed comments (1)
packages/components/shared/src/mixins/forward-aria-mixin.ts:288
- Re-forwarding one attribute can drop a shared reference contributed by another attribute. If both
aria-labelledbyanddata-label-idcurrently point to the same label, changing either one puts the shared old label inours, filters it out, and never restores the other attribute's contribution. Exclude old references only when no other tracked attribute contributes them.
const current =
(targetElement as unknown as Record<string, Element[] | null>)[elementsProp] ?? [],
ours = new Set<Element>([...(forwarded.get(name) ?? []), ...elements]);
// Keep references added by others (including the ones forwarded for another
// attribute), but replace the ones from our previous forward of this attribute
(targetElement as unknown as Record<string, Element[]>)[elementsProp] = [
...current.filter(el => !ours.has(el)),
...elements
];
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| * | ||
| * @default false | ||
| */ | ||
| @property({ type: Boolean }) override disabled?: boolean; |
Comment on lines
+332
to
+336
| #onClick = (event: MouseEvent): void => { | ||
| // This handles the case where the user clicks on the <label> element | ||
| // that is part of the `<sl-label>` in `<sl-form-field>`. | ||
| if (event.composedPath().at(0) === this) { | ||
| this.toggle(); |
| aria-describedby=${describedBy || nothing} | ||
| aria-labelledby=${labelledBy || nothing} | ||
| .checked=${!!this.checked} | ||
| ?disabled=${this.disabled} |
Comment on lines
+317
to
+319
| const ariaDisabled = | ||
| this.hasAttribute('aria-disabled') || | ||
| (this.hasUpdated && this.input.hasAttribute('aria-disabled')); |
Comment on lines
224
to
231
| const forwarded = forwardedElementsStorage.get(this), | ||
| previous = forwarded?.get(elementsProp) ?? [], | ||
| previous = forwarded?.get(name) ?? [], | ||
| current = | ||
| (target as unknown as Record<string, Element[] | null>)[elementsProp] ?? [], | ||
| remaining = current.filter(el => !previous.includes(el)); | ||
|
|
||
| (target as unknown as Record<string, Element[] | null>)[elementsProp] = | ||
| remaining.length ? remaining : null; |
Comment on lines
+5
to
+6
| Add a new `slot.js` export with a `getSlottedText()` utility. It returns the text assigned to a | ||
| slot, with whitespace collapsed and trimmed, or `null` when the given target is not a slot. This |
Comment on lines
+414
to
+420
| #onWrapperClick = (event: MouseEvent): void => { | ||
| event.preventDefault(); | ||
| event.stopPropagation(); | ||
|
|
||
| input.checked = !!this.checked; | ||
| input.setAttribute('aria-checked', this.checked ? 'true' : 'false'); | ||
| const path = event.composedPath(); | ||
| if (this.infotip && path.includes(this.infotip)) { | ||
| return; |
This was referenced Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Refactors
<sl-switch>so the switch itself is the form associated element, and adds atooltipproperty so a disabled-looking switch can explain why it is disabled.<input>,<label>and<style>to its light DOM. It usesElementInternalsfor the form value and validity, and renders an<input type="checkbox" role="switch">in its shadow DOM as the control for keyboard and assistive technology, following the WAI-ARIA Authoring Practices.ForwardAriaMixin, the same as<sl-button>and<sl-checkbox>. This makesaria-disabledwork: the switch looks disabled and cannot be toggled, but stays focusable so a tooltip can explain why. Fixes [SWITCH] Tooltip does not work on disabled sl-switch #3415.tooltipproperty, the same way<sl-button>has one. Hovering the switch centers the tooltip on the switch as a whole; focusing the toggle centers it on the toggle.toggle(force?)method to toggle the switch from JavaScript, since the switch no longer listens for clicks itself. It behaves like a user toggling it: emitssl-changeand marks the switch dirty.descriptionslot, linked witharia-describedby, pluschecked/has-description/no-labelcustom states andcontainer,description,label,tooltipandwrapperCSS parts. Part of [Radio and checkbox] Description per item #2903getSlottedText()utility to@sl-design-system/sharedfor reading the text assigned to a slot.Labelling a control that lives in the shadow DOM
Because the input moved into the shadow DOM, the
<label for="...">that<sl-label>renders no longer reaches it: label association binds to the host, and an IDREF cannot cross a shadow boundary.ForwardAriaMixinnow forwards thedata-label-idattribute<sl-label>sets on the control as if it werearia-labelledby, resolving it against the host's root and assigning it to the target as an element reference, which does cross the boundary.The attribute is left on the host, since
<sl-label>manages it.forwardedElementsStorageis now keyed by attribute name rather than by target property, soaria-labelledbyanddata-label-idboth contribute toariaLabelledByElementsinstead of overwriting each other.Breaking changes
See the changeset for the full list. In short:
inputnow returns the shadow DOM input and is internal, theinputslot is gone,formControlElementis the switch itself (so<label for>points at the<sl-switch>id),checkedis no longer reflected — usesl-switch:state(checked)instead of[checked]— andclick()no longer toggles the switch, usetoggle()instead.