Skip to content

feat(switch): refactor switch - #3604

Open
jpzwarte wants to merge 7 commits into
mainfrom
fix/3415-disabled-switch-tooltip-v2
Open

feat(switch): refactor switch#3604
jpzwarte wants to merge 7 commits into
mainfrom
fix/3415-disabled-switch-tooltip-v2

Conversation

@jpzwarte

@jpzwarte jpzwarte commented Aug 16, 2026

Copy link
Copy Markdown
Member

Summary

Refactors <sl-switch> so the switch itself is the form associated element, and adds a tooltip property so a disabled-looking switch can explain why it is disabled.

  • The switch no longer adds an <input>, <label> and <style> to its light DOM. It uses ElementInternals for 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.
  • ARIA attributes are forwarded to that input by ForwardAriaMixin, the same as <sl-button> and <sl-checkbox>. This makes aria-disabled work: 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.
  • New tooltip property, 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.
  • New 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: emits sl-change and marks the switch dirty.
  • New description slot, linked with aria-describedby, plus checked / has-description / no-label custom states and container, description, label, tooltip and wrapper CSS parts. Part of [Radio and checkbox] Description per item #2903
  • Adds a getSlottedText() utility to @sl-design-system/shared for 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. ForwardAriaMixin now forwards the data-label-id attribute <sl-label> sets on the control as if it were aria-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. forwardedElementsStorage is now keyed by attribute name rather than by target property, so aria-labelledby and data-label-id both contribute to ariaLabelledByElements instead of overwriting each other.

Breaking changes

See the changeset for the full list. In short: input now returns the shadow DOM input and is internal, the input slot is gone, formControlElement is the switch itself (so <label for> points at the <sl-switch> id), checked is no longer reflected — use sl-switch:state(checked) instead of [checked] — and click() no longer toggles the switch, use toggle() instead.

jpzwarte and others added 2 commits August 16, 2026 11:10
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-bot

changeset-bot Bot commented Aug 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2cb8f6e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 37 packages
Name Type
@sl-design-system/shared Minor
@sl-design-system/switch Major
@sl-design-system/accordion Patch
@sl-design-system/announcer Patch
@sl-design-system/avatar Patch
@sl-design-system/button Patch
@sl-design-system/callout Patch
@sl-design-system/checkbox Patch
@sl-design-system/data-source Patch
@sl-design-system/date-field Patch
@sl-design-system/dialog Patch
@sl-design-system/editor Patch
@sl-design-system/emoji Patch
@sl-design-system/form Patch
@sl-design-system/format-date Patch
@sl-design-system/format-number Patch
@sl-design-system/grid Patch
@sl-design-system/inline-message Patch
@sl-design-system/menu Patch
@sl-design-system/number-field Patch
@sl-design-system/paginator Patch
@sl-design-system/panel Patch
@sl-design-system/popover Patch
@sl-design-system/radio-group Patch
@sl-design-system/search-field Patch
@sl-design-system/select Patch
@sl-design-system/tabs Patch
@sl-design-system/tag Patch
@sl-design-system/text-area Patch
@sl-design-system/text-field Patch
@sl-design-system/toggle-button Patch
@sl-design-system/toggle-group Patch
@sl-design-system/tooltip Patch
@sl-design-system/tree Patch
@sl-design-system/virtual-list Patch
@sl-design-system/angular Patch
@sl-design-system/tool-bar Patch

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

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

@jpzwarte
jpzwarte marked this pull request as ready for review August 18, 2026 08:18
Copilot AI balanced review requested due to automatic review settings August 18, 2026 08:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-labelledby and data-label-id currently point to the same label, changing either one puts the shared old label in ours, 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;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SWITCH] Tooltip does not work on disabled sl-switch

2 participants