From 268e7f8080f39dadccf66e7a220b38e9f36094d0 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Wed, 10 Jun 2026 10:26:39 +0700 Subject: [PATCH] [ButtonBase] Add theme.focusRing for keyboard focus ring 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. --- CONTEXT.md | 35 ++++++ ...0001-render-focus-ring-with-css-outline.md | 19 ++++ ...2-focus-ring-model-and-auto-on-fallback.md | 24 ++++ docs/pages/experiments/focus-ring.tsx | 103 ++++++++++++++++++ .../mui-material/src/ButtonBase/ButtonBase.js | 85 +++++++++------ .../src/styles/createTheme.spec.ts | 24 +++- .../mui-material/src/styles/createTheme.ts | 2 +- .../src/styles/createThemeNoVars.d.ts | 13 +++ packages/mui-material/src/styles/index.d.ts | 1 + 9 files changed, 271 insertions(+), 35 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-render-focus-ring-with-css-outline.md create mode 100644 docs/adr/0002-focus-ring-model-and-auto-on-fallback.md create mode 100644 docs/pages/experiments/focus-ring.tsx diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000000000..b07d0168f4a554 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,35 @@ +# Context: Focus Ring Experiment + +Glossary for the focus-ring support experiment on Material UI. Definitions only — no implementation. + +## 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`. + +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. diff --git a/docs/adr/0001-render-focus-ring-with-css-outline.md b/docs/adr/0001-render-focus-ring-with-css-outline.md new file mode 100644 index 00000000000000..b7b957b55ae6d9 --- /dev/null +++ b/docs/adr/0001-render-focus-ring-with-css-outline.md @@ -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). diff --git a/docs/adr/0002-focus-ring-model-and-auto-on-fallback.md b/docs/adr/0002-focus-ring-model-and-auto-on-fallback.md new file mode 100644 index 00000000000000..da214ccb79be74 --- /dev/null +++ b/docs/adr/0002-focus-ring-model-and-auto-on-fallback.md @@ -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. +- **`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. diff --git a/docs/pages/experiments/focus-ring.tsx b/docs/pages/experiments/focus-ring.tsx new file mode 100644 index 00000000000000..70d2fd613c13ab --- /dev/null +++ b/docs/pages/experiments/focus-ring.tsx @@ -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 ( + + + + + + + + + Profile + Settings + + + ); +} + +function Section({ + title, + description, + theme, +}: { + title: string; + description: string; + theme: ReturnType; +}) { + return ( +
+ {title} + + {description} + + + + +
+ ); +} + +export default function FocusRing() { + return ( + + + Focus ring + + + 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. + + +
+
+
+ + + ); +} diff --git a/packages/mui-material/src/ButtonBase/ButtonBase.js b/packages/mui-material/src/ButtonBase/ButtonBase.js index d92162eb51f276..7b8b0591da757b 100644 --- a/packages/mui-material/src/ButtonBase/ButtonBase.js +++ b/packages/mui-material/src/ButtonBase/ButtonBase.js @@ -7,6 +7,7 @@ import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef'; import composeClasses from '@mui/utils/composeClasses'; import isFocusVisible from '@mui/utils/isFocusVisible'; import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; import useForkRef from '../utils/useForkRef'; import useEventCallback from '../utils/useEventCallback'; @@ -35,39 +36,57 @@ const useUtilityClasses = (ownerState) => { export const ButtonBaseRoot = styled('button', { name: 'MuiButtonBase', slot: 'Root', -})({ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - position: 'relative', - boxSizing: 'border-box', - WebkitTapHighlightColor: 'transparent', - backgroundColor: 'transparent', // Reset default value - // We disable the focus ring for mouse, touch and keyboard users. - outline: 0, - border: 0, - margin: 0, // Remove the margin in Safari - borderRadius: 0, - padding: 0, // Remove the padding in Firefox - cursor: 'pointer', - userSelect: 'none', - verticalAlign: 'middle', - MozAppearance: 'none', // Reset - WebkitAppearance: 'none', // Reset - textDecoration: 'none', - // So we take precedent over the style of a native element. - color: 'inherit', - '&::-moz-focus-inner': { - borderStyle: 'none', // Remove Firefox dotted outline. - }, - [`&.${buttonBaseClasses.disabled}`]: { - pointerEvents: 'none', // Disable link interactions - cursor: 'default', - }, - '@media print': { - colorAdjust: 'exact', - }, -}); +})( + memoTheme(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + boxSizing: 'border-box', + WebkitTapHighlightColor: 'transparent', + backgroundColor: 'transparent', // Reset default value + + // We disable the focus ring for mouse, touch and keyboard users. + outline: 0, + border: 0, + margin: 0, // Remove the margin in Safari + borderRadius: 0, + padding: 0, // Remove the padding in Firefox + cursor: 'pointer', + userSelect: 'none', + verticalAlign: 'middle', + MozAppearance: 'none', // Reset + WebkitAppearance: 'none', // Reset + textDecoration: 'none', + // So we take precedent over the style of a native element. + color: 'inherit', + '&::-moz-focus-inner': { + borderStyle: 'none', // Remove Firefox dotted outline. + }, + [`&.${buttonBaseClasses.disabled}`]: { + pointerEvents: 'none', // Disable link interactions + cursor: 'default', + }, + '@media print': { + colorAdjust: 'exact', + }, + variants: [ + { + props: ({ ownerState }) => + (ownerState.disableRipple && theme.focusRing !== false) || !!theme.focusRing, + style: { + [`&.${buttonBaseClasses.focusVisible}`]: { + outlineStyle: 'solid', + outlineColor: (theme.vars || theme).palette.primary.main, + outlineWidth: 2, + outlineOffset: 2, + ...theme.focusRing, + }, + }, + }, + ], + })), +); /** * `ButtonBase` contains as few styles as possible. diff --git a/packages/mui-material/src/styles/createTheme.spec.ts b/packages/mui-material/src/styles/createTheme.spec.ts index 26891704ac8de7..d758179d3972ed 100644 --- a/packages/mui-material/src/styles/createTheme.spec.ts +++ b/packages/mui-material/src/styles/createTheme.spec.ts @@ -1,4 +1,4 @@ -import { createTheme, ThemeOptions } from '@mui/material/styles'; +import { createTheme, ThemeOptions, FocusRing } from '@mui/material/styles'; import { buttonClasses } from '@mui/material/Button'; const theme = createTheme(); @@ -317,3 +317,25 @@ const theme = createTheme(); }, }); } + +// focusRing theme key +{ + createTheme({ focusRing: { outlineColor: 'red', outlineWidth: 2, outlineOffset: 3 } }); + createTheme({ focusRing: { outlineColor: 'red' } }); + createTheme({ focusRing: { outlineStyle: 'dashed' } }); + createTheme({ focusRing: false }); + createTheme({ focusRing: undefined }); + + const focusRing: FocusRing | false | undefined = theme.focusRing; + createTheme({ focusRing }); + + createTheme({ + // @ts-expect-error outlineWidth must be a number or string + focusRing: { outlineWidth: true }, + }); + + createTheme({ + // @ts-expect-error color is not a valid outline property + focusRing: { color: 'red' }, + }); +} diff --git a/packages/mui-material/src/styles/createTheme.ts b/packages/mui-material/src/styles/createTheme.ts index 68df408846dfed..57e722a215d159 100644 --- a/packages/mui-material/src/styles/createTheme.ts +++ b/packages/mui-material/src/styles/createTheme.ts @@ -11,7 +11,7 @@ import createThemeNoVars, { ThemeOptions as ThemeNoVarsOptions, } from './createThemeNoVars'; -export type { Theme, CssThemeVariables } from './createThemeNoVars'; +export type { Theme, CssThemeVariables, FocusRing } from './createThemeNoVars'; type CssVarsOptions = CssThemeVariables extends { enabled: true; diff --git a/packages/mui-material/src/styles/createThemeNoVars.d.ts b/packages/mui-material/src/styles/createThemeNoVars.d.ts index 68edc2cc3bc9b2..644fd92c9051e8 100644 --- a/packages/mui-material/src/styles/createThemeNoVars.d.ts +++ b/packages/mui-material/src/styles/createThemeNoVars.d.ts @@ -1,3 +1,4 @@ +import * as React from 'react'; import { ThemeOptions as SystemThemeOptions, Theme as SystemTheme, @@ -35,6 +36,16 @@ import { */ export interface CssThemeVariables {} +/** + * Outline properties of the keyboard focus ring, spread onto the `Mui-focusVisible` state. + * Omitted properties fall back to the defaults: `solid` style, `palette.primary.main` color, + * `2px` width, `2px` offset. + */ +export type FocusRing = Pick< + React.CSSProperties, + 'outlineColor' | 'outlineOffset' | 'outlineStyle' | 'outlineWidth' +>; + type CssVarsOptions = CssThemeVariables extends { enabled: true; } @@ -54,6 +65,7 @@ export interface ThemeOptions extends Omit, CssVar | ((palette: Palette) => TypographyVariantsOptions) | undefined; zIndex?: ZIndexOptions | undefined; + focusRing?: FocusRing | false | undefined; unstable_strictMode?: boolean | undefined; unstable_sxConfig?: SxConfig | undefined; modularCssLayers?: boolean | string | undefined; @@ -68,6 +80,7 @@ export interface BaseTheme extends SystemTheme { transitions: Transitions; typography: TypographyVariants; zIndex: ZIndex; + focusRing?: FocusRing | false | undefined; unstable_strictMode?: boolean | undefined; applyStyles: ApplyStyles; } diff --git a/packages/mui-material/src/styles/index.d.ts b/packages/mui-material/src/styles/index.d.ts index d4082be45c8e66..150a235da69033 100644 --- a/packages/mui-material/src/styles/index.d.ts +++ b/packages/mui-material/src/styles/index.d.ts @@ -7,6 +7,7 @@ export { ThemeOptions, Theme, CssThemeVariables, + FocusRing, } from './createTheme'; export { default as enhanceHighContrast, HighContrastTokens } from './enhanceHighContrast'; export { default as adaptV4Theme, DeprecatedThemeOptions } from './adaptV4Theme';