-
-
Notifications
You must be signed in to change notification settings - Fork 32.6k
[ButtonBase] Add theme.focusRing for keyboard focus ring #48647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
siriwatknp
wants to merge
2
commits into
mui:master
Choose a base branch
from
siriwatknp:exp/focus-ring
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # Context: Focus Ring Experiment | ||
|
|
||
| Glossary for the focus-ring support experiment on Material UI. Definitions only — no implementation. | ||
|
Check failure on line 3 in CONTEXT.md
|
||
|
|
||
| ## Glossary | ||
|
|
||
| ### Focus ring | ||
|
|
||
| A visible focus indicator rendered when a focusable component is keyboard-focused (`Mui-focusVisible`). Distinct from the **ripple**, which is separate, ripple-based feedback. | ||
|
|
||
| There are **two independent features** that each render a focus ring. They have separate triggers but share **one renderer**: the a11y fallback (feature 1) is the themed ring (feature 2) with default values. When both fire, **feature 2 wins** (explicit config beats defaults). Experiment scope: **ButtonBase and its derived components**. | ||
|
|
||
| ### A11y focus-ring fallback (feature 1) | ||
|
|
||
| Automatic ring shown when the ripple is disabled. The trigger is the **resolved** `disableRipple` value (`ownerState.disableRipple === true`), regardless of source — theme `defaultProps` (any level) or a per-instance prop. It does **not** key off `focusRipple` (a button that never had a focus pulse is not auto-ringed). A defensive accessibility safety-net: when a component's keyboard-focus indicator was the ripple pulse, disabling the ripple leaves nothing, so MUI supplies a ring. Fires regardless of whether the focus-ring theme is configured. With no theme config, its color defaults to `theme.palette.primary.main`. | ||
|
Check warning on line 15 in CONTEXT.md
|
||
|
|
||
| For components whose focus indicator is a background tint (`palette.action.focus`) rather than the ripple, the fallback ring is **additive** — ring on top of the existing tint. | ||
|
|
||
| ### Focus-ring theme (feature 2) | ||
|
|
||
| An opt-in styling feature: configuring the focus-ring theme key makes a focus ring appear. A deliberate design-system choice. Works **regardless of ripple state** — the ring appears even while the ripple is enabled (ring and ripple coexist on focus). | ||
|
|
||
| The key is `theme.focusRing`, which is **tri-state**: | ||
|
|
||
| - **`undefined`** (default) — feature 2 off (no ring while ripple is on); feature 1 still active (a11y fallback rings on `disableRipple`, using the default values below). | ||
| - **object** — feature 2 on: ring appears regardless of ripple state. Object *presence* is the opt-in (not any specific field). Feature 1 also adopts these values. | ||
| - **`false`** — hard kill-switch: no ring ever, **including** the a11y fallback. An explicit, discoverable way to opt out of the safety-net. | ||
|
|
||
| The object is typed as the outline subset of `React.CSSProperties` — `outlineColor`, `outlineWidth`, `outlineOffset`, `outlineStyle` — and is **spread directly** onto the `Mui-focusVisible` styles over the defaults (`outlineStyle: 'solid'`, `outlineColor: palette.primary.main`, `outlineWidth: 2`, `outlineOffset: 2`). No field-name mapping. Numbers mean px. | ||
|
|
||
| The ring is baked into **ButtonBase core** (the safety-net only works as default behavior). Customization beyond the theme key is free via `MuiButtonBase`/`MuiButton…` `styleOverrides`, which apply after the root and win. | ||
|
|
||
| ### Ripple | ||
|
|
||
| The existing `TouchRipple` feedback on `ButtonBase`. Today it is the only focus indicator on button-like components, and only when `focusRipple` is enabled and `disableRipple` is not set. | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| --- | ||
| status: accepted | ||
| --- | ||
|
|
||
| # Render the focus ring with CSS `outline` | ||
|
|
||
| The focus-ring experiment draws its ring with `outline` + `outline-offset`, not `box-shadow` or an `::after` pseudo-element. | ||
|
|
||
| Decisive reasons: (1) the ring is an **accessibility** feature, and in Windows High Contrast / `forced-colors` mode the OS discards `box-shadow` and pseudo-element backgrounds but preserves `outline` — the indicator must not vanish in the highest-need a11y mode; (2) `Button` and `Fab` already animate `box-shadow: shadows[6]` on `Mui-focusVisible` (Button.js:133, Fab.js:81), so a box-shadow ring would collide with that elevation, whereas `outline` sits orthogonally. `outline` also has zero layout shift, follows `border-radius` in all evergreen browsers, and maps 1:1 onto the theme key (`color`→`outline-color`, `width`→`outline-width`, `offset`→`outline-offset`). | ||
|
|
||
| ## Considered Options | ||
|
|
||
| - **`box-shadow` spread** — rejected: stripped in forced-colors mode; collides with existing elevation box-shadows on Button/Fab. | ||
| - **`::after` pseudo-element** — rejected: stripped in forced-colors mode; requires `position: relative` on the host and bespoke geometry. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - Overrides the existing `outline: 0` on `ButtonBase` (ButtonBase.js:47). | ||
| - An ancestor `overflow: hidden` can clip the outline (true of all three options; accepted). |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| --- | ||
| status: accepted | ||
| --- | ||
|
|
||
| # Focus-ring model: auto-on a11y fallback + tri-state `theme.focusRing` | ||
|
|
||
| The focus ring is exposed as **two features sharing one renderer**, controlled by `theme.focusRing` plus the resolved `disableRipple`: | ||
|
|
||
| - **Feature 1 — a11y fallback (on by default).** When `disableRipple` is true (any source: theme `defaultProps` or per-instance), ButtonBase renders a focus ring. This is a **default behavior change**: today `disableRipple` removes the only keyboard-focus indicator for ripple-pulse components (Button, IconButton, Fab, Tab, …), failing WCAG 2.4.7. The fallback uses default ring values (`outlineColor: palette.primary.main`, `outlineWidth: 2`, `outlineOffset: 2`, `outlineStyle: 'solid'`). It does not key off `focusRipple`. | ||
| - **Feature 2 — opt-in theme.** `theme.focusRing` is tri-state: `undefined` ⇒ feature 1 only; an **object** ⇒ ring appears regardless of ripple state (object presence is the opt-in, fields fill from defaults); **`false`** ⇒ hard kill-switch, no ring at all including the fallback. The object is typed as the outline subset of `React.CSSProperties` (`outlineColor`/`outlineWidth`/`outlineOffset`/`outlineStyle`) and is spread directly onto the `Mui-focusVisible` styles — no field-name mapping. | ||
|
|
||
| When both apply, feature 2's values win (the fallback is just the themed ring with defaults). | ||
|
|
||
| ## Why this shape | ||
|
|
||
| - **Auto-on, not opt-in, for feature 1:** a safety-net that must be explicitly enabled would not be a safety-net — most teams that set `disableRipple` would never know they removed their focus indicator. | ||
| - **Object-presence opt-in for feature 2:** lets us distinguish "user wants rings (even with ripple on)" from "user inherited defaults," which a always-present defaulted key could not. | ||
|
Check warning on line 17 in docs/adr/0002-focus-ring-model-and-auto-on-fallback.md
|
||
| - **`false` kill-switch:** an honest, discoverable escape for teams with their own focus design who want neither ripple nor the auto ring. (Per-style overrides via `styleOverrides` remain available for "different ring, not no ring.") | ||
|
|
||
| ## Consequences | ||
|
|
||
| - Shipping this changes default rendering for any app that sets `disableRipple` — they gain a visible outline on keyboard focus. Intended, but a visual change to call out in release notes. | ||
| - Components whose focus indicator is a background tint (`palette.action.focus`: MenuItem, Chip, ListItemButton, …) get the ring **additively** on top of the tint when `disableRipple` is set app-wide. | ||
| - Scope is ButtonBase and its derived components; other focusable components (TextField, Checkbox, …) are out of scope for the experiment. | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| 'use client'; | ||
| import * as React from 'react'; | ||
| import Box from '@mui/material/Box'; | ||
| import Stack from '@mui/material/Stack'; | ||
| import Typography from '@mui/material/Typography'; | ||
| import Button from '@mui/material/Button'; | ||
| import IconButton from '@mui/material/IconButton'; | ||
| import MenuList from '@mui/material/MenuList'; | ||
| import MenuItem from '@mui/material/MenuItem'; | ||
| import DeleteIcon from '@mui/icons-material/Delete'; | ||
| import { createTheme, ThemeProvider } from '@mui/material/styles'; | ||
|
|
||
| // 1. A11y fallback: ripple disabled via defaultProps -> ring appears automatically, | ||
| // no `focusRing` configured (default color = palette.text.primary). | ||
| const fallbackTheme = createTheme({ | ||
| components: { | ||
| MuiButtonBase: { defaultProps: { disableRipple: true } }, | ||
| }, | ||
| }); | ||
|
|
||
| // 2. Opt-in: `theme.focusRing` set -> ring on every keyboard focus, even with ripple on. | ||
| const optInTheme = createTheme({ | ||
| focusRing: { outlineColor: '#9c27b0', outlineWidth: 2, outlineOffset: 3 }, | ||
| }); | ||
|
|
||
| // 3. Kill-switch: `false` removes the ring entirely, including the disableRipple fallback. | ||
| const offTheme = createTheme({ | ||
| focusRing: false, | ||
| components: { | ||
| MuiButtonBase: { defaultProps: { disableRipple: true } }, | ||
| }, | ||
| }); | ||
|
|
||
| function Sample() { | ||
| return ( | ||
| <Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}> | ||
| <Button variant="contained">Contained</Button> | ||
| <Button variant="outlined">Outlined</Button> | ||
| <Button variant="text">Text</Button> | ||
| <IconButton aria-label="delete"> | ||
| <DeleteIcon /> | ||
| </IconButton> | ||
| <MenuList sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}> | ||
| <MenuItem>Profile</MenuItem> | ||
| <MenuItem>Settings</MenuItem> | ||
| </MenuList> | ||
| </Stack> | ||
| ); | ||
| } | ||
|
|
||
| function Section({ | ||
| title, | ||
| description, | ||
| theme, | ||
| }: { | ||
| title: string; | ||
| description: string; | ||
| theme: ReturnType<typeof createTheme>; | ||
| }) { | ||
| return ( | ||
| <section> | ||
| <Typography variant="h6">{title}</Typography> | ||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||
| {description} | ||
| </Typography> | ||
| <ThemeProvider theme={theme}> | ||
| <Sample /> | ||
| </ThemeProvider> | ||
| </section> | ||
| ); | ||
| } | ||
|
|
||
| export default function FocusRing() { | ||
| return ( | ||
| <Box sx={{ p: 6, maxWidth: 900 }}> | ||
| <Typography variant="h4" gutterBottom> | ||
| Focus ring | ||
| </Typography> | ||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}> | ||
| Tab through each group with the keyboard (the ring is keyboard-only — mouse clicks never | ||
| show it). In Windows High Contrast / forced-colors mode the outline is preserved, unlike the | ||
| ripple or background-tint indicators. | ||
| </Typography> | ||
| <Stack spacing={5}> | ||
| <Section | ||
| title="1. Accessibility fallback (automatic)" | ||
| description="disableRipple via MuiButtonBase.defaultProps and no focusRing config. The ring replaces the missing ripple focus indicator, defaulting to palette.primary.main." | ||
| theme={fallbackTheme} | ||
| /> | ||
| <Section | ||
| title="2. Opt-in theme (ripple stays on)" | ||
| description="theme.focusRing = { outlineColor: '#9c27b0', outlineWidth: 2, outlineOffset: 3 }. The ring shows on every keyboard focus regardless of ripple — ring and ripple coexist." | ||
| theme={optInTheme} | ||
| /> | ||
| <Section | ||
| title="3. Kill-switch" | ||
| description="theme.focusRing = false with disableRipple set. No ring at all, including the accessibility fallback — an explicit opt-out." | ||
| theme={offTheme} | ||
| /> | ||
| </Stack> | ||
| </Box> | ||
| ); | ||
| } |
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
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
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
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
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
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is "focus ring" the best name for this? Alternatives could be "focus outline" or "focus indicator"