diff --git a/.gitignore b/.gitignore index 2b4c1e6d0b5c43..1fd96e8c36f9cd 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ test-results/ # typescript *.tsbuildinfo next-env.d.ts + +# density-adapter screenshot harness (local-only outputs) +/density-screenshots/ +/scripts/density-screenshots/__baselines__/ +/scripts/density-screenshots/.playwright-output/ diff --git a/docs/data/material/components/buttons/buttons.a11y.json b/docs/data/material/components/buttons/buttons.a11y.json index c440f41f0f264e..146595d0af9bad 100644 --- a/docs/data/material/components/buttons/buttons.a11y.json +++ b/docs/data/material/components/buttons/buttons.a11y.json @@ -21,6 +21,10 @@ "status": "pass", "tags": ["wcag2a"] }, + "avoid-inline-spacing": { + "status": "pass", + "tags": ["wcag21aa"] + }, "button-name": { "status": "pass", "tags": ["wcag2a"] @@ -61,6 +65,10 @@ "status": "pass", "tags": ["wcag2a"] }, + "avoid-inline-spacing": { + "status": "pass", + "tags": ["wcag21aa"] + }, "button-name": { "status": "pass", "tags": ["wcag2a"] diff --git a/docs/pages/experiments/density-fixture.tsx b/docs/pages/experiments/density-fixture.tsx new file mode 100644 index 00000000000000..d550a5f8e7be21 --- /dev/null +++ b/docs/pages/experiments/density-fixture.tsx @@ -0,0 +1,384 @@ +'use client'; +import * as React from 'react'; +import { useRouter } from 'next/router'; +import Box from '@mui/material/Box'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import demos from 'docs/src/modules/components/densityDemos'; + +// Local verification fixture for the CSS-var density adapter (Button only). +// Used by scripts/density-screenshots. Renders Button's load-bearing matrix +// (shared `demos`) inside #density-scope; the harness sets `level` +// (default | dense | loose), which the scope translates into density-token +// overrides. `level=default` sets no tokens, so the render must be +// pixel-identical to the pre-change baseline. +const theme = createTheme({ cssVariables: true }); + +// Per-component density-token overrides for the review levels. `default` is +// empty on purpose — that render is the pixel-identical regression gate. +const scopes: Record> = { + Button: { + dense: { + ['--Button-small-pad' as any]: '2px 6px', + ['--Button-medium-pad' as any]: '3px 10px', + ['--Button-large-pad' as any]: '4px 14px', + }, + loose: { + ['--Button-small-pad' as any]: '8px 14px', + ['--Button-medium-pad' as any]: '12px 22px', + ['--Button-large-pad' as any]: '16px 30px', + }, + }, + MenuItem: { + dense: { + ['--MenuItem-minHeight' as any]: '36px', + ['--MenuItem-blockPad' as any]: '3px', + ['--MenuItem-inlinePad' as any]: '10px', + ['--MenuItem-dense-minHeight' as any]: '26px', + ['--MenuItem-dense-blockPad' as any]: '2px', + ['--MenuItem-dense-inlinePad' as any]: '8px', + }, + loose: { + ['--MenuItem-minHeight' as any]: '60px', + ['--MenuItem-blockPad' as any]: '12px', + ['--MenuItem-inlinePad' as any]: '28px', + ['--MenuItem-dense-minHeight' as any]: '44px', + ['--MenuItem-dense-blockPad' as any]: '8px', + ['--MenuItem-dense-inlinePad' as any]: '20px', + }, + }, + Tooltip: { + dense: { + ['--Tooltip-blockPad' as any]: '2px', + ['--Tooltip-inlinePad' as any]: '6px', + ['--Tooltip-offset' as any]: '10px', + ['--Tooltip-arrowSize' as any]: '9px', + }, + loose: { + ['--Tooltip-blockPad' as any]: '8px', + ['--Tooltip-inlinePad' as any]: '16px', + ['--Tooltip-offset' as any]: '20px', + ['--Tooltip-arrowSize' as any]: '16px', + }, + }, + OutlinedInput: { + dense: { + ['--OutlinedInput-medium-blockPad' as any]: '10px', + ['--OutlinedInput-small-blockPad' as any]: '5px', + ['--OutlinedInput-medium-inlinePad' as any]: '10px', + ['--OutlinedInput-small-inlinePad' as any]: '8px', + ['--InputAdornment-medium-gap' as any]: '4px', + ['--InputAdornment-small-gap' as any]: '2px', + }, + loose: { + ['--OutlinedInput-medium-blockPad' as any]: '24px', + ['--OutlinedInput-small-blockPad' as any]: '16px', + ['--OutlinedInput-medium-inlinePad' as any]: '20px', + ['--OutlinedInput-small-inlinePad' as any]: '16px', + ['--InputAdornment-medium-gap' as any]: '14px', + ['--InputAdornment-small-gap' as any]: '10px', + }, + }, + FilledInput: { + dense: { + ['--FilledInput-medium-topPad' as any]: '18px', + ['--FilledInput-medium-bottomPad' as any]: '4px', + ['--FilledInput-medium-inlinePad' as any]: '8px', + ['--FilledInputLabel-restY' as any]: '11px', + ['--FilledInputLabel-shrinkY' as any]: '5px', + }, + loose: { + ['--FilledInput-medium-topPad' as any]: '34px', + ['--FilledInput-medium-bottomPad' as any]: '12px', + ['--FilledInput-medium-inlinePad' as any]: '16px', + ['--FilledInputLabel-restY' as any]: '22px', + ['--FilledInputLabel-shrinkY' as any]: '10px', + }, + }, + Input: { + dense: { + ['--Input-medium-topPad' as any]: '2px', + ['--Input-bottomPad' as any]: '2px', + }, + loose: { + ['--Input-medium-topPad' as any]: '12px', + ['--Input-bottomPad' as any]: '12px', + }, + }, + Checkbox: { + dense: { + ['--Checkbox-medium-pad' as any]: '4px', + ['--Checkbox-small-pad' as any]: '2px', + }, + loose: { + ['--Checkbox-medium-pad' as any]: '14px', + ['--Checkbox-small-pad' as any]: '12px', + }, + }, + Radio: { + dense: { + ['--Radio-medium-pad' as any]: '4px', + ['--Radio-small-pad' as any]: '2px', + }, + loose: { + ['--Radio-medium-pad' as any]: '14px', + ['--Radio-small-pad' as any]: '12px', + }, + }, + Avatar: { + dense: { ['--Avatar-size' as any]: '28px' }, + loose: { ['--Avatar-size' as any]: '56px' }, + }, + ListItemButton: { + dense: { + ['--ListItemButton-blockPad' as any]: '4px', + ['--ListItemButton-dense-blockPad' as any]: '2px', + ['--ListItemButton-inlinePad' as any]: '10px', + }, + loose: { + ['--ListItemButton-blockPad' as any]: '14px', + ['--ListItemButton-dense-blockPad' as any]: '8px', + ['--ListItemButton-inlinePad' as any]: '28px', + }, + }, + Dialog: { + dense: { + ['--DialogTitle-blockPad' as any]: '10px', + ['--DialogTitle-inlinePad' as any]: '16px', + ['--DialogContent-blockPad' as any]: '12px', + ['--DialogContent-inlinePad' as any]: '16px', + ['--DialogActions-pad' as any]: '4px', + }, + loose: { + ['--DialogTitle-blockPad' as any]: '24px', + ['--DialogTitle-inlinePad' as any]: '32px', + ['--DialogContent-blockPad' as any]: '28px', + ['--DialogContent-inlinePad' as any]: '32px', + ['--DialogActions-pad' as any]: '16px', + }, + }, + BottomNavigation: { + dense: { + ['--BottomNavigation-height' as any]: '44px', + ['--BottomNavigationAction-inlinePad' as any]: '8px', + }, + loose: { + ['--BottomNavigation-height' as any]: '72px', + ['--BottomNavigationAction-inlinePad' as any]: '20px', + }, + }, + SnackbarContent: { + dense: { + ['--SnackbarContent-blockPad' as any]: '2px', + ['--SnackbarContent-inlinePad' as any]: '10px', + }, + loose: { + ['--SnackbarContent-blockPad' as any]: '14px', + ['--SnackbarContent-inlinePad' as any]: '28px', + }, + }, + PaginationItem: { + dense: { + ['--PaginationItem-small-size' as any]: '22px', + ['--PaginationItem-medium-size' as any]: '28px', + ['--PaginationItem-large-size' as any]: '34px', + }, + loose: { + ['--PaginationItem-small-size' as any]: '34px', + ['--PaginationItem-medium-size' as any]: '44px', + ['--PaginationItem-large-size' as any]: '52px', + }, + }, + Fab: { + dense: { + ['--Fab-small-size' as any]: '32px', + ['--Fab-medium-size' as any]: '40px', + ['--Fab-large-size' as any]: '48px', + }, + loose: { + ['--Fab-small-size' as any]: '48px', + ['--Fab-medium-size' as any]: '56px', + ['--Fab-large-size' as any]: '64px', + }, + }, + ButtonGroup: { + dense: { ['--ButtonGroup-minWidth' as any]: '28px' }, + loose: { ['--ButtonGroup-minWidth' as any]: '56px' }, + }, + Toolbar: { + dense: { + ['--Toolbar-inlinePad' as any]: '8px', + ['--Toolbar-wideInlinePad' as any]: '12px', + ['--Toolbar-denseMinHeight' as any]: '40px', + }, + loose: { + ['--Toolbar-inlinePad' as any]: '24px', + ['--Toolbar-wideInlinePad' as any]: '40px', + ['--Toolbar-denseMinHeight' as any]: '56px', + }, + }, + Stepper: { + dense: { + ['--Step-inlinePad' as any]: '4px', + ['--StepLabel-iconGap' as any]: '4px', + }, + loose: { + ['--Step-inlinePad' as any]: '16px', + ['--StepLabel-iconGap' as any]: '16px', + }, + }, + Autocomplete: { + dense: { + ['--Autocomplete-option-minHeight' as any]: '32px', + ['--Autocomplete-option-blockPad' as any]: '3px', + ['--Autocomplete-option-inlinePad' as any]: '10px', + }, + loose: { + ['--Autocomplete-option-minHeight' as any]: '60px', + ['--Autocomplete-option-blockPad' as any]: '12px', + ['--Autocomplete-option-inlinePad' as any]: '28px', + }, + }, + TableCell: { + dense: { + ['--TableCell-medium-blockPad' as any]: '8px', + ['--TableCell-small-blockPad' as any]: '3px', + ['--TableCell-inlinePad' as any]: '10px', + }, + loose: { + ['--TableCell-medium-blockPad' as any]: '24px', + ['--TableCell-small-blockPad' as any]: '12px', + ['--TableCell-inlinePad' as any]: '24px', + }, + }, + Badge: { + dense: { + ['--Badge-standard-size' as any]: '16px', + ['--Badge-standard-pad' as any]: '0 4px', + ['--Badge-dot-size' as any]: '4px', + }, + loose: { + ['--Badge-standard-size' as any]: '26px', + ['--Badge-standard-pad' as any]: '0 10px', + ['--Badge-dot-size' as any]: '10px', + }, + }, + Breadcrumbs: { + dense: { ['--Breadcrumbs-separatorGap' as any]: '4px' }, + loose: { ['--Breadcrumbs-separatorGap' as any]: '16px' }, + }, + ToggleButton: { + dense: { + ['--ToggleButton-small-pad' as any]: '4px', + ['--ToggleButton-medium-pad' as any]: '7px', + ['--ToggleButton-large-pad' as any]: '10px', + }, + loose: { + ['--ToggleButton-small-pad' as any]: '12px', + ['--ToggleButton-medium-pad' as any]: '16px', + ['--ToggleButton-large-pad' as any]: '22px', + }, + }, + CardContent: { + dense: { + ['--CardContent-pad' as any]: '8px', + ['--CardContent-padBottom' as any]: '12px', + }, + loose: { + ['--CardContent-pad' as any]: '32px', + ['--CardContent-padBottom' as any]: '40px', + }, + }, + Select: { + dense: { ['--Select-minHeight' as any]: '16px' }, + loose: { ['--Select-minHeight' as any]: '40px' }, + }, + Accordion: { + dense: { + ['--AccordionSummary-minHeight' as any]: '40px', + ['--AccordionSummary-expandedMinHeight' as any]: '48px', + ['--AccordionSummary-inlinePad' as any]: '12px', + ['--AccordionSummary-marginBlock' as any]: '8px', + ['--AccordionSummary-expandedMarginBlock' as any]: '12px', + ['--AccordionDetails-topPad' as any]: '4px', + ['--AccordionDetails-inlinePad' as any]: '12px', + ['--AccordionDetails-bottomPad' as any]: '12px', + }, + loose: { + ['--AccordionSummary-minHeight' as any]: '64px', + ['--AccordionSummary-expandedMinHeight' as any]: '88px', + ['--AccordionSummary-inlinePad' as any]: '28px', + ['--AccordionSummary-marginBlock' as any]: '20px', + ['--AccordionSummary-expandedMarginBlock' as any]: '28px', + ['--AccordionDetails-topPad' as any]: '16px', + ['--AccordionDetails-inlinePad' as any]: '28px', + ['--AccordionDetails-bottomPad' as any]: '28px', + }, + }, + Chip: { + dense: { + ['--Chip-medium-height' as any]: '26px', + ['--Chip-small-height' as any]: '20px', + ['--Chip-medium-padInline' as any]: '8px', + ['--Chip-small-padInline' as any]: '6px', + }, + loose: { + ['--Chip-medium-height' as any]: '40px', + ['--Chip-small-height' as any]: '32px', + ['--Chip-medium-padInline' as any]: '16px', + ['--Chip-small-padInline' as any]: '12px', + }, + }, + Alert: { + dense: { + ['--Alert-blockPad' as any]: '2px', + ['--Alert-inlinePad' as any]: '10px', + ['--Alert-iconGap' as any]: '6px', + }, + loose: { + ['--Alert-blockPad' as any]: '14px', + ['--Alert-inlinePad' as any]: '28px', + ['--Alert-iconGap' as any]: '20px', + }, + }, + Tab: { + dense: { + ['--Tab-minHeight' as any]: '40px', + ['--Tabs-minHeight' as any]: '40px', + ['--Tab-iconLabel-minHeight' as any]: '60px', + ['--Tab-blockPad' as any]: '8px', + ['--Tab-iconLabel-blockPad' as any]: '6px', + ['--Tab-inlinePad' as any]: '12px', + ['--Tab-icon-stackGap' as any]: '4px', + ['--Tab-icon-inlineGap' as any]: '6px', + }, + loose: { + ['--Tab-minHeight' as any]: '60px', + ['--Tabs-minHeight' as any]: '60px', + ['--Tab-iconLabel-minHeight' as any]: '88px', + ['--Tab-blockPad' as any]: '16px', + ['--Tab-iconLabel-blockPad' as any]: '12px', + ['--Tab-inlinePad' as any]: '28px', + ['--Tab-icon-stackGap' as any]: '10px', + ['--Tab-icon-inlineGap' as any]: '12px', + }, + }, +}; + +export default function DensityFixture() { + const router = useRouter(); + const component = (router.query.c as string) || 'Button'; + const level = (router.query.level as string) || 'default'; + const demo = demos[component] ??
No demo registered for "{component}".
; + const tokens = scopes[component]?.[level] ?? {}; + return ( + + + {demo} + + + ); +} diff --git a/docs/pages/experiments/density-playground.tsx b/docs/pages/experiments/density-playground.tsx new file mode 100644 index 00000000000000..7e89ad0a16afef --- /dev/null +++ b/docs/pages/experiments/density-playground.tsx @@ -0,0 +1,1601 @@ +'use client'; +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import Fab from '@mui/material/Fab'; +import Pagination from '@mui/material/Pagination'; +import SnackbarContent from '@mui/material/SnackbarContent'; +import BottomNavigation from '@mui/material/BottomNavigation'; +import BottomNavigationAction from '@mui/material/BottomNavigationAction'; +import Table from '@mui/material/Table'; +import TableHead from '@mui/material/TableHead'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; +import CssBaseline from '@mui/material/CssBaseline'; +import GlobalStyles from '@mui/material/GlobalStyles'; +import RadioGroup from '@mui/material/RadioGroup'; +import FormControl from '@mui/material/FormControl'; +import FormLabel from '@mui/material/FormLabel'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import Menu from '@mui/material/Menu'; +import ListItemButton from '@mui/material/ListItemButton'; +import List from '@mui/material/List'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import InboxIcon from '@mui/icons-material/Inbox'; +import TextField from '@mui/material/TextField'; +import Autocomplete from '@mui/material/Autocomplete'; +import InputAdornment from '@mui/material/InputAdornment'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import Checkbox from '@mui/material/Checkbox'; +import Radio from '@mui/material/Radio'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Paper from '@mui/material/Paper'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardActions from '@mui/material/CardActions'; +import CardHeader from '@mui/material/CardHeader'; +import Rating from '@mui/material/Rating'; +import Select from '@mui/material/Select'; +import InputLabel from '@mui/material/InputLabel'; +import Alert from '@mui/material/Alert'; +import Chip from '@mui/material/Chip'; +import Avatar from '@mui/material/Avatar'; +import Badge from '@mui/material/Badge'; +import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import Stepper from '@mui/material/Stepper'; +import Step from '@mui/material/Step'; +import StepLabel from '@mui/material/StepLabel'; +import Accordion from '@mui/material/Accordion'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import Typography from '@mui/material/Typography'; +import Divider from '@mui/material/Divider'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Tooltip from '@mui/material/Tooltip'; +import PaddingIcon from '@mui/icons-material/Padding'; +import TitleIcon from '@mui/icons-material/Title'; +import { + createTheme, + ThemeProvider, + enhanceCompactDensity, + enhanceNormalDensity, + enhanceComfortDensity, +} from '@mui/material/styles'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; + +const SCALE_KEYS = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const; +const PRESETS = ['unset', 'compact', 'normal', 'comfort'] as const; +const SIZES = ['small', 'medium', 'large'] as const; +const VARIANTS = ['text', 'outlined', 'contained'] as const; + +type Preset = (typeof PRESETS)[number]; + +// Visual-debug overlays, toggled by `data-debug-*` on the canvas. Pure CSS, +// layout-safe (absolute ::before + pointer-events:none), never touches the +// components' real styles. The label span sits above the padding overlay +// (z-index) so text stays crisp; its blue fill only shows in text mode. +// The padding-ring overlay: `inset:0` sizes it to the element's padding-box; +// `padding:inherit` shrinks its content-box to the element's content box, and +// the `exclude` mask knocks that center out → green fills only the padding ring. +const PADDING_RING = { + content: '""', + position: 'absolute', + inset: 0, + padding: 'inherit', + boxSizing: 'border-box', + borderRadius: 'inherit', + backgroundColor: 'rgba(46, 204, 64, 0.5)', // padding = green (DevTools convention) + pointerEvents: 'none', + WebkitMask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)', + WebkitMaskComposite: 'xor', + mask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)', + maskComposite: 'exclude', +} as const; + +const DEBUG_SX = { + '& .density-debug-text': { position: 'relative', zIndex: 1, borderRadius: '2px' }, + // Padding ring on ButtonBase (Button/MenuItem) + the Tooltip bubble. + '&[data-debug-padding] .MuiButtonBase-root, &[data-debug-padding] .MuiTooltip-tooltip': { + position: 'relative', + }, + '&[data-debug-padding] .MuiButtonBase-root::before': PADDING_RING, + '&[data-debug-padding] .MuiTooltip-tooltip::before': PADDING_RING, + '&[data-debug-text] .density-debug-text': { + backgroundColor: 'rgba(0, 116, 217, 0.32)', // text box = blue + }, +} as const; + +const PRESET_LABEL: Record = { + unset: 'none', + compact: 'compact', + normal: 'normal', + comfort: 'comfort', +}; + +const isDensityKey = (t: string) => (SCALE_KEYS as readonly string[]).includes(t); +const tokenize = (input: string) => input.trim().split(/\s+/).filter(Boolean); + +// A mapping input is ANY valid CSS value. A density key (`xxs`…`xxl`) is sugar +// for `var(--mui-density-)`; anything else passes through verbatim as raw +// CSS (`12px`, `2rem`, `auto`). 1 token → all sides; 2 → `block inline`. +const resolveValue = (input: string) => + tokenize(input) + .map((t) => (isDensityKey(t) ? `var(--mui-density-${t})` : t)) + .join(' '); + +// Empty = inert (no override, no error). >2 tokens = error. Otherwise ok — raw +// values are first-class, never rejected as "not a density key". +function parseMapping(input: string): { state: 'empty' | 'ok' | 'error'; error?: string } { + const tokens = tokenize(input); + if (tokens.length === 0) { + return { state: 'empty' }; + } + if (tokens.length > 2) { + return { state: 'error', error: 'max 2 values (block inline)' }; + } + return { state: 'ok' }; +} + +// Human-readable resolved value: keys show their px (from the active scale), +// raw values echo as typed. +const previewText = (input: string, scalePx: Record | null) => + tokenize(input) + .map((t) => (isDensityKey(t) ? (scalePx?.[t] ?? t) : t)) + .join(' '); + +// A readable property label from the token identity, e.g. +// '--Button-small-pad' → 'Button small padding', '--MenuItem-dense-blockPad' → +// 'Menu item dense block padding'. Split on `-` + camelCase, lowercase, cap the +// component word, expand `pad` → `padding`. +const fieldLabel = (cssVar: string) => { + const words = cssVar + .replace(/^--/, '') + .split('-') + .flatMap((seg) => seg.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ')) + .map((w) => w.toLowerCase()); + return words + .map((w, i) => (i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w)) + .join(' ') + .replace(/\bpad\b/g, 'padding'); +}; + +// Each preset maps to its `enhance*Density` fn; `unset` applies none. +const PRESET_FN = { + compact: enhanceCompactDensity, + normal: enhanceNormalDensity, + comfort: enhanceComfortDensity, +} as const; + +// --------------------------------------------------------------------------- +// Density-component registry. Only Button is de-prefixed/wired in this +// prototype; add entries here as more families gain a static `private_*Vars` +// map — the dropdown, canvas and mapping controls all iterate this registry. +// --------------------------------------------------------------------------- +interface DensityField { + key: string; // mapping-state key, e.g. 'smallPad' + cssVar: string; // token identity — raw-px placeholder lookup + var-mode override target + selector: string; // canvas-relative selector the preset emits on (no `#density-canvas` prefix) + // The real CSS property (or properties) to override in-scope — the emitted-override + // model writes the property directly, so it survives the source's seam removal. + // Omit → var-mode: write `cssVar` instead, letting the source's own seam route it. + // Used for calc-coupling children (Chip height) and multi-route/media fields with + // no discriminating class (Tab icon gaps, Toolbar gutters, Step gutter, Tooltip offset/arrow). + prop?: string | string[]; +} +interface DensityComponentDef { + canvasLabel: string; + fields: DensityField[]; + prefill: Record; + note?: string; // shown under the mapping group (stub / out-of-scope axis) + // Overrides are applied globally via GlobalStyles (see `overrideCss`), so a + // matrix is a plain demo render — no per-element token plumbing. + renderMatrix: () => React.ReactNode; +} + +function ButtonMatrix() { + return ( + + {SIZES.map((size) => ( + + + + {size} + + + + {VARIANTS.map((variant) => ( + + ))} + + + ))} + + ); +} + +// The Menu family's density tokens: List container block padding + MenuItem +// block/inline padding + min-height, keyed by the `dense` axis. Field key === +// mapping-state key. Sizing tokens (`minHeight`) accept raw px like any other — +// a density key is just sugar; heights ship as raw px per preset. +const MENU_FIELDS: DensityField[] = [ + { key: 'listBlockPad', cssVar: '--List-blockPad', prop: 'paddingBlock', selector: '.MuiList-padding' }, + { key: 'blockPad', cssVar: '--MenuItem-blockPad', prop: 'paddingBlock', selector: '.MuiMenuItem-root:not(.MuiMenuItem-dense)' }, + { key: 'inlinePad', cssVar: '--MenuItem-inlinePad', prop: 'paddingInline', selector: '.MuiMenuItem-gutters:not(.MuiMenuItem-dense)' }, + { key: 'minHeight', cssVar: '--MenuItem-minHeight', prop: 'minHeight', selector: '.MuiMenuItem-root:not(.MuiMenuItem-dense)' }, + { key: 'denseBlockPad', cssVar: '--MenuItem-dense-blockPad', prop: 'paddingBlock', selector: '.MuiMenuItem-dense' }, + { key: 'denseInlinePad', cssVar: '--MenuItem-dense-inlinePad', prop: 'paddingInline', selector: '.MuiMenuItem-gutters.MuiMenuItem-dense' }, + { key: 'denseMinHeight', cssVar: '--MenuItem-dense-minHeight', prop: 'minHeight', selector: '.MuiMenuItem-dense' }, +]; + +function MenuDemoItems() { + return ( + + + Default item + + + Selected item + + + + + + + With icon + + + + With divider + + + Dense item + + + + + + + Dense + icon + + + + ); +} + +function MenuMatrix() { + const [anchorEl, setAnchorEl] = React.useState(null); + // The popover portals outside `#density-canvas`, so manual overrides (scoped + // there) reach only the static list; both still follow the preset via context. + return ( + + + + +
+ + setAnchorEl(null)}> + + +
+
+ ); +} + +// Tooltip density tokens (regular/pointer only — `touch` is out of scope). +// Padding + anchor offset are spacing (prefill density keys); arrow size ships +// as raw px per preset (read live off the theme), like MenuItem min-height. +const TOOLTIP_FIELDS: DensityField[] = [ + { key: 'blockPad', cssVar: '--Tooltip-blockPad', prop: 'paddingBlock', selector: '.MuiTooltip-tooltip' }, + { key: 'inlinePad', cssVar: '--Tooltip-inlinePad', prop: 'paddingInline', selector: '.MuiTooltip-tooltip' }, + // Offset is emitted as per-placement margins by the preset (4 placements, no + // discriminating class) — the synthetic cssVar is label/placeholder only. + { key: 'offset', cssVar: '--Tooltip-offset', selector: '.MuiTooltip-tooltip' }, + // Calc-coupled: the arrow's width + height (calc) both derive from this real var. + { key: 'arrowSize', cssVar: '--Tooltip-arrowSize', selector: '.MuiTooltip-tooltip' }, +]; + +function TooltipMatrix() { + // Force open + inline (no portal) so the bubble sits inside the debug scope, + // picks up the padding-ring / text-box overlays, and receives the canvas-scoped + // token overrides. + const slotProps = { + popper: { disablePortal: true }, + } as const; + return ( + + Default tooltip} + open + placement="bottom" + slotProps={slotProps} + > + + + Arrow tooltip} + arrow + open + placement="bottom" + slotProps={slotProps} + > + + + + ); +} + +// OutlinedInput family: input block/inline padding (per size) + adornment gap +// (per size). All spacing → prefill density keys. The label resting-Y is a +// derived bridge (not a direct field). +const OUTLINED_INPUT_FIELDS: DensityField[] = [ + { key: 'mediumBlockPad', cssVar: '--OutlinedInput-medium-blockPad', prop: 'paddingBlock', selector: '.MuiOutlinedInput-root:not(.MuiInputBase-sizeSmall) .MuiOutlinedInput-input' }, + { key: 'smallBlockPad', cssVar: '--OutlinedInput-small-blockPad', prop: 'paddingBlock', selector: '.MuiInputBase-sizeSmall .MuiOutlinedInput-input' }, + { key: 'mediumInlinePad', cssVar: '--OutlinedInput-medium-inlinePad', prop: 'paddingInline', selector: '.MuiOutlinedInput-root:not(.MuiInputBase-sizeSmall) .MuiOutlinedInput-input' }, + { key: 'smallInlinePad', cssVar: '--OutlinedInput-small-inlinePad', prop: 'paddingInline', selector: '.MuiInputBase-sizeSmall .MuiOutlinedInput-input' }, + // Gap = start marginRight / end marginLeft (one token, no per-side discriminating + // class) — the preset emits the real margins per position; the synthetic cssVar + // is label/placeholder only. + { key: 'mediumGap', cssVar: '--InputAdornment-medium-gap', selector: '.MuiInputAdornment-root:not(.MuiInputAdornment-sizeSmall)' }, + { key: 'smallGap', cssVar: '--InputAdornment-small-gap', selector: '.MuiInputAdornment-sizeSmall' }, +]; + +function OutlinedInputMatrix() { + return ( + + Medium} variant="outlined" /> + Small} + variant="outlined" + size="small" + /> + Start adornment} + variant="outlined" + slotProps={{ + input: { startAdornment: $ }, + }} + /> + End adornment} + variant="outlined" + slotProps={{ input: { endAdornment: kg } }} + /> + + ); +} + +// FilledInput family: box top/bottom/inline padding (per size). All spacing → +// prefill density keys. The label rest/shrink Y follow the active preset (tuned +// raw px, not editable here). +const FILLED_INPUT_FIELDS: DensityField[] = [ + { key: 'mediumTopPad', cssVar: '--FilledInput-medium-topPad', prop: 'paddingTop', selector: '.MuiFilledInput-root:not(.MuiInputBase-sizeSmall) .MuiFilledInput-input' }, + { key: 'smallTopPad', cssVar: '--FilledInput-small-topPad', prop: 'paddingTop', selector: '.MuiInputBase-sizeSmall .MuiFilledInput-input' }, + { key: 'mediumBottomPad', cssVar: '--FilledInput-medium-bottomPad', prop: 'paddingBottom', selector: '.MuiFilledInput-root:not(.MuiInputBase-sizeSmall) .MuiFilledInput-input' }, + { key: 'smallBottomPad', cssVar: '--FilledInput-small-bottomPad', prop: 'paddingBottom', selector: '.MuiInputBase-sizeSmall .MuiFilledInput-input' }, + { key: 'mediumInlinePad', cssVar: '--FilledInput-medium-inlinePad', prop: 'paddingInline', selector: '.MuiFilledInput-root:not(.MuiInputBase-sizeSmall) .MuiFilledInput-input' }, + { key: 'smallInlinePad', cssVar: '--FilledInput-small-inlinePad', prop: 'paddingInline', selector: '.MuiInputBase-sizeSmall .MuiFilledInput-input' }, +]; + +function FilledInputMatrix() { + return ( + + Medium} variant="filled" /> + Small} + variant="filled" + size="small" + /> + Filled value} + variant="filled" + defaultValue="Value" + /> + + ); +} + +// Input (standard) family: input top/bottom padding (top per size, bottom +// shared). Inline is 0; the label floats above (no bridge). +const INPUT_FIELDS: DensityField[] = [ + { key: 'mediumTopPad', cssVar: '--Input-medium-topPad', prop: 'paddingTop', selector: '.MuiInput-root:not(.MuiInputBase-sizeSmall) .MuiInput-input' }, + { key: 'smallTopPad', cssVar: '--Input-small-topPad', prop: 'paddingTop', selector: '.MuiInputBase-sizeSmall .MuiInput-input' }, + { key: 'bottomPad', cssVar: '--Input-bottomPad', prop: 'paddingBottom', selector: '.MuiInput-input' }, +]; + +function InputMatrix() { + return ( + + Medium} variant="standard" /> + Small} + variant="standard" + size="small" + /> + + ); +} + +// Tabs family: Tab default + icon+label states (block pad + min-height each) + +// shared inline pad + icon gaps (stack/inline), plus the paired Tabs-root +// min-height. Spacing → density keys; min-heights → raw px (read off the theme). +const TAB_FIELDS: DensityField[] = [ + { key: 'minHeight', cssVar: '--Tab-minHeight', prop: 'minHeight', selector: '.MuiTab-root:not(.MuiTab-labelIcon)' }, + { key: 'tabsMinHeight', cssVar: '--Tabs-minHeight', prop: 'minHeight', selector: '.MuiTabs-root' }, + { key: 'iconLabelMinHeight', cssVar: '--Tab-iconLabel-minHeight', prop: 'minHeight', selector: '.MuiTab-root.MuiTab-labelIcon' }, + { key: 'blockPad', cssVar: '--Tab-blockPad', prop: 'paddingBlock', selector: '.MuiTab-root:not(.MuiTab-labelIcon)' }, + { key: 'iconLabelBlockPad', cssVar: '--Tab-iconLabel-blockPad', prop: 'paddingBlock', selector: '.MuiTab-root.MuiTab-labelIcon' }, + { key: 'inlinePad', cssVar: '--Tab-inlinePad', prop: 'paddingInline', selector: '.MuiTab-root' }, + // var-mode: one gap var → icon margin per iconPosition (top/bottom, start/end; no class). + { key: 'iconStackGap', cssVar: '--Tab-icon-stackGap', selector: '.MuiTab-root' }, + { key: 'iconInlineGap', cssVar: '--Tab-icon-inlineGap', selector: '.MuiTab-root' }, +]; + +function TabsMatrix() { + const lbl = (t: string) => {t}; + return ( + + + + + + + + } label={lbl('Top')} iconPosition="top" /> + } label={lbl('Top')} iconPosition="top" /> + + + } label={lbl('Start')} iconPosition="start" /> + } label={lbl('Start')} iconPosition="start" /> + + + ); +} + +// Checkbox family: the touch-target padding around the icon, per size (via +// SwitchBase). All spacing → density keys. +const CHECKBOX_FIELDS: DensityField[] = [ + { key: 'mediumPad', cssVar: '--Checkbox-medium-pad', prop: 'padding', selector: '.MuiCheckbox-root.MuiCheckbox-sizeMedium' }, + { key: 'smallPad', cssVar: '--Checkbox-small-pad', prop: 'padding', selector: '.MuiCheckbox-root.MuiCheckbox-sizeSmall' }, +]; + +function CheckboxMatrix() { + return ( + + + + + ); +} + +// Card family: CardContent padding (+ last-child), CardActions/CardHeader padding +// + gaps — all preset-reflowed via emitted overrides (no size axis). +const CARD_FIELDS: DensityField[] = [ + { key: 'pad', cssVar: '--CardContent-pad', prop: 'padding', selector: '.MuiCardContent-root' }, + { key: 'padBottom', cssVar: '--CardContent-padBottom', prop: 'paddingBottom', selector: '.MuiCardContent-root:last-child' }, + { key: 'actionsPad', cssVar: '--CardActions-pad', prop: 'padding', selector: '.MuiCardActions-root' }, + { key: 'actionsGap', cssVar: '--CardActions-childGap', prop: 'marginLeft', selector: '.MuiCardActions-spacing > :not(:first-of-type)' }, + { key: 'headerPad', cssVar: '--CardHeader-pad', prop: 'padding', selector: '.MuiCardHeader-root' }, + { key: 'headerAvatarGap', cssVar: '--CardHeader-avatarGap', prop: 'marginRight', selector: '.MuiCardHeader-avatar' }, +]; + +function CardMatrix() { + return ( + + R} + title={Card header} + subheader="With avatar" + /> + + + Body content with last-child bottom padding. + + + + + + + + ); +} + +function RatingMatrix() { + return ( + + + + + + ); +} + +// Select family: the content-box min-height floor (raw px). The visible density +// mostly comes from the underlying OutlinedInput padding (tokenized separately). +const SELECT_FIELDS: DensityField[] = [ + { key: 'minHeight', cssVar: '--Select-minHeight', prop: 'minHeight', selector: '.MuiSelect-select' }, +]; + +function SelectMatrix() { + return ( + + Age + + + ); +} + +// Alert family: root block/inline padding + icon→message gap (no size axis). +const ALERT_FIELDS: DensityField[] = [ + { key: 'blockPad', cssVar: '--Alert-blockPad', prop: 'paddingBlock', selector: '.MuiAlert-root' }, + { key: 'inlinePad', cssVar: '--Alert-inlinePad', prop: 'paddingInline', selector: '.MuiAlert-root' }, + // iconGap drives the icon's marginRight (child element). + { key: 'iconGap', cssVar: '--Alert-iconGap', prop: 'marginRight', selector: '.MuiAlert-icon' }, +]; + +function AlertMatrix() { + return ( + + + Info alert — icon gap + root padding. + + {}}> + Success alert with a close action. + + + ); +} + +// Chip family: height (per size — drives avatar/icon/deleteIcon via calc) + +// label inline padding (per size). Height = raw px; padInline = density keys. +const CHIP_FIELDS: DensityField[] = [ + // Calc-coupled var-mode: the single `--Chip-height` (scoped per size) drives + // avatar/icon/deleteIcon dims via calc — write the var so the derived children + // scale too (writing `height` would move only the box). + { key: 'mediumHeight', cssVar: '--Chip-height', selector: '.MuiChip-root.MuiChip-sizeMedium' }, + { key: 'smallHeight', cssVar: '--Chip-height', selector: '.MuiChip-root.MuiChip-sizeSmall' }, + { key: 'mediumPadInline', cssVar: '--Chip-medium-padInline', prop: 'paddingInline', selector: '.MuiChip-sizeMedium .MuiChip-label' }, + { key: 'smallPadInline', cssVar: '--Chip-small-padInline', prop: 'paddingInline', selector: '.MuiChip-sizeSmall .MuiChip-label' }, +]; + +function ChipMatrix() { + return ( + + A} label="Avatar" /> + } label="Icon" onDelete={() => {}} /> + {}} /> + {}} /> + + ); +} + +// Accordion family: Summary collapsed/expanded min-height + inline pad + +// content block margin; Details top/inline/bottom padding. min-heights = raw px, +// the rest = density keys. +const ACCORDION_FIELDS: DensityField[] = [ + { key: 'minHeight', cssVar: '--AccordionSummary-minHeight', prop: 'minHeight', selector: '.MuiAccordionSummary-root:not(.Mui-expanded)' }, + { key: 'expandedMinHeight', cssVar: '--AccordionSummary-expandedMinHeight', prop: 'minHeight', selector: '.MuiAccordionSummary-root.Mui-expanded' }, + { key: 'inlinePad', cssVar: '--AccordionSummary-inlinePad', prop: 'paddingInline', selector: '.MuiAccordionSummary-root' }, + { key: 'marginBlock', cssVar: '--AccordionSummary-marginBlock', prop: 'marginBlock', selector: '.MuiAccordionSummary-content:not(.Mui-expanded)' }, + { key: 'expandedMarginBlock', cssVar: '--AccordionSummary-expandedMarginBlock', prop: 'marginBlock', selector: '.MuiAccordionSummary-content.Mui-expanded' }, + { key: 'detailsTopPad', cssVar: '--AccordionDetails-topPad', prop: 'paddingTop', selector: '.MuiAccordionDetails-root' }, + { key: 'detailsInlinePad', cssVar: '--AccordionDetails-inlinePad', prop: 'paddingInline', selector: '.MuiAccordionDetails-root' }, + { key: 'detailsBottomPad', cssVar: '--AccordionDetails-bottomPad', prop: 'paddingBottom', selector: '.MuiAccordionDetails-root' }, +]; + +function AccordionMatrix() { + return ( + + + }> + Expanded summary + + + Details content padding. + + + + }> + Collapsed summary + + Hidden. + + + ); +} + +// Radio family: touch-target padding per size (via SwitchBase, like Checkbox). +const RADIO_FIELDS: DensityField[] = [ + { key: 'mediumPad', cssVar: '--Radio-medium-pad', prop: 'padding', selector: '.MuiRadio-root:not(.MuiRadio-sizeSmall)' }, + { key: 'smallPad', cssVar: '--Radio-small-pad', prop: 'padding', selector: '.MuiRadio-root.MuiRadio-sizeSmall' }, +]; + +function RadioMatrix() { + return ( + + + + + ); +} + +// Breadcrumbs family: the separator inline gap (single token, no size axis). +const BREADCRUMBS_FIELDS: DensityField[] = [ + { key: 'separatorGap', cssVar: '--Breadcrumbs-separatorGap', prop: 'marginInline', selector: '.MuiBreadcrumbs-separator' }, +]; + +function BreadcrumbsMatrix() { + return ( + + + Home + + + Catalog + + + Current + + + ); +} + +// ToggleButton family: uniform padding per size. +const TOGGLE_BUTTON_FIELDS: DensityField[] = [ + { key: 'smallPad', cssVar: '--ToggleButton-small-pad', prop: 'padding', selector: '.MuiToggleButton-root.MuiToggleButton-sizeSmall' }, + { key: 'mediumPad', cssVar: '--ToggleButton-medium-pad', prop: 'padding', selector: '.MuiToggleButton-root.MuiToggleButton-sizeMedium' }, + { key: 'largePad', cssVar: '--ToggleButton-large-pad', prop: 'padding', selector: '.MuiToggleButton-root.MuiToggleButton-sizeLarge' }, +]; + +function ToggleButtonMatrix() { + return ( + + {(['small', 'medium', 'large'] as const).map((size) => ( + + + {size} L + + + C + + + ))} + + ); +} + +// Avatar family: the square size (raw px; no size prop). +const AVATAR_FIELDS: DensityField[] = [ + { key: 'size', cssVar: '--Avatar-size', prop: ['width', 'height'], selector: '.MuiAvatar-root' }, +]; + +function AvatarMatrix() { + return ( + + A + B + + ); +} + +// Badge family: bubble size + padding, per state (standard / dot). +const BADGE_FIELDS: DensityField[] = [ + { key: 'standardSize', cssVar: '--Badge-standard-size', prop: ['minWidth', 'height'], selector: '.MuiBadge-badge.MuiBadge-standard' }, + { key: 'standardPad', cssVar: '--Badge-standard-pad', prop: 'padding', selector: '.MuiBadge-badge.MuiBadge-standard' }, + { key: 'dotSize', cssVar: '--Badge-dot-size', prop: ['minWidth', 'height'], selector: '.MuiBadge-badge.MuiBadge-dot' }, +]; + +function BadgeMatrix() { + return ( + + + + + + + + + ); +} + +// ButtonGroup family: the grouped-button min-width floor (raw px). +const BUTTON_GROUP_FIELDS: DensityField[] = [ + { key: 'minWidth', cssVar: '--ButtonGroup-minWidth', prop: 'minWidth', selector: '.MuiButtonGroup-grouped' }, +]; + +function ButtonGroupMatrix() { + return ( + + + + + + ); +} + +// TableCell family: block padding per size (medium/small) + shared inline pad. +const TABLE_CELL_FIELDS: DensityField[] = [ + { key: 'mediumBlockPad', cssVar: '--TableCell-medium-blockPad', prop: 'paddingBlock', selector: '.MuiTableCell-root.MuiTableCell-sizeMedium' }, + { key: 'smallBlockPad', cssVar: '--TableCell-small-blockPad', prop: 'paddingBlock', selector: '.MuiTableCell-root.MuiTableCell-sizeSmall' }, + { key: 'inlinePad', cssVar: '--TableCell-inlinePad', prop: 'paddingInline', selector: '.MuiTableCell-root' }, +]; + +function TableCellMatrix() { + return ( + + {(['medium', 'small'] as const).map((size) => ( + + + + + {size} name + + Value + + + + + Row one + 42 + + +
+ ))} +
+ ); +} + +// Autocomplete family: the option list geometry (mirrors MenuItem). The input's +// density comes from its variant (tokenized separately). +const AUTOCOMPLETE_FIELDS: DensityField[] = [ + { key: 'optionMinHeight', cssVar: '--Autocomplete-option-minHeight', prop: 'minHeight', selector: '.MuiAutocomplete-option' }, + { key: 'optionBlockPad', cssVar: '--Autocomplete-option-blockPad', prop: 'paddingBlock', selector: '.MuiAutocomplete-option' }, + { key: 'optionInlinePad', cssVar: '--Autocomplete-option-inlinePad', prop: 'paddingInline', selector: '.MuiAutocomplete-option' }, +]; + +function AutocompleteMatrix() { + return ( + } + /> + ); +} + +// Stepper family: Step horizontal gutter + StepLabel icon→label gap. +const STEPPER_FIELDS: DensityField[] = [ + // var-mode: one gutter var → first step padding-left / last step padding-right (no class). + { key: 'inlinePad', cssVar: '--Step-inlinePad', selector: '.MuiStep-root' }, + { key: 'iconGap', cssVar: '--StepLabel-iconGap', prop: 'paddingRight', selector: '.MuiStepLabel-iconContainer' }, +]; + +function StepperMatrix() { + return ( + + + + One + + + + + Two + + + + + Three + + + + ); +} + +// Toolbar (AppBar) family: gutter inline padding (base + ≥sm) + dense min-height. +const TOOLBAR_FIELDS: DensityField[] = [ + // Gutter padding is emitted base + ≥sm on the one `.gutters` class (the media + // bump has no discriminating selector) — synthetic cssVar is label/placeholder only. + { key: 'inlinePad', cssVar: '--Toolbar-inlinePad', selector: '.MuiToolbar-gutters' }, + { key: 'wideInlinePad', cssVar: '--Toolbar-wideInlinePad', selector: '.MuiToolbar-gutters' }, + { key: 'denseMinHeight', cssVar: '--Toolbar-denseMinHeight', prop: 'minHeight', selector: '.MuiToolbar-dense' }, +]; + +function ToolbarMatrix() { + return ( + + + + + Regular + + + + + + + Dense + + + + + ); +} + +// Fab family: circular size per size (raw px). +const FAB_FIELDS: DensityField[] = [ + { key: 'smallSize', cssVar: '--Fab-small-size', prop: ['width', 'height'], selector: '.MuiFab-root.MuiFab-sizeSmall' }, + { key: 'mediumSize', cssVar: '--Fab-medium-size', prop: ['width', 'height'], selector: '.MuiFab-root.MuiFab-sizeMedium' }, + { key: 'largeSize', cssVar: '--Fab-large-size', prop: ['width', 'height'], selector: '.MuiFab-root.MuiFab-sizeLarge' }, +]; + +function FabMatrix() { + return ( + + + + + + + + + + + + ); +} + +// Pagination family: the item box size per size (shared page/ellipsis). +const PAGINATION_FIELDS: DensityField[] = [ + { key: 'smallSize', cssVar: '--PaginationItem-small-size', prop: ['minWidth', 'height'], selector: '.MuiPaginationItem-sizeSmall' }, + { key: 'mediumSize', cssVar: '--PaginationItem-medium-size', prop: ['minWidth', 'height'], selector: '.MuiPaginationItem-root:not(.MuiPaginationItem-sizeSmall):not(.MuiPaginationItem-sizeLarge)' }, + { key: 'largeSize', cssVar: '--PaginationItem-large-size', prop: ['minWidth', 'height'], selector: '.MuiPaginationItem-sizeLarge' }, +]; + +function PaginationMatrix() { + return ( + + + + + + ); +} + +// SnackbarContent family: root block/inline padding (no size axis). +const SNACKBAR_FIELDS: DensityField[] = [ + { key: 'blockPad', cssVar: '--SnackbarContent-blockPad', prop: 'paddingBlock', selector: '.MuiSnackbarContent-root' }, + { key: 'inlinePad', cssVar: '--SnackbarContent-inlinePad', prop: 'paddingInline', selector: '.MuiSnackbarContent-root' }, +]; + +function SnackbarMatrix() { + return ( + Something happened} + action={ + + } + sx={{ mt: 1, width: 320 }} + /> + ); +} + +// BottomNavigation family: bar height + action inline padding. +const BOTTOM_NAV_FIELDS: DensityField[] = [ + { key: 'height', cssVar: '--BottomNavigation-height', prop: 'height', selector: '.MuiBottomNavigation-root' }, + { key: 'inlinePad', cssVar: '--BottomNavigationAction-inlinePad', prop: 'paddingInline', selector: '.MuiBottomNavigationAction-root' }, +]; + +function BottomNavigationMatrix() { + return ( + + } /> + } /> + } /> + + ); +} + +// Dialog family: title + content block/inline padding + actions padding. +const DIALOG_FIELDS: DensityField[] = [ + { key: 'titleBlockPad', cssVar: '--DialogTitle-blockPad', prop: 'paddingBlock', selector: '.MuiDialogTitle-root' }, + { key: 'titleInlinePad', cssVar: '--DialogTitle-inlinePad', prop: 'paddingInline', selector: '.MuiDialogTitle-root' }, + { key: 'contentBlockPad', cssVar: '--DialogContent-blockPad', prop: 'paddingBlock', selector: '.MuiDialogContent-root' }, + { key: 'contentInlinePad', cssVar: '--DialogContent-inlinePad', prop: 'paddingInline', selector: '.MuiDialogContent-root' }, + { key: 'actionsPad', cssVar: '--DialogActions-pad', prop: 'padding', selector: '.MuiDialogActions-root' }, +]; + +function DialogMatrix() { + return ( + + + Dialog title + + + Dialog content body text goes here. + + + + + + + ); +} + +// ListItemButton family: block padding (+ dense) + gutters inline padding. +const LIST_ITEM_BUTTON_FIELDS: DensityField[] = [ + { key: 'blockPad', cssVar: '--ListItemButton-blockPad', prop: 'paddingBlock', selector: '.MuiListItemButton-root:not(.MuiListItemButton-dense)' }, + { key: 'denseBlockPad', cssVar: '--ListItemButton-dense-blockPad', prop: 'paddingBlock', selector: '.MuiListItemButton-dense' }, + { key: 'inlinePad', cssVar: '--ListItemButton-inlinePad', prop: 'paddingInline', selector: '.MuiListItemButton-gutters' }, +]; + +function ListItemButtonMatrix() { + return ( + + + Regular item} /> + + + Selected item} /> + + + Dense item} /> + + + ); +} + +const COMPONENT_DEFS = { + Button: { + canvasLabel: 'Button (color="primary")', + // Canonical prefill matches enhanceDensity's own Button assignment. + fields: SIZES.map((size) => ({ + key: `${size}Pad`, + cssVar: `--Button-${size}-pad`, + prop: 'padding', + selector: `.MuiButton-size${size[0].toUpperCase()}${size.slice(1)}`, + })), + prefill: { smallPad: 'xxs sm', mediumPad: 'xs lg', largePad: 'sm xl' }, + renderMatrix: () => , + }, + Menu: { + canvasLabel: 'Menu — static list + popover (default + dense)', + fields: MENU_FIELDS, + // Canonical prefill matches enhanceDensity's own MuiList/MuiMenuItem mapping. + prefill: { + listBlockPad: 'sm', + blockPad: 'xs', + inlinePad: 'lg', + denseBlockPad: 'xxs', + denseInlinePad: 'md', + }, + renderMatrix: () => , + }, + Tooltip: { + canvasLabel: 'Tooltip — pointer (default + arrow); touch out of scope', + fields: TOOLTIP_FIELDS, + // Spacing tokens prefill density keys; arrow size (raw px) reads off the theme. + prefill: { + blockPad: 'xxs', + inlinePad: 'sm', + offset: 'lg', + }, + renderMatrix: () => , + }, + OutlinedInput: { + canvasLabel: 'OutlinedInput — size axis + adornments (label bridge)', + fields: OUTLINED_INPUT_FIELDS, + // All spacing → density keys (match the preset assignment). + prefill: { + mediumBlockPad: 'md', + smallBlockPad: 'sm', + mediumInlinePad: 'lg', + smallInlinePad: 'md', + mediumGap: 'sm', + smallGap: 'xxs', + }, + renderMatrix: () => , + }, + FilledInput: { + canvasLabel: 'FilledInput — size axis (box padding); label follows preset', + fields: FILLED_INPUT_FIELDS, + prefill: { + mediumTopPad: 'xl', + smallTopPad: 'lg', + mediumBottomPad: 'sm', + smallBottomPad: 'xxs', + mediumInlinePad: 'md', + smallInlinePad: 'md', + }, + renderMatrix: () => , + }, + Input: { + canvasLabel: 'Input (standard) — size axis (input top/bottom padding)', + fields: INPUT_FIELDS, + prefill: { + mediumTopPad: 'xs', + smallTopPad: 'xxs', + bottomPad: 'xs', + }, + renderMatrix: () => , + }, + Tabs: { + canvasLabel: 'Tabs — text / icon-top / icon-start (Tab+Tabs minHeight paired)', + fields: TAB_FIELDS, + // Spacing → density keys; min-heights (minHeight/tabsMinHeight/iconLabelMinHeight) + // read raw px off the theme. + prefill: { + blockPad: 'sm', + iconLabelBlockPad: 'xs', + inlinePad: 'lg', + iconStackGap: 'xs', + iconInlineGap: 'sm', + }, + renderMatrix: () => , + }, + Checkbox: { + canvasLabel: 'Checkbox — touch-target padding (medium + small)', + fields: CHECKBOX_FIELDS, + prefill: { mediumPad: 'sm', smallPad: 'xs' }, + renderMatrix: () => , + }, + Radio: { + canvasLabel: 'Radio — touch-target padding (medium + small)', + fields: RADIO_FIELDS, + prefill: { mediumPad: 'sm', smallPad: 'xs' }, + renderMatrix: () => , + }, + Avatar: { + canvasLabel: 'Avatar — square size (raw px)', + fields: AVATAR_FIELDS, + prefill: {}, // size = raw px, read off the theme + renderMatrix: () => , + }, + Fab: { + canvasLabel: 'Fab — circular size (small / medium / large)', + fields: FAB_FIELDS, + prefill: {}, // sizes = raw px, read off the theme + renderMatrix: () => , + }, + Pagination: { + canvasLabel: 'Pagination — item box size (small / medium / large)', + fields: PAGINATION_FIELDS, + prefill: {}, // sizes = raw px, read off the theme + renderMatrix: () => , + }, + SnackbarContent: { + canvasLabel: 'SnackbarContent — root padding', + fields: SNACKBAR_FIELDS, + prefill: { blockPad: 'xs', inlinePad: 'lg' }, + renderMatrix: () => , + }, + BottomNavigation: { + canvasLabel: 'BottomNavigation — bar height + action inline padding', + fields: BOTTOM_NAV_FIELDS, + prefill: { inlinePad: 'md' }, // height raw px off theme + renderMatrix: () => , + }, + Dialog: { + canvasLabel: 'Dialog — title / content / actions padding', + fields: DIALOG_FIELDS, + prefill: { + titleBlockPad: 'lg', + titleInlinePad: 'xl', + contentBlockPad: 'lg', + contentInlinePad: 'xl', + actionsPad: 'sm', + }, + renderMatrix: () => , + }, + ListItemButton: { + canvasLabel: 'ListItemButton — block padding (+ dense) + gutters', + fields: LIST_ITEM_BUTTON_FIELDS, + prefill: { blockPad: 'sm', denseBlockPad: 'xxs', inlinePad: 'lg' }, + renderMatrix: () => , + }, + ButtonGroup: { + canvasLabel: 'ButtonGroup — grouped-button min-width floor', + fields: BUTTON_GROUP_FIELDS, + prefill: {}, // minWidth = raw px, read off the theme + renderMatrix: () => , + }, + TableCell: { + canvasLabel: 'TableCell — block padding per size + inline padding', + fields: TABLE_CELL_FIELDS, + prefill: { mediumBlockPad: 'lg', smallBlockPad: 'xs', inlinePad: 'lg' }, + renderMatrix: () => , + }, + Autocomplete: { + canvasLabel: 'Autocomplete — option list min-height + padding (open)', + fields: AUTOCOMPLETE_FIELDS, + prefill: { optionBlockPad: 'xs', optionInlinePad: 'lg' }, // minHeight raw px off theme + renderMatrix: () => , + }, + Stepper: { + canvasLabel: 'Stepper — step gutter + icon→label gap', + fields: STEPPER_FIELDS, + prefill: { inlinePad: 'sm', iconGap: 'sm' }, + renderMatrix: () => , + }, + Toolbar: { + canvasLabel: 'AppBar/Toolbar — gutter padding + dense min-height', + fields: TOOLBAR_FIELDS, + prefill: { inlinePad: 'lg', wideInlinePad: 'xl' }, // denseMinHeight raw px off theme + renderMatrix: () => , + }, + Badge: { + canvasLabel: 'Badge — bubble size + padding (standard / dot)', + fields: BADGE_FIELDS, + prefill: { standardPad: '0 xs' }, // sizes = raw px, read off the theme + renderMatrix: () => , + }, + ToggleButton: { + canvasLabel: 'ToggleButton — uniform padding (small/medium/large)', + fields: TOGGLE_BUTTON_FIELDS, + prefill: { smallPad: 'sm', mediumPad: 'md', largePad: 'lg' }, + renderMatrix: () => , + }, + Breadcrumbs: { + canvasLabel: 'Breadcrumbs — separator inline gap', + fields: BREADCRUMBS_FIELDS, + prefill: { separatorGap: 'sm' }, + renderMatrix: () => , + }, + Card: { + canvasLabel: 'Card — header / content / actions padding + gaps', + fields: CARD_FIELDS, + prefill: { + pad: 'lg', + padBottom: 'xl', + actionsPad: 'sm', + actionsGap: 'sm', + headerPad: 'lg', + headerAvatarGap: 'lg', + }, + note: 'CardContent/CardActions/CardHeader padding + gaps reflow via the preset; no size axis.', + renderMatrix: () => , + }, + Rating: { + canvasLabel: 'Rating — star size (typography/icon axis)', + fields: [], + prefill: {}, + note: 'Star fontSize reflows via the preset typography config — out of scope for token editing.', + renderMatrix: () => , + }, + Select: { + canvasLabel: 'Select — content-box floor (padding via its OutlinedInput)', + fields: SELECT_FIELDS, + prefill: {}, // minHeight = raw px, read off the theme + renderMatrix: () => , + }, + Alert: { + canvasLabel: 'Alert — root padding + icon gap', + fields: ALERT_FIELDS, + prefill: { blockPad: 'xs', inlinePad: 'lg', iconGap: 'md' }, + renderMatrix: () => , + }, + Chip: { + canvasLabel: 'Chip — height (drives avatar/icon) + label inline padding', + fields: CHIP_FIELDS, + prefill: { mediumPadInline: 'md', smallPadInline: 'sm' }, // heights = raw px, read off theme + renderMatrix: () => , + }, + Accordion: { + canvasLabel: 'Accordion — summary min-height/margin/pad + details padding', + fields: ACCORDION_FIELDS, + // Spacing → density keys; min-heights read raw px off the theme. + prefill: { + inlinePad: 'lg', + marginBlock: 'md', + expandedMarginBlock: 'lg', + detailsTopPad: 'sm', + detailsInlinePad: 'lg', + detailsBottomPad: 'lg', + }, + renderMatrix: () => , + }, +} satisfies Record; + +type ComponentName = keyof typeof COMPONENT_DEFS; +type Selection = 'All' | ComponentName; + +const COMPONENTS = Object.keys(COMPONENT_DEFS) as ComponentName[]; + +// Read the value a preset assigned to a token, straight off the enhanced theme's +// component overrides (the same `addRootOverride` output). Single source of truth +// for raw-px sizing defaults — can't drift from what `enhance*Density` ships. +function themeTokenValue(theme: unknown, cssVar: string): string | undefined { + const components = (theme as { components?: Record })?.components ?? {}; + for (const name of Object.keys(components)) { + // Scan every slot's overrides, not just `root` — Tooltip's tokens land on + // the `tooltip` slot (it has no root slot). + const styleOverrides = components[name]?.styleOverrides ?? {}; + for (const slot of Object.keys(styleOverrides)) { + const layers = Array.isArray(styleOverrides[slot]) + ? styleOverrides[slot] + : [styleOverrides[slot]]; + for (let i = layers.length - 1; i >= 0; i -= 1) { + const layer = layers[i]; + if (layer && typeof layer === 'object' && cssVar in layer) { + return layer[cssVar] as string; + } + } + } + } + return undefined; +} + +// A blank override map — the default state. A field holds a value only once the +// user types; until then it's inert (placeholder shows the preset's canonical). +const emptyMapping = () => + Object.fromEntries(COMPONENTS.map((c) => [c, {}])) as Record< + ComponentName, + Record + >; + +// The value the active preset assigns a field — shown as the input placeholder. +// Spacing prefills its density key (preset-independent); sizing reads the raw px +// straight off the enhanced theme so it can't drift from what the preset ships. +const canonicalValue = (theme: unknown, comp: ComponentName, field: DensityField) => + (COMPONENT_DEFS[comp].prefill as Record)[field.key] ?? + themeTokenValue(theme, field.cssVar) ?? + ''; + +export default function DensityExperiment() { + const [preset, setPreset] = React.useState('unset'); + const [selection, setSelection] = React.useState('All'); + const [debug, setDebug] = React.useState([]); + + // User overrides only — empty until a field is typed. + const [mapping, setMapping] = + React.useState>>(emptyMapping); + + const mappingEnabled = preset !== 'unset'; + const visibleComponents: ComponentName[] = selection === 'All' ? COMPONENTS : [selection]; + + // Preset theme drives the canvas + field placeholders/legend. Stable per preset: + // overrides layer on separately (GlobalStyles), so typing never rebuilds it. + const presetTheme = React.useMemo(() => { + const base = createTheme({ cssVariables: true }); + return preset === 'unset' ? base : PRESET_FN[preset](base); + }, [preset]); + + // A new preset has different canonical values → drop stale overrides. + React.useEffect(() => { + setMapping(emptyMapping()); + }, [preset]); + + // Overrides → one GlobalStyles rule per target element, scoped to `#density-canvas` + // so the id-level specificity beats the preset's styleOverride and nothing leaks + // to the sidebar. Undefined until something is typed. + const overrideCss = React.useMemo(() => { + if (preset === 'unset') { + return undefined; + } + const rules: Record> = {}; + for (const comp of COMPONENTS) { + for (const field of COMPONENT_DEFS[comp].fields) { + const raw = mapping[comp]?.[field.key] ?? ''; + if (parseMapping(raw).state !== 'ok') { + continue; + } + const selector = `#density-canvas ${field.selector}`; + if (!rules[selector]) { + rules[selector] = {}; + } + const value = resolveValue(raw); + // property-mode: override the emitted CSS property directly (survives the + // source's seam removal). var-mode (no `prop`): write the private token var + // so the source's own seam routes it (calc-coupling / multi-route fields). + if (field.prop) { + for (const p of Array.isArray(field.prop) ? field.prop : [field.prop]) { + rules[selector][p] = value; + } + } else { + rules[selector][field.cssVar] = value; + } + } + } + return Object.keys(rules).length ? rules : undefined; + }, [preset, mapping]); + + // Active scale in px straight off the enhanced theme — single source of truth + // for the legend + preview, so it can't drift from what the preset applied. + const scalePx = + preset === 'unset' + ? null + : (presetTheme as unknown as { density: Record }).density; + + const setField = (comp: ComponentName, key: string, value: string) => + setMapping((m) => ({ ...m, [comp]: { ...m[comp], [key]: value } })); + + const resetMapping = () => setMapping(emptyMapping()); + + return ( + + + + {/* Title row — compact, single line. */} + + + Density — playground + + + Flip the preset · pick a component · remap its tokens to density steps + + + + {/* Control bar — full width: preset (left) · visual debug (right). */} + + + + enhanceDensity + + setPreset(event.target.value as Preset)} + > + {PRESETS.map((p) => ( + } + label={PRESET_LABEL[p]} + slotProps={{ typography: { variant: 'body2' } }} + /> + ))} + + + + + + Visual debug + + setDebug(next)} + aria-label="visual debug overlays" + > + + + + + + + + + + + + + + + {/* Content — sidebar (fixed Component + scrollable mapping) · scrollable canvas. */} + + + + + Component + + + + + + + Vars mapping + + {!mappingEnabled && ( + + ⓘ pick a preset to enable steps + + )} + {mappingEnabled && scalePx && ( + + {SCALE_KEYS.map((k) => `${k}=${scalePx[k]}`).join(' · ')} + + )} + {visibleComponents.map((comp) => ( + + + {comp} + + {(COMPONENT_DEFS[comp] as DensityComponentDef).note && ( + + {(COMPONENT_DEFS[comp] as DensityComponentDef).note} + + )} + + {COMPONENT_DEFS[comp].fields.map((field) => { + const value = mapping[comp]?.[field.key] ?? ''; + const canon = canonicalValue(presetTheme, comp, field); + const parsed = parseMapping(value); + const showError = mappingEnabled && parsed.state === 'error'; + let helper = ' '; + if (showError) { + helper = parsed.error ?? ' '; + } else if (mappingEnabled) { + // typed → preview the typed value; empty → the preset default it inherits + helper = previewText(value || canon, scalePx); + } + return ( + setField(comp, field.key, event.target.value)} + slotProps={{ + htmlInput: { + 'data-mapping-field': `${comp}-${field.key}`, + }, + }} + /> + ); + })} + + + ))} + + + + + {/* CANVAS — density-enhanced theme; scrolls independently. */} + + + {overrideCss && } + + + {visibleComponents.map((comp) => ( + + + {COMPONENT_DEFS[comp].canvasLabel} + + {COMPONENT_DEFS[comp].renderMatrix()} + + ))} + + + + + + ); +} diff --git a/docs/src/modules/components/densityDemos.tsx b/docs/src/modules/components/densityDemos.tsx new file mode 100644 index 00000000000000..f7753cf60c7572 --- /dev/null +++ b/docs/src/modules/components/densityDemos.tsx @@ -0,0 +1,419 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import Fab from '@mui/material/Fab'; +import Pagination from '@mui/material/Pagination'; +import SnackbarContent from '@mui/material/SnackbarContent'; +import BottomNavigation from '@mui/material/BottomNavigation'; +import BottomNavigationAction from '@mui/material/BottomNavigationAction'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import List from '@mui/material/List'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Tooltip from '@mui/material/Tooltip'; +import TextField from '@mui/material/TextField'; +import Autocomplete from '@mui/material/Autocomplete'; +import InputAdornment from '@mui/material/InputAdornment'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import Checkbox from '@mui/material/Checkbox'; +import Radio from '@mui/material/Radio'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Paper from '@mui/material/Paper'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Typography from '@mui/material/Typography'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Select from '@mui/material/Select'; +import Alert from '@mui/material/Alert'; +import Chip from '@mui/material/Chip'; +import Avatar from '@mui/material/Avatar'; +import Badge from '@mui/material/Badge'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Table from '@mui/material/Table'; +import TableHead from '@mui/material/TableHead'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; +import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import Stepper from '@mui/material/Stepper'; +import Step from '@mui/material/Step'; +import StepLabel from '@mui/material/StepLabel'; +import Accordion from '@mui/material/Accordion'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import InboxIcon from '@mui/icons-material/Inbox'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +// Force the tooltip open + inline (no portal) so it renders inside +// `#density-scope` and lands in the scoped screenshot; snap the transition so +// the capture is deterministic. +const staticTooltipSlotProps = { + popper: { disablePortal: true }, + transition: { appear: false, timeout: 0 }, +} as const; + +// Shared density demo matrix for the CSS-var density adapter. +// Consumed by the screenshot fixture (density-fixture). `level=default` (no token +// overrides) must stay pixel-identical to the pre-change baseline. +const demos: Record = { + Button: ( + + {(['small', 'medium', 'large'] as const).map((size) => ( + + + + + + ))} + + ), + MenuItem: ( + // MenuItem requires a MenuList/Menu ancestor (MenuListContext). + + Default item + Selected item + + + + + With icon + + With divider + No gutters + Dense item + + + + + Dense + icon + + + Dense no gutters + + + ), + Tooltip: ( + // Always-open, inline tooltips (no hover/portal) so they render inside the + // scoped screenshot. The box is left unpositioned on purpose — Popper then + // anchors against the viewport (correct coords); the fixed height just gives + // the scope a tall enough capture box for the bottom-placed bubbles. + + + + + + + + + + + ), + OutlinedInput: ( + // Labelled outlined fields, one per token group: size axis (medium/small) + + // start/end adornment. Each floating label exercises the `:has()` bridge. + + {/* Empty + unfocused → resting label, centered on the block padding (the + `--InputLabel-y` bridge crux). */} + + + $ }, + }} + /> + kg }, + }} + /> + + ), + FilledInput: ( + // Empty (resting label, inside the box) + valued (shrunk label, in the top + // strip) exercise both Y seams of the two-value label bridge. + + + + + + ), + Input: ( + // Standard fields, label floats above (no bridge). Empty → input top/bottom + // padding is the whole density lever. + + + + + ), + Tab: ( + // Text tabs (default state → block/inline pad + Tab/Tabs minHeight pairing), + // icon-top tabs (icon+label state → iconLabel block/minHeight + stackGap), + // icon-start tabs (inlineGap). + + + + + + + + } label="Top" iconPosition="top" /> + } label="Top" iconPosition="top" /> + + + } label="Start" iconPosition="start" /> + } label="Start" iconPosition="start" /> + + + ), + Checkbox: ( + // Touch-target padding around the icon (medium + small). + + + + + ), + Radio: ( + + + + + ), + Avatar: ( + + A + B + + ), + Fab: ( + + + + + + + + + + + + ), + PaginationItem: ( + + + + + + ), + ListItemButton: ( + + + + + + + + + + + + ), + Dialog: ( + // The dialog surface (Paper) with its 3 slots — no modal/portal so it renders + // inline in the scope; density lives in the title/content/actions padding. + + Dialog title + Dialog content body text goes here. + + + + + + ), + BottomNavigation: ( + + } /> + } /> + } /> + + ), + SnackbarContent: ( + + Undo + + } + sx={{ width: 320 }} + /> + ), + ButtonGroup: ( + + + + + + ), + Toolbar: ( + + + + Regular + + + + + Dense + + + + ), + Stepper: ( + + + One + + + Two + + + Three + + + ), + Autocomplete: ( + // Open + inline so the option list (the density lever) renders in the scope. + } + /> + ), + TableCell: ( + + {(['medium', 'small'] as const).map((size) => ( + + + + Name + Value + + + + + Row one + 42 + + +
+ ))} +
+ ), + Badge: ( + + + + + + + + + ), + ToggleButton: ( + + {(['small', 'medium', 'large'] as const).map((size) => ( + + L + C + R + + ))} + + ), + Breadcrumbs: ( + + + Home + + + Catalog + + Current + + ), + Accordion: ( + + + }>Expanded summary + Details content with top/inline/bottom padding. + + + }>Collapsed summary + Hidden details. + + + ), + Chip: ( + + + A} label="Avatar" /> + } label="Icon" onDelete={() => {}} /> + {}} /> + {}} /> + + + ), + Alert: ( + + Info alert — icon gap + root padding. + {}}> + Success alert with a close action. + + + ), + Select: ( + + Age + + + ), + CardContent: ( + + + Card title + + Body content with the last-child bottom padding. + + + + ), +}; + +export default demos; diff --git a/package.json b/package.json index d25ab71b5603ea..28fa4a2c3d0dc2 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "test:coverage": "pnpm test:unit run --coverage", "vitest": "vitest", "test:coverage:html": "pnpm test:unit run --coverage --coverage.reporter html", + "density:shot": "playwright test -c scripts/density-screenshots/playwright.config.mjs", + "density:shot:update": "playwright test -c scripts/density-screenshots/playwright.config.mjs --update-snapshots", "test:e2e": "pnpm -F ./test/e2e start", "test:e2e:dev": "pnpm -F ./test/e2e dev", "test:e2e-website": "playwright test test/e2e-website --config test/e2e-website/playwright.config.ts", diff --git a/packages/mui-material/src/Chip/Chip.js b/packages/mui-material/src/Chip/Chip.js index 2f35de5dfc8d04..5ace6546211df7 100644 --- a/packages/mui-material/src/Chip/Chip.js +++ b/packages/mui-material/src/Chip/Chip.js @@ -73,7 +73,9 @@ const ChipRoot = styled('div', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - height: 32, + // Calc-coupled: avatar/icon/deleteIcon dims derive from `--Chip-height` + // (fallback = master medium 32px) so density scales them with the box. + height: 'var(--Chip-height, 32px)', lineHeight: 1.5, color: (theme.vars || theme).palette.text.primary, backgroundColor: (theme.vars || theme).palette.action.selected, @@ -96,19 +98,20 @@ const ChipRoot = styled('div', { [`& .${chipClasses.avatar}`]: { marginLeft: 5, marginRight: -6, - width: 24, - height: 24, + width: 'calc(var(--Chip-height, 32px) - 8px)', + height: 'calc(var(--Chip-height, 32px) - 8px)', color: theme.vars ? theme.vars.palette.Chip.defaultAvatarColor : textColor, fontSize: theme.typography.pxToRem(12), }, [`& .${chipClasses.icon}`]: { marginLeft: 5, marginRight: -6, + fontSize: 'calc(var(--Chip-height, 32px) - 8px)', }, [`& .${chipClasses.deleteIcon}`]: { WebkitTapHighlightColor: 'transparent', color: theme.alpha((theme.vars || theme).palette.text.primary, 0.26), - fontSize: 22, + fontSize: 'calc(var(--Chip-height, 32px) - 10px)', cursor: 'pointer', margin: '0 5px 0 -6px', '&:hover': { @@ -141,21 +144,21 @@ const ChipRoot = styled('div', { { props: { size: 'small' }, style: { - height: 24, + height: 'var(--Chip-height, 24px)', [`& .${chipClasses.avatar}`]: { marginLeft: 4, marginRight: -4, - width: 18, - height: 18, + width: 'calc(var(--Chip-height, 24px) - 6px)', + height: 'calc(var(--Chip-height, 24px) - 6px)', fontSize: theme.typography.pxToRem(10), }, [`& .${chipClasses.icon}`]: { - fontSize: 18, + fontSize: 'calc(var(--Chip-height, 24px) - 6px)', marginLeft: 4, marginRight: -4, }, [`& .${chipClasses.deleteIcon}`]: { - fontSize: 16, + fontSize: 'calc(var(--Chip-height, 24px) - 8px)', marginRight: 4, marginLeft: -4, }, diff --git a/packages/mui-material/src/InputLabel/InputLabel.js b/packages/mui-material/src/InputLabel/InputLabel.js index f62e71601fce90..a3fee5681221f4 100644 --- a/packages/mui-material/src/InputLabel/InputLabel.js +++ b/packages/mui-material/src/InputLabel/InputLabel.js @@ -108,7 +108,9 @@ const InputLabelRoot = styled(FormLabel, { // zIndex: 1 will raise the label above opaque background-colors of input. zIndex: 1, pointerEvents: 'none', - transform: 'translate(12px, 16px) scale(1)', + // Resting/shrunk Y are seams the input (FilledInput) sets under density + // so the label tracks the box's top padding; defaults are today's px. + transform: 'translate(12px, var(--FilledInputLabel-restY, 16px)) scale(1)', maxWidth: 'calc(100% - 24px)', }, }, @@ -118,7 +120,7 @@ const InputLabelRoot = styled(FormLabel, { size: 'small', }, style: { - transform: 'translate(12px, 13px) scale(1)', + transform: 'translate(12px, var(--FilledInputLabel-restY, 13px)) scale(1)', }, }, { @@ -126,7 +128,7 @@ const InputLabelRoot = styled(FormLabel, { style: { userSelect: 'none', pointerEvents: 'auto', - transform: 'translate(12px, 7px) scale(0.75)', + transform: 'translate(12px, var(--FilledInputLabel-shrinkY, 7px)) scale(0.75)', maxWidth: 'calc(133% - 24px)', }, }, @@ -134,7 +136,7 @@ const InputLabelRoot = styled(FormLabel, { props: ({ variant, ownerState, size }) => variant === 'filled' && ownerState.shrink && size === 'small', style: { - transform: 'translate(12px, 4px) scale(0.75)', + transform: 'translate(12px, var(--FilledInputLabel-shrinkY, 4px)) scale(0.75)', }, }, { @@ -145,7 +147,9 @@ const InputLabelRoot = styled(FormLabel, { // see comment above on filled.zIndex zIndex: 1, pointerEvents: 'none', - transform: 'translate(14px, 16px) scale(1)', + // Resting-Y is a seam set by OutlinedInput (via `:has(~ &)`) so the + // label tracks the input's block padding under density; default 16px. + transform: 'translate(14px, var(--InputLabel-y, 16px)) scale(1)', maxWidth: 'calc(100% - 24px)', }, }, @@ -155,7 +159,7 @@ const InputLabelRoot = styled(FormLabel, { size: 'small', }, style: { - transform: 'translate(14px, 9px) scale(1)', + transform: 'translate(14px, var(--InputLabel-y, 9px)) scale(1)', }, }, { diff --git a/packages/mui-material/src/Tooltip/Tooltip.js b/packages/mui-material/src/Tooltip/Tooltip.js index 412dd3e43a5c4a..982adb4d08e2fc 100644 --- a/packages/mui-material/src/Tooltip/Tooltip.js +++ b/packages/mui-material/src/Tooltip/Tooltip.js @@ -70,32 +70,32 @@ const TooltipPopper = styled(Popper, { style: { [`&[data-popper-placement*="bottom"] .${tooltipClasses.arrow}`]: { top: 0, - marginTop: '-0.71em', + marginTop: 'calc(var(--Tooltip-arrowSize, 1em) * -0.71)', '&::before': { transformOrigin: '0 100%', }, }, [`&[data-popper-placement*="top"] .${tooltipClasses.arrow}`]: { bottom: 0, - marginBottom: '-0.71em', + marginBottom: 'calc(var(--Tooltip-arrowSize, 1em) * -0.71)', '&::before': { transformOrigin: '100% 0', }, }, [`&[data-popper-placement*="right"] .${tooltipClasses.arrow}`]: { - height: '1em', - width: '0.71em', + height: 'var(--Tooltip-arrowSize, 1em)', + width: 'calc(var(--Tooltip-arrowSize, 1em) * 0.71)', insetInlineStart: 0, - marginInlineStart: '-0.71em', + marginInlineStart: 'calc(var(--Tooltip-arrowSize, 1em) * -0.71)', '&::before': { transformOrigin: '100% 100%', }, }, [`&[data-popper-placement*="left"] .${tooltipClasses.arrow}`]: { - height: '1em', - width: '0.71em', + height: 'var(--Tooltip-arrowSize, 1em)', + width: 'calc(var(--Tooltip-arrowSize, 1em) * 0.71)', insetInlineEnd: 0, - marginInlineEnd: '-0.71em', + marginInlineEnd: 'calc(var(--Tooltip-arrowSize, 1em) * -0.71)', '&::before': { transformOrigin: '0 0', }, @@ -194,8 +194,8 @@ const TooltipArrow = styled('span', { memoTheme(({ theme }) => ({ overflow: 'hidden', position: 'absolute', - width: '1em', - height: '0.71em' /* = width / sqrt(2) = (length of the hypotenuse) */, + width: 'var(--Tooltip-arrowSize, 1em)', + height: 'calc(var(--Tooltip-arrowSize, 1em) * 0.71)' /* = width / sqrt(2) = (length of the hypotenuse) */, boxSizing: 'border-box', color: theme.vars ? theme.vars.palette.Tooltip.bg : theme.alpha(theme.palette.grey[700], 0.9), '&::before': { diff --git a/packages/mui-material/src/internal/SwitchBase.js b/packages/mui-material/src/internal/SwitchBase.js index bf3167074cda05..44221c51742e2e 100644 --- a/packages/mui-material/src/internal/SwitchBase.js +++ b/packages/mui-material/src/internal/SwitchBase.js @@ -25,7 +25,11 @@ const useUtilityClasses = (ownerState) => { const SwitchBaseRoot = styled(ButtonBase, { name: 'MuiSwitchBase', })({ - padding: 9, + // Density seam: the touch-target padding over the `9px` default. The concrete + // layer (Checkbox/Radio) routes its per-size public token into `--comp-pad`; + // unrouted (e.g. Switch) falls back to `9px`. + '--_pad': '9px', + padding: 'var(--comp-pad, var(--_pad))', borderRadius: '50%', variants: [ { diff --git a/packages/mui-material/src/styles/densityScale.ts b/packages/mui-material/src/styles/densityScale.ts new file mode 100644 index 00000000000000..06fdcbbbfdf06e --- /dev/null +++ b/packages/mui-material/src/styles/densityScale.ts @@ -0,0 +1,118 @@ +import { Theme } from './createTheme'; + +/** + * Named density steps, surfaced as `--mui-density-*` CSS vars. Presets assign a + * component's sized tokens to these steps (via `densityVars` + `addRootOverride`). + */ +export interface DensityScale { + xxs: string; + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + xxl: string; +} + +export type DensityKey = keyof DensityScale; + +export const DENSITY_KEYS: DensityKey[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl']; + +/** Theme shape the presets can enhance in place. */ +export type EnhanceableTheme = { + components?: Theme['components'] | undefined; + typography?: Record | undefined; + vars?: Record | undefined; +}; + +const cssVar = (key: DensityKey) => `--mui-density-${key}`; + +/** + * `var(--mui-density-*)` reference for each step. Presets read these to emit a + * component's density value at a chosen step, e.g. `padding: `${densityVars.xs} ${densityVars.lg}``. + */ +export const densityVars: DensityScale = DENSITY_KEYS.reduce((acc, key) => { + acc[key] = `var(${cssVar(key)})`; + return acc; +}, {} as DensityScale); + +/** + * PRIVATE density core shared by the three `enhance*Density` presets. Not + * re-exported from the styles barrel — presets are the public surface. + * + * **Scale emission only.** Emits the scale as `--mui-density-*` CSS vars at + * `:root` (via `MuiCssBaseline` — requires ``) and exposes it on + * `theme.density` / `theme.vars.density`. It is **component-agnostic**: it does + * NOT touch any `Mui*` component. Each preset maps component vars → density + * steps itself (`addRootOverride`), so a preset can point the same token at a + * different step than its siblings. + * + * @param themeInput - The created theme to enhance. + * @param scale - The preset's 7-step scale. + * @returns The enhanced theme. + */ +export function applyDensity( + themeInput: T, + scale: DensityScale, +): T & { density: DensityScale; components: NonNullable } { + const rootVars = DENSITY_KEYS.reduce>((acc, key) => { + acc[cssVar(key)] = scale[key]; + return acc; + }, {}); + + const theme = { ...themeInput } as T & { + density: DensityScale; + components: NonNullable; + }; + theme.density = scale; + theme.vars = { ...themeInput.vars, density: densityVars }; + + const c = themeInput.components; + const existingBaseline = c?.MuiCssBaseline?.styleOverrides; + const baselineObject = + existingBaseline && typeof existingBaseline === 'object' ? existingBaseline : undefined; + + theme.components = { + ...c, + MuiCssBaseline: { + ...c?.MuiCssBaseline, + styleOverrides: { + ...baselineObject, + ':root': { + ...(baselineObject as any)?.[':root'], + ...rootVars, + }, + }, + }, + }; + + return theme; +} + +/** + * Attach a `styleOverrides` object to a component slot, preserving any existing + * overrides for that slot (array-wrapped). Presets use this to add their + * component-var → density-step assignments after `applyDensity`. + * + * Defaults to the `root` slot (Button, MenuItem, …). Pass `slot` for components + * whose density seams live on a non-root slot — e.g. Tooltip has no `root` slot, + * so its tokens land on `tooltip` (the bubble, ancestor of the arrow). + * + * **Mutates `components` in place** — pass the enhanced theme's `components` + * (fresh, owned by `applyDensity`), never a theme's shared `components`. + */ +export function addRootOverride( + components: NonNullable, + name: string, + overrides: Record, + slot: string = 'root', +): void { + const component = (components as any)[name]; + (components as any)[name] = { + ...component, + styleOverrides: { + ...component?.styleOverrides, + [slot]: [component?.styleOverrides?.[slot], overrides], + }, + }; +} diff --git a/packages/mui-material/src/styles/enhanceComfortDensity.ts b/packages/mui-material/src/styles/enhanceComfortDensity.ts new file mode 100644 index 00000000000000..8975574e56b5c9 --- /dev/null +++ b/packages/mui-material/src/styles/enhanceComfortDensity.ts @@ -0,0 +1,628 @@ +import { addRootOverride, applyDensity, densityVars as d, DensityScale, EnhanceableTheme } from './densityScale'; +import tooltipClasses from '../Tooltip/tooltipClasses'; +import tabClasses from '../Tab/tabClasses'; +import accordionSummaryClasses from '../AccordionSummary/accordionSummaryClasses'; +import buttonGroupClasses from '../ButtonGroup/buttonGroupClasses'; +import autocompleteClasses from '../Autocomplete/autocompleteClasses'; +import inputLabelClasses from '../InputLabel/inputLabelClasses'; +import inputAdornmentClasses from '../InputAdornment/inputAdornmentClasses'; + +const scale: DensityScale = { + xxs: '6px', + xs: '8px', + sm: '12px', + md: '16px', + lg: '24px', + xl: '32px', + xxl: '40px', +}; + +export default function enhanceComfortDensity(theme: T) { + const enhanced = applyDensity(theme, scale); + addRootOverride(enhanced.components, 'MuiButton', { + // Emit padding directly on the size variants Button already ships (no seam). + variants: [ + { props: { size: 'small' }, style: { padding: `${d.xxs} ${d.sm}` } }, + { props: { size: 'medium' }, style: { padding: `${d.xs} ${d.lg}` } }, + { props: { size: 'large' }, style: { padding: `${d.sm} ${d.xl}` } }, + ], + }); + addRootOverride(enhanced.components, 'MuiMenuItem', { + // Height = raw px (density steps are spacing-only). Padding = density steps. + // Density axis is the `dense` boolean; inline pad only when gutters are on. + variants: [ + { props: { dense: false }, style: { minHeight: '56px', paddingTop: d.xs, paddingBottom: d.xs } }, + { props: { dense: true }, style: { minHeight: '40px', paddingTop: d.xxs, paddingBottom: d.xxs } }, + { props: { dense: false, disableGutters: false }, style: { paddingLeft: d.lg, paddingRight: d.lg } }, + { props: { dense: true, disableGutters: false }, style: { paddingLeft: d.md, paddingRight: d.md } }, + ], + }); + addRootOverride(enhanced.components, 'MuiList', { + // Menu/list vertical breathing (spacing token); subheader keeps paddingTop 0. + variants: [ + { props: { disablePadding: false }, style: { paddingTop: d.sm, paddingBottom: d.sm } }, + { props: ({ ownerState }: { ownerState: { subheader?: unknown } }) => ownerState.subheader, style: { paddingTop: 0 } }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiTooltip', + { + // Regular (pointer) tooltip only — `touch` keeps its master literals. + // Padding + per-placement anchor offset = density steps (non-touch); the + // arrow child derives its size from the single `--Tooltip-arrowSize` (raw + // px), left unset for `touch` so both variants keep scaling. + '--Tooltip-arrowSize': '14px', + variants: [ + { + props: ({ ownerState }: { ownerState: { touch?: boolean | undefined } }) => !ownerState.touch, + style: { + padding: `${d.xxs} ${d.sm}`, + [`.${tooltipClasses.popper}[data-popper-placement*="left"] &`]: { marginInlineEnd: d.lg }, + [`.${tooltipClasses.popper}[data-popper-placement*="right"] &`]: { marginInlineStart: d.lg }, + }, + }, + { + props: ({ ownerState }: { ownerState: { touch?: boolean | undefined; arrow?: boolean | undefined } }) => + !ownerState.touch && !ownerState.arrow, + style: { + [`.${tooltipClasses.popper}[data-popper-placement*="top"] &`]: { marginBottom: d.lg }, + [`.${tooltipClasses.popper}[data-popper-placement*="bottom"] &`]: { marginTop: d.lg }, + }, + }, + ], + }, + 'tooltip', + ); + addRootOverride(enhanced.components, 'MuiOutlinedInput', { + // Label bridge (calc-coupled): the floating label is a preceding sibling, so + // it can't read the input root's token — reach it via `:has(~ &)` and derive + // `--InputLabel-y` from the density step, keeping the component's -0.5/+0.5 + // per-size rounding. Root adornment/multiline padding = density steps. + [`.${inputLabelClasses.root}:has(~ &)`]: { '--InputLabel-y': `calc(${d.md} - 0.5px)` }, + variants: [ + { + props: { size: 'small' }, + style: { + [`.${inputLabelClasses.root}:has(~ &)`]: { '--InputLabel-y': `calc(${d.sm} + 0.5px)` }, + }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: d.lg }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined; size?: string | undefined } }) => + ownerState.startAdornment && ownerState.size === 'small', + style: { paddingLeft: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => ownerState.endAdornment, + style: { paddingRight: d.lg }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined; size?: string | undefined } }) => + ownerState.endAdornment && ownerState.size === 'small', + style: { paddingRight: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { padding: `${d.md} ${d.lg}` }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined; size?: string | undefined } }) => + ownerState.multiline && ownerState.size === 'small', + style: { padding: `${d.sm} ${d.md}` }, + }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiOutlinedInput', + { + // Box padding lives on the input slot for the plain (no adornment/multiline) + // case; the zero-resets mirror master so adornment/multiline defer to root. + padding: `${d.md} ${d.lg}`, + variants: [ + { props: { size: 'small' }, style: { padding: `${d.sm} ${d.md}` } }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { padding: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => + ownerState.endAdornment, + style: { paddingRight: 0 }, + }, + ], + }, + 'input', + ); + addRootOverride(enhanced.components, 'MuiInputAdornment', { + // Adornment gap (start marginRight / end marginLeft) + filled positionStart + // marginTop = density steps, per size (medium default / small). + variants: [ + { props: { position: 'start' }, style: { marginRight: d.sm } }, + { props: { position: 'end' }, style: { marginLeft: d.sm } }, + { + props: ({ ownerState }: { ownerState: { position?: string | undefined; size?: string | undefined } }) => + ownerState.position === 'start' && ownerState.size === 'small', + style: { marginRight: d.xxs }, + }, + { + props: ({ ownerState }: { ownerState: { position?: string | undefined; size?: string | undefined } }) => + ownerState.position === 'end' && ownerState.size === 'small', + style: { marginLeft: d.xxs }, + }, + { + props: { variant: 'filled' }, + style: { + [`&.${inputAdornmentClasses.positionStart}&:not(.${inputAdornmentClasses.hiddenLabel})`]: { + marginTop: d.lg, + }, + }, + }, + { + props: ({ ownerState }: { ownerState: { variant?: string | undefined; size?: string | undefined } }) => + ownerState.variant === 'filled' && ownerState.size === 'small', + style: { + [`&.${inputAdornmentClasses.positionStart}&:not(.${inputAdornmentClasses.hiddenLabel})`]: { + marginTop: d.md, + }, + }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiFilledInput', { + // Root padding (adornment/multiline) = density steps. The floating label is a + // preceding sibling — reach it via `:has(~ &)` and set its rest/shrink Y as + // tuned raw px (no clean formula from topPad). hiddenLabel block padding stays + // at master literals (out of scope). + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--FilledInputLabel-restY': '20px', + '--FilledInputLabel-shrinkY': '9px', + }, + variants: [ + { + props: { size: 'small' }, + style: { + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--FilledInputLabel-restY': '15px', + '--FilledInputLabel-shrinkY': '5px', + }, + }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => ownerState.endAdornment, + style: { paddingRight: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { padding: `${d.xl} ${d.md} ${d.sm}` }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined; size?: string | undefined } }) => + ownerState.multiline && ownerState.size === 'small', + style: { paddingTop: d.lg, paddingBottom: d.xxs }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined; hiddenLabel?: boolean | undefined } }) => + ownerState.multiline && ownerState.hiddenLabel, + style: { paddingTop: 16, paddingBottom: 17 }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { multiline?: boolean | undefined; hiddenLabel?: boolean | undefined; size?: string | undefined }; + }) => ownerState.multiline && ownerState.hiddenLabel && ownerState.size === 'small', + style: { paddingTop: 8, paddingBottom: 9 }, + }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiFilledInput', + { + // Box height = input top/bottom padding (density steps); inline = step. The + // adornment/multiline zero-resets mirror master; hiddenLabel block padding + // stays at master literals (out of scope). + paddingTop: d.xl, + paddingRight: d.md, + paddingBottom: d.sm, + paddingLeft: d.md, + variants: [ + { props: { size: 'small' }, style: { paddingTop: d.lg, paddingBottom: d.xxs } }, + { + props: ({ ownerState }: { ownerState: { hiddenLabel?: boolean | undefined } }) => ownerState.hiddenLabel, + style: { paddingTop: 16, paddingBottom: 17 }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => + ownerState.endAdornment, + style: { paddingRight: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { hiddenLabel?: boolean | undefined; size?: string | undefined } }) => + ownerState.hiddenLabel && ownerState.size === 'small', + style: { paddingTop: 8, paddingBottom: 9 }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { paddingTop: 0, paddingBottom: 0, paddingLeft: 0, paddingRight: 0 }, + }, + ], + }, + 'input', + ); + addRootOverride( + enhanced.components, + 'MuiInputBase', + { + // Standard input box padding (block only; inline stays 0). Emitted on the + // base key so standard Input inherits it via the cascade; Outlined/Filled + // override on their own keys (win by injection order). Multiline box padding + // lives on the InputBase root (left at master) — reset the input to 0 as + // master does. + paddingTop: d.xs, + paddingBottom: d.xs, + variants: [ + { props: { size: 'small' }, style: { paddingTop: d.xxs } }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { paddingTop: 0, paddingBottom: 0 }, + }, + ], + }, + 'input', + ); + addRootOverride(enhanced.components, 'MuiTab', { + // Min-heights = raw px (paired with MuiTabs base below); padding = steps. + minHeight: '56px', + paddingTop: d.sm, + paddingBottom: d.sm, + paddingLeft: d.lg, + paddingRight: d.lg, + variants: [ + { + props: ({ ownerState }: { ownerState: { icon?: unknown; label?: unknown } }) => + ownerState.icon && ownerState.label, + style: { minHeight: '84px', paddingTop: d.xs, paddingBottom: d.xs }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'top', + style: { [`& > .${tabClasses.icon}`]: { marginBottom: d.xs } }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'bottom', + style: { [`& > .${tabClasses.icon}`]: { marginTop: d.xs } }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'start', + style: { [`& > .${tabClasses.icon}`]: { marginRight: d.sm } }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'end', + style: { [`& > .${tabClasses.icon}`]: { marginLeft: d.sm } }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiTabs', { + minHeight: '56px', // == MuiTab base minHeight (the pairing) + }); + addRootOverride(enhanced.components, 'MuiCheckbox', { + // Touch-target padding per size (9px both sizes today) = density steps. + variants: [ + { props: { size: 'medium' }, style: { padding: d.sm } }, + { props: { size: 'small' }, style: { padding: d.xs } }, + ], + }); + addRootOverride(enhanced.components, 'MuiRadio', { + // Touch-target padding per size (9px both sizes today) = density steps. + variants: [ + { props: { size: 'medium' }, style: { padding: d.sm } }, + { props: { size: 'small' }, style: { padding: d.xs } }, + ], + }); + // Separator inline margins (spacing step) on the separator slot. + addRootOverride(enhanced.components, 'MuiBreadcrumbs', { marginLeft: d.sm, marginRight: d.sm }, 'separator'); + addRootOverride(enhanced.components, 'MuiToggleButton', { + // Emit uniform padding directly on the size variants ToggleButton ships (no seam). + variants: [ + { props: { size: 'small' }, style: { padding: d.sm } }, + { props: { size: 'medium' }, style: { padding: d.md } }, + { props: { size: 'large' }, style: { padding: d.lg } }, + ], + }); + addRootOverride(enhanced.components, 'MuiAvatar', { + // Square size = raw px (sizing). + width: '48px', + height: '48px', + }); + addRootOverride( + enhanced.components, + 'MuiBadge', + { + // Bubble = raw px (sizing); standard inline pad = step. Dot resizes; dot pad + // + borderRadius stay frozen at master. + variants: [ + { + props: { variant: 'standard' }, + style: { minWidth: '24px', height: '24px', padding: `0 ${d.xs}` }, + }, + { props: { variant: 'dot' }, style: { minWidth: '8px', height: '8px' } }, + ], + }, + 'badge', + ); + addRootOverride(enhanced.components, 'MuiButtonGroup', { + // Grouped-button min-width floor = raw px (sizing). + [`& .${buttonGroupClasses.grouped}`]: { minWidth: '48px' }, + }); + addRootOverride(enhanced.components, 'MuiTableCell', { + // Block pad per size (steps); inline pad shared. Re-assert the frozen + // checkbox/none affordances the size padding would otherwise clobber. + variants: [ + { props: { size: 'medium' }, style: { padding: `${d.lg} ${d.lg}` } }, + { props: { size: 'small' }, style: { padding: `${d.xs} ${d.lg}` } }, + { props: { padding: 'checkbox' }, style: { padding: '0 0 0 4px' } }, + { props: { padding: 'none' }, style: { padding: 0 } }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiAutocomplete', + { + // Option list (mirrors MenuItem) renders in a Popper → emit on the listbox + // slot: minHeight raw px, block/inline pad steps. + [`& .${autocompleteClasses.option}`]: { + minHeight: '56px', + paddingTop: d.xs, + paddingBottom: d.xs, + paddingLeft: d.lg, + paddingRight: d.lg, + }, + }, + 'listbox', + ); + // Horizontal step gutter: paddingLeft (first) / paddingRight (last) = step. + addRootOverride(enhanced.components, 'MuiStep', { + variants: [ + { + props: { orientation: 'horizontal', alternativeLabel: false, hasConnector: false }, + style: { paddingLeft: d.sm }, + }, + { + props: { orientation: 'horizontal', alternativeLabel: false, last: true }, + style: { paddingRight: d.sm }, + }, + ], + }); + // Icon→label gap on the iconContainer slot (step); alternativeLabel/vertical + // paddingRight:0 stay frozen (higher-specificity class + own variant literals). + addRootOverride(enhanced.components, 'MuiStepLabel', { paddingRight: d.sm }, 'iconContainer'); + addRootOverride(enhanced.components, 'MuiToolbar', { + // Gutter inline pad (steps, incl the sm-breakpoint bump); dense bar min-height + // (raw px). Regular min-height (theme.mixins.toolbar) stays frozen. + variants: [ + { + props: { disableGutters: false }, + style: { + paddingLeft: d.lg, + paddingRight: d.lg, + [(theme as unknown as { breakpoints: { up: (key: string) => string } }).breakpoints.up('sm')]: { + paddingLeft: d.xl, + paddingRight: d.xl, + }, + }, + }, + { props: { variant: 'dense' }, style: { minHeight: '56px' } }, + ], + }); + addRootOverride(enhanced.components, 'MuiFab', { + // Circular size = raw px per size (button-like action). Scoped to circular so + // the extended variant (auto width + literal height) stays frozen at master. + variants: [ + { props: { variant: 'circular', size: 'small' }, style: { width: '44px', height: '44px' } }, + { props: { variant: 'circular', size: 'medium' }, style: { width: '52px', height: '52px' } }, + { props: { variant: 'circular', size: 'large' }, style: { width: '64px', height: '64px' } }, + ], + }); + addRootOverride(enhanced.components, 'MuiPaginationItem', { + // Item box size = raw px per size: min-width on every item, height only on the + // button items (ellipsis keeps master's auto height). + variants: [ + { props: { size: 'small' }, style: { minWidth: '30px' } }, + { props: { size: 'medium' }, style: { minWidth: '36px' } }, + { props: { size: 'large' }, style: { minWidth: '44px' } }, + { + props: ({ ownerState }: { ownerState: { type?: string | undefined; size?: string | undefined } }) => + ownerState.type !== 'start-ellipsis' && + ownerState.type !== 'end-ellipsis' && + ownerState.size === 'small', + style: { height: '30px' }, + }, + { + props: ({ ownerState }: { ownerState: { type?: string | undefined; size?: string | undefined } }) => + ownerState.type !== 'start-ellipsis' && + ownerState.type !== 'end-ellipsis' && + ownerState.size === 'medium', + style: { height: '36px' }, + }, + { + props: ({ ownerState }: { ownerState: { type?: string | undefined; size?: string | undefined } }) => + ownerState.type !== 'start-ellipsis' && + ownerState.type !== 'end-ellipsis' && + ownerState.size === 'large', + style: { height: '44px' }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiSnackbarContent', { + // No size axis: root padding (block/inline steps). + padding: `${d.xs} ${d.lg}`, + }); + addRootOverride(enhanced.components, 'MuiBottomNavigation', { + height: '64px', + }); + addRootOverride(enhanced.components, 'MuiBottomNavigationAction', { + // Inline padding only; block padding stays master's 0. + paddingLeft: d.md, + paddingRight: d.md, + }); + addRootOverride(enhanced.components, 'MuiDialogTitle', { + padding: `${d.lg} ${d.xl}`, + }); + addRootOverride(enhanced.components, 'MuiDialogContent', { + // Base block/inline padding; re-assert the frozen dividers literal the base + // padding would otherwise clobber. + padding: `${d.lg} ${d.xl}`, + variants: [{ props: { dividers: true }, style: { padding: '16px 24px' } }], + }); + addRootOverride(enhanced.components, 'MuiDialogActions', { + padding: d.sm, + }); + addRootOverride(enhanced.components, 'MuiListItemButton', { + // Density axis is the `dense` boolean; inline pad only when gutters are on. + variants: [ + { props: { dense: false }, style: { paddingTop: d.sm, paddingBottom: d.sm } }, + { props: { dense: true }, style: { paddingTop: d.xxs, paddingBottom: d.xxs } }, + { props: { disableGutters: false }, style: { paddingLeft: d.lg, paddingRight: d.lg } }, + ], + }); + addRootOverride(enhanced.components, 'MuiCardContent', { + // No size axis: base padding + larger last-child bottom padding. + padding: d.lg, + '&:last-child': { paddingBottom: d.xl }, + }); + addRootOverride(enhanced.components, 'MuiCardActions', { + // No size axis: root padding + inter-child gap (spacing variant) = steps. + padding: d.sm, + variants: [ + { + props: { disableSpacing: false }, + style: { '& > :not(style) ~ :not(style)': { marginLeft: d.sm } }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiCardHeader', { + // Root padding = step (no size axis). + padding: d.lg, + }); + // Avatar→content gap on the avatar slot. + addRootOverride(enhanced.components, 'MuiCardHeader', { marginRight: d.lg }, 'avatar'); + // Action negative pulls counteract the control's own box; scale with density. + addRootOverride( + enhanced.components, + 'MuiCardHeader', + { + marginTop: `calc(${d.xxs} * -1)`, + marginRight: `calc(${d.sm} * -1)`, + marginBottom: `calc(${d.xxs} * -1)`, + }, + 'action', + ); + addRootOverride( + enhanced.components, + 'MuiSelect', + { + // Content-box floor (raw px); real padding comes from the input variant. + minHeight: '28px', + }, + 'select', + ); + addRootOverride(enhanced.components, 'MuiAlert', { + // No size axis: root padding (block/inline steps). + padding: `${d.xs} ${d.lg}`, + }); + // Icon→message gap on the icon slot (child element). + addRootOverride(enhanced.components, 'MuiAlert', { marginRight: d.md }, 'icon'); + // Height (raw px) drives avatar/icon/deleteIcon via calc off `--Chip-height`. + addRootOverride(enhanced.components, 'MuiChip', { + variants: [ + { props: { size: 'medium' }, style: { '--Chip-height': '36px' } }, + { props: { size: 'small' }, style: { '--Chip-height': '28px' } }, + ], + }); + // Label inline padding = density steps, unified per size on the label slot. + addRootOverride( + enhanced.components, + 'MuiChip', + { + variants: [ + { props: { size: 'medium' }, style: { paddingInline: d.md } }, + { props: { size: 'small' }, style: { paddingInline: d.sm } }, + ], + }, + 'label', + ); + addRootOverride(enhanced.components, 'MuiAccordionSummary', { + // Collapsed min-height raw px; inline padding = step. + minHeight: '56px', + padding: `0 ${d.lg}`, + variants: [ + { + props: ({ ownerState }: { ownerState: { disableGutters?: boolean | undefined } }) => + !ownerState.disableGutters, + // Re-assert expanded min-height (master literal wins by specificity else). + style: { [`&.${accordionSummaryClasses.expanded}`]: { minHeight: '76px' } }, + }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiAccordionSummary', + { + // Content block margin reduces with min-height (else it binds header height). + margin: `${d.md} 0`, + variants: [ + { + props: ({ ownerState }: { ownerState: { disableGutters?: boolean | undefined } }) => + !ownerState.disableGutters, + style: { [`&.${accordionSummaryClasses.expanded}`]: { margin: `${d.lg} 0` } }, + }, + ], + }, + 'content', + ); + addRootOverride(enhanced.components, 'MuiAccordionDetails', { + padding: `${d.sm} ${d.lg} ${d.lg}`, + }); + enhanced.typography = { + ...enhanced.typography, + button: { ...enhanced.typography?.button, fontSize: '0.9375rem', lineHeight: 2 }, + }; + return enhanced; +} diff --git a/packages/mui-material/src/styles/enhanceCompactDensity.ts b/packages/mui-material/src/styles/enhanceCompactDensity.ts new file mode 100644 index 00000000000000..22ef533fe46034 --- /dev/null +++ b/packages/mui-material/src/styles/enhanceCompactDensity.ts @@ -0,0 +1,628 @@ +import { addRootOverride, applyDensity, densityVars as d, DensityScale, EnhanceableTheme } from './densityScale'; +import tooltipClasses from '../Tooltip/tooltipClasses'; +import tabClasses from '../Tab/tabClasses'; +import accordionSummaryClasses from '../AccordionSummary/accordionSummaryClasses'; +import buttonGroupClasses from '../ButtonGroup/buttonGroupClasses'; +import autocompleteClasses from '../Autocomplete/autocompleteClasses'; +import inputLabelClasses from '../InputLabel/inputLabelClasses'; +import inputAdornmentClasses from '../InputAdornment/inputAdornmentClasses'; + +const scale: DensityScale = { + xxs: '2px', + xs: '4px', + sm: '6px', + md: '8px', + lg: '12px', + xl: '18px', + xxl: '24px', +}; + +export default function enhanceCompactDensity(theme: T) { + const enhanced = applyDensity(theme, scale); + addRootOverride(enhanced.components, 'MuiButton', { + // Emit padding directly on the size variants Button already ships (no seam). + variants: [ + { props: { size: 'small' }, style: { padding: `${d.xxs} ${d.sm}` } }, + { props: { size: 'medium' }, style: { padding: `${d.xs} ${d.lg}` } }, + { props: { size: 'large' }, style: { padding: `${d.sm} ${d.xl}` } }, + ], + }); + addRootOverride(enhanced.components, 'MuiMenuItem', { + // Height = raw px (density steps are spacing-only). Padding = density steps. + // Density axis is the `dense` boolean; inline pad only when gutters are on. + variants: [ + { props: { dense: false }, style: { minHeight: '36px', paddingTop: d.xs, paddingBottom: d.xs } }, + { props: { dense: true }, style: { minHeight: '28px', paddingTop: d.xxs, paddingBottom: d.xxs } }, + { props: { dense: false, disableGutters: false }, style: { paddingLeft: d.lg, paddingRight: d.lg } }, + { props: { dense: true, disableGutters: false }, style: { paddingLeft: d.md, paddingRight: d.md } }, + ], + }); + addRootOverride(enhanced.components, 'MuiList', { + // Menu/list vertical breathing (spacing token); subheader keeps paddingTop 0. + variants: [ + { props: { disablePadding: false }, style: { paddingTop: d.sm, paddingBottom: d.sm } }, + { props: ({ ownerState }: { ownerState: { subheader?: unknown } }) => ownerState.subheader, style: { paddingTop: 0 } }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiTooltip', + { + // Regular (pointer) tooltip only — `touch` keeps its master literals. + // Padding + per-placement anchor offset = density steps (non-touch); the + // arrow child derives its size from the single `--Tooltip-arrowSize` (raw + // px), left unset for `touch` so both variants keep scaling. + '--Tooltip-arrowSize': '10px', + variants: [ + { + props: ({ ownerState }: { ownerState: { touch?: boolean | undefined } }) => !ownerState.touch, + style: { + padding: `${d.xxs} ${d.sm}`, + [`.${tooltipClasses.popper}[data-popper-placement*="left"] &`]: { marginInlineEnd: d.lg }, + [`.${tooltipClasses.popper}[data-popper-placement*="right"] &`]: { marginInlineStart: d.lg }, + }, + }, + { + props: ({ ownerState }: { ownerState: { touch?: boolean | undefined; arrow?: boolean | undefined } }) => + !ownerState.touch && !ownerState.arrow, + style: { + [`.${tooltipClasses.popper}[data-popper-placement*="top"] &`]: { marginBottom: d.lg }, + [`.${tooltipClasses.popper}[data-popper-placement*="bottom"] &`]: { marginTop: d.lg }, + }, + }, + ], + }, + 'tooltip', + ); + addRootOverride(enhanced.components, 'MuiOutlinedInput', { + // Label bridge (calc-coupled): the floating label is a preceding sibling, so + // it can't read the input root's token — reach it via `:has(~ &)` and derive + // `--InputLabel-y` from the density step, keeping the component's -0.5/+0.5 + // per-size rounding. Root adornment/multiline padding = density steps. + [`.${inputLabelClasses.root}:has(~ &)`]: { '--InputLabel-y': `calc(${d.md} - 0.5px)` }, + variants: [ + { + props: { size: 'small' }, + style: { + [`.${inputLabelClasses.root}:has(~ &)`]: { '--InputLabel-y': `calc(${d.sm} + 0.5px)` }, + }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: d.lg }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined; size?: string | undefined } }) => + ownerState.startAdornment && ownerState.size === 'small', + style: { paddingLeft: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => ownerState.endAdornment, + style: { paddingRight: d.lg }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined; size?: string | undefined } }) => + ownerState.endAdornment && ownerState.size === 'small', + style: { paddingRight: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { padding: `${d.md} ${d.lg}` }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined; size?: string | undefined } }) => + ownerState.multiline && ownerState.size === 'small', + style: { padding: `${d.sm} ${d.md}` }, + }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiOutlinedInput', + { + // Box padding lives on the input slot for the plain (no adornment/multiline) + // case; the zero-resets mirror master so adornment/multiline defer to root. + padding: `${d.md} ${d.lg}`, + variants: [ + { props: { size: 'small' }, style: { padding: `${d.sm} ${d.md}` } }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { padding: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => + ownerState.endAdornment, + style: { paddingRight: 0 }, + }, + ], + }, + 'input', + ); + addRootOverride(enhanced.components, 'MuiInputAdornment', { + // Adornment gap (start marginRight / end marginLeft) + filled positionStart + // marginTop = density steps, per size (medium default / small). + variants: [ + { props: { position: 'start' }, style: { marginRight: d.sm } }, + { props: { position: 'end' }, style: { marginLeft: d.sm } }, + { + props: ({ ownerState }: { ownerState: { position?: string | undefined; size?: string | undefined } }) => + ownerState.position === 'start' && ownerState.size === 'small', + style: { marginRight: d.xxs }, + }, + { + props: ({ ownerState }: { ownerState: { position?: string | undefined; size?: string | undefined } }) => + ownerState.position === 'end' && ownerState.size === 'small', + style: { marginLeft: d.xxs }, + }, + { + props: { variant: 'filled' }, + style: { + [`&.${inputAdornmentClasses.positionStart}&:not(.${inputAdornmentClasses.hiddenLabel})`]: { + marginTop: d.lg, + }, + }, + }, + { + props: ({ ownerState }: { ownerState: { variant?: string | undefined; size?: string | undefined } }) => + ownerState.variant === 'filled' && ownerState.size === 'small', + style: { + [`&.${inputAdornmentClasses.positionStart}&:not(.${inputAdornmentClasses.hiddenLabel})`]: { + marginTop: d.md, + }, + }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiFilledInput', { + // Root padding (adornment/multiline) = density steps. The floating label is a + // preceding sibling — reach it via `:has(~ &)` and set its rest/shrink Y as + // tuned raw px (no clean formula from topPad). hiddenLabel block padding stays + // at master literals (out of scope). + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--FilledInputLabel-restY': '11px', + '--FilledInputLabel-shrinkY': '5px', + }, + variants: [ + { + props: { size: 'small' }, + style: { + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--FilledInputLabel-restY': '8px', + '--FilledInputLabel-shrinkY': '3px', + }, + }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => ownerState.endAdornment, + style: { paddingRight: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { padding: `${d.xl} ${d.md} ${d.sm}` }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined; size?: string | undefined } }) => + ownerState.multiline && ownerState.size === 'small', + style: { paddingTop: d.lg, paddingBottom: d.xxs }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined; hiddenLabel?: boolean | undefined } }) => + ownerState.multiline && ownerState.hiddenLabel, + style: { paddingTop: 16, paddingBottom: 17 }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { multiline?: boolean | undefined; hiddenLabel?: boolean | undefined; size?: string | undefined }; + }) => ownerState.multiline && ownerState.hiddenLabel && ownerState.size === 'small', + style: { paddingTop: 8, paddingBottom: 9 }, + }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiFilledInput', + { + // Box height = input top/bottom padding (density steps); inline = step. The + // adornment/multiline zero-resets mirror master; hiddenLabel block padding + // stays at master literals (out of scope). + paddingTop: d.xl, + paddingRight: d.md, + paddingBottom: d.sm, + paddingLeft: d.md, + variants: [ + { props: { size: 'small' }, style: { paddingTop: d.lg, paddingBottom: d.xxs } }, + { + props: ({ ownerState }: { ownerState: { hiddenLabel?: boolean | undefined } }) => ownerState.hiddenLabel, + style: { paddingTop: 16, paddingBottom: 17 }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => + ownerState.endAdornment, + style: { paddingRight: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { hiddenLabel?: boolean | undefined; size?: string | undefined } }) => + ownerState.hiddenLabel && ownerState.size === 'small', + style: { paddingTop: 8, paddingBottom: 9 }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { paddingTop: 0, paddingBottom: 0, paddingLeft: 0, paddingRight: 0 }, + }, + ], + }, + 'input', + ); + addRootOverride( + enhanced.components, + 'MuiInputBase', + { + // Standard input box padding (block only; inline stays 0). Emitted on the + // base key so standard Input inherits it via the cascade; Outlined/Filled + // override on their own keys (win by injection order). Multiline box padding + // lives on the InputBase root (left at master) — reset the input to 0 as + // master does. + paddingTop: d.xs, + paddingBottom: d.xs, + variants: [ + { props: { size: 'small' }, style: { paddingTop: d.xxs } }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { paddingTop: 0, paddingBottom: 0 }, + }, + ], + }, + 'input', + ); + addRootOverride(enhanced.components, 'MuiTab', { + // Min-heights = raw px (paired with MuiTabs base below); padding = steps. + minHeight: '40px', + paddingTop: d.sm, + paddingBottom: d.sm, + paddingLeft: d.lg, + paddingRight: d.lg, + variants: [ + { + props: ({ ownerState }: { ownerState: { icon?: unknown; label?: unknown } }) => + ownerState.icon && ownerState.label, + style: { minHeight: '60px', paddingTop: d.xs, paddingBottom: d.xs }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'top', + style: { [`& > .${tabClasses.icon}`]: { marginBottom: d.xs } }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'bottom', + style: { [`& > .${tabClasses.icon}`]: { marginTop: d.xs } }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'start', + style: { [`& > .${tabClasses.icon}`]: { marginRight: d.sm } }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'end', + style: { [`& > .${tabClasses.icon}`]: { marginLeft: d.sm } }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiTabs', { + minHeight: '40px', // == MuiTab base minHeight (the pairing) + }); + addRootOverride(enhanced.components, 'MuiCheckbox', { + // Touch-target padding per size (9px both sizes today) = density steps. + variants: [ + { props: { size: 'medium' }, style: { padding: d.sm } }, + { props: { size: 'small' }, style: { padding: d.xs } }, + ], + }); + addRootOverride(enhanced.components, 'MuiRadio', { + // Touch-target padding per size (9px both sizes today) = density steps. + variants: [ + { props: { size: 'medium' }, style: { padding: d.sm } }, + { props: { size: 'small' }, style: { padding: d.xs } }, + ], + }); + // Separator inline margins (spacing step) on the separator slot. + addRootOverride(enhanced.components, 'MuiBreadcrumbs', { marginLeft: d.sm, marginRight: d.sm }, 'separator'); + addRootOverride(enhanced.components, 'MuiToggleButton', { + // Emit uniform padding directly on the size variants ToggleButton ships (no seam). + variants: [ + { props: { size: 'small' }, style: { padding: d.sm } }, + { props: { size: 'medium' }, style: { padding: d.md } }, + { props: { size: 'large' }, style: { padding: d.lg } }, + ], + }); + addRootOverride(enhanced.components, 'MuiAvatar', { + // Square size = raw px (sizing). + width: '32px', + height: '32px', + }); + addRootOverride( + enhanced.components, + 'MuiBadge', + { + // Bubble = raw px (sizing); standard inline pad = step. Dot resizes; dot pad + // + borderRadius stay frozen at master. + variants: [ + { + props: { variant: 'standard' }, + style: { minWidth: '18px', height: '18px', padding: `0 ${d.xs}` }, + }, + { props: { variant: 'dot' }, style: { minWidth: '4px', height: '4px' } }, + ], + }, + 'badge', + ); + addRootOverride(enhanced.components, 'MuiButtonGroup', { + // Grouped-button min-width floor = raw px (sizing). + [`& .${buttonGroupClasses.grouped}`]: { minWidth: '32px' }, + }); + addRootOverride(enhanced.components, 'MuiTableCell', { + // Block pad per size (steps); inline pad shared. Re-assert the frozen + // checkbox/none affordances the size padding would otherwise clobber. + variants: [ + { props: { size: 'medium' }, style: { padding: `${d.lg} ${d.lg}` } }, + { props: { size: 'small' }, style: { padding: `${d.xs} ${d.lg}` } }, + { props: { padding: 'checkbox' }, style: { padding: '0 0 0 4px' } }, + { props: { padding: 'none' }, style: { padding: 0 } }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiAutocomplete', + { + // Option list (mirrors MenuItem) renders in a Popper → emit on the listbox + // slot: minHeight raw px, block/inline pad steps. + [`& .${autocompleteClasses.option}`]: { + minHeight: '36px', + paddingTop: d.xs, + paddingBottom: d.xs, + paddingLeft: d.lg, + paddingRight: d.lg, + }, + }, + 'listbox', + ); + // Horizontal step gutter: paddingLeft (first) / paddingRight (last) = step. + addRootOverride(enhanced.components, 'MuiStep', { + variants: [ + { + props: { orientation: 'horizontal', alternativeLabel: false, hasConnector: false }, + style: { paddingLeft: d.sm }, + }, + { + props: { orientation: 'horizontal', alternativeLabel: false, last: true }, + style: { paddingRight: d.sm }, + }, + ], + }); + // Icon→label gap on the iconContainer slot (step); alternativeLabel/vertical + // paddingRight:0 stay frozen (higher-specificity class + own variant literals). + addRootOverride(enhanced.components, 'MuiStepLabel', { paddingRight: d.sm }, 'iconContainer'); + addRootOverride(enhanced.components, 'MuiToolbar', { + // Gutter inline pad (steps, incl the sm-breakpoint bump); dense bar min-height + // (raw px). Regular min-height (theme.mixins.toolbar) stays frozen. + variants: [ + { + props: { disableGutters: false }, + style: { + paddingLeft: d.lg, + paddingRight: d.lg, + [(theme as unknown as { breakpoints: { up: (key: string) => string } }).breakpoints.up('sm')]: { + paddingLeft: d.xl, + paddingRight: d.xl, + }, + }, + }, + { props: { variant: 'dense' }, style: { minHeight: '40px' } }, + ], + }); + addRootOverride(enhanced.components, 'MuiFab', { + // Circular size = raw px per size (button-like action). Scoped to circular so + // the extended variant (auto width + literal height) stays frozen at master. + variants: [ + { props: { variant: 'circular', size: 'small' }, style: { width: '36px', height: '36px' } }, + { props: { variant: 'circular', size: 'medium' }, style: { width: '44px', height: '44px' } }, + { props: { variant: 'circular', size: 'large' }, style: { width: '52px', height: '52px' } }, + ], + }); + addRootOverride(enhanced.components, 'MuiPaginationItem', { + // Item box size = raw px per size: min-width on every item, height only on the + // button items (ellipsis keeps master's auto height). + variants: [ + { props: { size: 'small' }, style: { minWidth: '22px' } }, + { props: { size: 'medium' }, style: { minWidth: '28px' } }, + { props: { size: 'large' }, style: { minWidth: '36px' } }, + { + props: ({ ownerState }: { ownerState: { type?: string | undefined; size?: string | undefined } }) => + ownerState.type !== 'start-ellipsis' && + ownerState.type !== 'end-ellipsis' && + ownerState.size === 'small', + style: { height: '22px' }, + }, + { + props: ({ ownerState }: { ownerState: { type?: string | undefined; size?: string | undefined } }) => + ownerState.type !== 'start-ellipsis' && + ownerState.type !== 'end-ellipsis' && + ownerState.size === 'medium', + style: { height: '28px' }, + }, + { + props: ({ ownerState }: { ownerState: { type?: string | undefined; size?: string | undefined } }) => + ownerState.type !== 'start-ellipsis' && + ownerState.type !== 'end-ellipsis' && + ownerState.size === 'large', + style: { height: '36px' }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiSnackbarContent', { + // No size axis: root padding (block/inline steps). + padding: `${d.xs} ${d.lg}`, + }); + addRootOverride(enhanced.components, 'MuiBottomNavigation', { + height: '48px', + }); + addRootOverride(enhanced.components, 'MuiBottomNavigationAction', { + // Inline padding only; block padding stays master's 0. + paddingLeft: d.md, + paddingRight: d.md, + }); + addRootOverride(enhanced.components, 'MuiDialogTitle', { + padding: `${d.lg} ${d.xl}`, + }); + addRootOverride(enhanced.components, 'MuiDialogContent', { + // Base block/inline padding; re-assert the frozen dividers literal the base + // padding would otherwise clobber. + padding: `${d.lg} ${d.xl}`, + variants: [{ props: { dividers: true }, style: { padding: '16px 24px' } }], + }); + addRootOverride(enhanced.components, 'MuiDialogActions', { + padding: d.sm, + }); + addRootOverride(enhanced.components, 'MuiListItemButton', { + // Density axis is the `dense` boolean; inline pad only when gutters are on. + variants: [ + { props: { dense: false }, style: { paddingTop: d.sm, paddingBottom: d.sm } }, + { props: { dense: true }, style: { paddingTop: d.xxs, paddingBottom: d.xxs } }, + { props: { disableGutters: false }, style: { paddingLeft: d.lg, paddingRight: d.lg } }, + ], + }); + addRootOverride(enhanced.components, 'MuiCardContent', { + // No size axis: base padding + larger last-child bottom padding. + padding: d.lg, + '&:last-child': { paddingBottom: d.xl }, + }); + addRootOverride(enhanced.components, 'MuiCardActions', { + // No size axis: root padding + inter-child gap (spacing variant) = steps. + padding: d.sm, + variants: [ + { + props: { disableSpacing: false }, + style: { '& > :not(style) ~ :not(style)': { marginLeft: d.sm } }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiCardHeader', { + // Root padding = step (no size axis). + padding: d.lg, + }); + // Avatar→content gap on the avatar slot. + addRootOverride(enhanced.components, 'MuiCardHeader', { marginRight: d.lg }, 'avatar'); + // Action negative pulls counteract the control's own box; scale with density. + addRootOverride( + enhanced.components, + 'MuiCardHeader', + { + marginTop: `calc(${d.xxs} * -1)`, + marginRight: `calc(${d.sm} * -1)`, + marginBottom: `calc(${d.xxs} * -1)`, + }, + 'action', + ); + addRootOverride( + enhanced.components, + 'MuiSelect', + { + // Content-box floor (raw px); real padding comes from the input variant. + minHeight: '20px', + }, + 'select', + ); + addRootOverride(enhanced.components, 'MuiAlert', { + // No size axis: root padding (block/inline steps). + padding: `${d.xs} ${d.lg}`, + }); + // Icon→message gap on the icon slot (child element). + addRootOverride(enhanced.components, 'MuiAlert', { marginRight: d.md }, 'icon'); + // Height (raw px) drives avatar/icon/deleteIcon via calc off `--Chip-height`. + addRootOverride(enhanced.components, 'MuiChip', { + variants: [ + { props: { size: 'medium' }, style: { '--Chip-height': '28px' } }, + { props: { size: 'small' }, style: { '--Chip-height': '20px' } }, + ], + }); + // Label inline padding = density steps, unified per size on the label slot. + addRootOverride( + enhanced.components, + 'MuiChip', + { + variants: [ + { props: { size: 'medium' }, style: { paddingInline: d.md } }, + { props: { size: 'small' }, style: { paddingInline: d.sm } }, + ], + }, + 'label', + ); + addRootOverride(enhanced.components, 'MuiAccordionSummary', { + // Collapsed min-height raw px; inline padding = step. + minHeight: '40px', + padding: `0 ${d.lg}`, + variants: [ + { + props: ({ ownerState }: { ownerState: { disableGutters?: boolean | undefined } }) => + !ownerState.disableGutters, + // Re-assert expanded min-height (master literal wins by specificity else). + style: { [`&.${accordionSummaryClasses.expanded}`]: { minHeight: '52px' } }, + }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiAccordionSummary', + { + // Content block margin reduces with min-height (else it binds header height). + margin: `${d.md} 0`, + variants: [ + { + props: ({ ownerState }: { ownerState: { disableGutters?: boolean | undefined } }) => + !ownerState.disableGutters, + style: { [`&.${accordionSummaryClasses.expanded}`]: { margin: `${d.lg} 0` } }, + }, + ], + }, + 'content', + ); + addRootOverride(enhanced.components, 'MuiAccordionDetails', { + padding: `${d.sm} ${d.lg} ${d.lg}`, + }); + enhanced.typography = { + ...enhanced.typography, + button: { ...enhanced.typography?.button, fontSize: '0.8125rem', lineHeight: 1.5 }, + }; + return enhanced; +} diff --git a/packages/mui-material/src/styles/enhanceNormalDensity.ts b/packages/mui-material/src/styles/enhanceNormalDensity.ts new file mode 100644 index 00000000000000..e4c85dda48160f --- /dev/null +++ b/packages/mui-material/src/styles/enhanceNormalDensity.ts @@ -0,0 +1,626 @@ +import { addRootOverride, applyDensity, densityVars as d, DensityScale, EnhanceableTheme } from './densityScale'; +import tooltipClasses from '../Tooltip/tooltipClasses'; +import tabClasses from '../Tab/tabClasses'; +import accordionSummaryClasses from '../AccordionSummary/accordionSummaryClasses'; +import buttonGroupClasses from '../ButtonGroup/buttonGroupClasses'; +import autocompleteClasses from '../Autocomplete/autocompleteClasses'; +import inputLabelClasses from '../InputLabel/inputLabelClasses'; +import inputAdornmentClasses from '../InputAdornment/inputAdornmentClasses'; + +// Explicit px (self-contained, not spacing-derived). Normal keeps today's Button +// typography — no reflow — so only the padding→step assignment below. +const scale: DensityScale = { + xxs: '4px', + xs: '6px', + sm: '8px', + md: '12px', + lg: '16px', + xl: '24px', + xxl: '32px', +}; + +export default function enhanceNormalDensity(theme: T) { + const enhanced = applyDensity(theme, scale); + addRootOverride(enhanced.components, 'MuiButton', { + // Emit padding directly on the size variants Button already ships (no seam). + variants: [ + { props: { size: 'small' }, style: { padding: `${d.xxs} ${d.sm}` } }, + { props: { size: 'medium' }, style: { padding: `${d.xs} ${d.lg}` } }, + { props: { size: 'large' }, style: { padding: `${d.sm} ${d.xl}` } }, + ], + }); + addRootOverride(enhanced.components, 'MuiMenuItem', { + // Height = raw px (density steps are spacing-only). Padding = density steps. + // Density axis is the `dense` boolean; inline pad only when gutters are on. + variants: [ + { props: { dense: false }, style: { minHeight: '44px', paddingTop: d.xs, paddingBottom: d.xs } }, + { props: { dense: true }, style: { minHeight: '32px', paddingTop: d.xxs, paddingBottom: d.xxs } }, + { props: { dense: false, disableGutters: false }, style: { paddingLeft: d.lg, paddingRight: d.lg } }, + { props: { dense: true, disableGutters: false }, style: { paddingLeft: d.md, paddingRight: d.md } }, + ], + }); + addRootOverride(enhanced.components, 'MuiList', { + // Menu/list vertical breathing (spacing token); subheader keeps paddingTop 0. + variants: [ + { props: { disablePadding: false }, style: { paddingTop: d.sm, paddingBottom: d.sm } }, + { props: ({ ownerState }: { ownerState: { subheader?: unknown } }) => ownerState.subheader, style: { paddingTop: 0 } }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiTooltip', + { + // Regular (pointer) tooltip only — `touch` keeps its master literals. + // Padding + per-placement anchor offset = density steps (non-touch); the + // arrow child derives its size from the single `--Tooltip-arrowSize` (raw + // px), left unset for `touch` so both variants keep scaling. + '--Tooltip-arrowSize': '11px', + variants: [ + { + props: ({ ownerState }: { ownerState: { touch?: boolean | undefined } }) => !ownerState.touch, + style: { + padding: `${d.xxs} ${d.sm}`, + [`.${tooltipClasses.popper}[data-popper-placement*="left"] &`]: { marginInlineEnd: d.lg }, + [`.${tooltipClasses.popper}[data-popper-placement*="right"] &`]: { marginInlineStart: d.lg }, + }, + }, + { + props: ({ ownerState }: { ownerState: { touch?: boolean | undefined; arrow?: boolean | undefined } }) => + !ownerState.touch && !ownerState.arrow, + style: { + [`.${tooltipClasses.popper}[data-popper-placement*="top"] &`]: { marginBottom: d.lg }, + [`.${tooltipClasses.popper}[data-popper-placement*="bottom"] &`]: { marginTop: d.lg }, + }, + }, + ], + }, + 'tooltip', + ); + addRootOverride(enhanced.components, 'MuiOutlinedInput', { + // Label bridge (calc-coupled): the floating label is a preceding sibling, so + // it can't read the input root's token — reach it via `:has(~ &)` and derive + // `--InputLabel-y` from the density step, keeping the component's -0.5/+0.5 + // per-size rounding. Root adornment/multiline padding = density steps. + [`.${inputLabelClasses.root}:has(~ &)`]: { '--InputLabel-y': `calc(${d.md} - 0.5px)` }, + variants: [ + { + props: { size: 'small' }, + style: { + [`.${inputLabelClasses.root}:has(~ &)`]: { '--InputLabel-y': `calc(${d.sm} + 0.5px)` }, + }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: d.lg }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined; size?: string | undefined } }) => + ownerState.startAdornment && ownerState.size === 'small', + style: { paddingLeft: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => ownerState.endAdornment, + style: { paddingRight: d.lg }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined; size?: string | undefined } }) => + ownerState.endAdornment && ownerState.size === 'small', + style: { paddingRight: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { padding: `${d.md} ${d.lg}` }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined; size?: string | undefined } }) => + ownerState.multiline && ownerState.size === 'small', + style: { padding: `${d.sm} ${d.md}` }, + }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiOutlinedInput', + { + // Box padding lives on the input slot for the plain (no adornment/multiline) + // case; the zero-resets mirror master so adornment/multiline defer to root. + padding: `${d.md} ${d.lg}`, + variants: [ + { props: { size: 'small' }, style: { padding: `${d.sm} ${d.md}` } }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { padding: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => + ownerState.endAdornment, + style: { paddingRight: 0 }, + }, + ], + }, + 'input', + ); + addRootOverride(enhanced.components, 'MuiInputAdornment', { + // Adornment gap (start marginRight / end marginLeft) + filled positionStart + // marginTop = density steps, per size (medium default / small). + variants: [ + { props: { position: 'start' }, style: { marginRight: d.sm } }, + { props: { position: 'end' }, style: { marginLeft: d.sm } }, + { + props: ({ ownerState }: { ownerState: { position?: string | undefined; size?: string | undefined } }) => + ownerState.position === 'start' && ownerState.size === 'small', + style: { marginRight: d.xxs }, + }, + { + props: ({ ownerState }: { ownerState: { position?: string | undefined; size?: string | undefined } }) => + ownerState.position === 'end' && ownerState.size === 'small', + style: { marginLeft: d.xxs }, + }, + { + props: { variant: 'filled' }, + style: { + [`&.${inputAdornmentClasses.positionStart}&:not(.${inputAdornmentClasses.hiddenLabel})`]: { + marginTop: d.lg, + }, + }, + }, + { + props: ({ ownerState }: { ownerState: { variant?: string | undefined; size?: string | undefined } }) => + ownerState.variant === 'filled' && ownerState.size === 'small', + style: { + [`&.${inputAdornmentClasses.positionStart}&:not(.${inputAdornmentClasses.hiddenLabel})`]: { + marginTop: d.md, + }, + }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiFilledInput', { + // Root padding (adornment/multiline) = density steps. The floating label is a + // preceding sibling — reach it via `:has(~ &)` and set its rest/shrink Y as + // tuned raw px (no clean formula from topPad). hiddenLabel block padding stays + // at master literals (out of scope). + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--FilledInputLabel-restY': '15px', + '--FilledInputLabel-shrinkY': '7px', + }, + variants: [ + { + props: { size: 'small' }, + style: { + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--FilledInputLabel-restY': '10px', + '--FilledInputLabel-shrinkY': '4px', + }, + }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => ownerState.endAdornment, + style: { paddingRight: d.md }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { padding: `${d.xl} ${d.md} ${d.sm}` }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined; size?: string | undefined } }) => + ownerState.multiline && ownerState.size === 'small', + style: { paddingTop: d.lg, paddingBottom: d.xxs }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined; hiddenLabel?: boolean | undefined } }) => + ownerState.multiline && ownerState.hiddenLabel, + style: { paddingTop: 16, paddingBottom: 17 }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { multiline?: boolean | undefined; hiddenLabel?: boolean | undefined; size?: string | undefined }; + }) => ownerState.multiline && ownerState.hiddenLabel && ownerState.size === 'small', + style: { paddingTop: 8, paddingBottom: 9 }, + }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiFilledInput', + { + // Box height = input top/bottom padding (density steps); inline = step. The + // adornment/multiline zero-resets mirror master; hiddenLabel block padding + // stays at master literals (out of scope). + paddingTop: d.xl, + paddingRight: d.md, + paddingBottom: d.sm, + paddingLeft: d.md, + variants: [ + { props: { size: 'small' }, style: { paddingTop: d.lg, paddingBottom: d.xxs } }, + { + props: ({ ownerState }: { ownerState: { hiddenLabel?: boolean | undefined } }) => ownerState.hiddenLabel, + style: { paddingTop: 16, paddingBottom: 17 }, + }, + { + props: ({ ownerState }: { ownerState: { startAdornment?: unknown | undefined } }) => + ownerState.startAdornment, + style: { paddingLeft: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { endAdornment?: unknown | undefined } }) => + ownerState.endAdornment, + style: { paddingRight: 0 }, + }, + { + props: ({ ownerState }: { ownerState: { hiddenLabel?: boolean | undefined; size?: string | undefined } }) => + ownerState.hiddenLabel && ownerState.size === 'small', + style: { paddingTop: 8, paddingBottom: 9 }, + }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { paddingTop: 0, paddingBottom: 0, paddingLeft: 0, paddingRight: 0 }, + }, + ], + }, + 'input', + ); + addRootOverride( + enhanced.components, + 'MuiInputBase', + { + // Standard input box padding (block only; inline stays 0). Emitted on the + // base key so standard Input inherits it via the cascade; Outlined/Filled + // override on their own keys (win by injection order). Multiline box padding + // lives on the InputBase root (left at master) — reset the input to 0 as + // master does. + paddingTop: d.xs, + paddingBottom: d.xs, + variants: [ + { props: { size: 'small' }, style: { paddingTop: d.xxs } }, + { + props: ({ ownerState }: { ownerState: { multiline?: boolean | undefined } }) => ownerState.multiline, + style: { paddingTop: 0, paddingBottom: 0 }, + }, + ], + }, + 'input', + ); + addRootOverride(enhanced.components, 'MuiTab', { + // Min-heights = raw px (paired with MuiTabs base below); padding = steps. + minHeight: '48px', + paddingTop: d.sm, + paddingBottom: d.sm, + paddingLeft: d.lg, + paddingRight: d.lg, + variants: [ + { + props: ({ ownerState }: { ownerState: { icon?: unknown; label?: unknown } }) => + ownerState.icon && ownerState.label, + style: { minHeight: '72px', paddingTop: d.xs, paddingBottom: d.xs }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'top', + style: { [`& > .${tabClasses.icon}`]: { marginBottom: d.xs } }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'bottom', + style: { [`& > .${tabClasses.icon}`]: { marginTop: d.xs } }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'start', + style: { [`& > .${tabClasses.icon}`]: { marginRight: d.sm } }, + }, + { + props: ({ + ownerState, + }: { + ownerState: { icon?: unknown; label?: unknown; iconPosition?: string | undefined }; + }) => ownerState.icon && ownerState.label && ownerState.iconPosition === 'end', + style: { [`& > .${tabClasses.icon}`]: { marginLeft: d.sm } }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiTabs', { + minHeight: '48px', // == MuiTab base minHeight (the pairing) + }); + addRootOverride(enhanced.components, 'MuiCheckbox', { + // Touch-target padding per size (9px both sizes today) = density steps. + variants: [ + { props: { size: 'medium' }, style: { padding: d.sm } }, + { props: { size: 'small' }, style: { padding: d.xs } }, + ], + }); + addRootOverride(enhanced.components, 'MuiRadio', { + // Touch-target padding per size (9px both sizes today) = density steps. + variants: [ + { props: { size: 'medium' }, style: { padding: d.sm } }, + { props: { size: 'small' }, style: { padding: d.xs } }, + ], + }); + // Separator inline margins (spacing step) on the separator slot. + addRootOverride(enhanced.components, 'MuiBreadcrumbs', { marginLeft: d.sm, marginRight: d.sm }, 'separator'); + addRootOverride(enhanced.components, 'MuiToggleButton', { + // Emit uniform padding directly on the size variants ToggleButton ships (no seam). + variants: [ + { props: { size: 'small' }, style: { padding: d.sm } }, + { props: { size: 'medium' }, style: { padding: d.md } }, + { props: { size: 'large' }, style: { padding: d.lg } }, + ], + }); + addRootOverride(enhanced.components, 'MuiAvatar', { + // Square size = raw px (sizing). + width: '40px', + height: '40px', + }); + addRootOverride( + enhanced.components, + 'MuiBadge', + { + // Bubble = raw px (sizing); standard inline pad = step. Dot resizes; dot pad + // + borderRadius stay frozen at master. + variants: [ + { + props: { variant: 'standard' }, + style: { minWidth: '20px', height: '20px', padding: `0 ${d.xs}` }, + }, + { props: { variant: 'dot' }, style: { minWidth: '6px', height: '6px' } }, + ], + }, + 'badge', + ); + addRootOverride(enhanced.components, 'MuiButtonGroup', { + // Grouped-button min-width floor = raw px (sizing). + [`& .${buttonGroupClasses.grouped}`]: { minWidth: '40px' }, + }); + addRootOverride(enhanced.components, 'MuiTableCell', { + // Block pad per size (steps); inline pad shared. Re-assert the frozen + // checkbox/none affordances the size padding would otherwise clobber. + variants: [ + { props: { size: 'medium' }, style: { padding: `${d.lg} ${d.lg}` } }, + { props: { size: 'small' }, style: { padding: `${d.xs} ${d.lg}` } }, + { props: { padding: 'checkbox' }, style: { padding: '0 0 0 4px' } }, + { props: { padding: 'none' }, style: { padding: 0 } }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiAutocomplete', + { + // Option list (mirrors MenuItem) renders in a Popper → emit on the listbox + // slot: minHeight raw px, block/inline pad steps. + [`& .${autocompleteClasses.option}`]: { + minHeight: '44px', + paddingTop: d.xs, + paddingBottom: d.xs, + paddingLeft: d.lg, + paddingRight: d.lg, + }, + }, + 'listbox', + ); + // Horizontal step gutter: paddingLeft (first) / paddingRight (last) = step. + addRootOverride(enhanced.components, 'MuiStep', { + variants: [ + { + props: { orientation: 'horizontal', alternativeLabel: false, hasConnector: false }, + style: { paddingLeft: d.sm }, + }, + { + props: { orientation: 'horizontal', alternativeLabel: false, last: true }, + style: { paddingRight: d.sm }, + }, + ], + }); + // Icon→label gap on the iconContainer slot (step); alternativeLabel/vertical + // paddingRight:0 stay frozen (higher-specificity class + own variant literals). + addRootOverride(enhanced.components, 'MuiStepLabel', { paddingRight: d.sm }, 'iconContainer'); + addRootOverride(enhanced.components, 'MuiToolbar', { + // Gutter inline pad (steps, incl the sm-breakpoint bump); dense bar min-height + // (raw px). Regular min-height (theme.mixins.toolbar) stays frozen. + variants: [ + { + props: { disableGutters: false }, + style: { + paddingLeft: d.lg, + paddingRight: d.lg, + [(theme as unknown as { breakpoints: { up: (key: string) => string } }).breakpoints.up('sm')]: { + paddingLeft: d.xl, + paddingRight: d.xl, + }, + }, + }, + { props: { variant: 'dense' }, style: { minHeight: '48px' } }, + ], + }); + addRootOverride(enhanced.components, 'MuiFab', { + // Circular size = raw px per size (button-like action). Scoped to circular so + // the extended variant (auto width + literal height) stays frozen at master. + variants: [ + { props: { variant: 'circular', size: 'small' }, style: { width: '40px', height: '40px' } }, + { props: { variant: 'circular', size: 'medium' }, style: { width: '48px', height: '48px' } }, + { props: { variant: 'circular', size: 'large' }, style: { width: '56px', height: '56px' } }, + ], + }); + addRootOverride(enhanced.components, 'MuiPaginationItem', { + // Item box size = raw px per size: min-width on every item, height only on the + // button items (ellipsis keeps master's auto height). + variants: [ + { props: { size: 'small' }, style: { minWidth: '26px' } }, + { props: { size: 'medium' }, style: { minWidth: '32px' } }, + { props: { size: 'large' }, style: { minWidth: '40px' } }, + { + props: ({ ownerState }: { ownerState: { type?: string | undefined; size?: string | undefined } }) => + ownerState.type !== 'start-ellipsis' && + ownerState.type !== 'end-ellipsis' && + ownerState.size === 'small', + style: { height: '26px' }, + }, + { + props: ({ ownerState }: { ownerState: { type?: string | undefined; size?: string | undefined } }) => + ownerState.type !== 'start-ellipsis' && + ownerState.type !== 'end-ellipsis' && + ownerState.size === 'medium', + style: { height: '32px' }, + }, + { + props: ({ ownerState }: { ownerState: { type?: string | undefined; size?: string | undefined } }) => + ownerState.type !== 'start-ellipsis' && + ownerState.type !== 'end-ellipsis' && + ownerState.size === 'large', + style: { height: '40px' }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiSnackbarContent', { + // No size axis: root padding (block/inline steps). + padding: `${d.xs} ${d.lg}`, + }); + addRootOverride(enhanced.components, 'MuiBottomNavigation', { + height: '56px', + }); + addRootOverride(enhanced.components, 'MuiBottomNavigationAction', { + // Inline padding only; block padding stays master's 0. + paddingLeft: d.md, + paddingRight: d.md, + }); + addRootOverride(enhanced.components, 'MuiDialogTitle', { + padding: `${d.lg} ${d.xl}`, + }); + addRootOverride(enhanced.components, 'MuiDialogContent', { + // Base block/inline padding; re-assert the frozen dividers literal the base + // padding would otherwise clobber. + padding: `${d.lg} ${d.xl}`, + variants: [{ props: { dividers: true }, style: { padding: '16px 24px' } }], + }); + addRootOverride(enhanced.components, 'MuiDialogActions', { + padding: d.sm, + }); + addRootOverride(enhanced.components, 'MuiListItemButton', { + // Density axis is the `dense` boolean; inline pad only when gutters are on. + variants: [ + { props: { dense: false }, style: { paddingTop: d.sm, paddingBottom: d.sm } }, + { props: { dense: true }, style: { paddingTop: d.xxs, paddingBottom: d.xxs } }, + { props: { disableGutters: false }, style: { paddingLeft: d.lg, paddingRight: d.lg } }, + ], + }); + addRootOverride(enhanced.components, 'MuiCardContent', { + // No size axis: base padding + larger last-child bottom padding. + padding: d.lg, + '&:last-child': { paddingBottom: d.xl }, + }); + addRootOverride(enhanced.components, 'MuiCardActions', { + // No size axis: root padding + inter-child gap (spacing variant) = steps. + padding: d.sm, + variants: [ + { + props: { disableSpacing: false }, + style: { '& > :not(style) ~ :not(style)': { marginLeft: d.sm } }, + }, + ], + }); + addRootOverride(enhanced.components, 'MuiCardHeader', { + // Root padding = step (no size axis). + padding: d.lg, + }); + // Avatar→content gap on the avatar slot. + addRootOverride(enhanced.components, 'MuiCardHeader', { marginRight: d.lg }, 'avatar'); + // Action negative pulls counteract the control's own box; scale with density. + addRootOverride( + enhanced.components, + 'MuiCardHeader', + { + marginTop: `calc(${d.xxs} * -1)`, + marginRight: `calc(${d.sm} * -1)`, + marginBottom: `calc(${d.xxs} * -1)`, + }, + 'action', + ); + addRootOverride( + enhanced.components, + 'MuiSelect', + { + // Content-box floor (raw px); real padding comes from the input variant. + minHeight: '23px', + }, + 'select', + ); + addRootOverride(enhanced.components, 'MuiAlert', { + // No size axis: root padding (block/inline steps). + padding: `${d.xs} ${d.lg}`, + }); + // Icon→message gap on the icon slot (child element). + addRootOverride(enhanced.components, 'MuiAlert', { marginRight: d.md }, 'icon'); + // Height (raw px) drives avatar/icon/deleteIcon via calc off `--Chip-height`. + addRootOverride(enhanced.components, 'MuiChip', { + variants: [ + { props: { size: 'medium' }, style: { '--Chip-height': '32px' } }, + { props: { size: 'small' }, style: { '--Chip-height': '24px' } }, + ], + }); + // Label inline padding = density steps, unified per size on the label slot. + addRootOverride( + enhanced.components, + 'MuiChip', + { + variants: [ + { props: { size: 'medium' }, style: { paddingInline: d.md } }, + { props: { size: 'small' }, style: { paddingInline: d.sm } }, + ], + }, + 'label', + ); + addRootOverride(enhanced.components, 'MuiAccordionSummary', { + // Collapsed min-height raw px; inline padding = step. + minHeight: '48px', + padding: `0 ${d.lg}`, + variants: [ + { + props: ({ ownerState }: { ownerState: { disableGutters?: boolean | undefined } }) => + !ownerState.disableGutters, + // Re-assert expanded min-height (master literal wins by specificity else). + style: { [`&.${accordionSummaryClasses.expanded}`]: { minHeight: '64px' } }, + }, + ], + }); + addRootOverride( + enhanced.components, + 'MuiAccordionSummary', + { + // Content block margin reduces with min-height (else it binds header height). + margin: `${d.md} 0`, + variants: [ + { + props: ({ ownerState }: { ownerState: { disableGutters?: boolean | undefined } }) => + !ownerState.disableGutters, + style: { [`&.${accordionSummaryClasses.expanded}`]: { margin: `${d.lg} 0` } }, + }, + ], + }, + 'content', + ); + addRootOverride(enhanced.components, 'MuiAccordionDetails', { + padding: `${d.sm} ${d.lg} ${d.lg}`, + }); + return enhanced; +} diff --git a/packages/mui-material/src/styles/index.d.ts b/packages/mui-material/src/styles/index.d.ts index d4082be45c8e66..f24e48e5974caf 100644 --- a/packages/mui-material/src/styles/index.d.ts +++ b/packages/mui-material/src/styles/index.d.ts @@ -9,6 +9,10 @@ export { CssThemeVariables, } from './createTheme'; export { default as enhanceHighContrast, HighContrastTokens } from './enhanceHighContrast'; +export { default as enhanceCompactDensity } from './enhanceCompactDensity'; +export { default as enhanceNormalDensity } from './enhanceNormalDensity'; +export { default as enhanceComfortDensity } from './enhanceComfortDensity'; +export { DensityScale } from './densityScale'; export { default as adaptV4Theme, DeprecatedThemeOptions } from './adaptV4Theme'; export { Shadows } from './shadows'; export { ZIndex } from './zIndex'; diff --git a/packages/mui-material/src/styles/index.js b/packages/mui-material/src/styles/index.js index b9dfa4913c7d61..bb339991e439f2 100644 --- a/packages/mui-material/src/styles/index.js +++ b/packages/mui-material/src/styles/index.js @@ -26,6 +26,9 @@ export function experimental_sx() { } export { default as createTheme } from './createTheme'; export { default as enhanceHighContrast } from './enhanceHighContrast'; +export { default as enhanceCompactDensity } from './enhanceCompactDensity'; +export { default as enhanceNormalDensity } from './enhanceNormalDensity'; +export { default as enhanceComfortDensity } from './enhanceComfortDensity'; export { default as unstable_createMuiStrictModeTheme } from './createMuiStrictModeTheme'; export { default as createStyles } from './createStyles'; export { getUnit as unstable_getUnit, toUnitless as unstable_toUnitless } from './cssUtils'; diff --git a/scripts/density-screenshots/README.md b/scripts/density-screenshots/README.md new file mode 100644 index 00000000000000..8a97040139991b --- /dev/null +++ b/scripts/density-screenshots/README.md @@ -0,0 +1,49 @@ +# Density-adapter screenshot harness + +Local visual verification for the CSS-var density adapter — no Argos. +Decision/spec: [`docs/adr/0001-css-var-density-adapter.md`](../../docs/adr/0001-css-var-density-adapter.md). + +Asserts the default render is **pixel-identical** before/after the change +(Playwright `toHaveScreenshot`, `maxDiffPixels: 0`) and captures density +screenshots (token `dense`/`loose` levels) for human review. + +Unlike the `--mui-spacing` sibling experiment, density here is driven by +per-component tokens (`--Button--pad`, `--OutlinedInput--padBlock`), +so the review `level` maps to those tokens — see `scopes` in the fixture. + +## Prerequisites + +- `pnpm docs:dev` running (serves the fixture at + `/experiments/density-fixture`). Override the URL with `DENSITY_BASE_URL`. +- Chromium for Playwright: `pnpm exec playwright install chromium` (once). + +## Steps (per component) + +1. Add the component's matrix to the `demos` map (and its token overrides to + `scopes`) in `docs/pages/experiments/density-fixture.tsx`. +2. **Baseline (before)** — on the _unconverted_ component (from `master`): + + ```bash + git stash # or check out the component file(s) from master + COMPONENT= pnpm density:shot:update + git stash pop + ``` + + Writes the baseline to `scripts/density-screenshots/__baselines__/`. + +3. Implement / keep the density-adapter in the component. +4. **Assert + density (after)**: + + ```bash + COMPONENT= pnpm density:shot + ``` + + - Fails if the default render differs from the baseline (⇒ not + pixel-identical); a diff image is written under `.playwright-output/`. + - Writes `density-screenshots//after-{default,dense,loose}.png`. + +5. Eyeball `after-dense.png` / `after-loose.png` for density reflow (and, for + TextField, that the floating label stays centered). + +Outputs (`density-screenshots/`, `__baselines__/`, `.playwright-output/`) are +gitignored. diff --git a/scripts/density-screenshots/density.spec.mjs b/scripts/density-screenshots/density.spec.mjs new file mode 100644 index 00000000000000..9025e626fc1b2b --- /dev/null +++ b/scripts/density-screenshots/density.spec.mjs @@ -0,0 +1,35 @@ +import path from 'node:path'; +import { test, expect } from '@playwright/test'; + +// Component under verification, e.g. `COMPONENT=OutlinedInput pnpm density:shot`. +const component = process.env.COMPONENT || 'Button'; +const outDir = path.resolve(process.cwd(), 'density-screenshots', component); +const scopeSelector = '#density-scope'; + +async function scopeAt(page, level) { + await page.goto(`/experiments/density-fixture?c=${component}&level=${level}`); + const scope = page.locator(scopeSelector); + await scope.waitFor(); + await page.evaluate(() => document.fonts.ready); + return scope; +} + +test.describe.configure({ mode: 'serial' }); + +// Regression gate: the default render (no density tokens) must match the +// "before" baseline exactly. Capture the baseline on the UNCONVERTED component +// (from master) with --update-snapshots. +test(`${component} — default is pixel-identical to baseline`, async ({ page }) => { + const scope = await scopeAt(page, 'default'); + await scope.screenshot({ path: path.join(outDir, 'after-default.png') }); + await expect(scope).toHaveScreenshot(`${component}-default.png`); +}); + +// Density review (human only — new behavior, not assertable). Each level sets +// the component's density tokens (see density-fixture.tsx `scopes`). +for (const level of ['dense', 'loose']) { + test(`${component} — density ${level} (review)`, async ({ page }) => { + const scope = await scopeAt(page, level); + await scope.screenshot({ path: path.join(outDir, `after-${level}.png`) }); + }); +} diff --git a/scripts/density-screenshots/playwright.config.mjs b/scripts/density-screenshots/playwright.config.mjs new file mode 100644 index 00000000000000..0d29202f75f1ea --- /dev/null +++ b/scripts/density-screenshots/playwright.config.mjs @@ -0,0 +1,19 @@ +import { defineConfig } from '@playwright/test'; + +// Local verification harness for the CSS-var density adapter. +// See docs/adr/0001-css-var-density-adapter.md and ./README.md. +export default defineConfig({ + testDir: '.', + testMatch: /density\.spec\.mjs/, + // Keep Playwright's artifacts out of the repo's tracked test-results/. + outputDir: './.playwright-output', + // "before" baselines (gitignored) — co-located with the harness. + snapshotPathTemplate: '{testDir}/__baselines__/{arg}{ext}', + reporter: 'list', + use: { + baseURL: process.env.DENSITY_BASE_URL || 'http://localhost:3000', + viewport: { width: 760, height: 720 }, + }, + // Strict: the default render must be pixel-identical to the baseline. + expect: { toHaveScreenshot: { maxDiffPixels: 0 } }, +});