Skip to content

feat: fix residual md3 deviations in switch component - #5101

Closed
likevy wants to merge 5 commits into
callstack:mainfrom
likevy:feat/fix-residual-md3-deviations-in-switch
Closed

feat: fix residual md3 deviations in switch component#5101
likevy wants to merge 5 commits into
callstack:mainfrom
likevy:feat/fix-residual-md3-deviations-in-switch

Conversation

@likevy

@likevy likevy commented Sep 4, 2026

Copy link
Copy Markdown

Motivation

Switch is one of the MD3 reference components, but a review against the spec found small residual deviations, mostly accessibility gaps.
The significant one is breaking: a switch must now declare how it can be operated — onValueChange, readOnly, or disabled. readOnly keeps the enabled appearance and stays in the accessibility tree so its state is announced, and carries aria-readonly on web; it is not reported as disabled. Enforced by the props type plus a runtime guard for untyped callers.

Two things worth mentioning:

  • Drag is deliberately excluded. MD3 doesn't specify it
  • The props type shape is deliberate. The docs generator reads props off the component's parameter annotation and drops union members, so the parameter keeps the flat Props and the operability union sits on the component's declared type. SwitchProps exports the narrowed type, so the public type matches what the component accepts. Commented in place so it isn't "simplified" later.

Related issue

This closes the Switch review checklist:

  • Selected icon color uses primary; MD3 spec is onPrimaryContainer.
  • Vertical touch target is 40dp (the state-layer box); MD3 requires 48dp.
  • Keyboard focus applies neither the focus handle color nor a focus state layer.
  • Renders an enabled, focusable role="switch" with no onValueChange (an enabled no-op); require or infer explicit operability (handler / read-only / disabled).
  • No drag gesture (tap only), and the delayed handle-growth (100ms) can swallow immediate press feedback. — press feedback fixed; drag not added, see above.

Test plan

yarn lint, yarn typecheck and yarn test pass — 684 tests, 168 snapshots. Each commit is independently green, so the series bisects cleanly.

9 new tests in Switch.test.tsx cover the focus indicator, state layer and handle colour, the 52×48 touch target, and operability. The state-layer and handle-colour assertions fail if the focus fix is reverted (mutation-tested).

Manual, on the Switch example screen:

  1. Press and hold - the handle grows immediately, no lag.
  2. Tap just above/below the track, outside the visible 32dp - still toggles (48dp target); rows are ~8dp taller.
  3. "Default with icon when on" - the glyph is dark (onPrimaryContainer), not purple.
  4. New "Read-only" row - can't be toggled, still looks enabled rather than dimmed.
  5. Drawer, FAB and TextInput example toggles still work - now readOnly, with the row owning the press.
  6. Tab to a switch (web, or a hardware keyboard) - focus ring, state layer, primaryContainer handle; a mouse click must not light the ring. Read-only is skipped in the tab order and has aria-readonly="true".
  7. Screen reader - a read-only switch announces its on/off state, not disabled or actionable.

Run on Android, iOS and web, plus a screen-reader pass. All behaved as described.

Copilot AI balanced review requested due to automatic review settings September 4, 2026 09:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Native accessibility focus, runtime fallback semantics, and stale interaction states need correction.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Aligns the MD3 Switch with accessibility, focus, color, touch-target, and operability requirements.

Changes:

  • Adds explicit interactive, read-only, and disabled modes.
  • Corrects focus styling, icon color, press animation, and touch-target sizing.
  • Updates tests, examples, generated docs, and migration guidance.
File summaries
File Description
src/index.tsx Exports narrowed Switch props.
src/components/Switch/utils.ts Adds focus handle colors.
src/components/Switch/tokens.ts Updates MD3 tokens and sizing.
src/components/Switch/Switch.tsx Implements operability and visual changes.
src/components/__tests__/Switch.test.tsx Tests focus, accessibility, and operability.
src/components/__tests__/__snapshots__/Switch.test.tsx.snap Updates Switch snapshots.
example/src/Examples/TextInputExample.tsx Marks nested Switches read-only.
example/src/Examples/SwitchExample.tsx Adds a read-only example.
example/src/Examples/FABExample.tsx Marks the nested Switch read-only.
example/src/DrawerItems.tsx Marks drawer Switches read-only.
docs/src/data/componentDocs6x.json Regenerates Switch API documentation.
docs/6.x/docs/guides/migration.md Documents breaking migration steps.
docs/6.x/docs/components/Switch/Switch.mdx Updates Switch props and theming docs.
Review details

Suppressed comments (2)

src/components/Switch/Switch.tsx:189

  • This fallback only removes interaction handlers; its paint and aria-disabled/aria-readonly still describe an enabled switch. An untyped <Switch value /> therefore remains an enabled control that does nothing, which is the invalid state this runtime guard is intended to prevent. Either reject rendering or map the missing-operability case to a consistent disabled/read-only fallback.
  const isMissingOperability = !onValueChange && !isReadOnly && !isDisabled;

  if (isMissingOperability) {
    console.warn(
      'Switch: pass `onValueChange` to make the switch operable, or set `readOnly` or `disabled` to render it as a state indicator.'

src/components/Switch/Switch.tsx:421

  • On Android, focusable={false} also prevents accessibility focus, so read-only and disabled switches become undiscoverable by TalkBack instead of having their state announced. Restrict tab-order suppression to web, or otherwise separate keyboard focus from native accessibility focus.
        focusable={isInteractive}
  • Files reviewed: 13/13 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

(
| {
onValueChange: (value: boolean) => void;
readOnly?: false;
Comment on lines +390 to +394
// A non-interactive switch gets no press, hover, or focus affordances at all.
// It stays in the accessibility tree, so its state is still announced.
const interactionProps = isInteractive
? {
onPress: () => onValueChange?.(!checked),
Comment on lines +160 to +161
expect(control).toHaveProp('focusable', false);
expect(control).toBeChecked();
@likevy likevy closed this Sep 4, 2026
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.

2 participants