[WIP][Prototype] Add theme.focusVisible for a themeable keyboard focus ring#48743
Draft
siriwatknp wants to merge 35 commits into
Draft
[WIP][Prototype] Add theme.focusVisible for a themeable keyboard focus ring#48743siriwatknp wants to merge 35 commits into
siriwatknp wants to merge 35 commits into
Conversation
Render an outline focus ring on Mui-focusVisible: - auto fallback when disableRipple removes the ripple focus indicator - opt-in via theme.focusRing (outline CSSProperties), ripple-independent - theme.focusRing: false hard-disables the ring Experiment: design in CONTEXT.md + docs/adr, demo at docs/pages/experiments/focus-ring.tsx.
- Normalize focusRing at theme creation (true -> curated object, object merges over) - Vars theme: curated color = palette var (scheme-reactive); numeric -> px - Single Mui-focusVisible rule on ButtonBase; drop auto-on variants block - Widen type to boolean | React.CSSProperties; update createTheme type tests
Replace old auto-on/fallback demo with the must-tier playground (M1-M5): preset switcher, light/dark, all ButtonBase-derived + bare ButtonBase, keyboard journey + focused readout, elevation/disabled edge callouts.
… controls + gallery) Rework to match the agreed ASCII: header band (title, keyboard hint, live focused readout, light/dark top-right); sticky left CONTROLS (preset radios); right GALLERY. Layout-only — gallery + theme logic unchanged.
…ring) Row-by-row CSS Grid (label | component), two labelled buckets. Inner-ring components (Tab, MenuItem, ListItemButton) get an inset ring (outlineOffset -2) via their own ThemeProvider, so a scrollable container can't clip them.
Add every ring-bearing family to the right bucket (verified offsets): outer (+2) — ButtonGroup, Chip, Checkbox, Radio, Switch, Stepper, Pagination; inner (-2) — AccordionSummary, BottomNavigation, TableSortLabel.
…flow clip) Visual verify caught it: CardActionArea sits in a Card with overflow:hidden, so an outer ring (+2) is clipped to nothing. Inset (-2) draws inside the card -> visible.
- add utils/toPx (number->px, pass-through for strings/vars) - Tab, MenuItem, ListItemButton, BottomNavigationAction, CardActionArea: inset focus ring on Mui-focusVisible (outlineOffset calc(-1 * focusRing.outlineWidth)), so one app-level theme.focusRing renders correctly inside scroll/overflow-clipped containers - Switch: SwitchRoot overflow -> visible when focusRing set (else hidden) to un-clip the ring - docs experiment: single ThemeProvider; inset now from component source; move AccordionSummary/TableSortLabel to outer-ring (verified no clip)
- createTheme.test.js: focusRing normalization (true/object/transparent/boxShadow/ false/undefined) + vars theme (palette var, numeric->px fallback) - ButtonBase.test.js: ring on/off, recolor merge, transparent opt-out (browser-gated) - Tab.test.js: inset outlineOffset -2px on focus-visible (browser-gated) - Switch.test.js: root overflow visible when focusRing set, else hidden (browser-gated) - utils/toPx.test.ts
- docs/data/material/customization/focus-ring/: focus-ring.md + demos FocusRingDefault, FocusRingCustomization (js + tsx) - route docs/pages/material-ui/customization/focus-ring.js - pages.ts: nav entry under Customization (newFeature)
- N1 pointer walk (Prev/Next + n/total) via .Mui-focusVisible shim; real-Tab drops it (no double-ring) - N2 custom focusRing JSON editor (overrides preset; invalid -> inline error) - N3 CSS variables on/off toggle - N4 resolved theme.focusRing panel - N5 edge callouts: overflow:hidden clip + forced-colors
…led)
- resolve the ring root via closest('.MuiButtonBase-root') so Checkbox/Radio/Switch
get .Mui-focusVisible on the SwitchBase root, not the inner input
- skip disabled targets in the walk (isRingDisabled: Mui-disabled / aria-disabled / input.disabled)
- collect targets from document (data-ring-target lives only in the gallery) instead of a
ref that resolved null; drop the dead galleryRef
- remove CONTEXT.md (experiment-only glossary, not for upstream) - prettier format experiment page + Switch test
Deploy previewBundle size
Check out the code infra dashboard for more information about this PR. |
…ing to non-ButtonBase controls - API rename: theme.focusRing -> theme.focusVisible (key, --mui-focusVisible-* vars, FocusVisible type, docs page, demos, experiment page) - Extend curated ring beyond ButtonBase: Slider (thumb), Link (covers Breadcrumbs links), Autocomplete option (inset). Select items already covered via MenuItem. - Experiment page: add 'own focus' bucket (Slider/Link/Breadcrumbs) + Select/Autocomplete in inner-ring - Tests: browser-gated focus-visible tests for Link/Slider/Autocomplete
…0002 to v1 opt-in
- createThemeWithVars: resolve focusVisible from options+merge args (mirrors
createThemeNoVars) so createTheme({cssVariables:true},{focusVisible:true})
normalizes instead of leaving a raw boolean
- rewrite adr/0002: v1 is opt-in only, auto-on fallback deferred; document
reserved false + scope-by-mechanism
…her components
Use the root-level ...(theme.focusVisible && {...}) pattern like Slider/Tab instead
of a props:()=>Boolean variant. Gate the component=button variant outline:auto to the
non-themed case so the curated ring no longer relies on variant source order. Add a
button-Link regression test.
Switch applies components.MuiButtonBase.defaultProps.disableRipple app-wide to the preview theme. Demonstrates WCAG 2.4.7: ripple off + ring preset off leaves keyboard focus with no indicator; the curated ring restores it.
…onGroup - drop helper text + wrapper div so the switch aligns with the CSS-variables one - also set MuiButtonGroup defaultProps: ButtonGroup re-broadcasts disableRipple (default false) via context, shadowing the MuiButtonBase default
Replace the per-component outlineOffset recompute (6 files) with a shared insetFocusRing spread. The curated offset is now a sign-flip calc `calc(var(--_focusVisible-offset, 1) * <width>)`; clip-prone components set the var to -1. --_focusVisible-behavior lets a user box-shadow inset too, so a two-color (C40) ring no longer clips on Tab/MenuItem/etc.
Both declare their own focusVisible box-shadow after the ButtonBase/root ring (Fab's focus elevation, Slider's per-color halo), so a customized theme.focusVisible box-shadow lost the cascade. Re-assert theme.focusVisible in the same block so it wins. Curated (outline-only) is unaffected — no box-shadow key, so the elevation/halo stays.
- Button contained variant re-asserts theme.focusVisible after its focus
elevation (same fix as Fab/Slider), so a custom ring wins. disableElevation
keeps its explicit no-shadow.
- Expose focusVisibleVars from @mui/material/styles: { offset, behavior } — the
--_focusVisible-* handles, emitted regardless of cssVariables. Consumers
reference them to customize outlineOffset/box-shadow while keeping the
per-component inset behavior. insetFocusRing + the offset calc reuse them so
the var names are single-source.
…e under cssVariables - Checkbox/Radio spread insetFocusRing on own root (not SwitchBase → Switch stays outer); prototype buckets them inner-ring - add focusVisible to shouldSkipGeneratingVar: hoisting to a :root var freezes the embedded --_focusVisible-offset at :root (unset→+2), clipping the inset; inline it resolves per-component - flip all ring consumers (theme.vars || theme).focusVisible → theme.focusVisible (undefined in vars mode after skip) - tests: Checkbox/Radio inset in both cssVariables modes; createTheme vars asserts skip+inline
…ttonBase root The 3 SwitchBase form controls are outer-ring exceptions to the shared ButtonBase rule: - Checkbox/Radio draw the ring on the icon svg; Switch on the track slot (more common placements) - opt the ButtonBase root out via internalDisabledThemeFocusVisible so there is no double ring - prototype moves Checkbox/Radio back to the outer-ring bucket (Switch already there) - drop Radio's redundant internalDisabledThemeFocusVisible (SwitchBase already sets it) which also leaked to the DOM on root-slot override; strip it in the Switch slot-override conformance helper - tests: ring lands on the icon svg in both cssVariables modes, root ring stays off
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.
Preview: https://deploy-preview-48743--material-ui.netlify.app/experiments/focus-ring/
Part of #48718 (RFC). Continues the experiment in #48647 (closed) as a fresh, non-breaking v1 — the old auto-on / partial-merge code is dropped.
Summary
Opt-in
theme.focusVisiblerenders a curated keyboard focus ring onMui-focusVisibleacrossButtonBaseand its derived components. Non-breaking: nothing renders differently unless a theme setsfocusVisible.theme.focusVisibleundefined/falsefalsereserved as a future kill-switch)true2px solid primary.main,outline-offset: 2React.CSSProperties)CSS-variables theme: the curated color is emitted as a scheme-reactive palette var (
var(--mui-palette-primary-main)), correct in dark mode.Why
outlineis the default (not box-shadow)outlinesurvivesforced-colors(wherebox-shadowis stripped), has zero layout shift, and followsborder-radius. Abox-shadowis additive over the outline via an object (two-color WCAG C40). Components that animate their own focusbox-shadow— containedButton,Fab, andSlider's halo — re-asserttheme.focusVisiblein the same rule, so a custombox-shadowwins over their default focus shadow rather than being overridden by source order.Per-component inset (private vars, not per-component offset)
Components rendered inside a MUI-owned
overflow: hidden(Tab,MenuItem,ListItemButton,CardActionArea,BottomNavigationAction, and theAutocompletelistbox option) inset the ring by spreading a sharedinsetFocusRingon their root, which sets two private custom properties the curated ring reads:--_focusVisible-offset: -1— flips the outline-offset sign (the curated offset iscalc(var(--_focusVisible-offset, 1) * <width>), so the width still tracks a customizedoutlineWidth).--_focusVisible-behavior: inset— insets a user-providedbox-shadow, so a two-color (C40) ring no longer clips on these components.This replaces the earlier per-component
outline-offset: calc(-1 * outlineWidth)overrides (one helper instead of six) and unifies theButtonBasefamily with the non-ButtonBasecarriers. A user-providedoutlineOffsetstill replaces the calc (reaches non-clipped components; changing the inset on a clip-prone one is astyleOverridesescape hatch).Switchsets its rootoverflow: visiblewhen the ring is enabled.Customizing while keeping the inset behavior —
focusVisibleVarsThe private vars are emitted regardless of the
cssVariablesoption, so a consumer customizingoutlineOffset/box-shadowmust reference them or the ring clips on the inset components. They're exposed as a stable public handle from@mui/material/styles:focusVisibleVars={ offset: 'var(--_focusVisible-offset, 1)', behavior: 'var(--_focusVisible-behavior, )' }.insetFocusRingand the curated offset calc reuse it, so the var names are single-source.Docs
/material-ui/customization/focus-ring/)/experiments/focus-ringTests
createThemenormalization (incl. CSS-vars + numeric→px, the sign-flip offset calc, and thefocusVisibleVarspublic contract),ButtonBasering on/off/merge/transparent,Tab/Autocompleteinset + box-shadow inset via the behavior var,Button/Fab/Slidercustom box-shadow winning over the component's own focus shadow,Rating(active icon + empty-value label),Switchoverflow, and thetoPxutil.Status / scope
v1 is opt-in only. The automatic-on-
disableRipplea11y fallback is a separate follow-up RFC. RFC sign-off (curated color, scope boundary, merge semantics) is pending in #48718.