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/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000000000..b6b24ee9059a50 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,331 @@ +# Token adapter (CSS-var) + +How Material UI component styles are exposed as hand-authorable CSS variables so +a designer can tune them — per component, per size/variant/state, or +holistically — without touching component source or doing arithmetic. Two axes +share the same three-layer model: + +- **Dimension** axis (padding / gap / height) — the **density** adapter. +- **Color** axis (background / foreground / border, per variant, color, and + interaction state) — the **color/state** adapter. + +The original density framing follows. + +## Density (CSS-var adapter) + +How Material UI component dimensions (padding / gap / height) are exposed as +hand-authorable CSS variables so a designer can tune component density — per +component, per size, or holistically — without touching component source, doing +`calc` arithmetic, or riding the single `--mui-spacing` dial. + +This is the **adapter** sibling of the earlier `--mui-spacing`-derived +experiment (`feat/components-theme-spacing`): instead of one global dial, each +dimension is an overridable token whose default is a literal px. + +## Layers + +The component is read as **three layers of responsibility**, each owning a slice +of one cascade: + +- **Agnostic** — the styled root, no design meaning (no size/variant/color). Its + spacing surface is one public var it consumes directly, falling back to the + internal default (`padding: var(--Button-pad, var(--_pad))`). +- **Material UI** — Material Design's sizes/variants, all in `variants`: the + `(variant, size)` literal defaults (`--_pad`) and the sized-token routing + (`--Button-pad`) for built-in sizes. A custom size routes inline instead (it + needs the runtime size string). +- **Design system** — tunes the public **sized tokens** the Material UI layer + routes over its default (wired via `enhanceDensity`). + +## Language + +**Agnostic var** (public, the layer-1 surface): +The single per-property variable the styled root consumes, shape +`--Component-` — PascalCase component, short semantic key (`pad`, `gap`), +unprefixed (for example `--Button-pad`). Matches the existing component-var +convention (`--AppBar-background`). The root reads it with the internal default +behind it (`var(--Button-pad, var(--_pad))`); the Material UI layer **sets it in +a per-size `variants` block** to the sized-token routing (inline for custom +sizes). A designer tunes it through the sized token, not by setting it directly. +_Avoid_: literal CSS-property keys (`--Button-padding`), kebab keys, `--mui-`-prefixed component vars, "variable". + +**Sized token** (public, the design-system knob): +A size-scoped override, shape `--Component--` +(for example `--Button-small-pad`). Reflows only that one size. The Material UI +layer routes it over the internal default; when set at any scope it wins. +For a **size-varying** axis, resolution is **sized-only** — no all-sizes base token. +_Avoid_: "size variant token". + +**Base token** (public, only when per-size override is meaningless): +An axis can skip the **size layer** — internal default `--_` + seam, +consumed `var(--Component-, var(--_))`, nothing routes it, so a designer +sets the seam directly. Use this **only when tuning the axis per size makes no +sense**, because a base token can't be size-scoped from the theme. A +size-invariant _default_ is **not** enough: OutlinedInput's inline gutter is +`14px` for both sizes, yet it's a **sized token** (`--OutlinedInput--padInline`, +default `14px` each) so a design system can make small inputs denser inline. +Reach for a base token rarely; default to a **sized token**. +_Avoid_: a base token just because the default is size-invariant; a bare literal +default (use `--_`). + +**State token** (public, for a boolean compactness toggle like `dense`): +When the compactness axis is a **boolean** prop (`dense`) rather than a `size` +enum, the **default (off) state** is exposed through the plain seam +`--Component-` — consumed `var(--Component-, var(--_))`, **nothing +routes it in the base** (the seam _is_ the default knob, base-token-shaped) — and +**only the on state** gets a qualified token `--Component-dense-`, routed in +the `dense` variant over its own `--_` literal. A boolean has no name for +"off", so there is **no** `--Component-normal/regular/default-`: the absence +of the toggle is the plain seam, not an arbitrarily-named size. Contrast a +**sized token**, where every value (including `medium`) is qualified because each +is a real named size. Used by MenuItem, ListItem, ListItemButton, ListItemText. +The same shape **generalizes to pseudo-class interaction states** on the color +axis: **rest** is the unqualified base (the plain color token), and only the +non-rest states (`hover`, `active`, `focus`, `disabled`, `selected`) get a +qualified `` segment, routed in their pseudo-class block. The trigger +differs (a boolean prop vs a `:hover`/`.Mui-*` selector) but the rule is identical: +the absent/default state is never named. +_Avoid_: naming the off/rest state (`-normal-`, `-regular-`, `-default-`, `-rest-`); +routing the seam in the base; treating `dense` as a 2-value size enum. + +**Internal default**: +A private variable, shape `--_` (leading underscore, **no component +prefix**), **set in `variants`** per `(variant, size)` cell (medium defaults +reuse the `{ variant }` blocks), over a **universal default on the root** so a +custom variant/size still renders. It holds the Material default — today's exact +px for that cell. No prefix is needed because every cell that reads it also +sets it on the same element (Button; OutlinedInput's input and root each declare +their own) — so an ancestor's value never wins over a component's own. Lowest priority, so any sized token or +plain `styleOverrides` property still wins. +_Avoid_: exposing it as API, prefixing with the component name, "private token". + +**Token fallback**: +The default a token carries when unset — today's exact value for that cell. +**Dimension axis:** a literal px (the internal default `--_`). **Color axis:** +the **palette CSS var** the cell already references (`--mui-palette-primary-main`), +held by the color-axis internal default `--_`. Either way the +un-configured component renders pixel-/color-identical (Argos zero-diff). +_Avoid_: "default value", "initial". + +--- + +The terms below cover the **color axis** specifically. It is **three-layer, like +the dimension axis** (seam / private default / public knob) — not two. The private +default earns its keep here because every component exposes the **full standard +state set even where it is inert** (see _State standard_): an un-styled state must +fall back to the value that state shows *today*, which the rest seam alone can't +express without clobbering a styled state (e.g. hover-while-active). The private +default, **advanced by each genuinely-styled state**, captures that current value. + +**Variant seam** (the color-axis analog of the agnostic var): +The single per-property variable the styled root consumes for color, shape +`--variant-` (`--variant-containedBg`, `--variant-outlinedColor`, +`--variant-outlinedBorder`, `--variant-textBg`). **Pre-existing** (Button, since +the CSS-extraction conversion #41378) — we adopt it as the seam, we don't replace +it. Unlike the dimension seam it is **variant-qualified**, not agnostic: "background" +is solid for `contained`, transparent for `text`, so the variant lives in the seam +name. For a **value-state** it reads `var(--_)`; for an **inert +state** it routes `var(, var(--_))` (see _Value-state +vs inert-state_). +_Avoid_: treating it as public API directly; renaming/prefixing it; assuming one +agnostic color seam across variants. + +**Color internal default** (private, the color-axis analog of `--_`): +Shape `--_` (`--_containedBg`, leading underscore, no component +prefix). Holds the **current resolved value** of its prop — the value the element +shows right now, **including any rest/hover/disabled token override**, not the bare +palette literal. Each **value-state** sets it to `var(, )` in its block (`--_containedBg: var(--Button-contained-error-hover-bg, +)` in `&:hover`); the seam then just reads `var(--_)`. +An **inert state** reads it as its fallback, so it inherits whatever override the +value-states put there. +_Avoid_: omitting it (collapses to two vars and breaks inert standardized states); +storing the bare palette literal with the token routed *above* it in the seam (then +a rest override never reaches inert states — they snap back to the palette); exposing +it as API. + +**Value-state vs inert-state** (where a state's token lives): +A **value-state** genuinely sets the prop today (rest / hover / disabled for bg, +border, etc.): its token lives **inside** the default — +`--_: var(, )`, seam = `var(--_)` — so the override is +captured in the default and flows to every later state. An **inert state** does not +change the prop (focus / active on Button — only box-shadow moves): its token is +routed in the **seam over** the default — `--variant-: var(, var(--_))` +— settable, but unset it tracks the default. Which props are value vs inert is +per (state, prop): hover is a value-state for bg/border but inert for fg (Button +doesn't recolor text on hover). +_Avoid_: routing a value-state's token in the seam above the default (the +snap-to-palette bug); advancing the default in an inert state with a self-reference +`--_: var(, var(--_))` (guaranteed-invalid CSS). + +**Color token** (public, the design-system knob for color): +A fully-qualified override, shape `--Component----` +(`--Button-contained-error-hover-bg`); the **rest** state omits the `` +segment (`--Button-contained-error-bg`). Vocabulary: ` ∈ {bg, fg, border}` +(`fg` = foreground/text color; `border` = border **color** on this axis), +` ∈ {hover, focus, active, disabled, selected}` plus the unqualified rest. +Resolution is **per (variant, color, state, prop)** — the most +specific meaningful granularity, no coarse color-agnostic layer (mirrors the +dimension axis dropping the all-sizes base token). The name is built from the +per-color loop variable, so **custom palette colors get tokens for free** (parallels +custom-size routing). Variant-first ordering aligns with the seam (`--variant-contained…`). +_Avoid_: a color-agnostic token (`--Button-contained-hover-bg` — forces all colors +to one value); color-first ordering; omitting variant (breaks contained-vs-text). + +**State standard** (the predictable state surface): +Every component exposes the **same** non-rest state segments — `hover`, `focus`, +`active`, `disabled`, `selected` — for each variant×color×prop, **even where the +component does not change that property in that state today** (the routing is then +inert: token unset → tracks the current value). The goal is a *predictable* surface +(a designer knows `--Button---disabled-bg` exists without reading source), +traded against the extra inert CSS each component emits. `focus` maps to the +`.Mui-focusVisible` selector (keyboard focus), not `:focus`. Contrast the dimension +axis, where a token exists only where the axis is genuinely density-bearing. +_Avoid_: tokenizing "only where color varies today"; naming the rest state; using +`:focus` instead of `.Mui-focusVisible`; inventing per-component state sets. + +**Density scale** (tier-1): +A named, ordered set of density steps (`xxs / xs / sm / md / lg …`), values +derived from `theme.spacing`, surfaced as `--mui-density-*` CSS vars. The shared, +designer-facing holistic-density surface. **Emitted by `enhanceDensity`, not +`createTheme`** (runtime opt-in); its **types ship built-in** +(`theme.vars.density.*` always type-checks). +_Avoid_: "spacing scale" (that is `theme.spacing`), "grid". + +**enhanceDensity**: +A single post-`createTheme` function (mirroring `enhanceHighContrast`) that does +**both**: (a) emits the **density scale** as `--mui-density-*` and populates +`theme.vars.density`, and (b) injects per-component `styleOverrides.root` mapping +**sized tokens** to density steps +(`--Button-medium-pad: theme.vars.density.md`). `createTheme` is untouched. +Opt-in: without it, components render their literal-px defaults; with it, tuning +the density scale (or scoping `--mui-density-*`) reflows every wired component. +_Avoid_: "density preset" (that is the resulting effect, not the function). + +## Relationships + +- The styled root reads **one** agnostic var per property; **no JavaScript + conditional** lives in the styles implementation. The `(variant, size)` → px + matrix and the built-in-size routing are both **`variants` cells**, not a body + lookup table; only custom-size routing is inline. +- **Two vars, not one** (`--Button-pad` over `--_pad`): the cells write the + _value_ (`--_pad`), the routing writes a _reference_ (`--Button-pad`). One var + fails three ways — a self-referencing fallback in the inline bridge (invalid + CSS, forcing the literal back into runtime style), the `(variant×size)` and + size-only write-axes clobbering on one element, and losing the agnostic seam. + Full reasoning in `docs/adr/0001` → _Why two vars_. +- Override priority (high → low): plain `styleOverrides` property → **sized + token** → internal default (literal fallback). +- Custom (user-defined) sizes work for free: when the size isn't built-in, the + inline routing builds the sized-token name from the runtime size string; the + design system supplies the value via that token. +- **Token granularity follows the component's spacing structure; split only when + the impl forces it.** Button sets all sides together via one shorthand on one + element → one `pad` var (even though block 6 ≠ inline 8 — differing values alone + don't force a split). OutlinedInput _is_ forced: block vs inline land on + different elements/states and zero per adornment. **Both axes are sized** + (`--OutlinedInput--padBlock`/`-padInline`) — block defaults vary by size + (16.5/8.5), inline defaults don't (14 both) but it's sized anyway so density can + tune it per size. Its padding spans two elements (input when inline, root when + multiline/adorned — never both on a side at once), so each site tokenizes its + own literal in place rather than lifting size resolution to one owner; smallest + diff from master. +- **Cross-component coordination respects dependency direction.** The outlined + floating label must track the input's `padBlock`, but `InputLabel` is generic + (shared by all input variants) so it only exposes a seam (`--InputLabel-y`, + literal default). The **specific** component, `OutlinedInput`, owns the bridge: + it reaches its preceding-sibling label via `:has(~ &)` and sets `--InputLabel-y` + from its public token. Generic never names specific; one knob still drives both. +- **A shared internal base owns the agnostic layer; consumers route their own + tokens into it.** `SwitchBase` consumes the seam once + (`var(--SwitchBase-pad, var(--_pad))`); `Checkbox`/`Radio`/`Switch` (the Material + layer) each route a per-component sized token (`--Checkbox--pad`, …) into + that shared seam, staying independently tunable. The seam keeps the base's name + (plumbing); the knob is the per-component token. Delivery rides **custom-property + inheritance**, no descendant selector: where the consumer _is_ the base + (`styled(SwitchBase)`) it sets the seam on its own root; where it _wraps_ the + base (the Switch thumb), the wrapper root sets the seam and the base inherits it + (the base doesn't redeclare the seam). Caveat: the base _does_ redeclare + `--_` (what makes it unprefixed-safe), so an inherited `--_` is + shadowed — a wrapper needing a different per-state default feeds it through the + seam (set `--_` on the wrapper), not by inheriting `--_`. +- **enhanceDensity** (opt-in) connects tier-2 component tokens to the tier-1 + **density scale**; un-enhanced, the literal fallbacks reproduce today's pixels. +- This experiment does **not** ride `--mui-spacing`; holistic density comes from + the density scale, not that dial. +- **The color token carries only the axes the component has.** Button has variant + and palette-color → `--Button----`. MenuItem has + neither (single styled root, no `color` prop) → `--MenuItem--`. The + segments are dropped, not stubbed; the value-state/inert-state and + rest-unqualified rules are unchanged. MenuItem's compound states + (`selected:hover`, `selected:focus`) are real states with their own tokens + (`--MenuItem-selected-hover-`), resolved by selector specificity as before. +- **An inert prop with no native CSS home rides a zero-cost carrier.** MenuItem has + no border today, so its `border` token drives an **always-on inset box-shadow** + (`inset 0 0 0 1.5px var(--_border)`, default `transparent`) — invisible until set, + and never shifts layout (unlike a real `border`). The seam pattern is otherwise + identical to a component that already has the property (Button's `--variant-*`). + +## Example dialogue + +> **Dev:** "How do I shrink the padding of small buttons?" +> **Domain expert:** "Set the **sized token** `--Button-small-pad` at any scope; +> the Material UI layer routes it over its default, so every small button +> reflows. Resolution is sized-only — there's no all-sizes base token, so do it +> per size." +> **Dev:** "And with nothing set?" +> **Domain expert:** "The agnostic `--Button-pad` falls back to the **internal +> default** `--_pad` — the literal px set in the `(variant, size)` `variants` +> cell, pixel-identical to today. The `--mui-spacing` dial does nothing here; for +> holistic density you run **enhanceDensity** and tune the **density scale**." + +> **Dev:** "How do I recolor the hover background of contained error buttons?" +> **Domain expert:** "Set the **color token** `--Button-contained-error-hover-bg`. +> The `&:hover` block routes it over the **color internal default** `--_containedBg`, +> which hover has advanced to `palette.error.dark` — so unset it's today's hover +> color, set it's yours. It's `(variant, color, state, prop)`-specific: error only, +> hover only, background only." +> **Dev:** "Is there a knob for the disabled state even though Button uses a flat grey?" +> **Domain expert:** "Yes — `--Button-contained-error-disabled-bg` exists by the +> **state standard**, even though it's inert by default (falls back to +> `palette.action.disabledBackground`). Every component exposes the same +> `hover/focus/active/disabled/selected` set, so the surface is predictable." + +## Flagged ambiguities + +- "spacing token" meant both a `theme.spacing` key and a per-component value — + resolved: `theme.spacing` is untouched; per-component vars are the **agnostic + var** (layer-1 surface) and **sized tokens** (the design-system knob). +- "spacing scale" (earlier draft, tier-1) — renamed **density scale** and moved + to `theme.density`, to disambiguate from `theme.spacing`. +- Base (all-sizes-over-sized) token — dropped for **size-varying** axes; + resolution is sized-only, tune per size. A base token applies only when per-size + override is meaningless — _not_ merely when the default is size-invariant + (OutlinedInput's `14px` inline gutter is still a **sized** token so density can + tune it per size). +- Var key — single `pad` shorthand only when the impl sets all sides together on + one element (Button); split per axis (`padBlock`/`padInline`) when forced — + axes on different elements/states or different shapes (OutlinedInput). Sides are + symmetric within an axis, so `padding: ` stays RTL-safe. +- Color `` foreground — the seam names it `Color` (`--variant-containedColor`), + but the public token already carries a palette `` segment, so foreground + is **`fg`** (avoids `--Button-…-error-color`, two "color" meanings) — the public + vocabulary is deliberately separate from the internal seam suffix. +- Color `` border — kept as bare **`border`** (not `borderColor`/`bc`): + `bc` reads as background-color, and `borderColor` is verbose. **Known caveat:** + on the color axis `border` means border _color_; if border _width_ ever becomes + a dimension-axis token this must be revisited to disambiguate. +- "state token" — originally a binary-prop concept (`dense`); now also the + color-axis pseudo-class states. Same shape (rest/off unqualified, only non-rest + named), different trigger. The color axis additionally fixes a **standard** set + (`hover/focus/active/disabled/selected`) emitted even when inert, where the + dimension axis tokenizes only genuinely-density-bearing axes. +- Where the value-state token lives — **corrected after the grill.** First draft + (and ADR-0002 v1) routed every state's token in the seam *over* a `--_` that + held the bare palette literal. Bug: overriding only the **rest** token left + inert states (focus/active) falling back to the palette literal, so a recoloured + button **snapped back to the palette colour on focus** (visible as default purple + on a re-greyed secondary). Fix: a **value-state** puts its token *inside* + `--_` (`--_: var(, )`, seam reads `var(--_)`); only + **inert** states route over `--_`. Now `--_` is the current resolved + value and overrides propagate. See _Value-state vs inert-state_. diff --git a/docs/adr/0001-css-var-density-adapter.md b/docs/adr/0001-css-var-density-adapter.md new file mode 100644 index 00000000000000..b9c6eba479d79a --- /dev/null +++ b/docs/adr/0001-css-var-density-adapter.md @@ -0,0 +1,286 @@ +# Component density via a CSS-var adapter, resolved in variants + +Component dimensions are exposed as public CSS variables with **literal-px +fallbacks**, resolved through an **internal var whose value lives in `variants`** +(inline style bridges only the custom-size case), instead of riding the single +`--mui-spacing` dial (`feat/components-theme-spacing`) or emitting a static +per-(variant, size) token matrix (`poc/css-vars-map`). + +## Context + +We want designers to tune density — per component, per size, or holistically — +without editing component source, writing `calc`, or accepting that every +dimension reflows off one global `--mui-spacing` value. + +Constraints that shaped the design: + +- **Pixel-identical default.** The un-configured theme must render today's exact + px for every `(variant, size)` cell (Argos zero-diff). +- **Literal defaults in `variants`, not the body.** The `(variant, size)` → + px matrix uses the idiomatic `variants` mechanism (Button already has these + cells for `fontSize`); no JS lookup table lives in the component body. +- **Support user-provided sizes.** A custom `size` added via the theme must get + the same tunability as built-in sizes — built-in routing is per-size `variants` + blocks; a custom size falls back to inline routing (dynamic size string). +- **No JavaScript conditionals in the styles implementation.** The `styled()` body must + not branch on `ownerState.size`/`variant` to pick a value. +- **Non-breaking.** Existing variant/size padding and existing + `styleOverrides`/`sx` overrides must keep working unchanged. + +## Decision + +The component is read as **three layers of responsibility**, each owning a +distinct slice of the same cascade: + +1. **Agnostic** — the styled root with no design meaning (no size/variant/color). + Its whole spacing surface is one public var it consumes directly, falling back + to the internal default: `padding: var(--Button-pad, var(--_pad))`. +2. **Material UI** — Material Design's sizes/variants, all in `variants`: the + `(variant, size)` literal **defaults** (`--_pad`) and the **sized-token + routing** for the built-in sizes (`--Button-pad`). No JS lookup in the body. + Custom (non-built-in) sizes route inline instead — the one case that needs the + runtime size string. +3. **Design system** — overrides through the public **sized token** the Material + UI layer routes over its default (driven by `enhanceDensity`). + +Per property, the chain (inline padding on Button): + +- **Agnostic var** `--Button-pad` (public) — the styled root's only spacing + consumption point; **set by a built-in-size `variants` block** to the + sized-token routing (inline for custom sizes), falling back to `--_pad`. +- **Sized token** `--Button--pad` (public) — the design-system knob; + reflows one size. +- **Internal default** `--_pad` (private, leading underscore) — the Material + default, **set in `variants`** per `(variant, size)`, with a universal default + on the root so a custom variant/size still renders a sane value. + +Resolution for a **size-varying** axis is **sized-only** (no all-sizes base +token): the sized token wins, else the Material default. An axis whose default is +the same every size can still be **sized** — and usually should be, so a design +system can tune it per size (density). A plain **base token** `--Component-` +over an internal default `--_` (consumed `var(--Component-, var(--_))`, +no size layer/routing) is reserved for the rare axis where per-size override is +genuinely meaningless. + +When the compactness axis is a **boolean** prop (`dense`) rather than a `size` +enum, use a **state token**: the default (off) state is the plain seam +`--Component-` (base-token-shaped — nothing routes it in the base), and only +the on state is qualified `--Component-dense-`, routed in the `dense` +variant. A boolean has no name for "off", so there is no +`--Component-normal/regular/default-` — unlike a size enum, where every value +(including `medium`) is qualified because each is a real named size. Used by +MenuItem, ListItem, ListItemButton, and ListItemText. + +The styled root has **one** consumption point per property and **no conditional**; +the defaults and built-in-size routing are plain `variants` entries: + +```js +const ButtonRoot = styled(ButtonBase)({ + '--_pad': '6px 16px', // universal default (today's root padding) + padding: 'var(--Button-pad, var(--_pad))', // agnostic layer + variants: [ + // routing for built-in sizes (deduped CSS) + { + props: { size: 'small' }, + style: { '--Button-pad': 'var(--Button-small-pad, var(--_pad))' }, + }, + // literal default per (variant, size); medium lives in the { variant } blocks (DRY) + { props: { variant: 'text', size: 'small' }, style: { '--_pad': '4px 5px' } }, + ], +}); +``` + +Only **custom sizes** route inline — the one case needing the runtime size +string (so custom sizes stay tunable without registering a variant): + +```js +const buttonSizes = ['small', 'medium', 'large']; +const densityVars = buttonSizes.includes(size) + ? undefined // built-in: routed via variants above + : { '--Button-pad': `var(--Button-${size}-pad, var(--_pad))` }; +; +``` + +### Why two vars (`--Button-pad` and `--_pad`), not one + +Three reasons, all pointing the same way: + +1. **Values belong in `variants`; the inline bridge must stay value-free.** The + literal px is a design decision — it must live in `variants`, co-located with + the rest of the variant's styling, statically deduped, smallest diff from + today. Inline style is only a **bridge** for the one dynamic case (a custom + size's token name) and must carry **no values**, only routing. Two vars allow + that: cells write the value (`--_pad`), the bridge writes a _reference_ + (`--Button-pad: var(--Button--pad, var(--_pad))`). With a single + `--_pad`, the custom-size bridge would have to write + `--_pad: var(--Button--pad, var(--_pad))` — a property referencing + **itself**, which CSS treats as guaranteed-invalid. The only escape is + embedding the literal back into the inline string, dragging the value into + runtime style — exactly what we moved out. +2. **Two write-axes on one element clobber if they share a name.** The literal + varies by **(variant × size)** (the cells); the token interception varies by + **size only** (`--Button--pad`, the routing). A single `--_pad` keeps + exactly one: routing-wins loses the per-variant literal (and the size block + can't supply the right fallback — it doesn't know the variant); literal-wins + never consults the token, so no override. Two names let each axis write + independently; `--Button-pad` chains to `--_pad`, the root reads `--Button-pad`. +3. **Layer seam (naming).** `--Button-pad` is public-shaped because it's the + **agnostic-layer seam** — the var a no-design consumer of the bare root would + set; Material UI takes it over to inject token routing. `--_pad` is private: + Material UI's internal default, not a contract. + +Holistic density is a separate, opt-in layer driven by a **single** +`enhanceDensity(theme)` function (mirroring `enhanceHighContrast`) that does +both jobs: it **emits** the density scale as `--mui-density-*` (and populates +`theme.vars.density`), and **maps** sized tokens to density steps via injected +`styleOverrides.root` (`--Button-medium-pad: var(--mui-density-md)`). +`createTheme` is left untouched. Types for `theme.vars.density` ship built-in; +the vars exist at runtime only after `enhanceDensity` runs. + +We considered making `density` a first-class `createTheme` node so the normal +css-var generator emits the vars. That is more "correct" (the vars participate +in the standard generation and can be re-scoped at any level), but it requires +`createTheme`/css-vars surgery. For an experiment we chose the self-contained +function: easy to A/B, easy to delete, no core change. The cost is that +post-hoc-emitted vars live outside the standard `theme.vars` pipeline. + +Scope: **Button** and the **outlined input family** (OutlinedInput, with +InputBase/TextField to follow) for this experiment. + +### OutlinedInput specifics + +Same three-tier model, with two component-driven differences: + +- **Both axes are sized.** Block (`16.5px`→`8.5px`) varies by size → sized token + `--OutlinedInput--padBlock`. The `14px` inline gutter is _constant_ across + sizes, but it's **sized too** → `--OutlinedInput--padInline` (default + `14px` each size) so a design system can make small inputs denser inline. (We + first modeled inline as a single size-invariant **base token**, but that can't + be size-scoped from the theme — a flaw for density — so we promoted it; a base + token is now reserved for axes where per-size override is meaningless.) Block and + inline are still split because the impl applies them separately — different + elements/states, per-adornment side-zeroing. Each axis is routed per size **in + place** on the element/variant that consumes it (input + root cells), so sizing + inline adds a `&& size === 'small'` re-route beside each size-agnostic adornment + variant — no lift, both axes wired identically. (Filled/Standard have asymmetric + block padding — `4/5`, `25/8` — so a shared InputBase block seam would need a + richer, per-side shape; deferred.) +- **Two consuming elements, tokenized in place.** Padding lives on the input + (non-multiline) _and_ the root (multiline) — and the two never both apply block + padding at once (multiline zeroes the input's). Rather than lift size resolution + to a single owner, **each site keeps master's literal-bearing cell and + tokenizes in place**: input base + input `{ size: small }`, root `multiline` + + root `{ multiline && small }`, each declaring its own `--_padBlock` and routing + the size token. This keeps the smallest diff from master (no restructuring, no + inheritance reliance, no dropped variants); the minor cost is the size routing + written twice (input vs root-multiline), which is honest — they are genuinely + separate code paths. Unprefixed `--_padBlock` stays safe because every cell that + reads it also sets it on the same element. + +**Closing the loop — the floating label.** In a `TextField`, `InputLabel` is a +_preceding sibling_ of the input. The resting label must track the block padding +or it decenters when density is tuned. True centering is `labelY = padBlock` +exactly (`(lineHeight + 2·padBlock)/2 − lineHeight/2`); today's `16px`/`9px` are +that with ±0.5px historical rounding. + +The bridge must respect the **dependency direction**: `InputLabel` is generic +(shared by outlined/filled/standard) and must not name a specific input's token. +So `InputLabel` only exposes a seam — its outlined resting transform reads +`var(--InputLabel-y, )` — and **OutlinedInput owns the bridge**. Because +the label precedes the input, OutlinedInput reaches it with `:has` (sibling +combinators only match _following_ siblings) and, per size, derives the label +seam straight from its public sized token (a cross-element rule must reference the +public token, not the input's internal `--_padBlock`): + +```js +// InputLabel — generic seam, literal default +transform: 'translate(14px, var(--InputLabel-y, 16px)) scale(1)' // small: 9px + +// OutlinedInputRoot — base (medium) + size:small variant +[`.${inputLabelClasses.root}:has(~ &)`]: { + '--InputLabel-y': 'calc(var(--OutlinedInput-medium-padBlock, 16.5px) - 0.5px)', // small: small token + 0.5px = 9px +}, +``` + +Defaults compute to exactly `16px`/`9px` (Argos zero-diff); setting +`--OutlinedInput--padBlock` reflows input and label together — single knob, +no FormControl, no `enhanceDensity`. The **shrunk** label (`-9px`, in the notch on +the top border) is padding-independent and stays literal. InputBase needs no +change — OutlinedInput fully overrides its padding. + +Why `:has` and not the alternatives: putting the calc in `InputLabel` would make +a generic component name a sibling's token (wrong direction); a flat-scope +`--InputLabel-y` can't be size-specific for mixed-size pages; routing through +`enhanceDensity` defers single-knob to the design-system layer. The `:has` rule +keeps the coupling in the one component that legitimately owns it. Cost: needs +`:has()` (Chrome 105 / Safari 15.4 / Firefox 121). + +### Shared internal base (SwitchBase → Checkbox, Radio, Switch) + +When several components share one styled base, the **agnostic layer lives on the +base**: `SwitchBase` consumes the seam once (`padding: var(--SwitchBase-pad, +var(--_pad))`, `--_pad: 9px`), and each consumer — the **Material layer** — routes +its own per-component public token into the shared seam. The seam keeps the +_base's_ name (`--SwitchBase-pad`, plumbing); the designer-facing knob is the +per-component sized token (`--Checkbox--pad`, `--Radio--pad`, +`--Switch--pad`). Each consumer stays independently tunable while the base +holds the one consumption point. + +Two reader topologies, both relying on **custom-property inheritance** (no +descendant selector, no added specificity): + +- **Consumer is the base.** `Checkbox`/`Radio` are `styled(SwitchBase)`, so their + size variants set `--SwitchBase-pad` on the very element that consumes it. +- **Consumer wraps the base.** The `Switch` thumb is a `SwitchBase` _inside_ + `SwitchRoot`; the root sets `--SwitchBase-pad` and the thumb **inherits** it. + This works precisely because `SwitchBase` does not redeclare the seam. + +The inheritance caveat is the mirror of why `--_` is safe unprefixed: the +base **redeclares `--_`** on itself, so an inherited `--_` is shadowed. +The seam inherits (not redeclared); the internal default does not. So a wrapper +that needs a value different from the base's feeds it **through the seam** — set +the seam directly on the wrapper (preferred), not the shadowed `--_`. Switch +does exactly this: it sets `--SwitchBase-pad` to a derived `calc` (below). + +**Interlocked geometry — derive, don't tokenize one axis.** A `Switch`'s width, +height, thumb, touch target and travel all move together; tokenizing the thumb +pad alone drifts the thumb off the track. So Switch tokenizes its real dims per +size (`--Switch--width/height/thumbSize/touchSize` + the track gutter +`--Switch--pad`) and **derives** the coupled values with `calc`, feeding the +shared seam: SwitchBase pad +`= (touchSize − thumbSize) / 2`; the absolutely-positioned button stays centered +via `top = (height − touchSize) / 2` and checked `transform: translateX(width − +touchSize)`; the thumb slot reads `thumbSize`. Defaults (`touchSize == height`) +compute to today's `9/4` pad, `0` top, `20/16` travel — pixel-identical. +`enhanceDensity` _can_ wire Switch precisely because it derives: it maps the input +dims to scale steps (the `xxl` step covers the wider track) and pad/top/travel/ +radius re-derive, so the geometry stays valid (`touchSize == height` keeps it +centered, `width > touchSize` keeps travel positive). + +## Consequences + +- **Pixel-identical default & non-breaking.** Literals come from the `variants` + cells (`--_pad`) over a universal root default, so a custom variant/size still + renders; public tokens, `styleOverrides`, and `sx` all still win via the cascade. +- **No inline for built-in sizes.** Routing for `small`/`medium`/`large` lives in + `variants` (deduped CSS), so the common case carries no per-instance `style` + attr. Only a **custom size** routes inline. +- **Custom sizes work for free** — the inline routing builds the sized-token name + from the runtime size string; the design system supplies the value via that + token, no variant registration needed. +- **No `--mui-spacing` reflow.** Components opt out of the global dial; holistic + density flows through the density scale + `enhanceDensity` instead. +- **calc resolves only in a real browser** (jsdom does not), so density + assertions belong in browser/visual tests, not jsdom unit tests. + +### Accepted trade-offs + +| Trade-off | Why we can live with it | +| :----------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--Button-pad` is public-shaped but not a designer knob in the assembled Button (plumbing) | It's the agnostic seam; the real knob is `--Button--pad`, documented. The name marks the layer boundary. | +| Two vars per property instead of one | Mandatory (see _Why two vars_); the indirection is mechanical and documented. | +| Unprefixed `--_pad` could inherit a foreign value | Every built-in cell plus the root universal default set it on the element; revisit a prefix only if cross-component collisions surface as the pattern spreads. | +| `pad` shorthand is coarse (an override sets all sides) | Button padding is symmetric; tiny token surface; granular logical props can come later. | +| `var()` unresolved in jsdom (no computed-px assertions) | Argos covers default visuals; the chain is declarative and inspectable. | +| Inline still present for custom sizes | Rare; the built-in common case carries zero inline. | +| Per-property boilerplate grows with rollout | Acceptable for the payoff (runtime scoped theming); extract a helper before component #3. | diff --git a/docs/adr/0002-css-var-color-state-adapter.md b/docs/adr/0002-css-var-color-state-adapter.md new file mode 100644 index 00000000000000..eac948c1910131 --- /dev/null +++ b/docs/adr/0002-css-var-color-state-adapter.md @@ -0,0 +1,192 @@ +# Color/state tuning via a 3-layer CSS-var adapter, standardized across states + +Component color (background / foreground / border) is exposed as public CSS +variables — **per variant, per palette color, per interaction state** — layered +**over the pre-existing `--variant-*` seam** and resolved through a private +per-state default, so a designer can tune any state's color without editing +component source. This is the **color axis** sibling of the dimension/density +adapter (ADR-0001); the two share the three-layer reading. + +## Context + +We want designers to recolor a component per `(variant, color, state)` — e.g. +"make the hover background of contained error buttons hot pink", "tint disabled +buttons per color" — without editing source, and with **today's exact colors +when nothing is set** (Argos zero-diff). + +Two facts shape the design: + +- **There is already a color seam.** Button has carried `--variant-containedBg`, + `--variant-outlinedColor`, `--variant-outlinedBorder`, `--variant-textBg`, … + since the CSS-extraction conversion (#41378). The per-color `variants` block + sets these, the variant block consumes them, and `&:hover` **reassigns the + value**. It is undocumented and unprefixed. We **adopt it as the seam**, not + replace it (a wholesale rewrite would be a large, pointless diff). +- **The seam is variant-qualified, not agnostic.** Unlike the dimension seam + (`--Button-pad` carries no variant meaning), "background" means a solid for + `contained` and transparent for `text`, so the variant lives in the seam name. + The public token inherits that — variant leads the token, aligning with the + seam. + +Constraints inherited from ADR-0001: color-identical default, no JS conditional +in the styles body (states/colors are `variants`/pseudo-class cells), existing +`styleOverrides`/`sx` keep working. + +## Decision + +### The token shape + +```text +--Component---- public color token (designer knob) +--variant- seam (pre-existing consumption point) +--_ private default (advanced per state) +``` + +- ` ∈ {bg, fg, border}` — `fg` = foreground/text color (not `color`, which + would collide with the palette `` segment); `border` = border **color** + on this axis (bare name kept for now — see _Accepted trade-offs_). +- ` ∈ {hover, focus, active, disabled, selected}`; the **rest** state omits + the `` segment. `focus` maps to the `.Mui-focusVisible` selector + (keyboard focus), not `:focus`. +- `` is built from the per-color loop variable, so **custom palette colors + get tokens for free** (parallel to custom-size routing in ADR-0001). + +Resolution is **per `(variant, color, state, prop)`** — the most specific +meaningful granularity, no coarse color-agnostic layer (a single +`--Button-contained-hover-bg` would force every color to one value). This mirrors +the dimension axis dropping the all-sizes base token. + +### Three layers — value-states vs inert-states + +`--_` holds the prop's **current resolved value** — the value the element +shows now, *including any rest/hover/disabled token override*. The crux is **where +a state's token lives**: + +- **Value-state** (the state genuinely sets the prop today — rest, hover for bg, + disabled): its token goes **inside** `--_`, with the palette literal as the + fallback; the seam just reads `var(--_)`. The override is now in the default, + so every later state inherits it. +- **Inert state** (the state changes nothing today — focus, active on Button): + its token routes in the **seam over** `var(--_)`. Settable, but unset it + tracks the default. + +```js +// per-color variants block (one per palette color) +{ + props: { color: 'error' /* loop var */ }, + style: { + // rest (value-state): token INSIDE the default, seam reads it + '--_containedBg': 'var(--Button-contained-error-bg, var(--mui-palette-error-main))', + '--variant-containedBg': 'var(--_containedBg)', + + '@media (hover: hover)': { + '&:hover': { + // hover (value-state for bg): advance the default with the hover token inside + '--_containedBg': 'var(--Button-contained-error-hover-bg, var(--mui-palette-error-dark))', + '--variant-containedBg': 'var(--_containedBg)', + }, + }, + + // active (inert — Button moves only box-shadow): route the token over the default. + // Unset → tracks --_containedBg, which already carries any rest/hover override + // (so a re-coloured button stays re-coloured on press; press-while-hover = dark). + '&:active': { + '--variant-containedBg': 'var(--Button-contained-error-active-bg, var(--_containedBg))', + }, + + // disabled (value-state): advance with the disabled token, neutral grey fallback + [`&.${buttonClasses.disabled}`]: { + '--_containedBg': + 'var(--Button-contained-error-disabled-bg, var(--mui-palette-action-disabledBackground))', + '--variant-containedBg': 'var(--_containedBg)', + }, + }, +} +``` + +> **Correction (post-implementation).** This ADR's first draft put *every* state's +> token in the seam *over* a `--_` that held the bare palette literal. That +> breaks override propagation: setting only the **rest** token left focus/active +> falling back to the palette literal, so a re-coloured button **snapped to the +> palette colour on focus** (a re-greyed secondary flashed default purple). The +> structure above — value-state token *inside* the default — is the fix. + +### Standardize the full state set, even where inert + +Every component exposes the **same** non-rest states +(`hover/focus/active/disabled/selected`) for each variant×color×prop, **even +where the component changes nothing in that state today** (the routing is then +inert). A designer can rely on `--Button---disabled-bg` existing without +reading source. This is the opposite stance from the dimension axis, where a +token exists only where the axis is genuinely density-bearing — colour states are +a small, fixed, universal vocabulary, so a *predictable* surface beats a *minimal* +one. + +### Why three vars, not two + +The simpler design — seam + public token, with the **palette var as the inline +fallback** (`--variant-containedBg: var(--token, var(--mui-palette-error-main))`) +— works only if we tokenize *just the states the component already styles*. The +moment we standardize an **inert** state, two layers break it: + +- An inert `&:active` block must set `--variant-containedBg` (so its token is + live), and an **unset** token must reproduce today's active color — which is the + **hover** color when pressing-while-hovering. A two-layer fallback can only point + at the *rest* palette var, so an unset active token would clobber hover on every + click. A `var(--token, var(--variant-containedBg))` self-reference is + guaranteed-invalid CSS. +- The **private default** `--_` captures "the value this element + currently shows": value-states write their token *into* it, inert states read it + as their fallback — so inert states track the live value (including overrides) + instead of overwriting it. (An inert state can't write the default itself: a + `--_: var(--token, var(--_))` self-reference is guaranteed-invalid CSS.) + +So the three-layer shape is **forced by standardization**, restoring symmetry with +the dimension axis (which is also three-layer). + +### No `enhanceColor` — the palette is the holistic surface + +The dimension axis needed `enhanceDensity` because it invented a new tier-1 scale +(`--mui-density-*`). Color already **has** its holistic dial: the **palette**. +Tuning `palette.error.dark` reflows every contained-error hover for free (the +tokens fall back to it). The color tokens exist for **per-component-state +deviations** from the palette, not a parallel global scale. So there is no +`enhanceColor` function and no color "scale" node — `createTheme`'s palette is +tier-1, the color tokens are tier-2. + +## Alternatives considered + +- **Two-layer (seam + token, palette fallback).** Smaller, no private var. + Rejected: cannot keep inert standardized states zero-diff (the hover/active + clobber above). +- **Tokenize only where color varies today.** Smallest bundle. Rejected: the + surface becomes unpredictable — a designer must read each component's source to + learn which state knobs exist. +- **Adopt `--variant-*` as the public API, or replace it.** Rejected: it's + unprefixed and variant-qualified in a way we don't want as a contract; layering + a documented public token over it costs less and keeps the seam an internal + detail. +- **A coarse color-agnostic token** (`--Button-contained-hover-bg`). Rejected: + forces all palette colors to one value, destroying per-color semantics. + +## Consequences + +- **Color-identical default & non-breaking.** Unset tokens fall back through the + private default to today's palette var; `styleOverrides`/`sx` still win. +- **Per-state color is independently tunable**, including states the component + doesn't style today, via a predictable, uniform token surface. +- **Custom palette colors are tunable for free** (token name built from the loop + variable). +- **Bundle grows** with inert routing per component — the price of the standard. +- The **private default looks redundant** next to the palette var until you hit + the hover/active interplay; it is mechanical and documented here. + +### Accepted trade-offs + +| Trade-off | Why we can live with it | +| :-- | :-- | +| Inert state routing emitted on every component | Buys a predictable surface (knob exists without reading source); colour states are a small fixed set. | +| Three vars per `(variant, prop)` instead of two | Forced by standardizing inert states (see _Why three vars_); restores symmetry with the dimension axis. | +| `border` under-specifies (width? style? color?) | On this axis it is always **color**; `bc` reads as background-color and `borderColor` is verbose. **Revisit if a border-width (dimension) token ever appears.** | +| `--variant-*` seam stays unprefixed/undocumented as plumbing | It's the consumption point, not the contract; the public token is the documented knob. | +| Disabled becomes per-color-tunable though Button uses a flat grey today | Default is the same `palette.action.*` literal for every colour (zero-diff); the per-color knob is upside, not cost. | diff --git a/docs/adr/density-adapter-rollout.md b/docs/adr/density-adapter-rollout.md new file mode 100644 index 00000000000000..9739eb25cb91df --- /dev/null +++ b/docs/adr/density-adapter-rollout.md @@ -0,0 +1,277 @@ +# Density adapter — rollout goal + +Spread CSS-var density adapter to more components. Decision/spec: `0001-css-var-density-adapter.md`. + +**Goal:** each density dimension = one hand-authorable CSS var. Default render +pixel-identical to today. No `calc` math for users, no `--mui-spacing` dial. + +## The 3 vars (per density axis) + +```text +--Component-- public sized token (designer knob, enhanceDensity target) +--Component- agnostic seam (styled root consumes this) +--_ internal default (Material literal, lives in variants) +``` + +Root consumes seam, seam falls to internal default: + +```js +padding: 'var(--Button-pad, var(--_pad))'; +``` + +Resolution = **sized-only** for a size-varying axis. Sized token wins -> else +internal default. No all-sizes-over-sized base token. + +**Size-invariant default ≠ base token.** If an axis's default is the same every +size (e.g. OutlinedInput inline `14px`) you _can_ skip the size layer — base +token `--Component-`, consumed `var(--Component-, var(--_))`, +nothing routes it. But only when per-size override is genuinely meaningless, +because a **base token can't be tuned per size from the theme**. If a design +system might want that axis denser on small (density!), **size it anyway**: same +default both sizes, but expose `--Component--`. OutlinedInput sizes +_both_ padBlock and padInline for this reason (inline default `14px` each size). + +**Boolean `dense` axis (state token).** When compactness is a **boolean** prop +(`dense`) not a `size` enum, don't name the off-state. The **default state is the +plain seam** `--Component-` (base-token-shaped: nothing routes it in the +base; designer sets it directly); **only `dense` is qualified** +`--Component-dense-`, routed in the `dense` variant: + +```js +// base: default state = plain seam, falls to the internal default literal +'--_padBlock': '8px', +paddingTop: 'var(--ListItem-padBlock, var(--_padBlock))', +// { dense } variant: own literal + route the dense token +'--_padBlock': '4px', +'--ListItem-padBlock': 'var(--ListItem-dense-padBlock, var(--_padBlock))', +``` + +No `--Component-normal/regular/default-` — a boolean has no name for "off". +(MenuItem, ListItem, ListItemButton, ListItemText.) + +## Recipe A — small component (Button) + +One element. `pad` shorthand (all sides move together). + +1. Root: universal default + consume. + ```js + '--_pad': '6px 16px', + padding: 'var(--Button-pad, var(--_pad))', + ``` +2. Variants — literal default per `(variant, size)`. + ```js + { props: { variant: 'text', size: 'small' }, style: { '--_pad': '4px 5px' } } + // medium defaults reuse the { variant } blocks (DRY) + ``` +3. Variants — built-in size routing (deduped CSS, no inline). + ```js + { props: { size: 'small' }, style: { '--Button-pad': 'var(--Button-small-pad, var(--_pad))' } } + ``` +4. Custom size -> route inline (only non-built-in size emits a `style` attr). + ```js + const densityVars = ['small', 'medium', 'large'].includes(size) + ? undefined + : { '--Button-pad': `var(--Button-${size}-pad, var(--_pad))` }; + ``` +5. `enhanceDensity` maps `--Button--pad` -> `--mui-density-*` step. + +## Recipe B — big component (OutlinedInput) + +Padding spans 2 elements (root when multiline, input otherwise) + paired sibling +(InputLabel). More dimensions but token model is _simpler_. + +**Pick real axis + shape.** Block (`16.5 -> 8.5`) varies by size -> **sized** +`padBlock`. Inline default is `14px` both sizes, but a design system may want +per-size inline density -> **size it too** (`padInline`, same `14px` default each +size). Both axes sized, routed per size. Split block/inline forced: they land on +different elements/states + zero per adornment. + +**Two elements, tokenize in place.** Padding lives on the input (non-multiline, +inline gutters) _and_ the root (multiline, adornment gutters) — never both on the +same side at once (multiline zeroes input padding; an adorned side zeroes the +input and gutters from the root). Keep master's split: each site declares its own +`--_` + routes the size token, right where the literal was. No lift to a +single owner, no inheritance, no dropped variants — smallest diff from master. + +```js +// input base + root multiline cell: +'--_padBlock': '16.5px', +'--_padInline': '14px', +'--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, var(--_padBlock))', +'--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', +padding: 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', +// each size-small cell re-routes its axis to the small token: +// input { size: small } -> padBlock + padInline small (input owns both) +// root { multiline && small } -> padBlock + padInline small +// root { startAdornment/endAdornment && small } -> padInline small (gutter) +``` + +Cost of sizing inline in place: the size-agnostic adornment gutters need a small +re-route, so each adornment variant gains a `&& size === 'small'` sibling. Cheap +(one line each), and keeps both axes wired identically (no lift). + +**Paired sibling component (the label).** Generic component must not name +specific component token. Label exposes own seam: + +```js +// InputLabel — generic, literal default +transform: 'translate(14px, var(--InputLabel-y, 16px)) scale(1)'; // small: 9px +``` + +Specific component owns bridge. Label = _preceding_ sibling -> reach via `:has`. +Cross-element rule -> derive the label seam straight from the **public sized +token** + literal fallback (can't read the input's internal `--_padBlock`): + +```js +// OutlinedInputRoot, per size +[`.${inputLabelClasses.root}:has(~ &)`]: { + '--InputLabel-y': 'calc(var(--OutlinedInput-medium-padBlock, 16.5px) - 0.5px)', // small: small token + 0.5px +} +``` + +One knob (`--OutlinedInput--padBlock`) -> input box + label move together. + +## Recipe C — shared internal base (SwitchBase -> Checkbox / Radio / Switch) + +Several components share one styled base. Put the **agnostic layer on the base**: +it consumes the seam once, with the internal default. Each consumer is the +**Material layer** -> routes its _own_ per-component public token into the shared +seam. + +```js +// SwitchBase (shared, agnostic): consume once +'--_pad': '9px', +padding: 'var(--SwitchBase-pad, var(--_pad))', +``` + +The seam keeps the **base's** name (`--SwitchBase-pad`) — it's plumbing; the +public knob is the per-component sized token (`--Checkbox--pad`). + +Two reader topologies: + +- **Consumer _is_ the base** (Checkbox/Radio = `styled(SwitchBase)`): route on + the consumer's own root, same element, no selector. + ```js + { props: { size: 'small' }, style: { '--SwitchBase-pad': 'var(--Checkbox-small-pad, var(--_pad))' } } + ``` +- **Consumer _wraps_ the base** as a descendant (the Switch thumb is a + `SwitchBase` inside `SwitchRoot`): set the seam on the wrapper root — the base + **inherits** it. No descendant selector (custom props inherit; the base doesn't + redeclare the seam). + ```js + // SwitchRoot: thumb inherits this + '--SwitchBase-pad': 'var(--Switch-medium-pad, var(--_pad))', + ``` + +**Inheritance caveat.** The _seam_ inherits because the base doesn't redeclare it. +But the base **does** redeclare `--_` (that's what keeps it unprefixed-safe), +so an inherited `--_` is _shadowed_ on the base. A wrapper that needs a value +different from the base's feeds it **through the seam** — set the seam directly on +the wrapper, not the shadowed `--_`. + +**Interlocked geometry -> derive, don't tokenize one axis.** When a component's +dims move together (a `Switch`: width/height/thumb/touch/travel), tokenizing one +(the thumb pad) alone drifts the thumb off the track. Tokenize the real dims per +size (incl. the track gutter `pad`) and **derive** the coupled values with `calc`, +feeding the seam: + +```js +// SwitchRoot, per size: --Switch--{width,height,thumbSize,touchSize,pad} +padding: 'var(--Switch-pad, var(--_pad))', // track gutter (own axis) +'--SwitchBase-pad': 'calc((var(--Switch-touchSize) - var(--Switch-thumbSize)) / 2)', +// thumb button (absolute): keep it centered +top: 'calc((var(--Switch-height) - var(--Switch-touchSize)) / 2)', +// checked: travel = width - touch +transform: 'translateX(calc(var(--Switch-width) - var(--Switch-touchSize)))', +``` + +`touchSize == height` by default -> pad `9/4`, top `0`, travel `20/16` +(pixel-identical). `enhanceDensity` can still wire it: map the input dims to scale +steps and the derived values stay valid (Switch uses `xxl` for the wider track). + +## Gotchas + +- **Split axes only when the impl forces it.** Differing values per side is NOT + enough. If all sides are set together via one shorthand on one element, keep one + key — Button uses `pad` even though block 6 ≠ inline 8. Split per axis + (`padBlock`/`padInline`) only when the impl applies them separately: different + elements/states (OutlinedInput block on input vs root-multiline), independent + side-zeroing (adornments), or different token shapes (sized block + base inline). + OutlinedInput is forced; Button is not. Don't over-tokenize. +- **Two vars, not one.** Cells write value (`--_pad`), routing writes reference + (`--Button-pad`). One var fails 3 ways: inline bridge self-references (invalid + CSS) -> literal leaks to runtime; `(variant×size)` vs size-only writes clobber + on one element; lose the seam. +- **Uniform consume shape — every axis.** Always `var(--Component-, +var(--_))`, including a size-invariant **base** axis. Two real mistakes to + avoid: (a) **bare literal default** for a base axis (`var(--seam, 14px)`) — + instead define `--_` (e.g. `--_padInline: 14px`) so the default lives in + one place and the shape matches sized axes; (b) **dropping a fallback because + the seam "is always set"** — keep it; the uniform shape is the contract (Button + `var(--Button-pad, var(--_pad))`; a sized axis carries `--_` in _both_ the + routing and the consume — that double-reference is intentional). Consistency + over minimalism. +- **Unprefixed `--_` safe only if every instance sets own.** Custom prop + inherits. Co-located setter (Button) or every root re-sets (OutlinedInput) -> + ancestor value never wins. Else prefix it. +- **Sibling can't inherit.** Sibling vars need common ancestor. Specific + component reaches sibling via `:has(~ &)`. Note: `+`/`~` match _following_ + siblings only -> `:has` makes the _earlier_ element the subject. +- **One element can't see another's internal var.** Label can't read input-root + `--_padBlock`. Reference **public** token (visible at `:root`) + literal + fallback. Never the internal var across elements. +- **Inline padding = outer gutter, not the adornment↔input gap.** The inline + token (`padInline`) is the border→first/last content inset (border→adornment + when adorned). The adornment↔text gap is the adornment's own margin + (`InputAdornment` marginRight/Left, ~8px), separate and untouched. Don't read + the gutter as the gap, and don't expect tokenizing it to move that gap. +- **Check if component defaults `size`.** Most components destructure a default + (Button: `size = 'medium'`) -> `ownerState.size` always valid -> `{ size: medium }` + variant matches, fine. But context-driven ones (InputBase/OutlinedInput read + `size` from FormControl, **no** default) -> `size` can be `undefined` -> put + medium routing in **base**, not a `{ size: medium }` variant (won't match + undefined). Tell: does the component have a `{ size: medium }` variant today? +- **Shorthand vs longhand.** Use `padding` shorthand to set block + override + earlier longhand (e.g. InputBase `paddingTop`); zero sides after with + `paddingLeft: 0`. +- **Pixel-identical = exact calc.** `calc` must compute today's px exactly + (`16.5 - 0.5 = 16` exact -> Argos zero-diff). Per-size sign trick when offset + flips (med `-0.5`, small `+0.5`). +- **`:has()` support** — Chrome 105 / Safari 15.4 / Firefox 121. Fine for + experiment; confirm baseline before ship. +- **`calc`/`var` resolve in browser only, not jsdom.** Assert density in + visual/screenshot tests, not unit. + +## Verify (per component) + +Screenshot harness `scripts/density-screenshots/` (`maxDiffPixels: 0`): + +1. Add matrix to `density-fixture.tsx` `demos` (+ token overrides to `scopes`). +2. Baseline from **master** (default unchanged by design): + `git checkout master -- ` -> `COMPONENT=X pnpm density:shot:update` -> restore. +3. `COMPONENT=X pnpm density:shot` -> default == baseline (gate) + dense/loose for eyeball. + +## Naming + +- Public seam/token: `--Component-` / `--Component--`. PascalCase + component, short semantic key (`pad`, `gap`, `padBlock`). Matches `--AppBar-background`. +- Internal: `--_` (leading underscore, no prefix). +- Key granularity = component's real spacing structure. One shorthand key + (`pad`) when sides set together on one element; split per axis only when forced + (see gotcha). Per axis: **sized by default** (per-size tunable); base token only + if per-size override is genuinely meaningless — a size-invariant _default_ alone + doesn't justify base (size it so density can tune it per size). +- **Boolean toggle (`dense`)** = **state token**: off-state is the plain seam + `--Component-` (don't qualify it); only the on-state is qualified + `--Component-dense-`. Never `--Component-normal/regular/default-`. + +## Order to roll out + +Small single-element first (prove pattern) -> bigger multi-element -> paired +sibling family. Done: Button, OutlinedInput (+ InputLabel, TextField outlined), +the dashboard set (Chip, IconButton, MenuItem, ListItem(+Button/Icon/Text), +ListSubheader, Toolbar, Tab/Tabs, TablePagination, CardContent, Select, +Breadcrumbs, InputAdornment, Badge), and the SwitchBase family (Checkbox, Radio, +Switch — Recipe C). Next candidates: FilledInput, Input (standard) — note +asymmetric block padding (`4/5`, `25/8`) -> need per-side seam, not single +`padBlock`. 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/color-showcase.tsx b/docs/pages/experiments/color-showcase.tsx new file mode 100644 index 00000000000000..7dd9ea3b74cc9e --- /dev/null +++ b/docs/pages/experiments/color-showcase.tsx @@ -0,0 +1,226 @@ +"use client"; +import * as React from "react"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Divider from "@mui/material/Divider"; +import Button from "@mui/material/Button"; +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import CssBaseline from "@mui/material/CssBaseline"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; + +// Client-facing showcase for the CSS-var color/state adapter (docs/adr/0002). +// Unlike density there is no `enhance*` function: the palette is the holistic +// surface, and the public color tokens are per-(variant, color, state, prop) +// deviations. Each preset is just a map of `--Button----` +// values applied to the gallery wrapper — demonstrating "set a token at any scope". +// The Default preset sets nothing, so every Button renders today's exact colors. + +type PresetKey = "default" | "brand"; + +type TokenMap = Record; + +// An enterprise look: primary = steel blue, secondary = neutral grey, recoloured +// (rest + hover) across all three variants from one place — the design-system +// override surface. Other palette colours are left untouched to show the override +// is scoped per (variant, color). +const brandBlue = "#2F6CA3"; +const brandBlueHover = "#255A8A"; +const brandGreyInk = "#374151"; +const brandGreyBorder = "#CBD2DA"; + +const presetTokens: Record = { + default: {}, + brand: { + // primary — blue + "--Button-contained-primary-bg": brandBlue, + "--Button-contained-primary-fg": "#FFFFFF", + "--Button-contained-primary-hover-bg": brandBlueHover, + // disabled contained = a light tint of the brand colour (not the default grey) + "--Button-contained-primary-disabled-bg": "#9FC2DB", + "--Button-contained-primary-disabled-fg": "#FFFFFF", + "--Button-text-primary-fg": brandBlue, + "--Button-text-primary-hover-bg": "rgba(47, 108, 163, 0.08)", + "--Button-outlined-primary-fg": brandBlue, + "--Button-outlined-primary-border": brandBlue, + "--Button-outlined-primary-hover-bg": "rgba(47, 108, 163, 0.08)", + // secondary — grey + "--Button-contained-secondary-bg": "#5B6675", + "--Button-contained-secondary-fg": "#FFFFFF", + "--Button-contained-secondary-hover-bg": "#4A5462", + "--Button-contained-secondary-disabled-bg": "#C2C7CE", + "--Button-contained-secondary-disabled-fg": "#FFFFFF", + "--Button-text-secondary-fg": brandGreyInk, + "--Button-text-secondary-hover-bg": "rgba(55, 65, 81, 0.08)", + "--Button-outlined-secondary-fg": brandGreyInk, + "--Button-outlined-secondary-border": brandGreyBorder, + "--Button-outlined-secondary-hover-bg": "rgba(55, 65, 81, 0.06)", + }, +}; + +const presetLabels: Record = { + default: "Default", + brand: "Brand", +}; + +const presetBlurbs: Record = { + default: "No tokens set — pixel-identical to today.", + brand: "primary → steel blue, secondary → grey, across all variants.", +}; + +const variants = ["text", "outlined", "contained"] as const; +const colors = ["primary", "secondary"] as const; + +const theme = createTheme({ cssVariables: true, shape: { borderRadius: 6 } }); + +const mono = { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 12, +} as const; + +function TokenPanel({ preset }: { preset: PresetKey }) { + const entries = Object.entries(presetTokens[preset]); + return ( +
+ + Color tokens ({entries.length}) + + {entries.length === 0 ? ( + + None — every Button falls back to its palette default. + + ) : ( + + {entries.map(([key, value]) => ( + + + {key} + + {": "} + + + + {value} + + + + ))} + + )} +
+ ); +} + +function Gallery() { + return ( + + {variants.map((variant) => ( + + + {variant} + + + {colors.map((color) => ( + + + + + ))} + + + ))} + + ); +} + +export default function ColorShowcase() { + const [preset, setPreset] = React.useState("default"); + + return ( + + + + + + Color presets + + + Each preset is a map of public `--Button-*` tokens applied to the gallery. Default sets + nothing and is identical to today. + + next && setPreset(next)} + sx={{ mb: 1 }} + > + {(Object.keys(presetLabels) as PresetKey[]).map((key) => ( + + {presetLabels[key]} + + ))} + + + {presetBlurbs[preset]} + + + States are interactive: hover with the mouse, Tab for focus, press for active. + + + + + Scope: Button only (first rollout). rest + hover + focus + active + disabled. + selected/inherit deferred. + + + + + + + + + ); +} diff --git a/docs/pages/experiments/density-fixture.tsx b/docs/pages/experiments/density-fixture.tsx new file mode 100644 index 00000000000000..3ccddaea7d40f8 --- /dev/null +++ b/docs/pages/experiments/density-fixture.tsx @@ -0,0 +1,300 @@ +'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 (docs/adr/0001). +// Used by scripts/density-screenshots. Renders one component's load-bearing +// matrix (shared `demos`) inside #density-scope; the harness sets `level` +// (default | dense | loose), which the scope translates into per-component +// 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', + }, + }, + OutlinedInput: { + dense: { + ['--OutlinedInput-small-padBlock' as any]: '4px', + ['--OutlinedInput-medium-padBlock' as any]: '10px', + ['--OutlinedInput-small-padInline' as any]: '6px', + ['--OutlinedInput-medium-padInline' as any]: '8px', + }, + loose: { + ['--OutlinedInput-small-padBlock' as any]: '14px', + ['--OutlinedInput-medium-padBlock' as any]: '28px', + ['--OutlinedInput-small-padInline' as any]: '20px', + ['--OutlinedInput-medium-padInline' as any]: '24px', + }, + }, + Chip: { + dense: { + ['--Chip-small-height' as any]: '18px', + ['--Chip-medium-height' as any]: '24px', + ['--Chip-small-padInline' as any]: '4px', + ['--Chip-medium-padInline' as any]: '6px', + }, + loose: { + ['--Chip-small-height' as any]: '32px', + ['--Chip-medium-height' as any]: '44px', + ['--Chip-small-padInline' as any]: '14px', + ['--Chip-medium-padInline' as any]: '20px', + }, + }, + IconButton: { + dense: { + ['--IconButton-small-pad' as any]: '1px', + ['--IconButton-medium-pad' as any]: '3px', + ['--IconButton-large-pad' as any]: '6px', + }, + loose: { + ['--IconButton-small-pad' as any]: '10px', + ['--IconButton-medium-pad' as any]: '16px', + ['--IconButton-large-pad' as any]: '22px', + }, + }, + MenuItem: { + dense: { + ['--MenuItem-minHeight' as any]: '36px', + ['--MenuItem-dense-minHeight' as any]: '24px', + ['--MenuItem-padBlock' as any]: '2px', + ['--MenuItem-dense-padBlock' as any]: '1px', + ['--MenuItem-padInline' as any]: '8px', + ['--MenuItem-dense-padInline' as any]: '6px', + }, + loose: { + ['--MenuItem-minHeight' as any]: '64px', + ['--MenuItem-dense-minHeight' as any]: '48px', + ['--MenuItem-padBlock' as any]: '14px', + ['--MenuItem-dense-padBlock' as any]: '10px', + ['--MenuItem-padInline' as any]: '28px', + ['--MenuItem-dense-padInline' as any]: '24px', + }, + }, + ListItemButton: { + dense: { + ['--ListItemButton-padBlock' as any]: '2px', + ['--ListItemButton-dense-padBlock' as any]: '0px', + ['--ListItemButton-padInline' as any]: '8px', + ['--ListItemButton-dense-padInline' as any]: '4px', + }, + loose: { + ['--ListItemButton-padBlock' as any]: '16px', + ['--ListItemButton-dense-padBlock' as any]: '12px', + ['--ListItemButton-padInline' as any]: '32px', + ['--ListItemButton-dense-padInline' as any]: '24px', + }, + }, + ListItemIcon: { + dense: { ['--ListItemIcon-minWidth' as any]: '24px' }, + loose: { ['--ListItemIcon-minWidth' as any]: '56px' }, + }, + ListItemText: { + dense: { + ['--ListItemText-marginBlock' as any]: '1px', + ['--ListItemText-dense-marginBlock' as any]: '0px', + ['--ListItemText-insetPad' as any]: '32px', + ['--ListItemText-dense-insetPad' as any]: '24px', + }, + loose: { + ['--ListItemText-marginBlock' as any]: '12px', + ['--ListItemText-dense-marginBlock' as any]: '8px', + ['--ListItemText-insetPad' as any]: '72px', + ['--ListItemText-dense-insetPad' as any]: '64px', + }, + }, + ListSubheader: { + dense: { + ['--ListSubheader-height' as any]: '32px', + ['--ListSubheader-padInline' as any]: '8px', + ['--ListSubheader-inset' as any]: '48px', + }, + loose: { + ['--ListSubheader-height' as any]: '64px', + ['--ListSubheader-padInline' as any]: '28px', + ['--ListSubheader-inset' as any]: '96px', + }, + }, + Toolbar: { + dense: { + ['--Toolbar-dense-minHeight' as any]: '32px', + ['--Toolbar-padInline' as any]: '8px', + }, + loose: { + ['--Toolbar-dense-minHeight' as any]: '72px', + ['--Toolbar-padInline' as any]: '40px', + }, + }, + Tab: { + dense: { + ['--Tab-padBlock' as any]: '4px', + ['--Tab-padInline' as any]: '8px', + ['--Tab-minHeight' as any]: '32px', + ['--Tab-iconSpacing' as any]: '2px', + }, + loose: { + ['--Tab-padBlock' as any]: '20px', + ['--Tab-padInline' as any]: '28px', + ['--Tab-minHeight' as any]: '72px', + ['--Tab-iconSpacing' as any]: '14px', + }, + }, + Tabs: { + dense: { + ['--Tabs-minHeight' as any]: '32px', + ['--Tab-padBlock' as any]: '4px', + ['--Tab-padInline' as any]: '8px', + ['--Tab-minHeight' as any]: '32px', + ['--Tab-iconSpacing' as any]: '2px', + }, + loose: { + ['--Tabs-minHeight' as any]: '72px', + ['--Tab-padBlock' as any]: '20px', + ['--Tab-padInline' as any]: '32px', + ['--Tab-minHeight' as any]: '72px', + ['--Tab-iconSpacing' as any]: '14px', + }, + }, + TablePagination: { + dense: { + ['--TablePagination-minHeight' as any]: '36px', + ['--TablePagination-actionsSpacing' as any]: '8px', + ['--TablePagination-selectSpacing' as any]: '12px', + }, + loose: { + ['--TablePagination-minHeight' as any]: '72px', + ['--TablePagination-actionsSpacing' as any]: '40px', + ['--TablePagination-selectSpacing' as any]: '56px', + }, + }, + CardContent: { + dense: { + ['--CardContent-pad' as any]: '8px', + ['--CardContent-padBottom' as any]: '10px', + }, + loose: { + ['--CardContent-pad' as any]: '32px', + ['--CardContent-padBottom' as any]: '40px', + }, + }, + Select: { + dense: { ['--Select-minHeight' as any]: '0.8em' }, + loose: { ['--Select-minHeight' as any]: '2.4em' }, + }, + Breadcrumbs: { + dense: { ['--Breadcrumbs-separatorGap' as any]: '2px' }, + loose: { ['--Breadcrumbs-separatorGap' as any]: '20px' }, + }, + InputAdornment: { + dense: { + ['--InputAdornment-small-gap' as any]: '2px', + ['--InputAdornment-medium-gap' as any]: '3px', + ['--InputAdornment-small-marginTop' as any]: '6px', + ['--InputAdornment-medium-marginTop' as any]: '10px', + }, + loose: { + ['--InputAdornment-small-gap' as any]: '16px', + ['--InputAdornment-medium-gap' as any]: '24px', + ['--InputAdornment-small-marginTop' as any]: '24px', + ['--InputAdornment-medium-marginTop' as any]: '32px', + }, + }, + Badge: { + dense: { + ['--Badge-standard-pad' as any]: '0 3px', + ['--Badge-standard-size' as any]: '14px', + ['--Badge-dot-size' as any]: '5px', + }, + loose: { + ['--Badge-standard-pad' as any]: '0 10px', + ['--Badge-standard-size' as any]: '28px', + ['--Badge-dot-size' as any]: '12px', + }, + }, + Checkbox: { + dense: { + ['--Checkbox-small-pad' as any]: '3px', + ['--Checkbox-medium-pad' as any]: '5px', + }, + loose: { + ['--Checkbox-small-pad' as any]: '7px', + ['--Checkbox-medium-pad' as any]: '13px', + }, + }, + Radio: { + dense: { + ['--Radio-small-pad' as any]: '3px', + ['--Radio-medium-pad' as any]: '5px', + }, + loose: { + ['--Radio-small-pad' as any]: '7px', + ['--Radio-medium-pad' as any]: '13px', + }, + }, + Switch: { + // Tune the interlocked dims + track gutter (pad); thumb pad/top/travel re-derive + // (touchSize == height keeps the thumb centered). thumbSize < height; width > + // touchSize; pad < height/2. + dense: { + ['--Switch-small-width' as any]: '32px', + ['--Switch-small-height' as any]: '18px', + ['--Switch-small-thumbSize' as any]: '12px', + ['--Switch-small-touchSize' as any]: '18px', + ['--Switch-small-pad' as any]: '5px', + ['--Switch-medium-width' as any]: '44px', + ['--Switch-medium-height' as any]: '24px', + ['--Switch-medium-thumbSize' as any]: '16px', + ['--Switch-medium-touchSize' as any]: '24px', + ['--Switch-medium-pad' as any]: '8px', + }, + loose: { + ['--Switch-small-width' as any]: '52px', + ['--Switch-small-height' as any]: '32px', + ['--Switch-small-thumbSize' as any]: '24px', + ['--Switch-small-touchSize' as any]: '32px', + ['--Switch-small-pad' as any]: '10px', + ['--Switch-medium-width' as any]: '76px', + ['--Switch-medium-height' as any]: '48px', + ['--Switch-medium-thumbSize' as any]: '34px', + ['--Switch-medium-touchSize' as any]: '48px', + ['--Switch-medium-pad' as any]: '16px', + }, + }, +}; +// TextField rides the same OutlinedInput tokens; OutlinedInput's `:has` rule +// drives the label's --InputLabel-y, so input box + label move together. +scopes.TextField = scopes.OutlinedInput; + +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-showcase.tsx b/docs/pages/experiments/density-showcase.tsx new file mode 100644 index 00000000000000..298cbafd7ee9ef --- /dev/null +++ b/docs/pages/experiments/density-showcase.tsx @@ -0,0 +1,282 @@ +'use client'; +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +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 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 FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; +import CssBaseline from '@mui/material/CssBaseline'; +import { createTheme, ThemeProvider, enhanceDensity, DensityScale } from '@mui/material/styles'; +import demos from 'docs/src/modules/components/densityDemos'; + +// Client-facing showcase for the CSS-var density adapter (docs/adr/0001). +// Three presets each map to one `enhanceDensity(theme, { scale })` call — the +// only knob is the 7-step density scale. Flip a preset -> the whole gallery +// reflows because every component pulls its sized tokens from `--mui-density-*`. + +type PresetKey = 'compact' | 'normal' | 'comfort'; + +// `normal` = enhanceDensity defaults (theme.spacing) -> pixel-identical to today. +// compact/comfort override every step explicitly. +const presetScales: Record | undefined> = { + compact: { + xxs: '2px', + xs: '4px', + sm: '6px', + md: '8px', + lg: '12px', + xl: '18px', + xxl: '24px', + }, + normal: undefined, + comfort: { + xxs: '6px', + xs: '8px', + sm: '12px', + md: '16px', + lg: '24px', + xl: '32px', + xxl: '40px', + }, +}; + +const presetLabels: Record = { + compact: 'Compact', + normal: 'Normal', + comfort: 'Comfort', +}; + +const scaleKeys: (keyof DensityScale)[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl']; + +// Build the three enhanced themes once. +const baseTheme = createTheme({ cssVariables: true }); +const themes: Record> = { + compact: enhanceDensity(createTheme({ cssVariables: true }), { scale: presetScales.compact }), + normal: enhanceDensity(createTheme({ cssVariables: true })), + comfort: enhanceDensity(createTheme({ cssVariables: true }), { scale: presetScales.comfort }), +}; + +// Clean px readout for the scale. Under `cssVariables`, `theme.spacing()` returns +// a `calc(... var(--mui-spacing) ...)` string, so for the Normal preset we read +// the resolved px from a non-css-var theme instead. +const displayScales: Record = { + compact: presetScales.compact as DensityScale, + normal: enhanceDensity(createTheme()).density, + comfort: presetScales.comfort as DensityScale, +}; + +// Pull the density-var mappings enhanceDensity injected per component. Each +// component's `styleOverrides.root` is `[originalRoot, { '--Component-*': ... }]`; +// scan every plain-object element for the top-level `--*` token entries. +type VarMap = Record; +function collectComponentVars(theme: ReturnType): Record { + const out: Record = {}; + const components = (theme.components ?? {}) as Record; + Object.keys(components).forEach((name) => { + if (name === 'MuiCssBaseline') { + return; + } + const root = components[name]?.styleOverrides?.root; + const elements = Array.isArray(root) ? root : [root]; + const vars: VarMap = {}; + elements.forEach((el) => { + if (!el || typeof el !== 'object') { + return; + } + Object.keys(el).forEach((key) => { + if (key.startsWith('--')) { + vars[key] = String(el[key]); + } + }); + }); + if (Object.keys(vars).length > 0) { + out[name.replace(/^Mui/, '')] = vars; + } + }); + return out; +} + +const componentVarsByPreset: Record> = { + compact: collectComponentVars(themes.compact), + normal: collectComponentVars(themes.normal), + comfort: collectComponentVars(themes.comfort), +}; + +const mono = { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: 12, +} as const; + +// Outline every box inside a demo so the density effect (padding/height shifts) +// is visible at a glance. `outline` is drawn outside the box and takes no layout +// space, so toggling it never reflows the gallery — the geometry stays honest. +const demoOutlineSx = { + '& *': { + outline: '1px solid rgba(244, 67, 54, 0.5)', + outlineOffset: '-1px', + }, +} as const; + +function ScalePanel({ preset }: { preset: PresetKey }) { + const scale = displayScales[preset]; + return ( +
+ + Density scale + + + {scaleKeys.map((key) => ( + + {`--mui-density-${key}`} + {scale[key]} + + ))} + +
+ ); +} + +function VarsPanel({ preset }: { preset: PresetKey }) { + const byComponent = componentVarsByPreset[preset]; + const names = Object.keys(byComponent); + return ( +
+ + Component tokens ({names.length}) + + + {names.map((name) => ( + + } sx={{ minHeight: 36, px: 0 }}> + {name} + + + + {Object.entries(byComponent[name]).map(([key, value]) => ( + + + {key} + + {': '} + + {value} + + + ))} + + + + ))} + +
+ ); +} + +export default function DensityShowcase() { + const [preset, setPreset] = React.useState('normal'); + const [outline, setOutline] = React.useState(false); + + return ( + // Outer shell uses the base theme; the gallery + sidebar readouts use the + // enhanced theme so they reflect the active preset. + + + + + Density presets + + + One scale drives every component. Normal is pixel-identical to today. + + next && setPreset(next)} + sx={{ mb: 2 }} + > + {(Object.keys(presetLabels) as PresetKey[]).map((key) => ( + + {presetLabels[key]} + + ))} + + setOutline(event.target.checked)} + /> + } + label="Outline demos" + sx={{ mb: 2, display: 'flex' }} + /> + + + + + + + + + + {Object.keys(demos).map((name) => ( + + + {name} + + {demos[name]} + + ))} + + + + + + ); +} diff --git a/docs/pages/experiments/density-tokens.tsx b/docs/pages/experiments/density-tokens.tsx new file mode 100644 index 00000000000000..4738c3dce98eaa --- /dev/null +++ b/docs/pages/experiments/density-tokens.tsx @@ -0,0 +1,280 @@ +'use client'; +import * as React from 'react'; +import { createTheme, ThemeProvider, enhanceDensity } from '@mui/material/styles'; +import CssBaseline from '@mui/material/CssBaseline'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import InputAdornment from '@mui/material/InputAdornment'; +import InputLabel from '@mui/material/InputLabel'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import Paper from '@mui/material/Paper'; +import Slider from '@mui/material/Slider'; +import Stack from '@mui/material/Stack'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; + +// Density experiment — CSS-var adapter (docs/adr/0001-css-var-density-adapter.md). +// Agnostic layer: Button consumes `var(--Button-pad, var(--_pad))`. Material UI +// layer sets the (variant, size) literal default `--_pad` and the built-in-size +// routing `--Button-pad: var(--Button--pad, var(--_pad))` in `variants` +// (custom sizes route inline). `enhanceDensity` wires the sized tokens to the +// `--mui-density-*` scale. OutlinedInput applies the same model block-only: the +// root routes `--OutlinedInput-padBlock`, the input inherits it. + +const VARIANTS = ['text', 'outlined', 'contained'] as const; +const SIZES = ['small', 'medium', 'large'] as const; + +const theme = enhanceDensity(createTheme({ cssVariables: true })); + +function ButtonMatrix() { + return ( + + {VARIANTS.map((variant) => ( + + + {variant} + + + {SIZES.map((size) => ( + + ))} + + + ))} + + ); +} + +function OutlinedInputMatrix() { + return ( + + {(['small', 'medium'] as const).map((size) => ( + + + + @} + /> + + {`label ${size}`} + + + + ))} + + ); +} + +function Panel({ + title, + caption, + style, + children, +}: { + title: string; + caption: string; + style?: React.CSSProperties; + children: React.ReactNode; +}) { + return ( + + {title} + + {caption} + + {children} + + ); +} + +export default function DensityTokens() { + // --mui-density-* live retune (overrides the scale at this scope). + const [densityXs, setDensityXs] = React.useState(6); + const [densityLg, setDensityLg] = React.useState(16); + // Per-token overrides (sized-only). + const [smallPad, setSmallPad] = React.useState(''); + const [largePad, setLargePad] = React.useState(''); + // OutlinedInput block-density overrides. + const [smallPadBlock, setSmallPadBlock] = React.useState(''); + const [mediumPadBlock, setMediumPadBlock] = React.useState(''); + + const densityScope: React.CSSProperties = { + // Retunes every enhanced button without rebuilding the theme. + ['--mui-density-xs' as any]: `${densityXs}px`, + ['--mui-density-lg' as any]: `${densityLg}px`, + }; + + const tokenScope: React.CSSProperties = { + ...(smallPad ? { ['--Button-small-pad' as any]: smallPad } : null), + ...(largePad ? { ['--Button-large-pad' as any]: largePad } : null), + }; + + const inputTokenScope: React.CSSProperties = { + ...(smallPadBlock ? { ['--OutlinedInput-small-padBlock' as any]: smallPadBlock } : null), + ...(mediumPadBlock ? { ['--OutlinedInput-medium-padBlock' as any]: mediumPadBlock } : null), + }; + + return ( + + + + + + Density tokens — CSS-var adapter + + + The agnostic layer consumes --Button-pad; the Material UI layer feeds it + inline through the sized token --Button-<size>-pad over an internal + literal default, so the default is pixel-identical. Resolution is sized-only (no all-sizes + base token). enhanceDensity wires the sized tokens to the{' '} + --mui-density-* scale. + + + + + + + + + + + + + + Density scale + + + --mui-density-xs (medium block): {densityXs}px + + setDensityXs(value as number)} + /> + + + + --mui-density-lg (medium inline): {densityLg}px + + setDensityLg(value as number)} + /> + + + + + + + Per-token override (granular) + setSmallPad(event.target.value)} + /> + setLargePad(event.target.value)} + /> + + + Scoped preview + + + + + + + + + + OutlinedInput — block density + + + Input density is vertical only: the root routes the size-resolved{' '} + --OutlinedInput-padBlock and the input inherits it; the{' '} + 14px inline gutter is constant. Set{' '} + --OutlinedInput-<size>-padBlock to retune — it reflows the input + (non-multiline) and the root (multiline) together, across adornments. The last column + is a FormControl + InputLabel + OutlinedInput: OutlinedInput + reaches its sibling label via :has and sets --InputLabel-y from + the same token, so the resting label stays centered under override. + + + + + + + + + + + setSmallPadBlock(event.target.value)} + /> + setMediumPadBlock(event.target.value)} + /> + + + + enhanceDensity toggle + + } + label="enhanceDensity is applied to this page's theme (createTheme is untouched)." + /> + + + ); +} diff --git a/docs/pages/experiments/menu-showcase.tsx b/docs/pages/experiments/menu-showcase.tsx new file mode 100644 index 00000000000000..a3dcb80713dcb2 --- /dev/null +++ b/docs/pages/experiments/menu-showcase.tsx @@ -0,0 +1,187 @@ +"use client"; +import * as React from "react"; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Divider from "@mui/material/Divider"; +import MenuList from "@mui/material/MenuList"; +import MenuItem from "@mui/material/MenuItem"; +import ToggleButton from "@mui/material/ToggleButton"; +import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; +import CssBaseline from "@mui/material/CssBaseline"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; + +// Client-facing showcase for the CSS-var color/state adapter (docs/adr/0002) on +// MenuItem — single-axis (no variant/color), so tokens are `--MenuItem--`. +// Background is a value-state in every state; foreground and the inset-ring border +// are inert by default (settable per state). Each preset is a map applied to the +// MenuList; Default sets nothing and is identical to today. + +type PresetKey = "default" | "brand"; + +type TokenMap = Record; + +const presetTokens: Record = { + default: {}, + // Enterprise look from the mockup: grey hover, a subtle focus fill, and a + // light-blue selected fill with a blue border + blue text. Focus indication is + // an outline (a separate concern), so it is not tokenized as a border here. + brand: { + "--MenuItem-hover-bg": "#EFF1F3", + "--MenuItem-focus-bg": "#E8EFF6", + "--MenuItem-selected-bg": "#DCEAF5", + "--MenuItem-selected-fg": "#1B4B73", + "--MenuItem-selected-border": "#A9CCE6", + "--MenuItem-selected-hover-bg": "#CFE2F2", + "--MenuItem-selected-focus-bg": "#DCEAF5", + }, +}; + +const presetLabels: Record = { + default: "Default", + brand: "Brand", +}; + +const presetBlurbs: Record = { + default: "No tokens set — identical to today.", + brand: "Grey hover, subtle focus fill, light-blue selected with border + ink.", +}; + +const theme = createTheme({ cssVariables: true, shape: { borderRadius: 6 } }); + +const mono = { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 12, +} as const; + +// `--_bg` is the internal default the styled root reads; forcing it inline is the +// only way to preview the `:hover` fill without an actual pointer over the item. +const forceHover = { + "--_bg": "var(--MenuItem-hover-bg, rgba(0, 0, 0, 0.04))", +} as React.CSSProperties; + +function TokenPanel({ preset }: { preset: PresetKey }) { + const entries = Object.entries(presetTokens[preset]); + return ( +
+ + Color tokens ({entries.length}) + + {entries.length === 0 ? ( + + None — every MenuItem falls back to its default. + + ) : ( + + {entries.map(([key, value]) => ( + + + {key} + + {": "} + + + + {value} + + + + ))} + + )} +
+ ); +} + +function Menu({ preset }: { preset: PresetKey }) { + return ( + + + Solid + {/* forced focus-visible to preview the ring without keyboard nav */} + Rectangular + {/* forced hover fill (see forceHover) */} + Hexagonal + Point connection net + + + ); +} + +export default function MenuShowcase() { + const [preset, setPreset] = React.useState("default"); + + return ( + + + + + + Menu presets + + + Each preset is a map of public `--MenuItem-*` tokens applied to the MenuList. Default + sets nothing and is identical to today. + + next && setPreset(next)} + sx={{ mb: 1 }} + > + {(Object.keys(presetLabels) as PresetKey[]).map((key) => ( + + {presetLabels[key]} + + ))} + + + {presetBlurbs[preset]} + + + Rows show rest · focus (forced) · hover (forced) · selected. Real hover/keyboard focus + also work. + + + + + Scope: MenuItem. bg (per state) + fg + border ring. disabled stays opacity-based. + + + + + + + + + ); +} diff --git a/docs/src/modules/components/densityDemos.tsx b/docs/src/modules/components/densityDemos.tsx new file mode 100644 index 00000000000000..865057f24dc78f --- /dev/null +++ b/docs/src/modules/components/densityDemos.tsx @@ -0,0 +1,615 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Chip from '@mui/material/Chip'; +import Avatar from '@mui/material/Avatar'; +import IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import ListSubheader from '@mui/material/ListSubheader'; +import Toolbar from '@mui/material/Toolbar'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TablePagination from '@mui/material/TablePagination'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import CardActions from '@mui/material/CardActions'; +import Select from '@mui/material/Select'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Link from '@mui/material/Link'; +import Badge from '@mui/material/Badge'; +import Checkbox from '@mui/material/Checkbox'; +import Radio from '@mui/material/Radio'; +import Switch from '@mui/material/Switch'; +import Typography from '@mui/material/Typography'; +import FaceIcon from '@mui/icons-material/Face'; +import DeleteIcon from '@mui/icons-material/Delete'; +import InboxIcon from '@mui/icons-material/Inbox'; +import DraftsIcon from '@mui/icons-material/Drafts'; +import FavoriteIcon from '@mui/icons-material/Favorite'; +import VisibilityIcon from '@mui/icons-material/Visibility'; + +// Shared density demo matrices for the CSS-var density adapter (docs/adr/0001). +// Consumed by both the screenshot fixture (density-fixture) and the client +// showcase (density-showcase). Each entry renders one component's load-bearing +// size/variant matrix. `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) => ( + + + + + + ))} + + ), + OutlinedInput: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + @} + /> + + ))} + + ), + TextField: ( + + {(['medium', 'small'] as const).map((size) => ( + + {`outlined ${size}`} + + + ))} + + $, + endAdornment: kg, + }, + }} + /> + + + ), + Chip: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + A} /> + } /> + {}} /> + {}} + icon={} + /> + + ))} + + ), + IconButton: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + 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 + + + ), + ListItem: ( + + + Default item + + + + } + > + With secondary action + + Divider item + + + + + + Dense item + + + + } + > + Dense with action + + Dense, no gutters + + + ), + ListItemButton: ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + ListItemIcon: ( + + + + + + + + + + + + + + + + + + + + + + + + + ), + ListItemText: ( + + {([false, true] as const).map((dense) => ( + + + + + + + + + + + + + + + ))} + + ), + ListSubheader: ( + + + Gutters (default) + + + Inset + + + Disable gutters + + + Primary color + + + ), + Toolbar: ( + + {(['regular', 'dense'] as const).map((variant) => ( + + + {variant} gutters + action + + + {variant} no-gutters + action + + + ))} + + ), + Tab: ( + // Tab requires a Tabs ancestor (RovingTabIndexContext). + + {}}> + + + + + {}}> + } label="Top" iconPosition="top" value={0} /> + } label="Bottom" iconPosition="bottom" value={1} /> + } label="Start" iconPosition="start" value={2} /> + } label="End" iconPosition="end" value={3} /> + + {}}> + } aria-label="icon only" value={0} /> + + + + ), + Tabs: ( + + + + + + + + } label="Top" iconPosition="top" /> + } label="Bottom" iconPosition="bottom" /> + } label="Start" iconPosition="start" /> + } label="End" iconPosition="end" /> + + + } aria-label="fav" /> + } aria-label="del" /> + + + ), + TablePagination: ( + + + + + {}} + onRowsPerPageChange={() => {}} + /> + + + {}} + onRowsPerPageChange={() => {}} + /> + + + {}} + onRowsPerPageChange={() => {}} + /> + + +
+
+ ), + CardContent: ( + + + + Default + All-sides padding via --CardContent-pad. + + + + + Last-child + + Extra bottom inset (--CardContent-padBottom) since this is the last child. + + + + + + Above actions + Not last child -> base pad only on the bottom. + + + + + + + ), + Select: ( + + + + + + + ), + Breadcrumbs: ( + + + + Home + + + Catalog + + Shoes + + + + Home + + + Library + + + Data + + Reports + + + + Home + + + Catalog + + + Accessories + + Belts + + + ), + InputAdornment: ( + + {(['small', 'medium'] as const).map((size) => ( + + $ }, + }} + /> + + + + + + ), + }, + }} + /> + + ))} + + ), + Badge: ( + + + + + + + + + + + + + + + + + + ), + Checkbox: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + + + + + + ))} + + ), + Radio: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + + + + + ))} + + ), + Switch: ( + + {(['small', 'medium'] as const).map((size) => ( + + + + + + + + ))} + + ), +}; + +export default demos; diff --git a/package.json b/package.json index bc79cce8e22d66..947d6d59113317 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/Badge/Badge.js b/packages/mui-material/src/Badge/Badge.js index 45f29b514875a6..5ebf4072cb4e77 100644 --- a/packages/mui-material/src/Badge/Badge.js +++ b/packages/mui-material/src/Badge/Badge.js @@ -80,10 +80,14 @@ const BadgeBadge = styled('span', { fontFamily: theme.typography.fontFamily, fontWeight: theme.typography.fontWeightMedium, fontSize: theme.typography.pxToRem(12), - minWidth: RADIUS_STANDARD * 2, + '--_pad': '0 6px', + '--_size': `${RADIUS_STANDARD * 2}px`, + '--Badge-pad': 'var(--Badge-standard-pad, var(--_pad))', + '--Badge-size': 'var(--Badge-standard-size, var(--_size))', + minWidth: 'var(--Badge-size, var(--_size))', lineHeight: 1, - padding: '0 6px', - height: RADIUS_STANDARD * 2, + padding: 'var(--Badge-pad, var(--_pad))', + height: 'var(--Badge-size, var(--_size))', borderRadius: RADIUS_STANDARD, zIndex: 1, // Render the badge on top of potential ripples. '@media (forced-colors: active)': { @@ -106,10 +110,14 @@ const BadgeBadge = styled('span', { { props: { variant: 'dot' }, style: { + '--_pad': '0px', + '--_size': `${RADIUS_DOT * 2}px`, + '--Badge-pad': 'var(--Badge-dot-pad, var(--_pad))', + '--Badge-size': 'var(--Badge-dot-size, var(--_size))', borderRadius: RADIUS_DOT, - height: RADIUS_DOT * 2, - minWidth: RADIUS_DOT * 2, - padding: 0, + height: 'var(--Badge-size, var(--_size))', + minWidth: 'var(--Badge-size, var(--_size))', + padding: 'var(--Badge-pad, var(--_pad))', }, }, { diff --git a/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js index 63cb6bae7f3538..91e2f297f9e326 100644 --- a/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js +++ b/packages/mui-material/src/Breadcrumbs/Breadcrumbs.js @@ -49,10 +49,11 @@ const BreadcrumbsSeparator = styled('li', { name: 'MuiBreadcrumbs', slot: 'Separator', })({ + '--_separatorGap': '8px', display: 'flex', userSelect: 'none', - marginLeft: 8, - marginRight: 8, + marginLeft: 'var(--Breadcrumbs-separatorGap, var(--_separatorGap))', + marginRight: 'var(--Breadcrumbs-separatorGap, var(--_separatorGap))', }); function insertSeparators(items, className, separator, ownerState) { diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index 13d4b1ecad8c32..5bdfbc3e8ac22a 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -1,22 +1,22 @@ -'use client'; -import * as React from 'react'; -import PropTypes from 'prop-types'; -import clsx from 'clsx'; -import resolveProps from '@mui/utils/resolveProps'; -import composeClasses from '@mui/utils/composeClasses'; -import { unstable_useId as useId } from '../utils'; -import rootShouldForwardProp from '../styles/rootShouldForwardProp'; -import { styled } from '../zero-styled'; -import memoTheme from '../utils/memoTheme'; -import { useDefaultProps } from '../DefaultPropsProvider'; -import ButtonBase from '../ButtonBase'; -import CircularProgress from '../CircularProgress'; -import capitalize from '../utils/capitalize'; -import createSimplePaletteValueFilter from '../utils/createSimplePaletteValueFilter'; -import buttonClasses, { getButtonUtilityClass } from './buttonClasses'; -import ButtonGroupContext from '../ButtonGroup/ButtonGroupContext'; -import ButtonGroupButtonContext from '../ButtonGroup/ButtonGroupButtonContext'; -import { getTransitionStyles } from '../transitions/utils'; +"use client"; +import * as React from "react"; +import PropTypes from "prop-types"; +import clsx from "clsx"; +import resolveProps from "@mui/utils/resolveProps"; +import composeClasses from "@mui/utils/composeClasses"; +import { unstable_useId as useId } from "../utils"; +import rootShouldForwardProp from "../styles/rootShouldForwardProp"; +import { styled } from "../zero-styled"; +import memoTheme from "../utils/memoTheme"; +import { useDefaultProps } from "../DefaultPropsProvider"; +import ButtonBase from "../ButtonBase"; +import CircularProgress from "../CircularProgress"; +import capitalize from "../utils/capitalize"; +import createSimplePaletteValueFilter from "../utils/createSimplePaletteValueFilter"; +import buttonClasses, { getButtonUtilityClass } from "./buttonClasses"; +import ButtonGroupContext from "../ButtonGroup/ButtonGroupContext"; +import ButtonGroupButtonContext from "../ButtonGroup/ButtonGroupButtonContext"; +import { getTransitionStyles } from "../transitions/utils"; const useUtilityClasses = (ownerState) => { const { color, disableElevation, fullWidth, size, variant, loading, loadingPosition, classes } = @@ -24,19 +24,19 @@ const useUtilityClasses = (ownerState) => { const slots = { root: [ - 'root', - loading && 'loading', + "root", + loading && "loading", variant, `size${capitalize(size)}`, `color${capitalize(color)}`, - disableElevation && 'disableElevation', - fullWidth && 'fullWidth', + disableElevation && "disableElevation", + fullWidth && "fullWidth", loading && `loadingPosition${capitalize(loadingPosition)}`, ], - startIcon: ['icon', 'startIcon'], - endIcon: ['icon', 'endIcon'], - loadingIndicator: ['loadingIndicator'], - loadingWrapper: ['loadingWrapper'], + startIcon: ["icon", "startIcon"], + endIcon: ["icon", "endIcon"], + loadingIndicator: ["loadingIndicator"], + loadingWrapper: ["loadingWrapper"], }; const composedClasses = composeClasses(slots, getButtonUtilityClass, classes); @@ -49,35 +49,123 @@ const useUtilityClasses = (ownerState) => { const commonIconStyles = [ { - props: { size: 'small' }, + props: { size: "small" }, style: { - '& > *:nth-of-type(1)': { + "& > *:nth-of-type(1)": { fontSize: 18, }, }, }, { - props: { size: 'medium' }, + props: { size: "medium" }, style: { - '& > *:nth-of-type(1)': { + "& > *:nth-of-type(1)": { fontSize: 20, }, }, }, { - props: { size: 'large' }, + props: { size: "large" }, style: { - '& > *:nth-of-type(1)': { + "& > *:nth-of-type(1)": { fontSize: 22, }, }, }, ]; +// Built-in sizes route padding via variants; any other size routes inline. +const buttonSizes = ["small", "medium", "large"]; + +// Color/state adapter (docs/adr/0002). Each variant-prop is consumed through the +// pre-existing `--variant-*` seam over a private `--_` default. +// Two kinds of state: +// • value-state (rest / hover / disabled) — genuinely sets the prop. Its public +// token lives INSIDE the default: `--_: var(, )`, and the +// seam reads `var(--_)`. So a rest/hover override is captured in the default. +// • inert-state (focus / active) — Button moves only box-shadow. It routes its +// token in the SEAM over the default: `--variant-: var(, var(--_))`. +// Unset, it inherits whatever the default currently resolves to (incl. a rest or +// hover override) — never the bare palette literal. +// Unset, every token falls back to today's palette value (color-identical). `rest` +// omits the `` segment; `focus` maps to `.Mui-focusVisible`. +const buildButtonColorVars = (theme, color) => { + const vars = theme.vars || theme; + const main = vars.palette[color].main; + const dark = vars.palette[color].dark; + const contrastText = vars.palette[color].contrastText; + const hoverBg = theme.alpha(main, vars.palette.action.hoverOpacity); + const outlinedBorderRest = theme.alpha(main, 0.5); + const disabledBg = vars.palette.action.disabledBackground; + const disabledFg = vars.palette.action.disabled; + + // One entry per variant-prop: its seam suffix, variant, prop key, and the palette + // literal each value-state resolves to today (`hover`/`disabled` omitted = inert there). + const props = [ + { + vp: "containedBg", + variant: "contained", + prop: "bg", + rest: main, + hover: dark, + disabled: disabledBg, + }, + { + vp: "containedColor", + variant: "contained", + prop: "fg", + rest: contrastText, + disabled: disabledFg, + }, + { vp: "textColor", variant: "text", prop: "fg", rest: main, disabled: disabledFg }, + { vp: "textBg", variant: "text", prop: "bg", rest: "transparent", hover: hoverBg }, + { vp: "outlinedColor", variant: "outlined", prop: "fg", rest: main, disabled: disabledFg }, + { + vp: "outlinedBorder", + variant: "outlined", + prop: "border", + rest: outlinedBorderRest, + hover: main, + disabled: disabledBg, + }, + { vp: "outlinedBg", variant: "outlined", prop: "bg", rest: "transparent", hover: hoverBg }, + ]; + const token = (variant, state, prop) => + `--Button-${variant}-${color}${state ? `-${state}` : ""}-${prop}`; + + // Build a state's style. `valueKey` names the palette field for value-states + // (rest = the literal directly); null marks an inert state (all props routed). + const block = (state, valueKey) => { + const style = {}; + props.forEach((p) => { + const value = valueKey === "rest" ? p.rest : p[valueKey]; + if (value !== undefined) { + // value-state: token inside the default, seam just reads it + style[`--_${p.vp}`] = `var(${token(p.variant, state, p.prop)}, ${value})`; + style[`--variant-${p.vp}`] = `var(--_${p.vp})`; + } else { + // inert for this state: route the token over the current default + style[`--variant-${p.vp}`] = `var(${token(p.variant, state, p.prop)}, var(--_${p.vp}))`; + } + }); + return style; + }; + + return { + ...block("", "rest"), + "@media (hover: hover)": { "&:hover": block("hover", "hover") }, + [`&.${buttonClasses.focusVisible}`]: block("focus", null), + "&:active": block("active", null), + // disabled: the value-states advance to today's neutral greys (color-invariant); + // replaces the literal disabled rules removed from root/contained/outlined. + [`&.${buttonClasses.disabled}`]: block("disabled", "disabled"), + }; +}; + const ButtonRoot = styled(ButtonBase, { - shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === 'classes', - name: 'MuiButton', - slot: 'Root', + shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === "classes", + name: "MuiButton", + slot: "Root", overridesResolver: (props, styles) => { const { ownerState } = props; @@ -85,7 +173,7 @@ const ButtonRoot = styled(ButtonBase, { styles.root, styles[ownerState.variant], styles[`size${capitalize(ownerState.size)}`], - ownerState.color === 'inherit' && styles.colorInherit, + ownerState.color === "inherit" && styles.colorInherit, ownerState.disableElevation && styles.disableElevation, ownerState.fullWidth && styles.fullWidth, ownerState.loading && styles.loading, @@ -94,69 +182,80 @@ const ButtonRoot = styled(ButtonBase, { })( memoTheme(({ theme }) => { const inheritContainedBackgroundColor = - theme.palette.mode === 'light' ? theme.palette.grey[300] : theme.palette.grey[800]; + theme.palette.mode === "light" ? theme.palette.grey[300] : theme.palette.grey[800]; const inheritContainedHoverBackgroundColor = - theme.palette.mode === 'light' ? theme.palette.grey.A100 : theme.palette.grey[700]; + theme.palette.mode === "light" ? theme.palette.grey.A100 : theme.palette.grey[700]; return { ...theme.typography.button, + // Agnostic layer: the only spacing surface the styled root reads. `--_pad` + // is the universal default (today's root padding); variants specialize it + // per (variant, size), so a custom variant/size still gets a sane value. + "--_pad": "6px 16px", + padding: "var(--Button-pad, var(--_pad))", minWidth: 64, - padding: '6px 16px', border: 0, borderRadius: (theme.vars || theme).shape.borderRadius, - ...getTransitionStyles(theme, ['background-color', 'box-shadow', 'border-color', 'color'], { + ...getTransitionStyles(theme, ["background-color", "box-shadow", "border-color", "color"], { duration: theme.transitions.duration.short, }), - '&:hover': { - textDecoration: 'none', - }, - [`&.${buttonClasses.disabled}`]: { - color: (theme.vars || theme).palette.action.disabled, + "&:hover": { + textDecoration: "none", }, variants: [ + // Built-in size routing (CSS, deduped) — exposes the public sized token + // over the internal default. Custom sizes are routed inline instead. { - props: { variant: 'contained' }, + props: { size: "small" }, + style: { "--Button-pad": "var(--Button-small-pad, var(--_pad))" }, + }, + { + props: { size: "medium" }, + style: { "--Button-pad": "var(--Button-medium-pad, var(--_pad))" }, + }, + { + props: { size: "large" }, + style: { "--Button-pad": "var(--Button-large-pad, var(--_pad))" }, + }, + { + props: { variant: "contained" }, style: { + "--_pad": "6px 16px", // medium default; small/large override below color: `var(--variant-containedColor)`, backgroundColor: `var(--variant-containedBg)`, boxShadow: (theme.vars || theme).shadows[2], - '&:hover': { + "&:hover": { boxShadow: (theme.vars || theme).shadows[4], // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { + "@media (hover: none)": { boxShadow: (theme.vars || theme).shadows[2], }, }, - '&:active': { + "&:active": { boxShadow: (theme.vars || theme).shadows[8], }, [`&.${buttonClasses.focusVisible}`]: { boxShadow: (theme.vars || theme).shadows[6], }, [`&.${buttonClasses.disabled}`]: { - color: (theme.vars || theme).palette.action.disabled, boxShadow: (theme.vars || theme).shadows[0], - backgroundColor: (theme.vars || theme).palette.action.disabledBackground, }, }, }, { - props: { variant: 'outlined' }, + props: { variant: "outlined" }, style: { - padding: '5px 15px', - border: '1px solid currentColor', + "--_pad": "5px 15px", // medium default; small/large override below + border: "1px solid currentColor", borderColor: `var(--variant-outlinedBorder, currentColor)`, backgroundColor: `var(--variant-outlinedBg)`, color: `var(--variant-outlinedColor)`, - [`&.${buttonClasses.disabled}`]: { - border: `1px solid ${(theme.vars || theme).palette.action.disabledBackground}`, - }, }, }, { - props: { variant: 'text' }, + props: { variant: "text" }, style: { - padding: '6px 8px', + "--_pad": "6px 8px", // medium default; small/large override below color: `var(--variant-textColor)`, backgroundColor: `var(--variant-textBg)`, }, @@ -165,115 +264,99 @@ const ButtonRoot = styled(ButtonBase, { .filter(createSimplePaletteValueFilter()) .map(([color]) => ({ props: { color }, - style: { - '--variant-textColor': (theme.vars || theme).palette[color].main, - '--variant-outlinedColor': (theme.vars || theme).palette[color].main, - '--variant-outlinedBorder': theme.alpha( - (theme.vars || theme).palette[color].main, - 0.5, - ), - '--variant-containedColor': (theme.vars || theme).palette[color].contrastText, - '--variant-containedBg': (theme.vars || theme).palette[color].main, - '@media (hover: hover)': { - '&:hover': { - '--variant-containedBg': (theme.vars || theme).palette[color].dark, - '--variant-textBg': theme.alpha( - (theme.vars || theme).palette[color].main, - (theme.vars || theme).palette.action.hoverOpacity, - ), - '--variant-outlinedBorder': (theme.vars || theme).palette[color].main, - '--variant-outlinedBg': theme.alpha( - (theme.vars || theme).palette[color].main, - (theme.vars || theme).palette.action.hoverOpacity, - ), - }, - }, - }, + style: buildButtonColorVars(theme, color), })), { props: { - color: 'inherit', + color: "inherit", }, style: { - color: 'inherit', - borderColor: 'currentColor', - '--variant-containedBg': theme.vars + color: "inherit", + borderColor: "currentColor", + "--variant-containedBg": theme.vars ? theme.vars.palette.Button.inheritContainedBg : inheritContainedBackgroundColor, - '@media (hover: hover)': { - '&:hover': { - '--variant-containedBg': theme.vars + "@media (hover: hover)": { + "&:hover": { + "--variant-containedBg": theme.vars ? theme.vars.palette.Button.inheritContainedHoverBg : inheritContainedHoverBackgroundColor, - '--variant-textBg': theme.alpha( + "--variant-textBg": theme.alpha( (theme.vars || theme).palette.text.primary, - (theme.vars || theme).palette.action.hoverOpacity, + (theme.vars || theme).palette.action.hoverOpacity ), - '--variant-outlinedBg': theme.alpha( + "--variant-outlinedBg": theme.alpha( (theme.vars || theme).palette.text.primary, - (theme.vars || theme).palette.action.hoverOpacity, + (theme.vars || theme).palette.action.hoverOpacity ), }, }, + // inherit isn't routed through buildButtonColorVars, so restore the + // disabled greys the removed literal rules used to provide. + [`&.${buttonClasses.disabled}`]: { + color: (theme.vars || theme).palette.action.disabled, + borderColor: (theme.vars || theme).palette.action.disabledBackground, + "--variant-containedBg": (theme.vars || theme).palette.action.disabledBackground, + }, }, }, { props: { - size: 'small', - variant: 'text', + size: "small", + variant: "text", }, style: { - padding: '4px 5px', + "--_pad": "4px 5px", fontSize: theme.typography.pxToRem(13), }, }, { props: { - size: 'large', - variant: 'text', + size: "large", + variant: "text", }, style: { - padding: '8px 11px', + "--_pad": "8px 11px", fontSize: theme.typography.pxToRem(15), }, }, { props: { - size: 'small', - variant: 'outlined', + size: "small", + variant: "outlined", }, style: { - padding: '3px 9px', + "--_pad": "3px 9px", fontSize: theme.typography.pxToRem(13), }, }, { props: { - size: 'large', - variant: 'outlined', + size: "large", + variant: "outlined", }, style: { - padding: '7px 21px', + "--_pad": "7px 21px", fontSize: theme.typography.pxToRem(15), }, }, { props: { - size: 'small', - variant: 'contained', + size: "small", + variant: "contained", }, style: { - padding: '4px 10px', + "--_pad": "4px 10px", fontSize: theme.typography.pxToRem(13), }, }, { props: { - size: 'large', - variant: 'contained', + size: "large", + variant: "contained", }, style: { - padding: '8px 22px', + "--_pad": "8px 22px", fontSize: theme.typography.pxToRem(15), }, }, @@ -282,79 +365,79 @@ const ButtonRoot = styled(ButtonBase, { disableElevation: true, }, style: { - boxShadow: 'none', - '&:hover': { - boxShadow: 'none', + boxShadow: "none", + "&:hover": { + boxShadow: "none", }, [`&.${buttonClasses.focusVisible}`]: { - boxShadow: 'none', + boxShadow: "none", }, - '&:active': { - boxShadow: 'none', + "&:active": { + boxShadow: "none", }, [`&.${buttonClasses.disabled}`]: { - boxShadow: 'none', + boxShadow: "none", }, }, }, { props: { fullWidth: true }, - style: { width: '100%' }, + style: { width: "100%" }, }, { props: { - loadingPosition: 'center', + loadingPosition: "center", }, style: { - ...getTransitionStyles(theme, ['background-color', 'box-shadow', 'border-color'], { + ...getTransitionStyles(theme, ["background-color", "box-shadow", "border-color"], { duration: theme.transitions.duration.short, }), [`&.${buttonClasses.loading}`]: { - color: 'transparent', + color: "transparent", }, }, }, ], }; - }), + }) ); -const ButtonStartIcon = styled('span', { - name: 'MuiButton', - slot: 'StartIcon', +const ButtonStartIcon = styled("span", { + name: "MuiButton", + slot: "StartIcon", overridesResolver: (props, styles) => { const { ownerState } = props; return [styles.startIcon, ownerState.loading && styles.startIconLoadingStart]; }, })(({ theme }) => ({ - display: 'inherit', - alignItems: 'center', + display: "inherit", + alignItems: "center", marginRight: 8, marginLeft: -4, - '&::before': { + "&::before": { content: '"\\200b"', width: 0, - overflow: 'hidden', + overflow: "hidden", }, variants: [ { - props: { size: 'small' }, + props: { size: "small" }, style: { marginLeft: -2, }, }, { - props: { loadingPosition: 'start', loading: true }, + props: { loadingPosition: "start", loading: true }, style: { - ...getTransitionStyles(theme, ['opacity'], { + ...getTransitionStyles(theme, ["opacity"], { duration: theme.transitions.duration.short, }), opacity: 0, }, }, { - props: { loadingPosition: 'start', loading: true, fullWidth: true }, + props: { loadingPosition: "start", loading: true, fullWidth: true }, style: { marginRight: -8, }, @@ -363,36 +446,36 @@ const ButtonStartIcon = styled('span', { ], })); -const ButtonEndIcon = styled('span', { - name: 'MuiButton', - slot: 'EndIcon', +const ButtonEndIcon = styled("span", { + name: "MuiButton", + slot: "EndIcon", overridesResolver: (props, styles) => { const { ownerState } = props; return [styles.endIcon, ownerState.loading && styles.endIconLoadingEnd]; }, })(({ theme }) => ({ - display: 'inherit', + display: "inherit", marginRight: -4, marginLeft: 8, variants: [ { - props: { size: 'small' }, + props: { size: "small" }, style: { marginRight: -2, }, }, { - props: { loadingPosition: 'end', loading: true }, + props: { loadingPosition: "end", loading: true }, style: { - ...getTransitionStyles(theme, ['opacity'], { + ...getTransitionStyles(theme, ["opacity"], { duration: theme.transitions.duration.short, }), opacity: 0, }, }, { - props: { loadingPosition: 'end', loading: true, fullWidth: true }, + props: { loadingPosition: "end", loading: true, fullWidth: true }, style: { marginLeft: -8, }, @@ -401,25 +484,25 @@ const ButtonEndIcon = styled('span', { ], })); -const ButtonLoadingIndicator = styled('span', { - name: 'MuiButton', - slot: 'LoadingIndicator', +const ButtonLoadingIndicator = styled("span", { + name: "MuiButton", + slot: "LoadingIndicator", })(({ theme }) => ({ - display: 'none', - position: 'absolute', - visibility: 'visible', + display: "none", + position: "absolute", + visibility: "visible", variants: [ - { props: { loading: true }, style: { display: 'flex' } }, + { props: { loading: true }, style: { display: "flex" } }, { - props: { loadingPosition: 'start' }, + props: { loadingPosition: "start" }, style: { left: 14, }, }, { props: { - loadingPosition: 'start', - size: 'small', + loadingPosition: "start", + size: "small", }, style: { left: 10, @@ -427,8 +510,8 @@ const ButtonLoadingIndicator = styled('span', { }, { props: { - variant: 'text', - loadingPosition: 'start', + variant: "text", + loadingPosition: "start", }, style: { left: 6, @@ -436,24 +519,24 @@ const ButtonLoadingIndicator = styled('span', { }, { props: { - loadingPosition: 'center', + loadingPosition: "center", }, style: { - left: '50%', - transform: 'translate(-50%)', + left: "50%", + transform: "translate(-50%)", color: (theme.vars || theme).palette.action.disabled, }, }, { - props: { loadingPosition: 'end' }, + props: { loadingPosition: "end" }, style: { right: 14, }, }, { props: { - loadingPosition: 'end', - size: 'small', + loadingPosition: "end", + size: "small", }, style: { right: 10, @@ -461,37 +544,37 @@ const ButtonLoadingIndicator = styled('span', { }, { props: { - variant: 'text', - loadingPosition: 'end', + variant: "text", + loadingPosition: "end", }, style: { right: 6, }, }, { - props: { loadingPosition: 'start', fullWidth: true }, + props: { loadingPosition: "start", fullWidth: true }, style: { - position: 'relative', + position: "relative", left: -10, }, }, { - props: { loadingPosition: 'end', fullWidth: true }, + props: { loadingPosition: "end", fullWidth: true }, style: { - position: 'relative', + position: "relative", right: -10, }, }, ], })); -const ButtonLoadingIconPlaceholder = styled('span', { - name: 'MuiButton', - slot: 'LoadingIconPlaceholder', +const ButtonLoadingIconPlaceholder = styled("span", { + name: "MuiButton", + slot: "LoadingIconPlaceholder", })({ - display: 'inline-block', - width: '1em', - height: '1em', + display: "inline-block", + width: "1em", + height: "1em", }); const Button = React.forwardRef(function Button(inProps, ref) { @@ -499,11 +582,11 @@ const Button = React.forwardRef(function Button(inProps, ref) { const contextProps = React.useContext(ButtonGroupContext); const buttonGroupButtonContextPositionClassName = React.useContext(ButtonGroupButtonContext); const resolvedProps = resolveProps(contextProps, inProps); - const props = useDefaultProps({ props: resolvedProps, name: 'MuiButton' }); + const props = useDefaultProps({ props: resolvedProps, name: "MuiButton" }); const { children, - color = 'primary', - component = 'button', + color = "primary", + component = "button", className, disabled = false, disableElevation = false, @@ -514,11 +597,11 @@ const Button = React.forwardRef(function Button(inProps, ref) { id: idProp, loading = null, loadingIndicator: loadingIndicatorProp, - loadingPosition = 'center', - size = 'medium', + loadingPosition = "center", + size = "medium", startIcon: startIconProp, type, - variant = 'text', + variant = "text", ...other } = props; @@ -545,7 +628,15 @@ const Button = React.forwardRef(function Button(inProps, ref) { const classes = useUtilityClasses(ownerState); - const startIcon = (startIconProp || (loading && loadingPosition === 'start')) && ( + // Material UI layer: built-in sizes route the public sized token via variants + // (deduped CSS). A custom size has no such variant, so route it inline — the + // token name carries the runtime size string, keeping custom sizes tunable for + // free. The `--_pad` default lives in the variants. See docs/adr/0001. + const densityVars = buttonSizes.includes(size) + ? undefined + : { "--Button-pad": `var(--Button-${size}-pad, var(--_pad))` }; + + const startIcon = (startIconProp || (loading && loadingPosition === "start")) && ( {startIconProp || ( ); - const endIcon = (endIconProp || (loading && loadingPosition === 'end')) && ( + const endIcon = (endIconProp || (loading && loadingPosition === "end")) && ( {endIconProp || ( ); - const positionClassName = buttonGroupButtonContextPositionClassName || ''; + const positionClassName = buttonGroupButtonContextPositionClassName || ""; const loader = - typeof loading === 'boolean' ? ( + typeof loading === "boolean" ? ( // use plain HTML span to minimize the runtime overhead - + {loading && ( {loadingIndicator} @@ -597,12 +688,13 @@ const Button = React.forwardRef(function Button(inProps, ref) { type={type} id={loading ? loadingId : idProp} {...other} + style={{ ...densityVars, ...other.style }} classes={forwardedClasses} > {startIcon} - {loadingPosition !== 'end' && loader} + {loadingPosition !== "end" && loader} {children} - {loadingPosition === 'end' && loader} + {loadingPosition === "end" && loader} {endIcon} ); @@ -632,7 +724,7 @@ Button.propTypes /* remove-proptypes */ = { * @default 'primary' */ color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(['inherit', 'primary', 'secondary', 'success', 'error', 'info', 'warning']), + PropTypes.oneOf(["inherit", "primary", "secondary", "success", "error", "info", "warning"]), PropTypes.string, ]), /** @@ -702,20 +794,24 @@ Button.propTypes /* remove-proptypes */ = { * The loading indicator can be positioned on the start, end, or the center of the button. * @default 'center' */ - loadingPosition: PropTypes.oneOf(['center', 'end', 'start']), + loadingPosition: PropTypes.oneOf(["center", "end", "start"]), /** * The size of the component. * `small` is equivalent to the dense button styling. * @default 'medium' */ size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(['small', 'medium', 'large']), + PropTypes.oneOf(["small", "medium", "large"]), PropTypes.string, ]), /** * Element placed before the children. */ startIcon: PropTypes.node, + /** + * @ignore + */ + style: PropTypes.object, /** * The system prop that allows defining system overrides as well as additional CSS styles. */ @@ -733,7 +829,7 @@ Button.propTypes /* remove-proptypes */ = { * @default 'text' */ variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(['contained', 'outlined', 'text']), + PropTypes.oneOf(["contained", "outlined", "text"]), PropTypes.string, ]), }; diff --git a/packages/mui-material/src/CardContent/CardContent.js b/packages/mui-material/src/CardContent/CardContent.js index 769163a78edbad..6e447fd831cfa2 100644 --- a/packages/mui-material/src/CardContent/CardContent.js +++ b/packages/mui-material/src/CardContent/CardContent.js @@ -21,9 +21,11 @@ const CardContentRoot = styled('div', { name: 'MuiCardContent', slot: 'Root', })({ - padding: 16, + '--_pad': '16px', + '--_padBottom': '24px', + padding: 'var(--CardContent-pad, var(--_pad))', '&:last-child': { - paddingBottom: 24, + paddingBottom: 'var(--CardContent-padBottom, var(--_padBottom))', }, }); diff --git a/packages/mui-material/src/Checkbox/Checkbox.js b/packages/mui-material/src/Checkbox/Checkbox.js index ef4cbfc2c328f7..b13141cbe2527e 100644 --- a/packages/mui-material/src/Checkbox/Checkbox.js +++ b/packages/mui-material/src/Checkbox/Checkbox.js @@ -55,6 +55,16 @@ const CheckboxRoot = styled(SwitchBase, { memoTheme(({ theme }) => ({ color: (theme.vars || theme).palette.text.secondary, variants: [ + // Density: route the per-size public token into SwitchBase's seam. Default + // 9px both sizes (pixel-identical); size enables per-size density tuning. + { + props: { size: 'small' }, + style: { '--SwitchBase-pad': 'var(--Checkbox-small-pad, var(--_pad))' }, + }, + { + props: { size: 'medium' }, + style: { '--SwitchBase-pad': 'var(--Checkbox-medium-pad, var(--_pad))' }, + }, { props: { color: 'default', disableRipple: false }, style: { diff --git a/packages/mui-material/src/Chip/Chip.js b/packages/mui-material/src/Chip/Chip.js index 2f35de5dfc8d04..251ab9350d1e49 100644 --- a/packages/mui-material/src/Chip/Chip.js +++ b/packages/mui-material/src/Chip/Chip.js @@ -73,7 +73,11 @@ const ChipRoot = styled('div', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - height: 32, + // Agnostic seam: the styled root reads `--Chip-height`; `--_height` is the + // universal default (today's medium height). Size variants route the public + // sized token over it. See docs/adr/0001. + '--_height': '32px', + height: 'var(--Chip-height, var(--_height))', lineHeight: 1.5, color: (theme.vars || theme).palette.text.primary, backgroundColor: (theme.vars || theme).palette.action.selected, @@ -93,22 +97,27 @@ const ChipRoot = styled('div', { opacity: (theme.vars || theme).palette.action.disabledOpacity, pointerEvents: 'none', }, + // Density adapter (docs/adr/0001): avatar/icon/deleteIcon scale with the + // chip height. Each is `calc(var(--Chip-height) - inset)` where the inset + // reproduces today's medium size (height 32: avatar/icon 24, deleteIcon 22); + // the small variant overrides the inset for height 24. [`& .${chipClasses.avatar}`]: { marginLeft: 5, marginRight: -6, - width: 24, - height: 24, + width: 'calc(var(--Chip-height, var(--_height)) - 8px)', + height: 'calc(var(--Chip-height, var(--_height)) - 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, var(--_height)) - 8px)', }, [`& .${chipClasses.deleteIcon}`]: { WebkitTapHighlightColor: 'transparent', color: theme.alpha((theme.vars || theme).palette.text.primary, 0.26), - fontSize: 22, + fontSize: 'calc(var(--Chip-height, var(--_height)) - 10px)', cursor: 'pointer', margin: '0 5px 0 -6px', '&:hover': { @@ -116,6 +125,16 @@ const ChipRoot = styled('div', { }, }, variants: [ + // Built-in size routing (CSS, deduped) — exposes the public sized token + // over the internal default. Custom sizes are routed inline instead. + { + props: { size: 'small' }, + style: { '--Chip-height': 'var(--Chip-small-height, var(--_height))' }, + }, + { + props: { size: 'medium' }, + style: { '--Chip-height': 'var(--Chip-medium-height, var(--_height))' }, + }, { props: { color: 'primary', @@ -141,21 +160,21 @@ const ChipRoot = styled('div', { { props: { size: 'small' }, style: { - height: 24, + '--_height': '24px', // small default; medium default lives in base [`& .${chipClasses.avatar}`]: { marginLeft: 4, marginRight: -4, - width: 18, - height: 18, + width: 'calc(var(--Chip-height, var(--_height)) - 6px)', + height: 'calc(var(--Chip-height, var(--_height)) - 6px)', fontSize: theme.typography.pxToRem(10), }, [`& .${chipClasses.icon}`]: { - fontSize: 18, + fontSize: 'calc(var(--Chip-height, var(--_height)) - 6px)', marginLeft: 4, marginRight: -4, }, [`& .${chipClasses.deleteIcon}`]: { - fontSize: 16, + fontSize: 'calc(var(--Chip-height, var(--_height)) - 8px)', marginRight: 4, marginLeft: -4, }, @@ -200,7 +219,9 @@ const ChipRoot = styled('div', { [`&.${chipClasses.focusVisible}`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.action.selected, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.focusOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, ), }, }, @@ -226,13 +247,17 @@ const ChipRoot = styled('div', { '&:hover': { backgroundColor: theme.alpha( (theme.vars || theme).palette.action.selected, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.hoverOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.hoverOpacity + }`, ), }, [`&.${chipClasses.focusVisible}`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.action.selected, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.focusOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, ), }, '&:active': { @@ -328,29 +353,40 @@ const ChipLabel = styled('span', { })({ overflow: 'hidden', textOverflow: 'ellipsis', - paddingLeft: 12, - paddingRight: 12, + // Agnostic seam: the label reads `--Chip-padInline` on both sides; `--_padInline` + // is the universal default (today's medium filled inline padding). Variants + // specialize the default per (variant, size) and size variants route the public + // sized token over it. See docs/adr/0001. + '--_padInline': '12px', + paddingLeft: 'var(--Chip-padInline, var(--_padInline))', + paddingRight: 'var(--Chip-padInline, var(--_padInline))', whiteSpace: 'nowrap', variants: [ + // Built-in size routing (CSS, deduped). Custom sizes are routed inline. + { + props: { size: 'small' }, + style: { '--Chip-padInline': 'var(--Chip-small-padInline, var(--_padInline))' }, + }, + { + props: { size: 'medium' }, + style: { '--Chip-padInline': 'var(--Chip-medium-padInline, var(--_padInline))' }, + }, { props: { variant: 'outlined' }, style: { - paddingLeft: 11, - paddingRight: 11, + '--_padInline': '11px', // medium outlined default }, }, { props: { size: 'small' }, style: { - paddingLeft: 8, - paddingRight: 8, + '--_padInline': '8px', // small filled default }, }, { props: { size: 'small', variant: 'outlined' }, style: { - paddingLeft: 7, - paddingRight: 7, + '--_padInline': '7px', // small outlined default }, }, ], @@ -442,6 +478,21 @@ const Chip = React.forwardRef(function Chip(inProps, ref) { const classes = useUtilityClasses(ownerState); + // Material UI layer: built-in sizes route the public sized tokens via variants + // (deduped CSS). A custom size has no such variant, so route it inline on the + // root — the tokens inherit down to the label. The `--_*` defaults live in the + // variants. See docs/adr/0001. + const densityVars = + size === 'small' || size === 'medium' + ? undefined + : { + '--Chip-height': `var(--Chip-${size}-height, var(--_height))`, + // padInline is consumed on the label (a child); the root doesn't own + // `--_padInline`, so a cross-element route must fall back to a literal, + // never the internal var (else a missing token resolves to invalid -> 0). + '--Chip-padInline': `var(--Chip-${size}-padInline, 12px)`, + }; + const moreProps = component === ButtonBase ? { @@ -509,6 +560,7 @@ const Chip = React.forwardRef(function Chip(inProps, ref) { disabled: clickable && disabled ? true : undefined, tabIndex: skipFocusWhenDisabled && disabled ? -1 : tabIndex, ...moreProps, + ...(densityVars && { style: densityVars }), }, getSlotProps: (handlers) => ({ ...handlers, diff --git a/packages/mui-material/src/IconButton/IconButton.js b/packages/mui-material/src/IconButton/IconButton.js index 7bc22dce98e66b..2adc1ca1832f79 100644 --- a/packages/mui-material/src/IconButton/IconButton.js +++ b/packages/mui-material/src/IconButton/IconButton.js @@ -15,6 +15,9 @@ import capitalize from '../utils/capitalize'; import iconButtonClasses, { getIconButtonUtilityClass } from './iconButtonClasses'; import { getTransitionStyles } from '../transitions/utils'; +// Built-in sizes route padding via variants; any other size routes inline. +const iconButtonSizes = ['small', 'medium', 'large']; + const useUtilityClasses = (ownerState) => { const { classes, disabled, color, edge, size, loading } = ownerState; @@ -53,13 +56,31 @@ const IconButtonRoot = styled(ButtonBase, { textAlign: 'center', flex: '0 0 auto', fontSize: theme.typography.pxToRem(24), - padding: 8, + // Agnostic layer: the only spacing surface the styled root reads. `--_pad` + // is the universal default (today's medium padding); size variants specialize + // it, so a custom size still gets a sane value. See docs/adr/0001. + '--_pad': '8px', + padding: 'var(--IconButton-pad, var(--_pad))', borderRadius: '50%', color: (theme.vars || theme).palette.action.active, ...getTransitionStyles(theme, 'background-color', { duration: theme.transitions.duration.shortest, }), variants: [ + // Built-in size routing (CSS, deduped) — exposes the public sized token + // over the internal default. Custom sizes are routed inline instead. + { + props: { size: 'small' }, + style: { '--IconButton-pad': 'var(--IconButton-small-pad, var(--_pad))' }, + }, + { + props: { size: 'medium' }, + style: { '--IconButton-pad': 'var(--IconButton-medium-pad, var(--_pad))' }, + }, + { + props: { size: 'large' }, + style: { '--IconButton-pad': 'var(--IconButton-large-pad, var(--_pad))' }, + }, { props: (props) => !props.disableRipple, style: { @@ -125,14 +146,14 @@ const IconButtonRoot = styled(ButtonBase, { { props: { size: 'small' }, style: { - padding: 5, + '--_pad': '5px', fontSize: theme.typography.pxToRem(18), }, }, { props: { size: 'large' }, style: { - padding: 12, + '--_pad': '12px', fontSize: theme.typography.pxToRem(28), }, }, @@ -199,6 +220,14 @@ const IconButton = React.forwardRef(function IconButton(inProps, ref) { const classes = useUtilityClasses(ownerState); + // Material UI layer: built-in sizes route the public sized token via variants + // (deduped CSS). A custom size has no such variant, so route it inline — the + // token name carries the runtime size string, keeping custom sizes tunable for + // free. The `--_pad` default lives in the variants. See docs/adr/0001. + const densityVars = iconButtonSizes.includes(size) + ? undefined + : { '--IconButton-pad': `var(--IconButton-${size}-pad, var(--_pad))` }; + return ( {typeof loading === 'boolean' && ( @@ -328,6 +358,10 @@ IconButton.propTypes /* remove-proptypes */ = { PropTypes.oneOf(['small', 'medium', 'large']), PropTypes.string, ]), + /** + * @ignore + */ + style: PropTypes.object, /** * The system prop that allows defining system overrides as well as additional CSS styles. */ diff --git a/packages/mui-material/src/InputAdornment/InputAdornment.js b/packages/mui-material/src/InputAdornment/InputAdornment.js index abb06efb23735f..d268c3e07a5388 100644 --- a/packages/mui-material/src/InputAdornment/InputAdornment.js +++ b/packages/mui-material/src/InputAdornment/InputAdornment.js @@ -50,7 +50,21 @@ const InputAdornmentRoot = styled('div', { alignItems: 'center', whiteSpace: 'nowrap', color: (theme.vars || theme).palette.action.active, + // Internal defaults (Material literals). `size` is read from FormControl with no + // default (can be undefined) -> medium routing lives in base, not a `{ size: 'medium' }` variant. + '--_gap': '8px', + '--_marginTop': '16px', + '--InputAdornment-gap': 'var(--InputAdornment-medium-gap, var(--_gap))', + '--InputAdornment-marginTop': 'var(--InputAdornment-medium-marginTop, var(--_marginTop))', variants: [ + // Built-in size routing — exposes the public sized token over the internal default. + { + props: { size: 'small' }, + style: { + '--InputAdornment-gap': 'var(--InputAdornment-small-gap, var(--_gap))', + '--InputAdornment-marginTop': 'var(--InputAdornment-small-marginTop, var(--_marginTop))', + }, + }, { props: { variant: 'filled', @@ -58,7 +72,7 @@ const InputAdornmentRoot = styled('div', { style: { [`&.${inputAdornmentClasses.positionStart}&:not(.${inputAdornmentClasses.hiddenLabel})`]: { - marginTop: 16, + marginTop: 'var(--InputAdornment-marginTop, var(--_marginTop))', }, }, }, @@ -67,7 +81,7 @@ const InputAdornmentRoot = styled('div', { position: 'start', }, style: { - marginRight: 8, + marginRight: 'var(--InputAdornment-gap, var(--_gap))', }, }, { @@ -75,7 +89,7 @@ const InputAdornmentRoot = styled('div', { position: 'end', }, style: { - marginLeft: 8, + marginLeft: 'var(--InputAdornment-gap, var(--_gap))', }, }, { diff --git a/packages/mui-material/src/InputLabel/InputLabel.js b/packages/mui-material/src/InputLabel/InputLabel.js index f62e71601fce90..c2cdcd729e575e 100644 --- a/packages/mui-material/src/InputLabel/InputLabel.js +++ b/packages/mui-material/src/InputLabel/InputLabel.js @@ -145,7 +145,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 generic seam; the sibling input owns its value (see + // OutlinedInput's `:has` rule). Default is today's literal. + transform: 'translate(14px, var(--InputLabel-y, 16px)) scale(1)', maxWidth: 'calc(100% - 24px)', }, }, @@ -155,7 +157,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/ListItem/ListItem.js b/packages/mui-material/src/ListItem/ListItem.js index bc9d4b0946a4c3..ecc8ee8c65d812 100644 --- a/packages/mui-material/src/ListItem/ListItem.js +++ b/packages/mui-material/src/ListItem/ListItem.js @@ -58,26 +58,38 @@ export const ListItemRoot = styled('div', { width: '100%', boxSizing: 'border-box', textAlign: 'left', + // Density adapter: `dense` is the compactness axis (boolean). Default state = + // plain seam `--ListItem-` over `--_`; the `dense` variant re-routes + // the seam to `--ListItem-dense-`. + '--_padBlock': '8px', + '--_padInline': '16px', variants: [ { props: ({ ownerState }) => !ownerState.disablePadding, style: { - paddingTop: 8, - paddingBottom: 8, + paddingTop: 'var(--ListItem-padBlock, var(--_padBlock))', + paddingBottom: 'var(--ListItem-padBlock, var(--_padBlock))', }, }, { props: ({ ownerState }) => !ownerState.disablePadding && ownerState.dense, style: { - paddingTop: 4, - paddingBottom: 4, + '--_padBlock': '4px', + '--ListItem-padBlock': 'var(--ListItem-dense-padBlock, var(--_padBlock))', }, }, { props: ({ ownerState }) => !ownerState.disablePadding && !ownerState.disableGutters, style: { - paddingLeft: 16, - paddingRight: 16, + paddingLeft: 'var(--ListItem-padInline, var(--_padInline))', + paddingRight: 'var(--ListItem-padInline, var(--_padInline))', + }, + }, + { + props: ({ ownerState }) => + !ownerState.disablePadding && !ownerState.disableGutters && ownerState.dense, + style: { + '--ListItem-padInline': 'var(--ListItem-dense-padInline, var(--_padInline))', }, }, { diff --git a/packages/mui-material/src/ListItemButton/ListItemButton.js b/packages/mui-material/src/ListItemButton/ListItemButton.js index 32e6699882e90e..80dfd2c4b9dd6a 100644 --- a/packages/mui-material/src/ListItemButton/ListItemButton.js +++ b/packages/mui-material/src/ListItemButton/ListItemButton.js @@ -65,8 +65,13 @@ const ListItemButtonRoot = styled(ButtonBase, { minWidth: 0, boxSizing: 'border-box', textAlign: 'left', - paddingTop: 8, - paddingBottom: 8, + // Density adapter: `dense` is the compactness axis (boolean). Default state = + // plain seam `--ListItemButton-` over `--_`; the `dense` variant + // re-routes the seam to `--ListItemButton-dense-`. + '--_padBlock': '8px', + '--_padInline': '16px', + paddingTop: 'var(--ListItemButton-padBlock, var(--_padBlock))', + paddingBottom: 'var(--ListItemButton-padBlock, var(--_padBlock))', ...getTransitionStyles(theme, 'background-color', { duration: theme.transitions.duration.shortest, }), @@ -86,14 +91,18 @@ const ListItemButtonRoot = styled(ButtonBase, { [`&.${listItemButtonClasses.focusVisible}`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.focusOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, ), }, }, [`&.${listItemButtonClasses.selected}:hover`]: { backgroundColor: theme.alpha( (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.hoverOpacity}`, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.hoverOpacity + }`, ), // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { @@ -128,15 +137,23 @@ const ListItemButtonRoot = styled(ButtonBase, { { props: ({ ownerState }) => !ownerState.disableGutters, style: { - paddingLeft: 16, - paddingRight: 16, + // gutters owns the inline axis (default state = plain seam). + paddingLeft: 'var(--ListItemButton-padInline, var(--_padInline))', + paddingRight: 'var(--ListItemButton-padInline, var(--_padInline))', }, }, { props: ({ ownerState }) => ownerState.dense, style: { - paddingTop: 4, - paddingBottom: 4, + '--_padBlock': '4px', // dense default; routes the dense block token + '--ListItemButton-padBlock': 'var(--ListItemButton-dense-padBlock, var(--_padBlock))', + }, + }, + { + props: ({ ownerState }) => ownerState.dense && !ownerState.disableGutters, + style: { + // gutters + dense: re-route inline to the dense token (block already routed above). + '--ListItemButton-padInline': 'var(--ListItemButton-dense-padInline, var(--_padInline))', }, }, ], diff --git a/packages/mui-material/src/ListItemIcon/ListItemIcon.js b/packages/mui-material/src/ListItemIcon/ListItemIcon.js index 81aeb8629aede4..fd51b98c1f6353 100644 --- a/packages/mui-material/src/ListItemIcon/ListItemIcon.js +++ b/packages/mui-material/src/ListItemIcon/ListItemIcon.js @@ -29,7 +29,8 @@ const ListItemIconRoot = styled('div', { }, })( memoTheme(({ theme }) => ({ - minWidth: theme.spacing(4.5), + '--_minWidth': theme.spacing(4.5), + minWidth: 'var(--ListItemIcon-minWidth, var(--_minWidth))', color: (theme.vars || theme).palette.action.active, flexShrink: 0, display: 'inline-flex', diff --git a/packages/mui-material/src/ListItemText/ListItemText.js b/packages/mui-material/src/ListItemText/ListItemText.js index 8b81317e5d2955..954c99c9b78ee9 100644 --- a/packages/mui-material/src/ListItemText/ListItemText.js +++ b/packages/mui-material/src/ListItemText/ListItemText.js @@ -40,8 +40,16 @@ const ListItemTextRoot = styled('div', { })({ flex: '1 1 auto', minWidth: 0, - marginTop: 4, - marginBottom: 4, + // Density adapter (docs/adr/0001): each spacing literal becomes + // `var(--seam, var(--_))`, tokenized in place. The compactness dimension + // is `dense` (boolean) — default state = plain seam `--ListItemText-` over + // `--_`; the dense variant re-routes the seam to its own token. + // `marginBlock` (top+bottom move together) varies by `multiline`, so its literal + // default is set per (dense × multiline) cell while routing keys on dense only. + // `insetPad` is the inset indentation. + '--_marginBlock': '4px', + marginTop: 'var(--ListItemText-marginBlock, var(--_marginBlock))', + marginBottom: 'var(--ListItemText-marginBlock, var(--_marginBlock))', // Combine this and the below selector once https://github.com/emotion-js/emotion/issues/3366 is solved [`.${typographyClasses.root}:where(& .${listItemTextClasses.primary})`]: { display: 'block', @@ -50,17 +58,29 @@ const ListItemTextRoot = styled('div', { display: 'block', }, variants: [ + { + props: ({ ownerState }) => ownerState.dense, + style: { + '--ListItemText-marginBlock': 'var(--ListItemText-dense-marginBlock, var(--_marginBlock))', + }, + }, { props: ({ ownerState }) => ownerState.primary && ownerState.secondary, style: { - marginTop: 6, - marginBottom: 6, + '--_marginBlock': '6px', }, }, { props: ({ ownerState }) => ownerState.inset, style: { - paddingLeft: 56, + '--_insetPad': '56px', + paddingLeft: 'var(--ListItemText-insetPad, var(--_insetPad))', + }, + }, + { + props: ({ ownerState }) => ownerState.inset && ownerState.dense, + style: { + '--ListItemText-insetPad': 'var(--ListItemText-dense-insetPad, var(--_insetPad))', }, }, ], diff --git a/packages/mui-material/src/ListSubheader/ListSubheader.js b/packages/mui-material/src/ListSubheader/ListSubheader.js index 36bd3865891efe..cb80bf40e478bf 100644 --- a/packages/mui-material/src/ListSubheader/ListSubheader.js +++ b/packages/mui-material/src/ListSubheader/ListSubheader.js @@ -42,7 +42,12 @@ const ListSubheaderRoot = styled('li', { })( memoTheme(({ theme }) => ({ boxSizing: 'border-box', - lineHeight: '48px', + // Internal defaults (Material literals). Base tokens: ListSubheader has no + // size prop, so per-size tuning is meaningless — density tunes these directly. + '--_height': '48px', + '--_padInline': '16px', + '--_inset': '72px', + lineHeight: 'var(--ListSubheader-height, var(--_height))', listStyle: 'none', color: (theme.vars || theme).palette.text.secondary, fontFamily: theme.typography.fontFamily, @@ -68,14 +73,14 @@ const ListSubheaderRoot = styled('li', { { props: ({ ownerState }) => !ownerState.disableGutters, style: { - paddingLeft: 16, - paddingRight: 16, + paddingLeft: 'var(--ListSubheader-padInline, var(--_padInline))', + paddingRight: 'var(--ListSubheader-padInline, var(--_padInline))', }, }, { props: ({ ownerState }) => ownerState.inset, style: { - paddingLeft: 72, + paddingLeft: 'var(--ListSubheader-inset, var(--_inset))', }, }, { diff --git a/packages/mui-material/src/MenuItem/MenuItem.js b/packages/mui-material/src/MenuItem/MenuItem.js index 68df44938b4404..f67ca8e300d0c6 100644 --- a/packages/mui-material/src/MenuItem/MenuItem.js +++ b/packages/mui-material/src/MenuItem/MenuItem.js @@ -1,25 +1,25 @@ -'use client'; -import * as React from 'react'; -import PropTypes from 'prop-types'; -import clsx from 'clsx'; -import composeClasses from '@mui/utils/composeClasses'; -import rootShouldForwardProp from '../styles/rootShouldForwardProp'; -import { styled } from '../zero-styled'; -import memoTheme from '../utils/memoTheme'; -import { useDefaultProps } from '../DefaultPropsProvider'; -import ListContext from '../List/ListContext'; -import ButtonBase from '../ButtonBase'; -import useEnhancedEffect from '../utils/useEnhancedEffect'; -import focusWithVisible from '../utils/focusWithVisible'; -import useForkRef from '../utils/useForkRef'; -import useId from '../utils/useId'; -import { useRovingTabIndexItem } from '../utils/useRovingTabIndex'; -import { dividerClasses } from '../Divider'; -import { listItemIconClasses } from '../ListItemIcon'; -import { listItemTextClasses } from '../ListItemText'; -import { useMenuListContext } from '../MenuList/MenuListContext'; -import { useSelectFocusSource } from '../Select/utils'; -import menuItemClasses, { getMenuItemUtilityClass } from './menuItemClasses'; +"use client"; +import * as React from "react"; +import PropTypes from "prop-types"; +import clsx from "clsx"; +import composeClasses from "@mui/utils/composeClasses"; +import rootShouldForwardProp from "../styles/rootShouldForwardProp"; +import { styled } from "../zero-styled"; +import memoTheme from "../utils/memoTheme"; +import { useDefaultProps } from "../DefaultPropsProvider"; +import ListContext from "../List/ListContext"; +import ButtonBase from "../ButtonBase"; +import useEnhancedEffect from "../utils/useEnhancedEffect"; +import focusWithVisible from "../utils/focusWithVisible"; +import useForkRef from "../utils/useForkRef"; +import useId from "../utils/useId"; +import { useRovingTabIndexItem } from "../utils/useRovingTabIndex"; +import { dividerClasses } from "../Divider"; +import { listItemIconClasses } from "../ListItemIcon"; +import { listItemTextClasses } from "../ListItemText"; +import { useMenuListContext } from "../MenuList/MenuListContext"; +import { useSelectFocusSource } from "../Select/utils"; +import menuItemClasses, { getMenuItemUtilityClass } from "./menuItemClasses"; export const overridesResolver = (props, styles) => { const { ownerState } = props; @@ -36,12 +36,12 @@ const useUtilityClasses = (ownerState) => { const { disabled, dense, divider, disableGutters, selected, classes } = ownerState; const slots = { root: [ - 'root', - dense && 'dense', - disabled && 'disabled', - !disableGutters && 'gutters', - divider && 'divider', - selected && 'selected', + "root", + dense && "dense", + disabled && "disabled", + !disableGutters && "gutters", + divider && "divider", + selected && "selected", ], }; @@ -53,60 +53,97 @@ const useUtilityClasses = (ownerState) => { }; }; -const MenuItemRoot = styled(ButtonBase, { - shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === 'classes', - name: 'MuiMenuItem', - slot: 'Root', - overridesResolver, -})( - memoTheme(({ theme }) => ({ - ...theme.typography.body1, - display: 'flex', - justifyContent: 'flex-start', - alignItems: 'center', - position: 'relative', - textDecoration: 'none', - minHeight: 48, - paddingTop: 6, - paddingBottom: 6, - boxSizing: 'border-box', - whiteSpace: 'nowrap', - '&:hover': { - textDecoration: 'none', - backgroundColor: (theme.vars || theme).palette.action.hover, +// Color/state adapter (docs/adr/0002), single-axis: MenuItem has no variant or +// palette-color prop, so the token is `--MenuItem--` (rest omits the +// `` segment). Background is a value-state in every state (each genuinely +// sets it), so it advances the internal default `--_bg` with the token inside, and +// the root reads `var(--_bg)`. Foreground and border are inert today (MenuItem +// changes neither per state), routed over `--_fg` / `--_border` so they stay +// zero-diff (inherited text / transparent ring) yet become settable — the ring is +// an inset box-shadow, so adding one never shifts the menu's layout. `focus` maps +// to `.Mui-focusVisible`. Compound states (`selected:hover`, `selected:focus`) win +// by specificity, exactly as before. +const buildMenuItemColorVars = (theme) => { + const vars = theme.vars || theme; + const action = vars.palette.action; + const primary = vars.palette.primary.main; + const selectedBg = theme.alpha(primary, action.selectedOpacity); + const selectedHoverBg = theme.alpha( + primary, + `${action.selectedOpacity} + ${action.hoverOpacity}` + ); + const selectedFocusBg = theme.alpha( + primary, + `${action.selectedOpacity} + ${action.focusOpacity}` + ); + + // One state's style: advance bg (value-state, token inside the default), and + // route the inert fg/border tokens over the rest defaults. + const stateStyle = (name, bgDefault) => ({ + "--_bg": `var(--MenuItem-${name}-bg, ${bgDefault})`, + color: `var(--MenuItem-${name}-fg, var(--_fg))`, + boxShadow: `inset 0 0 0 1.5px var(--MenuItem-${name}-border, var(--_border))`, + }); + + return { + "&:hover": { + textDecoration: "none", + ...stateStyle("hover", action.hover), // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { - backgroundColor: 'transparent', + "@media (hover: none)": { + "--_bg": "var(--MenuItem-bg, transparent)", }, }, [`&.${menuItemClasses.selected}`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - (theme.vars || theme).palette.action.selectedOpacity, - ), - [`&.${menuItemClasses.focusVisible}`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.focusOpacity}`, - ), - }, + ...stateStyle("selected", selectedBg), + [`&.${menuItemClasses.focusVisible}`]: stateStyle("selected-focus", selectedFocusBg), }, [`&.${menuItemClasses.selected}:hover`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.hoverOpacity}`, - ), + ...stateStyle("selected-hover", selectedHoverBg), // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - (theme.vars || theme).palette.action.selectedOpacity, - ), + "@media (hover: none)": { + "--_bg": `var(--MenuItem-selected-bg, ${selectedBg})`, }, }, - [`&.${menuItemClasses.focusVisible}`]: { - backgroundColor: (theme.vars || theme).palette.action.focus, - }, + [`&.${menuItemClasses.focusVisible}`]: stateStyle("focus", action.focus), + }; +}; + +const MenuItemRoot = styled(ButtonBase, { + shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === "classes", + name: "MuiMenuItem", + slot: "Root", + overridesResolver, +})( + memoTheme(({ theme }) => ({ + ...theme.typography.body1, + // Density adapter (docs/adr/0001): `dense` is the compactness axis (boolean). + // The default (non-dense) state is the plain seam `--MenuItem-` over the + // internal default `--_`; the `dense` variant re-routes the seam to the + // `--MenuItem-dense-` token. Block + min-height live on the root + // unconditionally; inline gutters live on the !disableGutters variant. + "--_minHeight": "48px", + "--_padBlock": "6px", + // Color/state seams (docs/adr/0002): rest defaults hold today's values (no bg, + // inherited text, transparent ring) with the rest token inside; the root reads + // them once. State advances live in buildMenuItemColorVars below. + "--_bg": "var(--MenuItem-bg, transparent)", + "--_fg": "var(--MenuItem-fg, inherit)", + "--_border": "var(--MenuItem-border, transparent)", + display: "flex", + justifyContent: "flex-start", + alignItems: "center", + position: "relative", + textDecoration: "none", + minHeight: "var(--MenuItem-minHeight, var(--_minHeight))", + paddingTop: "var(--MenuItem-padBlock, var(--_padBlock))", + paddingBottom: "var(--MenuItem-padBlock, var(--_padBlock))", + boxSizing: "border-box", + whiteSpace: "nowrap", + backgroundColor: "var(--_bg)", + color: "var(--_fg)", + boxShadow: "inset 0 0 0 1.5px var(--_border)", + ...buildMenuItemColorVars(theme), [`&.${menuItemClasses.disabled}`]: { opacity: (theme.vars || theme).palette.action.disabledOpacity, }, @@ -125,57 +162,65 @@ const MenuItemRoot = styled(ButtonBase, { paddingLeft: 36, }, [`& .${listItemIconClasses.root}`]: { - minWidth: 36, + minWidth: "var(--ListItemIcon-minWidth, 36px)", }, variants: [ { props: ({ ownerState }) => !ownerState.disableGutters, style: { - paddingLeft: 16, - paddingRight: 16, + "--_padInline": "16px", + paddingLeft: "var(--MenuItem-padInline, var(--_padInline))", + paddingRight: "var(--MenuItem-padInline, var(--_padInline))", + }, + }, + { + props: ({ ownerState }) => !ownerState.disableGutters && ownerState.dense, + style: { + "--MenuItem-padInline": "var(--MenuItem-dense-padInline, var(--_padInline))", }, }, { props: ({ ownerState }) => ownerState.divider, style: { borderBottom: `1px solid ${(theme.vars || theme).palette.divider}`, - backgroundClip: 'padding-box', + backgroundClip: "padding-box", }, }, { props: ({ ownerState }) => !ownerState.dense, style: { - [theme.breakpoints.up('sm')]: { - minHeight: 'auto', + [theme.breakpoints.up("sm")]: { + minHeight: "auto", }, }, }, { props: ({ ownerState }) => ownerState.dense, style: { - minHeight: 32, // https://m2.material.io/components/menus#specs > Dense - paddingTop: 4, - paddingBottom: 4, + "--_minHeight": "32px", // https://m2.material.io/components/menus#specs > Dense + "--_padBlock": "4px", + "--MenuItem-minHeight": "var(--MenuItem-dense-minHeight, var(--_minHeight))", + "--MenuItem-padBlock": "var(--MenuItem-dense-padBlock, var(--_padBlock))", ...theme.typography.body2, [`& .${listItemIconClasses.root} svg`]: { - fontSize: '1.25rem', + fontSize: "1.25rem", }, }, }, ], - })), + })) ); const MenuItem = React.forwardRef(function MenuItem(inProps, ref) { - const props = useDefaultProps({ props: inProps, name: 'MuiMenuItem' }); + const props = useDefaultProps({ props: inProps, name: "MuiMenuItem" }); const { autoFocus: shouldAutoFocusOnMount = false, - component = 'li', + component = "li", dense = false, divider = false, disableGutters = false, focusVisibleClassName, - role = 'menuitem', + role = "menuitem", tabIndex: tabIndexProp, className, ...other @@ -188,7 +233,7 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) { dense: dense || context.dense || false, disableGutters, }), - [context.dense, dense, disableGutters], + [context.dense, dense, disableGutters] ); const menuListContext = useMenuListContext(); const rovingItemId = useId(); @@ -203,9 +248,9 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) { if (shouldAutoFocusOnMount) { if (menuItemRef.current) { focusWithVisible(menuItemRef.current, focusSource); - } else if (process.env.NODE_ENV !== 'production') { + } else if (process.env.NODE_ENV !== "production") { console.error( - 'MUI: Unable to set focus to a MenuItem whose component has not been rendered.', + "MUI: Unable to set focus to a MenuItem whose component has not been rendered." ); } } @@ -237,7 +282,7 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) { let tabIndex; if (tabIndexProp !== undefined) { tabIndex = tabIndexProp; - } else if (menuListContext.variant === 'selectedMenu') { + } else if (menuListContext.variant === "selectedMenu") { tabIndex = rovingItemProps.tabIndex; } else if (!props.disabled || itemsFocusableWhenDisabled) { // In `menu` variant, registration still drives arrow-key navigation even diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index bb851995a8224d..4a0a76b8c1c5bd 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -11,6 +11,7 @@ import memoTheme from '../utils/memoTheme'; import createSimplePaletteValueFilter from '../utils/createSimplePaletteValueFilter'; import { useDefaultProps } from '../DefaultPropsProvider'; import outlinedInputClasses, { getOutlinedInputUtilityClass } from './outlinedInputClasses'; +import inputLabelClasses from '../InputLabel/inputLabelClasses'; import InputBase, { rootOverridesResolver as inputBaseRootOverridesResolver, inputOverridesResolver as inputBaseInputOverridesResolver, @@ -46,6 +47,18 @@ const OutlinedInputRoot = styled(InputBaseRoot, { const borderColor = theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)'; return { + // Density adapter (docs/adr/0001): each padding literal becomes + // `var(--seam, var(--_))`, tokenized in place. Both axes are sized — + // each seam routes the per-size public token (block + inline). The internal + // defaults live in the variants that consume them (below), like Button's + // `--_pad`. Inline default is 14px for both sizes; the per-size inline + // tokens let a design system tune it per size anyway. + // The outlined label centers on the input's block padding. It's a preceding + // sibling, so reach it via :has and derive its resting-Y seam straight from + // the public sized token. Medium resolves to 16px (16.5 - 0.5 rounding). + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--InputLabel-y': 'calc(var(--OutlinedInput-medium-padBlock, 16.5px) - 0.5px)', + }, position: 'relative', borderRadius: (theme.vars || theme).shape.borderRadius, [`&:hover .${outlinedInputClasses.notchedOutline}`]: { @@ -84,28 +97,60 @@ const OutlinedInputRoot = styled(InputBaseRoot, { }, }, }, + { + props: { size: 'small' }, + style: { + // Small label resolves to 9px (8.5 + 0.5). + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--InputLabel-y': 'calc(var(--OutlinedInput-small-padBlock, 8.5px) + 0.5px)', + }, + }, + }, { props: ({ ownerState }) => ownerState.startAdornment, style: { - paddingLeft: 14, + '--_padInline': '14px', + '--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', + paddingLeft: 'var(--OutlinedInput-padInline, var(--_padInline))', + }, + }, + { + props: ({ ownerState, size }) => ownerState.startAdornment && size === 'small', + style: { + '--OutlinedInput-padInline': 'var(--OutlinedInput-small-padInline, var(--_padInline))', }, }, { props: ({ ownerState }) => ownerState.endAdornment, style: { - paddingRight: 14, + '--_padInline': '14px', + '--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', + paddingRight: 'var(--OutlinedInput-padInline, var(--_padInline))', + }, + }, + { + props: ({ ownerState, size }) => ownerState.endAdornment && size === 'small', + style: { + '--OutlinedInput-padInline': 'var(--OutlinedInput-small-padInline, var(--_padInline))', }, }, { props: ({ ownerState }) => ownerState.multiline, style: { - padding: '16.5px 14px', + '--_padBlock': '16.5px', + '--_padInline': '14px', + '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, var(--_padBlock))', + '--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', + padding: + 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', }, }, { props: ({ ownerState, size }) => ownerState.multiline && size === 'small', style: { - padding: '8.5px 14px', + '--_padBlock': '8.5px', + '--OutlinedInput-padBlock': 'var(--OutlinedInput-small-padBlock, var(--_padBlock))', + '--OutlinedInput-padInline': 'var(--OutlinedInput-small-padInline, var(--_padInline))', }, }, ], @@ -134,7 +179,15 @@ const OutlinedInputInput = styled(InputBaseInput, { overridesResolver: inputBaseInputOverridesResolver, })( memoTheme(({ theme }) => ({ - padding: '16.5px 14px', + // Both axes: `var(--seam, var(--_))`, both sized — each seam routes the + // per-size public token over the internal default, specialized by the size + // variant below. Defaults are the Material px (inline 14px both sizes). + '--_padBlock': '16.5px', + '--_padInline': '14px', + '--OutlinedInput-padBlock': 'var(--OutlinedInput-medium-padBlock, var(--_padBlock))', + '--OutlinedInput-padInline': 'var(--OutlinedInput-medium-padInline, var(--_padInline))', + padding: + 'var(--OutlinedInput-padBlock, var(--_padBlock)) var(--OutlinedInput-padInline, var(--_padInline))', '&:-webkit-autofill': { ...(!theme.vars && { WebkitBoxShadow: theme.palette.mode === 'light' ? null : '0 0 0 100px #266798 inset', @@ -151,11 +204,11 @@ const OutlinedInputInput = styled(InputBaseInput, { }, variants: [ { - props: { - size: 'small', - }, + props: { size: 'small' }, style: { - padding: '8.5px 14px', + '--_padBlock': '8.5px', + '--OutlinedInput-padBlock': 'var(--OutlinedInput-small-padBlock, var(--_padBlock))', + '--OutlinedInput-padInline': 'var(--OutlinedInput-small-padInline, var(--_padInline))', }, }, { diff --git a/packages/mui-material/src/Radio/Radio.js b/packages/mui-material/src/Radio/Radio.js index 48e1d7fd57df59..217d972d1449a6 100644 --- a/packages/mui-material/src/Radio/Radio.js +++ b/packages/mui-material/src/Radio/Radio.js @@ -50,6 +50,16 @@ const RadioRoot = styled(SwitchBase, { color: (theme.vars || theme).palette.action.disabled, }, variants: [ + // Density: route the per-size public token into SwitchBase's seam. Default + // 9px both sizes (pixel-identical); size enables per-size density tuning. + { + props: { size: 'small' }, + style: { '--SwitchBase-pad': 'var(--Radio-small-pad, var(--_pad))' }, + }, + { + props: { size: 'medium' }, + style: { '--SwitchBase-pad': 'var(--Radio-medium-pad, var(--_pad))' }, + }, { props: { color: 'default', disabled: false, disableRipple: false }, style: { diff --git a/packages/mui-material/src/Select/SelectInput.js b/packages/mui-material/src/Select/SelectInput.js index d56648e4afb3d2..63df96964ef203 100644 --- a/packages/mui-material/src/Select/SelectInput.js +++ b/packages/mui-material/src/Select/SelectInput.js @@ -88,8 +88,13 @@ const SelectSelect = styled(StyledSelectSelect, { })({ // Win specificity over the input base [`&.${selectClasses.select}`]: { + // Density seam: base axis (size-invariant — keeps select content box matched + // to the input line box for text-field height consistency; per-size + // compactness comes from the input root padding). Consume shape stays uniform + // with sized axes: var(seam, var(internal default)). + '--_minHeight': '1.4375em', // Required for select\text-field height consistency height: 'auto', // Resets for multiple select with chips - minHeight: '1.4375em', // Required for select\text-field height consistency + minHeight: 'var(--Select-minHeight, var(--_minHeight))', textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', diff --git a/packages/mui-material/src/Switch/Switch.js b/packages/mui-material/src/Switch/Switch.js index adbc8603dae33a..1b7202fbbbc60d 100644 --- a/packages/mui-material/src/Switch/Switch.js +++ b/packages/mui-material/src/Switch/Switch.js @@ -52,11 +52,32 @@ const SwitchRoot = styled('span', { ]; }, })({ + // Density (docs/adr/0001): Switch geometry is interlocked, so the knobs are the + // dims (width/height/thumbSize/touchSize) + the track gutter (pad); the thumb's + // touch padding and travel are *derived* so the thumb stays centered on the track. + // SwitchBase pad = (touchSize - thumbSize) / 2 (centers thumb in the button) + // button top = (height - touchSize) / 2 (centers button in the root) + // checked travel = width - touchSize + // Defaults: touchSize == height -> pad 9/4, top 0, travel 20/16 (pixel-identical). + // The thumb (SwitchBase) and Thumb/Track slots inherit these seams (custom props + // inherit; they don't redeclare them). `--_pad` here is the root's gutter default + // (the track inset), distinct from the thumb's own SwitchBase `--_pad`. + '--_width': '58px', // 34 (track) + 12 (gutter) * 2 + '--_height': '38px', // 14 (track) + 12 (gutter) * 2 + '--_thumbSize': '20px', + '--_touchSize': '38px', + '--_pad': '12px', + '--Switch-width': 'var(--Switch-medium-width, var(--_width))', + '--Switch-height': 'var(--Switch-medium-height, var(--_height))', + '--Switch-thumbSize': 'var(--Switch-medium-thumbSize, var(--_thumbSize))', + '--Switch-touchSize': 'var(--Switch-medium-touchSize, var(--_touchSize))', + '--Switch-pad': 'var(--Switch-medium-pad, var(--_pad))', + '--SwitchBase-pad': 'calc((var(--Switch-touchSize) - var(--Switch-thumbSize)) / 2)', display: 'inline-flex', - width: 34 + 12 * 2, - height: 14 + 12 * 2, + width: 'var(--Switch-width, var(--_width))', + height: 'var(--Switch-height, var(--_height))', overflow: 'hidden', - padding: 12, + padding: 'var(--Switch-pad, var(--_pad))', boxSizing: 'border-box', position: 'relative', flexShrink: 0, @@ -77,19 +98,17 @@ const SwitchRoot = styled('span', { { props: { size: 'small' }, style: { - width: 40, - height: 24, - padding: 7, - [`& .${switchClasses.thumb}`]: { - width: 16, - height: 16, - }, - [`& .${switchClasses.switchBase}`]: { - padding: 4, - [`&.${switchClasses.checked}`]: { - transform: 'translateX(16px)', - }, - }, + // Re-route the dims + gutter to the small tokens; pad/top/travel re-derive. + '--_width': '40px', + '--_height': '24px', + '--_thumbSize': '16px', + '--_touchSize': '24px', + '--_pad': '7px', + '--Switch-width': 'var(--Switch-small-width, var(--_width))', + '--Switch-height': 'var(--Switch-small-height, var(--_height))', + '--Switch-thumbSize': 'var(--Switch-small-thumbSize, var(--_thumbSize))', + '--Switch-touchSize': 'var(--Switch-small-touchSize, var(--_touchSize))', + '--Switch-pad': 'var(--Switch-small-pad, var(--_pad))', }, }, ], @@ -110,7 +129,8 @@ const SwitchSwitchBase = styled(SwitchBase, { })( memoTheme(({ theme }) => ({ position: 'absolute', - top: 0, + // Center the touch target in the root (top 0 when touchSize == height). + top: 'calc((var(--Switch-height, var(--_height)) - var(--Switch-touchSize, var(--_touchSize))) / 2)', left: 0, zIndex: 1, // Render above the focus ripple. color: theme.vars @@ -120,7 +140,9 @@ const SwitchSwitchBase = styled(SwitchBase, { duration: theme.transitions.duration.shortest, }), [`&.${switchClasses.checked}`]: { - transform: 'translateX(20px)', + // Travel = root width - touch target (keeps the thumb symmetric on the track). + transform: + 'translateX(calc(var(--Switch-width, var(--_width)) - var(--Switch-touchSize, var(--_touchSize))))', }, [`&.${switchClasses.disabled}`]: { color: theme.vars @@ -194,7 +216,10 @@ const SwitchTrack = styled('span', { memoTheme(({ theme }) => ({ height: '100%', width: '100%', - borderRadius: 14 / 2, + // Full pill: half the track thickness (height minus the two gutters). Inherits + // the seams from SwitchRoot. Medium -> 7px; small clamps to a pill either way. + borderRadius: + 'calc((var(--Switch-height, var(--_height)) - 2 * var(--Switch-pad, var(--_pad))) / 2)', zIndex: -1, ...getTransitionStyles(theme, ['opacity', 'background-color'], { duration: theme.transitions.duration.shortest, @@ -221,8 +246,8 @@ const SwitchThumb = styled('span', { backgroundColor: 'currentColor', boxSizing: 'border-box', border: '1px solid transparent', - width: 20, - height: 20, + width: 'var(--Switch-thumbSize, var(--_thumbSize))', + height: 'var(--Switch-thumbSize, var(--_thumbSize))', borderRadius: '50%', })), ); diff --git a/packages/mui-material/src/Tab/Tab.js b/packages/mui-material/src/Tab/Tab.js index 05ddee7f3a0ba7..0522b119c94d27 100644 --- a/packages/mui-material/src/Tab/Tab.js +++ b/packages/mui-material/src/Tab/Tab.js @@ -54,9 +54,15 @@ const TabRoot = styled(ButtonBase, { maxWidth: 360, minWidth: 90, position: 'relative', - minHeight: 48, + // Density seams: Tab has no `size` prop, so each axis is a base token + // (`--Tab-`) over an internal default (`--_`). The labelIcon state + // owns its own block/min-height literals; everything else reads these. + '--_padBlock': '12px', + '--_padInline': '16px', + '--_minHeight': '48px', + minHeight: 'var(--Tab-minHeight, var(--_minHeight))', flexShrink: 0, - padding: '12px 16px', + padding: 'var(--Tab-padBlock, var(--_padBlock)) var(--Tab-padInline, var(--_padInline))', overflow: 'hidden', whiteSpace: 'normal', textAlign: 'center', @@ -82,9 +88,12 @@ const TabRoot = styled(ButtonBase, { { props: ({ ownerState }) => ownerState.icon && ownerState.label, style: { - minHeight: 72, - paddingTop: 9, - paddingBottom: 9, + // labelIcon owns its own compactness + block padding literals; inline + // padding stays from the base shorthand (unchanged at 16px). + '--_minHeight': '72px', + '--_padBlock': '9px', + paddingTop: 'var(--Tab-padBlock, var(--_padBlock))', + paddingBottom: 'var(--Tab-padBlock, var(--_padBlock))', }, }, { @@ -92,7 +101,8 @@ const TabRoot = styled(ButtonBase, { ownerState.icon && ownerState.label && iconPosition === 'top', style: { [`& > .${tabClasses.icon}`]: { - marginBottom: 6, + '--_iconSpacing': '6px', + marginBottom: 'var(--Tab-iconSpacing, var(--_iconSpacing))', }, }, }, @@ -101,7 +111,8 @@ const TabRoot = styled(ButtonBase, { ownerState.icon && ownerState.label && iconPosition === 'bottom', style: { [`& > .${tabClasses.icon}`]: { - marginTop: 6, + '--_iconSpacing': '6px', + marginTop: 'var(--Tab-iconSpacing, var(--_iconSpacing))', }, }, }, @@ -110,7 +121,8 @@ const TabRoot = styled(ButtonBase, { ownerState.icon && ownerState.label && iconPosition === 'start', style: { [`& > .${tabClasses.icon}`]: { - marginRight: theme.spacing(1), + '--_iconSpacing': theme.spacing(1), + marginRight: 'var(--Tab-iconSpacing, var(--_iconSpacing))', }, }, }, @@ -119,7 +131,8 @@ const TabRoot = styled(ButtonBase, { ownerState.icon && ownerState.label && iconPosition === 'end', style: { [`& > .${tabClasses.icon}`]: { - marginLeft: theme.spacing(1), + '--_iconSpacing': theme.spacing(1), + marginLeft: 'var(--Tab-iconSpacing, var(--_iconSpacing))', }, }, }, diff --git a/packages/mui-material/src/Tab/Tab.test.js b/packages/mui-material/src/Tab/Tab.test.js index 46f5500ebfdc82..065baf03e2ea08 100644 --- a/packages/mui-material/src/Tab/Tab.test.js +++ b/packages/mui-material/src/Tab/Tab.test.js @@ -191,7 +191,10 @@ describe('', () => { expect(wrapper).to.have.class('test-icon'); }); - it('should have bottom margin when passed together with label', () => { + // The icon gap is now a CSS var (var(--Tab-iconSpacing, ...)); jsdom can't + // resolve var(), so assert in a real browser only. Default render is verified + // pixel-identical by the density screenshot harness. + it.skipIf(isJsdom())('should have bottom margin when passed together with label', () => { render( } label="foo" /> diff --git a/packages/mui-material/src/TablePagination/TablePagination.js b/packages/mui-material/src/TablePagination/TablePagination.js index 45c2b598ea838e..61b8cbc5285b27 100644 --- a/packages/mui-material/src/TablePagination/TablePagination.js +++ b/packages/mui-material/src/TablePagination/TablePagination.js @@ -45,18 +45,22 @@ const TablePaginationToolbar = styled(Toolbar, { }), })( memoTheme(({ theme }) => ({ - minHeight: 52, + // Base density seams: no `size` prop here, so each axis is a size-invariant + // base token. `--_` carries today's literal; the seam falls back to it. + '--_minHeight': '52px', + '--_actionsSpacing': '20px', + minHeight: 'var(--TablePagination-minHeight, var(--_minHeight))', paddingRight: 2, [`${theme.breakpoints.up('xs')} and (orientation: landscape)`]: { - minHeight: 52, + minHeight: 'var(--TablePagination-minHeight, var(--_minHeight))', }, [theme.breakpoints.up('sm')]: { - minHeight: 52, + minHeight: 'var(--TablePagination-minHeight, var(--_minHeight))', paddingRight: 2, }, [`& .${tablePaginationClasses.actions}`]: { flexShrink: 0, - marginLeft: 20, + marginLeft: 'var(--TablePagination-actionsSpacing, var(--_actionsSpacing))', }, })), ); @@ -91,7 +95,10 @@ const TablePaginationSelect = styled(Select, { color: 'inherit', fontSize: 'inherit', flexShrink: 0, - marginRight: 32, + // Base density seam for the select-to-rows gap. Co-located default keeps the + // unprefixed `--_selectSpacing` from inheriting a foreign value. + '--_selectSpacing': '32px', + marginRight: 'var(--TablePagination-selectSpacing, var(--_selectSpacing))', marginLeft: 8, [`& .${tablePaginationClasses.select}`]: { paddingLeft: 8, diff --git a/packages/mui-material/src/Tabs/Tabs.js b/packages/mui-material/src/Tabs/Tabs.js index c9dc851fb0c0f6..a0cd9b3a42dce9 100644 --- a/packages/mui-material/src/Tabs/Tabs.js +++ b/packages/mui-material/src/Tabs/Tabs.js @@ -77,7 +77,10 @@ const TabsRoot = styled('div', { })( memoTheme(({ theme }) => ({ overflow: 'hidden', - minHeight: 48, + // Density adapter (docs/adr/0001): base token, 48px literal fallback keeps the + // default pixel-identical. Tabs is the Tab's parent, so it can't read the + // child's `--Tab-minHeight` — it carries its own seam. + minHeight: 'var(--Tabs-minHeight, 48px)', // Add iOS momentum scrolling for iOS < 13.0 WebkitOverflowScrolling: 'touch', display: 'flex', diff --git a/packages/mui-material/src/Toolbar/Toolbar.js b/packages/mui-material/src/Toolbar/Toolbar.js index dbfde2fb54eb23..46939ac77a5362 100644 --- a/packages/mui-material/src/Toolbar/Toolbar.js +++ b/packages/mui-material/src/Toolbar/Toolbar.js @@ -31,15 +31,21 @@ const ToolbarRoot = styled('div', { position: 'relative', display: 'flex', alignItems: 'center', + // Gutter default (the responsive sm bump is set in the gutters variant). + // Only the `dense` minHeight is tokenized; the `regular` height stays driven + // by the public `theme.mixins.toolbar` so existing customization keeps working. + '--_minHeight': '48px', + '--_padInline': theme.spacing(2), variants: [ { props: ({ ownerState }) => !ownerState.disableGutters, style: { - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), + // Gutters are shared across variants -> base token (no size layer), + // consumed directly with the internal default as fallback. + paddingLeft: 'var(--Toolbar-padInline, var(--_padInline))', + paddingRight: 'var(--Toolbar-padInline, var(--_padInline))', [theme.breakpoints.up('sm')]: { - paddingLeft: theme.spacing(3), - paddingRight: theme.spacing(3), + '--_padInline': theme.spacing(3), }, }, }, @@ -48,7 +54,9 @@ const ToolbarRoot = styled('div', { variant: 'dense', }, style: { - minHeight: 48, + '--_minHeight': '48px', + '--Toolbar-minHeight': 'var(--Toolbar-dense-minHeight, var(--_minHeight))', + minHeight: 'var(--Toolbar-minHeight, var(--_minHeight))', }, }, { diff --git a/packages/mui-material/src/internal/SwitchBase.js b/packages/mui-material/src/internal/SwitchBase.js index 5257bfe688e49e..e837e251205cc8 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 adapter (docs/adr/0001): SwitchBase is the agnostic layer shared by + // Checkbox/Radio (and the Switch thumb). It consumes one seam; the Material + // layer (Checkbox/Radio) routes its per-size public token into --SwitchBase-pad. + '--_pad': '9px', + padding: 'var(--SwitchBase-pad, var(--_pad))', borderRadius: '50%', variants: [ { diff --git a/packages/mui-material/src/styles/enhanceDensity.ts b/packages/mui-material/src/styles/enhanceDensity.ts new file mode 100644 index 00000000000000..e9d49c9ccbd690 --- /dev/null +++ b/packages/mui-material/src/styles/enhanceDensity.ts @@ -0,0 +1,465 @@ +import { Theme } from './createTheme'; +import inputLabelClasses from '../InputLabel/inputLabelClasses'; + +/** + * Named density steps, surfaced as `--mui-density-*` CSS vars. Components wired + * by `enhanceDensity` pull their spacing tokens from these. + */ +export interface DensityScale { + xxs: string; + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + xxl: string; +} + +export interface DensityOptions { + /** + * Override any density step. Defaults derive from `theme.spacing`. + */ + scale?: Partial | undefined; +} + +type DensityKey = keyof DensityScale; + +const densityKeys: DensityKey[] = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl']; + +// Default scale: t-shirt steps derived from the theme spacing unit. +const defaultMultiplier: Record = { + xxs: 0.5, + xs: 0.75, + sm: 1, + md: 1.5, + lg: 2, + xl: 3, + xxl: 4, +}; + +const cssVar = (key: DensityKey) => `--mui-density-${key}`; + +/** + * Enhances a created theme with a holistic density layer. + * + * Does both jobs in one call (mirroring `enhanceHighContrast`): + * 1. **Emits** the density scale as `--mui-density-*` CSS vars at `:root` + * (via `MuiCssBaseline` — requires ``) and exposes them on + * `theme.density` / `theme.vars.density`. + * 2. **Maps** each component's sized tokens to density steps through injected + * `styleOverrides.root` (e.g. `--Button-medium-pad: var(--mui-density-xs) var(--mui-density-lg)`). + * + * `createTheme` is left untouched; without this function components render their + * literal-px defaults. See `docs/adr/0001-css-var-density-adapter.md`. + * + * @param themeInput - The created theme to enhance. + * @param options - Override the density scale. + * @returns The enhanced theme. + * + * @example + * const theme = enhanceDensity(createTheme({ cssVariables: true })); + * + * @example + * const theme = enhanceDensity(createTheme(), { scale: { lg: '12px' } }); + */ +export default function enhanceDensity< + T extends { + spacing: (value: number) => string | number; + components?: Theme['components'] | undefined; + vars?: Record | undefined; + }, +>(themeInput: T, options?: DensityOptions): T & { density: DensityScale } { + const scale = densityKeys.reduce((acc, key) => { + acc[key] = options?.scale?.[key] ?? String(themeInput.spacing(defaultMultiplier[key])); + return acc; + }, {} as DensityScale); + + const rootVars = densityKeys.reduce>((acc, key) => { + acc[cssVar(key)] = scale[key]; + return acc; + }, {}); + + const varRefs = densityKeys.reduce((acc, key) => { + acc[key] = `var(${cssVar(key)})`; + return acc; + }, {} as DensityScale); + + const theme = { ...themeInput } as T & { density: DensityScale }; + theme.density = scale; + theme.vars = { ...themeInput.vars, density: varRefs }; + + 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, + }, + }, + }, + MuiButton: { + ...c?.MuiButton, + styleOverrides: { + ...c?.MuiButton?.styleOverrides, + root: [ + c?.MuiButton?.styleOverrides?.root, + { + // Sized-only: each size's `pad` shorthand (block inline) maps to its + // own density step, so tuning the scale keeps the per-size matrix. + '--Button-small-pad': `${varRefs.xxs} ${varRefs.sm}`, + '--Button-medium-pad': `${varRefs.xs} ${varRefs.lg}`, + '--Button-large-pad': `${varRefs.sm} ${varRefs.xl}`, + }, + ], + }, + }, + MuiChip: { + ...c?.MuiChip, + styleOverrides: { + ...c?.MuiChip?.styleOverrides, + root: [ + c?.MuiChip?.styleOverrides?.root, + { + '--Chip-small-height': varRefs.xl, + '--Chip-medium-height': varRefs.xxl, + '--Chip-small-padInline': varRefs.sm, + '--Chip-medium-padInline': varRefs.md, + }, + ], + }, + }, + MuiIconButton: { + ...c?.MuiIconButton, + styleOverrides: { + ...c?.MuiIconButton?.styleOverrides, + root: [ + c?.MuiIconButton?.styleOverrides?.root, + { + '--IconButton-small-pad': varRefs.xs, + '--IconButton-medium-pad': varRefs.sm, + '--IconButton-large-pad': varRefs.lg, + }, + ], + }, + }, + MuiMenuItem: { + ...c?.MuiMenuItem, + styleOverrides: { + ...c?.MuiMenuItem?.styleOverrides, + root: [ + c?.MuiMenuItem?.styleOverrides?.root, + { + '--MenuItem-minHeight': varRefs.xl, + '--MenuItem-dense-minHeight': varRefs.lg, + '--MenuItem-padBlock': varRefs.xs, + '--MenuItem-dense-padBlock': varRefs.xxs, + '--MenuItem-padInline': varRefs.lg, + '--MenuItem-dense-padInline': varRefs.md, + }, + ], + }, + }, + MuiListItem: { + ...c?.MuiListItem, + styleOverrides: { + ...c?.MuiListItem?.styleOverrides, + root: [ + c?.MuiListItem?.styleOverrides?.root, + { + '--ListItem-padBlock': varRefs.sm, + '--ListItem-dense-padBlock': varRefs.xxs, + '--ListItem-padInline': varRefs.lg, + '--ListItem-dense-padInline': varRefs.md, + }, + ], + }, + }, + MuiListItemButton: { + ...c?.MuiListItemButton, + styleOverrides: { + ...c?.MuiListItemButton?.styleOverrides, + root: [ + c?.MuiListItemButton?.styleOverrides?.root, + { + '--ListItemButton-padBlock': varRefs.sm, + '--ListItemButton-dense-padBlock': varRefs.xs, + '--ListItemButton-padInline': varRefs.lg, + '--ListItemButton-dense-padInline': varRefs.md, + }, + ], + }, + }, + MuiListItemIcon: { + ...c?.MuiListItemIcon, + styleOverrides: { + ...c?.MuiListItemIcon?.styleOverrides, + root: [ + c?.MuiListItemIcon?.styleOverrides?.root, + { + '--ListItemIcon-minWidth': `calc(36px + ${varRefs.md})`, + }, + ], + }, + }, + MuiListItemText: { + ...c?.MuiListItemText, + styleOverrides: { + ...c?.MuiListItemText?.styleOverrides, + root: [ + c?.MuiListItemText?.styleOverrides?.root, + { + // Sized-only: regular vs dense compactness each maps to its own step. + // marginBlock = vertical row spacing (smaller = denser); insetPad = + // indentation. + '--ListItemText-marginBlock': varRefs.xs, + '--ListItemText-dense-marginBlock': varRefs.xxs, + '--ListItemText-insetPad': `calc(${varRefs.xl} + ${varRefs.lg})`, + '--ListItemText-dense-insetPad': varRefs.xl, + }, + ], + }, + }, + MuiListSubheader: { + ...c?.MuiListSubheader, + styleOverrides: { + ...c?.MuiListSubheader?.styleOverrides, + root: [ + c?.MuiListSubheader?.styleOverrides?.root, + { + // Base tokens (no size layer): map the agnostic seams directly. + '--ListSubheader-height': varRefs.xl, + '--ListSubheader-padInline': varRefs.md, + '--ListSubheader-inset': `calc(${varRefs.xl} + ${varRefs.lg})`, + }, + ], + }, + }, + MuiToolbar: { + ...c?.MuiToolbar, + styleOverrides: { + ...c?.MuiToolbar?.styleOverrides, + root: [ + c?.MuiToolbar?.styleOverrides?.root, + { + // Only `dense` minHeight is tokenized (regular stays mixins.toolbar); + // gutter padInline is a base token. + '--Toolbar-dense-minHeight': varRefs.lg, + '--Toolbar-padInline': varRefs.md, + }, + ], + }, + }, + MuiTab: { + ...c?.MuiTab, + styleOverrides: { + ...c?.MuiTab?.styleOverrides, + root: [ + c?.MuiTab?.styleOverrides?.root, + { + // Base tokens: Tab has no size prop, so map the agnostic seams + // directly to density steps (no per-size tokens to route). + '--Tab-padBlock': varRefs.sm, + '--Tab-padInline': varRefs.lg, + '--Tab-minHeight': `calc(${varRefs.xl} + ${varRefs.lg})`, + '--Tab-iconSpacing': varRefs.xs, + }, + ], + }, + }, + MuiTabs: { + ...c?.MuiTabs, + styleOverrides: { + ...c?.MuiTabs?.styleOverrides, + root: [ + c?.MuiTabs?.styleOverrides?.root, + { + // Match Tab's minHeight step so the bar tracks the tabs it contains. + '--Tabs-minHeight': `calc(${varRefs.xl} + ${varRefs.lg})`, + }, + ], + }, + }, + MuiTablePagination: { + ...c?.MuiTablePagination, + styleOverrides: { + ...c?.MuiTablePagination?.styleOverrides, + root: [ + c?.MuiTablePagination?.styleOverrides?.root, + { + '--TablePagination-minHeight': `calc(${varRefs.xl} + ${varRefs.md})`, + '--TablePagination-actionsSpacing': varRefs.lg, + '--TablePagination-selectSpacing': varRefs.xl, + }, + ], + }, + }, + MuiCardContent: { + ...c?.MuiCardContent, + styleOverrides: { + ...c?.MuiCardContent?.styleOverrides, + root: [ + c?.MuiCardContent?.styleOverrides?.root, + { + // CardContent has no size prop -> base tokens (no per-size layer). + '--CardContent-pad': varRefs.lg, + '--CardContent-padBottom': varRefs.xl, + }, + ], + }, + }, + MuiSelect: { + ...c?.MuiSelect, + styleOverrides: { + ...c?.MuiSelect?.styleOverrides, + root: [ + c?.MuiSelect?.styleOverrides?.root, + { + // Base axis (no size layer) — single agnostic seam, mapped to a + // mid-step so density nudges the select content-box floor uniformly. + '--Select-minHeight': varRefs.lg, + }, + ], + }, + }, + MuiBreadcrumbs: { + ...c?.MuiBreadcrumbs, + styleOverrides: { + ...c?.MuiBreadcrumbs?.styleOverrides, + root: [ + c?.MuiBreadcrumbs?.styleOverrides?.root, + { + '--Breadcrumbs-separatorGap': varRefs.sm, + }, + ], + }, + }, + MuiInputAdornment: { + ...c?.MuiInputAdornment, + styleOverrides: { + ...c?.MuiInputAdornment?.styleOverrides, + root: [ + c?.MuiInputAdornment?.styleOverrides?.root, + { + '--InputAdornment-small-gap': varRefs.xxs, + '--InputAdornment-medium-gap': varRefs.sm, + '--InputAdornment-small-marginTop': varRefs.md, + '--InputAdornment-medium-marginTop': varRefs.lg, + }, + ], + }, + }, + MuiBadge: { + ...c?.MuiBadge, + styleOverrides: { + ...c?.MuiBadge?.styleOverrides, + root: [ + c?.MuiBadge?.styleOverrides?.root, + { + '--Badge-standard-pad': `0 ${varRefs.sm}`, + '--Badge-standard-size': varRefs.lg, + '--Badge-dot-pad': '0px', + '--Badge-dot-size': varRefs.xs, + }, + ], + }, + }, + MuiOutlinedInput: { + ...c?.MuiOutlinedInput, + styleOverrides: { + ...c?.MuiOutlinedInput?.styleOverrides, + root: [ + c?.MuiOutlinedInput?.styleOverrides?.root, + { + // Sized block/inline padding per size; block < inline to keep the + // input's 16.5/14 feel. + '--OutlinedInput-medium-padBlock': varRefs.md, + '--OutlinedInput-small-padBlock': varRefs.sm, + '--OutlinedInput-medium-padInline': varRefs.lg, + '--OutlinedInput-small-padInline': varRefs.md, + // The outlined label resting-Y tracks the input's block padding, but + // the label is a preceding sibling — it can't read the input's + // `--OutlinedInput-*-padBlock` (custom props don't inherit sibling -> + // sibling). So derive `--InputLabel-y` straight from the density step + // (which the label DOES inherit from `:root`), matching the + // component's -0.5/+0.5 rounding per size. + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--InputLabel-y': `calc(${varRefs.md} - 0.5px)`, + }, + variants: [ + { + props: { size: 'small' }, + style: { + [`.${inputLabelClasses.root}:has(~ &)`]: { + '--InputLabel-y': `calc(${varRefs.sm} + 0.5px)`, + }, + }, + }, + ], + }, + ], + }, + }, + MuiCheckbox: { + ...c?.MuiCheckbox, + styleOverrides: { + ...c?.MuiCheckbox?.styleOverrides, + root: [ + c?.MuiCheckbox?.styleOverrides?.root, + { + // Touch-target padding (9px default both sizes), via SwitchBase. + '--Checkbox-medium-pad': varRefs.sm, + '--Checkbox-small-pad': varRefs.xs, + }, + ], + }, + }, + MuiRadio: { + ...c?.MuiRadio, + styleOverrides: { + ...c?.MuiRadio?.styleOverrides, + root: [ + c?.MuiRadio?.styleOverrides?.root, + { + '--Radio-medium-pad': varRefs.sm, + '--Radio-small-pad': varRefs.xs, + }, + ], + }, + }, + MuiSwitch: { + ...c?.MuiSwitch, + styleOverrides: { + ...c?.MuiSwitch?.styleOverrides, + root: [ + c?.MuiSwitch?.styleOverrides?.root, + { + // Switch dims are composed from scale steps to land on today's sizes + // at the default scale, then track density proportionally. pad/top/ + // travel/radius re-derive, so the geometry stays valid (touchSize == + // height -> centered; width > touchSize -> positive travel). + '--Switch-medium-width': `calc(${varRefs.xxl} * 2 - 6px)`, // 58 + '--Switch-medium-height': `calc(${varRefs.xxl} + ${varRefs.xs})`, // 38 + '--Switch-medium-touchSize': `calc(${varRefs.xxl} + ${varRefs.xs})`, // 38 (= height) + '--Switch-medium-thumbSize': `calc(${varRefs.lg} + ${varRefs.xxs})`, // 20 + '--Switch-medium-pad': varRefs.md, // 12 + '--Switch-small-width': `calc(${varRefs.xxl} + ${varRefs.sm})`, // 40 + '--Switch-small-height': varRefs.xl, // 24 + '--Switch-small-touchSize': varRefs.xl, // 24 (= height) + '--Switch-small-thumbSize': varRefs.lg, // 16 + '--Switch-small-pad': `calc(${varRefs.sm} - 1px)`, // 7 + }, + ], + }, + }, + }; + + return theme; +} diff --git a/packages/mui-material/src/styles/index.d.ts b/packages/mui-material/src/styles/index.d.ts index d4082be45c8e66..cda729dc9e7ca9 100644 --- a/packages/mui-material/src/styles/index.d.ts +++ b/packages/mui-material/src/styles/index.d.ts @@ -9,6 +9,7 @@ export { CssThemeVariables, } from './createTheme'; export { default as enhanceHighContrast, HighContrastTokens } from './enhanceHighContrast'; +export { default as enhanceDensity, DensityScale, DensityOptions } from './enhanceDensity'; 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..c78e02b9604d71 100644 --- a/packages/mui-material/src/styles/index.js +++ b/packages/mui-material/src/styles/index.js @@ -26,6 +26,7 @@ export function experimental_sx() { } export { default as createTheme } from './createTheme'; export { default as enhanceHighContrast } from './enhanceHighContrast'; +export { default as enhanceDensity } from './enhanceDensity'; 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 } }, +});