[theme] Add enhanceDensity - #48749
Conversation
59837ad to
5389da7
Compare
Deploy previewBundle size
Check out the code infra dashboard for more information about this PR. |
4549eae to
6ce32e1
Compare
97b009b to
c9bc7e8
Compare
c9bc7e8 to
c371ca2
Compare
| const spacing = (...args: ReadonlyArray<number | string>): string => { | ||
| if (!args.some(isKeyArg)) { | ||
| return String(prevSpacing(...args)); | ||
| } | ||
| return args | ||
| .map((arg) => { | ||
| if (typeof arg === 'string') { | ||
| if (isDensityKey(arg)) { | ||
| return resolveKey(arg, false); | ||
| } | ||
| if (arg.startsWith('-') && isDensityKey(arg.slice(1))) { | ||
| return resolveKey(arg.slice(1) as DensityKey, true); | ||
| } | ||
| } | ||
| return String(prevSpacing(arg)); | ||
| }) | ||
| .join(' '); | ||
| }; |
There was a problem hiding this comment.
is there a way to simplify this, reduce iteration and conditions? make it more performant?
Rewrites customization/density around the opt-in enhancer: usage with a drag-to-compare demo, the named spacing scale and the augmented theme.spacing(), scale overrides and recipes, proportional scaling, and the pre-existing component-props approach kept as a section. Flags the page as a new feature in the sidebar.
…, spacing via lookup DensityMultipliers unexported; applyDensity takes Record<DensityKey, number>. DensityScaleOverrides is px numbers — same type as defaultDensityScale, so X merges by spread and gridHeights reads steps directly (regex parse + silent canonical fallback gone); overrides negate to -6px, symmetric with the multiplier path. Every key + negated pull resolved once per theme, so the spacing wrapper is a hash lookup — isDensityKey/isKeyArg and the per-arg DENSITY_KEYS scan dropped.
Removes the --mui-scaling dial: the private applyScaling pass (dial var, spacing-unit and radius re-emit, typography calc wrapping), its tests, and the docs section. The step block still resolves against the theme's own --mui-spacing, so a custom spacing unit is unaffected. Density is now the only lever in this PR.
sx spacing values short-circuited on strings, so a step name reached the
stylesheet verbatim and did nothing. getValue now resolves names the
transformer advertises via `keys` and passes every other string through as
raw CSS, which covers p/m (with their side and axis forms) and gap, plus
responsive values. On a CSS variables theme the transformer is built from
theme.vars.spacing, so createUnaryUnit routes names back through the
scale-aware spacing function while numbers keep their existing output.
Types: the spacing props now use SpacingPropValue (SpacingKey | number |
(string & {})). A bare `string` member absorbed the key literals during
union reduction, which is why they never autocompleted.
Reverts the sx spacing prop types to their csstype-derived form. Resolving the step names is worth having; enumerating 43 properties in @mui/system to autocomplete them is not — the list would have to grow per property family, and sizing (width/height, where touch-target is most useful) runs through a different transform with conflicting number semantics, so it could not be covered the same way. theme.spacing() stays the documented path for every property the spacing props do not cover.
The interactive box sizes controls rather than spacing them, so it never
belonged on the spacing ladder: it emitted a --mui-spacing-touch-target
variable and resolved through theme.spacing() and the sx spacing props,
where it is meaningless, while sx sizing props (height, the one place it is
useful) could never reach it.
It now ships as a plain px length passed into the shared emissions. The
override signature is unchanged — enhanceDensity(theme, { 'touch-target': 40 })
still moves every control — and private_defaultDensityScale keeps the value
so sibling packages can derive from it. Consequences, both intended: it no
longer tracks a custom spacing unit, and it can no longer be overridden from
CSS at runtime.
…heme-components helpers to utils The key resolution lives in @mui/system but was only covered through the density enhancer in @mui/material. Adds tests at the level the behavior belongs to — spacing (padding/margin), cssGrid (gap) and styleFunctionSx — driven by a spacing function that advertises names via `keys` rather than by the density scale, so they test the contract and not the feature. Covered: resolution on every spacing prop, responsive values, the theme.vars path where the transformer is a string, raw CSS / typo / multiplier passthrough, and a transformer that registers nothing. Reverting getValue fails 12 of them. addRootOverride and addDefaultProps move to src/utils as internal helpers, kept off the utils barrel like areArraysEqual and contains.
The curtain compared two showcases side by side; the seam cut through components mid-word because each side rendered at a different width, and nothing named the value behind any dimension. One button, one switch instead. Every number is read off the rendered element, so the annotations track what the theme emits.
Outlined button so the border and padding read as separate bands. The padding ring and the icon-to-label gap now carry the colors devtools uses, and each label points at the center of what it measures with one dashed leader instead of a pair of bracketing guides.
Dashes on the padding band and the icon so each measured region reads the same way, and the resolved value moves into parentheses after the name it resolves from.
Scale sizing key, identifiers, docs prose, annotation tokens, and the verify gate all follow. Also updates the secondary-action gutter assertion to the tuned 12px value from 89debb4.
| }, | ||
| { | ||
| props: { variant: 'filled' }, | ||
| style: { transform: 'translate(var(--_inlinePad), var(--_restY)) scale(1)' }, |
There was a problem hiding this comment.
The translate(var(--_inlinePad), var(--_restY)) transforms here and at 561/565, and the Switch translateX(calc(var(--_width) ...)) at 711/731, are never mirrored by cssjanus. Its translate handling only matches numeric literals (signedQuantPattern in cssjanus 2.3.1), so with direction: 'rtl' and the documented stylis-plugin-rtl cache, master's literal translate(14px, ...) / translateX(20px) gets flipped but these rules do not, and they win at equal specificity.
In practice: an outlined or filled label at rest sits 14px past the input's right edge, outside the box, and then jumps inside on focus because the literal shrink rule at 569 is flipped. A checked Switch thumb translates rightwards, out of the track. LTR and non-enhanced themes are unaffected.
Options I can see: emit the inline offset through a [dir="rtl"] & (or theme.direction === 'rtl') variant that negates the var, or write --_inlinePad on the FormControl with a sign that flips under RTL. The Switch checked transform needs the same treatment. An RTL render test would catch this class of issue.
| { | ||
| props: ({ ownerState }: { ownerState: { formControl?: object | undefined } }) => | ||
| !!ownerState.formControl, | ||
| style: { transform: 'translate(0, var(--_restY)) scale(1)' }, |
There was a problem hiding this comment.
The comment above says a missing writer "must break visibly", but the only writers for --_restY / --_inlinePad are the .MuiInputLabel-root:has(~ &) rules on OutlinedInput (326), FilledInput (426) and Input (484). Any other input root inside a FormControl is a legitimate case: a bare <InputBase />, a styled(InputBase) custom input like the CustomizedSelects docs demo, or MUI X's PickersTextField.
With those, the formControl variant here produces transform: translate(0, var(--_restY)) scale(1) with the var unset. That is invalid at computed-value time, so the label falls back to transform: none and lands in the top-left corner of the control instead of sitting inside it.
I would give the label a fallback that matches master (var(--_restY, 20px) for the standard rest position, var(--_inlinePad, 14px) for outlined/filled), or scope these variants to the three roots that actually have a writer.
| // (empty values) — so the ladder falls back to its canonical 8px basis. | ||
| const stepValue: (multiplier: number) => string = Array.isArray((prevSpacing as any).unit) | ||
| ? (multiplier) => `${multiplier * 8}px` | ||
| : (multiplier) => String(prevSpacing(multiplier)); |
There was a problem hiding this comment.
A second application is easy to hit: a nested <ThemeProvider theme={(outer) => enhanceDensity(outer, {...})}>, or an MUI X enhancer that calls enhanceDensity internally on top of the app's own call. With outer = enhanceDensity(createTheme(), { small: 6 }) and an inner enhanceDensity(outer, { 'touch-target': 40 }):
stepValueresolves through the first wrapper's numeric path, sotheme.spacing('small')reverts to12pxand the 6px override is silently lost.generateStyleSheets()(167) returns two:rootstep blocks.addRootOverride(addRootOverride.ts:30-34) sees theDENSITY_LAYERSmarker and splices a full second copy of every emission before the user tail.addDefaultProps(addDefaultProps.ts:30) keeps the first pass'sMuiCircularProgresssize32px, because "user defaults win" now applies to the first pass.
A marker on the theme (or on spacing) that lets a second call reuse the stored scale, merge the new overrides and skip re-emission would cover all four. A test for the twice-applied case would be good as well.
| 'MuiInputBase', | ||
| { | ||
| height: 'auto', | ||
| paddingBlock: spacing('x-small'), |
There was a problem hiding this comment.
The standard Input, bare InputBase and FilledInput land on three different heights. With the defaults: <TextField variant="standard" /> renders 8 + 24 + 6 = 38px, <InputBase /> 8 + 24 + 8 = 40px, and <TextField variant="filled" /> stays at master's 56px, while OutlinedInput lands on 32px. The PR table and the demo caption ("same box") say inputs share the target, so a form that mixes variants gets three control heights.
Nothing catches it: scripts/verifyDensityAnnotations.mjs:167 skips height rows that have no token, and enhanceDensity.test.ts never measures an input height. If filled is meant to keep 56px on purpose, the docs should say so, and the standard/bare paths should still derive from touchTarget the way the outlined one does.
| props: { size: 'small' }, | ||
| style: { | ||
| [`.${formControlClasses.root}:has(> &)`]: { | ||
| '--_outlinedInputPadBlock': spacing('xx-small'), |
There was a problem hiding this comment.
size="small" pins --_outlinedInputPadBlock to the fixed xx-small step, while medium derives (touch-target - 1lh) / 2. At the default scale both come out at 4 + 24 + 4 = 32px (master: 40 vs 56). Under the docs' "high" recipe (touch-target: 24, xx-small: 2) medium becomes 24px while small stays 28px, so the order inverts.
Every other sized control derives small from smallBox = touch-target - x-small. The outlined input (and the Autocomplete small variant that reuses this) should probably do the same: (smallBox - 1lh) / 2.
| @@ -0,0 +1,1456 @@ | |||
| import type { Breakpoint } from '..'; | |||
There was a problem hiding this comment.
This is the only '..' import under packages/*/src outside tests. Why not use import type { Breakpoint } from '@mui/system' (have seen it somewhere else) instead?
| * exercise it (a `gap` needs children to put a gap between). | ||
| * | ||
| * Run against the demo-shot Vite host: | ||
| * npx vite .demo-harness --port 5099 & |
There was a problem hiding this comment.
First time I read about .demo-harness. Is this referenced elsewhere or a dead end?
There was a problem hiding this comment.
this file should not be in git, I will remove it.
| expect(theme.components.MuiButton?.styleOverrides?.root).to.not.equal(undefined); | ||
| }); | ||
|
|
||
| test('a user styleOverride on the incoming theme stays the winning layer', () => { |
There was a problem hiding this comment.
This contract test only checks array order and never renders, so it cannot see the specificity problem in the :has(). Other gaps worth covering given the findings above: applying the enhancer twice, function- and array-valued user styleOverrides, a user slotProps default on the same slot as a density default, cssVarPrefix: '', negative keys through sx (mt: '-x-small'), and a lookup-table spacing function through the "no broken values" walker at 409.
| // Fractional multipliers land on holes with array spacing | ||
| // (`createTheme({ spacing: [0, 4, 8] })` has no index 0.25) — route them | ||
| // through the canonical 8px basis there instead of emitting ''. | ||
| const fraction: (multiplier: number) => string = Array.isArray((spacing as any).unit) |
There was a problem hiding this comment.
Same array check and same 8px basis as densityScale.ts:75. If applyDensity exposed its resolver (or attached it to the theme), this file would not need its own copy, and the lookup-table guard only has to land once.
- simpler intro, drop per-component px list (implementation detail) - fix CSS-vars `theme.spacing()` return: carries the px fallback - "All components" heading, "applied to" wording, icon-size rename artifact - closed-scale callout info -> warning Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
The default fallback is the computed `calc()`, not a literal px — a plain length only appears for a step given an explicit value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
xx-small -> xxSmall, x-small -> xSmall, x-large -> xLarge, xx-large -> xxLarge, touch-target -> touchTarget, icon-size -> iconSize. CSS vars follow (--mui-spacing-xxSmall), as do negated pulls and sx values. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
The ladder is fractions of the spacing unit; an array defines none, so the old guards silently substituted an 8px basis and ignored the user's array. Warn in dev and return the theme unenhanced, which lets `fraction()` go — all 17 sites are plain `spacing()` now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
`applyDensity` infers its return; the array-spacing bail returns the input as-is. `enhanceDensity` no longer promises a non-optional `components` — it emits for some components, not all, so `theme.components?.*` is right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
A tiny-size component; it doesn't need density adjustments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
`--_arrowSize` replaces the hardcoded 1em/0.71em arrow geometry and `--_spacing` the 14px/24px placement margins, so a theme retargets one variable instead of re-declaring every placement selector. Shrinks the shared density layer by 41 lines (2 added, 43 removed): the four placement margin rules and the popper arrow re-asserts collapse to a single `--_spacing` and the arrow slot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
`--_dialogMargin` replaces the hardcoded 32px margin and the five `calc(100% - 64px)` bounds, so a theme retargets one variable instead of restating every maxWidth/scroll/fullWidth rule. Shrinks the shared density layer by 41 lines (1 added, 42 removed): the whole `fullScreen: false`-scoped variants block — including the generated per-breakpoint `scroll: 'body'` entries — becomes a single var assignment. Master's own fullScreen rules now win on their own, so the scoping guard is no longer needed. The media-query boundaries stay at master's literal 32*2, since media queries cannot read custom properties. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
The steps were multiples of the theme's spacing unit while a `scale`
override was a plain number, i.e. px. On a rem theme the two landed in
different families: `{ small: 6 }` emitted `6px` next to `medium`'s
`calc(2 * 0.5rem)`, and the overridden step silently stopped scaling with
its neighbours. An array unit was worse — the fractional steps have no
index to land on, so `xxSmall` and `small` resolved to empty strings and
43 emitted values carried the hole. It was refused outright for that.
The ladder is now absolute px and overrides take the same path, so every
unit shape works: number, `<number>px`, rem/em/%, function, array. On a
vars theme a px-expressible unit still emits `calc(<n> * var(--mui-spacing,
<unit>))` — the same length, but reachable from plain CSS.
`STEP_MULTIPLIERS` and `defaultDensityScale` were the same ladder written
twice (multipliers x 8); `DEFAULT_STEP_PX` is now the single source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
The scale table showed px that were only true at the default unit; it now reads as absolute values, with a section on how `spacing` relates to the steps. The annotation tokens follow the emissions off the spacing unit (`0.75 x spacing` -> `6px`), and a caption whose token already IS the measurement no longer prints it twice (`6px (6px)` -> `6px`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
98245a9 to
fbf1e49
Compare
The camelCase scale rename swept the demos too, but `aspect` is the annotation vocabulary (`padding`/`margin`/`gap`/`icon`/`touch-target`), not a scale key. `'touchTarget'` never matched `Aspect`; it drew correctly only because touch-target is the fall-through branch, and `docs/` was not in the typecheck that ran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
Half its fields only applied to one aspect — `after` to gap, `side` to a
band, `outlined`/`pointer`/`wrap` to touch-target — enforced by comment,
so `{ aspect: 'gap', outlined: true }` compiled and silently did nothing.
`resolveClaims` already branches on aspect; the type now says so, and each
branch carries only the knobs it reads. `Aspect` derives from the union so
the vocabulary is declared once.
The touch-target block was an implicit fall-through that would not narrow;
it now guards explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
`densityAnnotations` and `densityAnnotationSpecs` are imported by the demos, never displayed as one, so their JS twins were ~1,400 lines that only existed to drift. The demos resolve the TypeScript directly; the existing `ignoreList` already had the mechanism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
This reverts commit 05e89b1.
`touchTarget` and `iconSize` emitted as literal px, so a vars theme could retune every step from plain CSS but not the boxes those steps padded — the one dimension a dense layout most wants to move. They now ship as `--<prefix>-touchTarget` and `--<prefix>-iconSize`, under their own names rather than the spacing namespace: they size a box rather than space one, and `theme.spacing()` still does not resolve them. Components emit the reference with the px as its fallback, so a static theme is unchanged and a `scale` override moves variable and fallback together. The sizing table moves next to the step table in `densityScale`, which now owns the whole `:root` emission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
| })( | ||
| memoTheme(({ theme }) => ({ | ||
| margin: 32, | ||
| '--_dialogMargin': '32px', |
There was a problem hiding this comment.
using to private variables reduce the styleOverrides ~40 LOCs.
| { | ||
| props: ({ ownerState }) => ownerState.arrow, | ||
| style: { | ||
| '--_arrowSize': '1em', |
There was a problem hiding this comment.
using private variables reduces the styleOverrides ~100LOCs
Local tooling, like the playground it checks — nothing references it and it is not wired into CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
The density page is the guide; a second API blurb only gives the scale names somewhere else to go stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKc2g9aWdMRtNQSJSSFJHU
closes #48746
Preview: https://deploy-preview-48749--material-ui.netlify.app/material-ui/customization/density/
Summary
Adds an opt-in density system to
@mui/material.enhanceDensity(theme, scale?)— coherent control sizing. Out of the box, "medium" is not one size: at default props, controls span 32→56px (Button 36.5, Switch 38, IconButton 40, Checkbox/Radio 42, ToggleButton/Tab 48, outlined TextField 56 — measured, see table below). The enhancer maps every component onto a single named spacing scale so that same-size controls share the same box:The scale ships as
--mui-spacing-*CSS variables plus a keyedtheme.spacing():One value sits outside the ladder:
touchTarget, the box that medium-size controls converge on. It sizes controls rather than spacing them, so it emits as a plain length — no CSS variable, not a spacing key — while still moving with the same override object.The same names work in the
sxspacing props, so application layout can sit on the scale the components use:Every other CSS property takes the steps through
theme.spacing(), which works anywhere and autocompletes.There are no built-in density modes. A denser or roomier app overrides the ladder with a plain object (documented as copy-paste recipes), and every enhanced component follows:
Not applying the enhancer changes nothing — components keep today's literal defaults, verified by a pixel-parity test suite and visual regression run.
Measured default heights (medium size) before/after:
The interactive playground used to develop the mapping (per-component inspection, per-seam knobs, visual debug overlays, pixel-parity harness against
master) is kept out of this PR as local tooling to keep the diff reviewable; a public sandbox showcase is on the TODO list.For Reviewers
49 files: 24 under
packages/— most of it the per-component emission map and its tests — 24 indocs/for the documentation page and its demos, and one annotation-verifier script.Design decisions
theme.spacingcarries the scale — no new theme node. The enhancer wraps the existing spacing function, so step names resolve alongside the numbers and raw CSS it already accepts. Nothing new to learn, and un-keyed calls behave exactly as before.spacingunit. Ascaleoverride is a plain number, which can only mean px — and MUI X reads those same numbers to derive sizes in JS — so the steps it replaces have to be px too. Deriving them from the unit instead split the two apart: onspacing: '0.5rem',{ small: 6 }emitted6pxbesidemedium'scalc(2 * 0.5rem), and the overridden step quietly stopped scaling with its neighbours. Every unit shape now works, arrays included — previously the fractional steps had no index to land on, soxxSmallandsmallresolved to empty strings. On a CSS-variables theme a px-expressible unit still emitscalc(<n> * var(--mui-spacing, <unit>)): the same length, reachable from plain CSS.sxtakes the step names too. Spacing props used to pass any string straight through, so name resolution had to move intogetValue. A theme that never went through the enhancer registers no names and stays byte-identical to today. The prop types are deliberately unchanged — covering them would mean enumerating names per property family.@mui/systemadditions (createSpacing.ts):SpacingKeyOverrides, the augmentation hook that makes step names autocomplete (same convention asBreakpointOverrides), andspacing.unit, the raw option stored on the function so an enhancer can read it. This is the only public surface added outside@mui/material.sharedDensityComponents.ts), emitted as themestyleOverrides; the few values CSS can't reach go throughdefaultProps, with the app's own defaults winning. A user's existingstyleOverridesare composed with the emissions, never replaced.defaultDensityScaleis barrel-exported asprivate_defaultDensityScaleso a sibling MUI X enhancer can merge those recipes over the canonical numbers.Cost when unused: nothing changes unless
enhanceDensity(theme)is called — no new runtime on the default path, and the enhancer tree-shakes when unimported. Un-enhanced parity is verified by a local pixel-parity harness againstmaster; porting the density contract (per-size anchor boxes + unchanged-by-default) into the package test suite is on the TODO list.Suggested review order:
enhanceDensity.ts→densityScale.ts→createSpacing.ts(system) → skimsharedDensityComponents.tsfor the emission pattern → tests.TODO
enhanceDensity(render the existing docs templates as the test subject)customization/densityaround the new API; move thesizedefault-props approach to a subtopicpackages/mui-material