diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000000000..f94954937d6b03 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,61 @@ +# Public CSS Variables & Density POC + +POC exploring whether Material UI components should expose hand-authorable CSS variables for spacing/color/typography, so theming is easier and "density" can be experimented with. Branch: `poc/css-vars-map`. + +## Language + +**Public CSS variable** (always **agnostic**): +A component-scoped CSS custom property (e.g. `--Button-padding-block`, `--Button-bg`) that consumers may set directly in their own stylesheets. **Hard rule:** exactly one variable per styleable property, encoding **no variant/size/color/state** in the name (state is itself design language). It is **neutral to any design language** — it lifts the property up a layer so the component can be _re-expressed in a different design language_, i.e. reset to a neutral baseline. It defaults to the Material spec (via private per-variant/size fallbacks); overriding it **opts that property out of the spec** and hands the consumer full control. No `--mui-` prefix. See `docs/adr/0002-agnostic-public-css-vars.md`. +_Avoid_: variant/size/color-specific public names (`--Button-contained-bg`); "internal var"/"computed var" (those are private, like `--variant-*` or Badge's `--Badge-translate`). + +**Agnostic axes** (geometry / density / elevation / color / typography): +The five categories every agnostic variable belongs to — the systematic structure for _determining_ a component's vars and the completeness check. Walk the axes; for each, expose one var per property the component actually paints/sizes. + +- **geometry**: width, height, radius, border-width +- **density**: margin, padding, gap +- **elevation**: shadow, transform +- **color**: color, bg, border-color, ring +- **typography**: font-size, line-height, font-weight, text-transform, letter-spacing + +A proposed var that fits no axis is a smell — it is probably **anatomy/behavior** (label float, the outline notch, ripple, DOM shape), which is handled by props/variants/slots, not the agnostic surface. Agnostic vars = the design language's _look_; props/variants/slots = its _anatomy_. + +**Spacing token** _(conceptual — not built in this POC)_: +A named step in a future spacing scale (`xxs, xs, sm, md, lg, xl, xxl`). Raised in the original sketch as the long-term target, but **the POC does not implement it**. Today MUI has only `theme.spacing()` (an 8px-base multiplier) which, under `cssVariables: true`, emits a single **`--mui-spacing`** dial. The POC relies on that single dial; a named token scale is left for later and would slot in underneath without changing component code. +_Avoid_: treating `xxs…xxl` as something the POC ships; confusing with Grid breakpoint keys (`xxs/xs/sm…`), which are unrelated. + +**Density**: +A set of key-values (spacing/font-size/line-height/weight) applied as a group, varying across different viewports and different apps. In the POC, the coarse density lever is overriding `--mui-spacing` at a scope. + +**`--mui-spacing`**: +The single base spacing-unit runtime variable emitted when `createTheme({ cssVariables: true })` is used (default `8px`). `theme.spacing(n)` compiles to `calc(var(--mui-spacing) * n)`, so overriding it at any scope reflows all spacing-derived values. This is the existing runtime dial the POC reuses instead of a new `--spacing-*` scale. + +**Inward dependency rule**: +A component's CSS-var fallback chain may reference only its own var and the vars of components it renders/extends (inward), never the vars of its consumers (outward). InputBase must not mention OutlinedInput or TextField; OutlinedInput (extends InputBase) may reference `--InputBase-*`; TextField (renders OutlinedInput) maps `--OutlinedInput-*: var(--TextField-*)`. Details, worked example, and rejected alternatives: `docs/design/public-css-var-layering.md`. + +## Relationships + +- A **Public CSS variable** (component padding) defaults to `theme.spacing(n)`, i.e. `calc(var(--mui-spacing) * n)`. +- Coarse **Density** = override **`--mui-spacing`** at a scope (reflows everything). Fine density = override individual **Public CSS variables** per component. +- **TextField color/border/radius knobs (outlined):** `--TextField-color` (text), `--TextField-border-color`, `--TextField-border-width`, `--TextField-radius`, mapped to variant-level `--OutlinedInput-*` (inward rule), consumed by the NotchedOutline slot / root. `--TextField-bg` intentionally absent (InputBase defines no background — only expose vars for properties the component actually styles). Border-width focus uses `var(--OutlinedInput-border-width, 2px)`: default keeps 1px→2px on focus; when customized, focus = resting width (not thinner). Border-color routes through `var(--OutlinedInput-border-color, )` in every state — hover, focus, error, and disabled — so a custom border color persists across all of them. +- **TextField** owns no padding. Its single knob is **`--TextField-height`** (not padding), mapped to the variant-level `--OutlinedInput-height` (not directly to `--InputBase-height`, which would shadow a page-level base knob). The **Input slot** (InputBaseInput / OutlinedInputInput) is the real consumer. Resolution order: variant knob (`--OutlinedInput-*`) → base knob (`--InputBase-*`) → literal/derived default. +- **Input vertical size is height-driven, not padding-driven.** `--InputBase-height` defaults to `theme.spacing(7)` (medium) / `spacing(5)` (small) — clean spacing multiples — so inputs ride `--mui-spacing` density via height. `padding-block` is _derived_: `(height − line-height·font-size)/2`, with `--InputBase-padding-block` as an escape hatch. `padding-inline` is spacing-derived independently. Floor: when `height < line-height·font-size`, derived padding goes negative (font-size scaling, deferred, or a clamp would fix). + +## Decisions + +- **Public = reading (A):** hand-authorable fixed API, not an internal theme-only mechanism. No `--mui-` prefix on component vars. +- **POC runs under `cssVariables: true`.** Density mechanic is **A1** + `--mui-spacing` global override; no new `--spacing-*` token scale yet. +- **Density is holistic (A):** overriding `--mui-spacing` reflows the whole UI (layout + component internals), not just controls. +- **POC dimensions:** padding (block/inline) + line-height (inputs only — load-bearing in the height formula; no Button line-height var) + font-size (`--Button-font-size`; inputs via `--InputBase-font-size` with variant chain `--OutlinedInput-font-size` and TextField mapping `--TextField-font-size`). Font-weight and color deferred. +- **Font-size & the input height formula compose for free:** the derived padding uses `1em` for the line box, and `1em` = the input's own font-size. So changing `--*-font-size` re-centers the text inside the fixed `--*-height` automatically — no extra line-height var needed for the font-size axis. +- **Color vars are agnostic, two-tier.** Public `--Button-bg` / `--Button-color` / `--Button-border-color` / `--Button-border-width` / `--Button-radius` / `--Button-shadow` (resting elevation, contained) / `--Button-ring` (opt-in focus-visible `outline`, default `0`) are _variant-agnostic_ and layer **over** the existing private `--variant-*` palette source: `var(--Button-bg, var(--variant-containedBg))`. Default falls through to `--variant-*` (palette via `color` prop) → unbroken. The agnostic var **flattens** across variants (setting `--Button-bg` tints text/outlined too) — intended: overriding = opting out of the spec. The private `--variant-*` are **not** public API (spec-default source only, may change); they are never promoted. Names are explicit (`-border-color`, `-border-width`) so width and color don't collide. `--Button-border-width` also feeds the outlined padding compensation (`calc(padding − var(--Button-border-width, 1px))`), so changing border width keeps the button size constant. +- **Agnostic var honored in every state (not "resting-only").** Every assignment of a var-backed property — including interactive-state rules — must route through the var: `var(--X, )`. A direct literal in a state rule is a bug (e.g. Button `boxShadow: shadows[4]` on hover clobbered `--Button-shadow`; OutlinedInput hover/focus `borderColor` clobbered `--OutlinedInput-border-color` — both fixed). Color already behaved correctly because hover reassigns the inner `--variant-*` while the outer `--Button-bg` wins. Applies to **every** assignment with **no exceptions** — resting, interactive states (hover/active/focus), and affordance states (`disabled`, `error`, `disableElevation`) all route through the var. So a set `--Button-bg`/`--OutlinedInput-border-color` wins everywhere, including disabled/error. Customizing a property forgoes the spec's state styling for it (disabled greying, error red, hover delta); remap `--mui-palette-*` to keep the spec's states instead. +- **Agnostic vars sort into five axes** — geometry / density / elevation / color / typography (see _Agnostic axes_ above). This is the systematic method for determining a component's vars and the completeness check: a var fitting no axis is anatomy/behavior, which belongs to props/variants/slots (e.g. floating-label opt-out), not the agnostic surface. See `docs/adr/0002-agnostic-public-css-vars.md`. +- **POC build scope:** Button (direct-padding density) + OutlinedInput with TextField→OutlinedInput→InputBase mapping (layered height-derived density). FilledInput / standard Input / Select deferred. +- **Density demo:** one `docs/pages/experiments/` page showing both diagram branches — "Different Apps" (`--mui-spacing` overridden via a class scope) and "Different Viewports" (`--mui-spacing` overridden in a `@media` query) — with the same Buttons/TextFields reflowing live. + +## Example dialogue + +> **Dev:** "If I set `--TextField-padding-block` on a wrapper, does it reach the ``?" +> **Maintainer:** "Yes — TextField maps it to `--OutlinedInput-padding-block`, which the Input slot consumes. But for vertical size you usually don't touch padding at all; you set **`--InputBase-height`** and padding is derived to keep the text centered." +> **Dev:** "And global density?" +> **Maintainer:** "Override **`--mui-spacing`** at a scope. Button padding scales directly; input height scales (it defaults to `spacing(7)`) and its padding recomputes. One dial, whole UI." diff --git a/docs/adr/0001-public-css-var-inward-dependency.md b/docs/adr/0001-public-css-var-inward-dependency.md new file mode 100644 index 00000000000000..867cbbe96e929e --- /dev/null +++ b/docs/adr/0001-public-css-var-inward-dependency.md @@ -0,0 +1,16 @@ +# Public CSS variables flow inward only + +Public component CSS variables (e.g. `--Button-padding-block`, `--InputBase-padding-block`) are a fixed, hand-authorable API. To keep components decoupled, a component's variable fallback chain may reference only its own variables and those of components it renders or extends (inward), never its consumers (outward). + +Concretely: `InputBaseInput` consumes `var(--InputBase-padding-block, )`; `OutlinedInputInput` (extends InputBase) may add its own knob with an inward fallback `var(--OutlinedInput-padding-block, var(--InputBase-padding-block, ))`; `TextField` (renders OutlinedInput) maps its knob downward with `--OutlinedInput-padding-block: var(--TextField-padding-block)`. No inner component names `--TextField-*`. + +## Why + +The rejected alternative was a single nested-fallback chain at the consumer — `var(--InputBase-padding-block, var(--OutlinedInput-padding-block, var(--TextField-padding-block, )))`. It is shorter but bakes the outer component's variable name into the base component's CSS, coupling InputBase to TextField. The inward-only rule preserves standalone use of inner components and lets each layer expose its own knob. + +## Consequences + +- A user setting `--TextField-padding-block` drives the rendered Input via the downward mapping; defaults survive because an unset `var(--TextField-padding-block)` yields the guaranteed-invalid value, so downstream `var(--…, )` falls through to the literal. +- When wrapped by TextField, the TextField root re-declares `--OutlinedInput-padding-block`, so setting that inner knob on an _ancestor of_ the TextField is shadowed. Contract: use `--TextField-*` for TextField, inner knobs for standalone Inputs. + +See [Public CSS Variable Layering](../design/public-css-var-layering.md) for detailed mechanics, a worked input example, and the rejected alternatives. diff --git a/docs/adr/0002-agnostic-public-css-vars.md b/docs/adr/0002-agnostic-public-css-vars.md new file mode 100644 index 00000000000000..0c694a008ff993 --- /dev/null +++ b/docs/adr/0002-agnostic-public-css-vars.md @@ -0,0 +1,29 @@ +# Public CSS variables are design-agnostic, one per property + +The public CSS-variable API exposes exactly **one variable per styleable property** (`--Button-padding-block`, `--Button-font-size`, `--Button-bg`, `--Button-color`, `--Button-border-color`, `--Button-border-width`, `--Button-radius`, `--Button-shadow`, `--InputBase-height`, …). Public names encode **no variant, size, color, or state**. Each variable defaults to the Material Design spec via _private_ per-variant/size fallbacks (e.g. `--variant-containedBg`, the literal size paddings). Overriding a public variable **opts that property out of the spec** across every variant/size/state — you trade the built-in design language for full manual control. + +## Why + +The intent is to keep the public surface small and **unopinionated about design language**. An agnostic var is **neutral to any design language** (not just Material's) — it lifts a property up a layer so the component can be _re-expressed_ in another design language, i.e. reset to a neutral baseline. We explicitly rejected variant/size/color-specific public variables (`--Button-contained-bg`, `--Button-small-padding-block`, `--Button-primary-bg`): they multiply the API surface and bake MUI's variant/size/color taxonomy into a permanent contract. The private `--variant-*` machinery stays private (it carries the spec defaults); it is not promoted to public API. + +## Structure: the five axes + +Every agnostic variable belongs to exactly one of five axes, which double as the method for **determining** a component's vars: + +| Axis | Covers | +| -------------- | ------------------------------------------------------------------- | +| **geometry** | width, height, radius, border-width | +| **density** | margin, padding, gap | +| **elevation** | shadow, transform | +| **color** | color, bg, border-color, ring | +| **typography** | font-size, line-height, font-weight, text-transform, letter-spacing | + +To determine a component's vars: walk the axes; for each, expose one var per property the component actually paints/sizes — collapsing the variant × size × color × state matrix to a single var, and only exposing what the component paints (no `--TextField-bg`, because outlined InputBase paints no fill). + +A proposed var that fits no axis is a smell: it is probably **anatomy/behavior** (label float, the outline notch, ripple, DOM shape), which is handled by a complementary mechanism — props/variants/slots — not the agnostic surface. A CSS var can restyle the _look_; only a prop/variant/slot can change DOM and semantics (e.g. opting an outlined input out of its floating label: a var could zero the notch visually, but the `` stays vestigial and the label's a11y position is unaddressed). **Agnostic vars = the design language's look; props/variants/slots = its anatomy.** + +## Consequences + +- **Override flattens** — a custom `--Button-bg` tints text/outlined buttons too; a custom `--Button-padding-block` collapses the size matrix. Intended: you left the spec, so the per-variant/size distinctions no longer apply. +- **The var is honored in every assignment — no exceptions.** A property that has an agnostic var must consume it at _every_ assignment: resting, interactive states (hover/active/focus), _and_ affordance states (`disabled`, `error`, `disableElevation`), each written as `var(--X, )`. A direct literal anywhere — e.g. `boxShadow: shadows[4]` on hover, `borderColor: action.disabledBackground` on disabled — is a **bug**: it clobbers the var. So a set `--Button-bg` / `--OutlinedInput-border-color` wins _everywhere_, including disabled and error. Consequence: customizing a property fully opts out of the spec for it — the component's hover/active delta, disabled greying, error red, and elevation-off no longer show for that property. To keep the spec's state styling, remap `--mui-palette-*` / `--mui-spacing` instead of the component var. +- Adding a new public var is a deliberate act: it must be a single agnostic property knob, not a variant/size/color slot. diff --git a/docs/design/public-css-var-layering.md b/docs/design/public-css-var-layering.md new file mode 100644 index 00000000000000..dd3e5c52cd1ff0 --- /dev/null +++ b/docs/design/public-css-var-layering.md @@ -0,0 +1,168 @@ +# Public CSS Variable Layering + +Detailed companion to [ADR-0001](../adr/0001-public-css-var-inward-dependency.md). Explains _why_ the inward-dependency rule exists, _how_ the CSS mechanics work, a fully worked input example, and the alternatives that were considered and dropped. + +--- + +## 1. Why it matters + +Public component CSS variables (`--Button-padding-block`, `--InputBase-padding-block`, …) are a **fixed, hand-authorable API**. Once shipped, a variable name is a compatibility contract. That raises one architectural risk above all others: **coupling between component layers through variable names.** + +A Material UI input is built from nested layers: + +```text +TextField (wrapper — renders one Input flavor, owns no padding) + └─ OutlinedInput (extends InputBase, adds the notched-outline look) + └─ InputBase (the base; its Input slot is what actually paints padding) +``` + +These layers are also used **independently**: people render `` or `` directly, without a `TextField`. So the dependency direction must mirror the composition direction — outer knows inner, never the reverse. + +> **The rule:** a component's variable fallback chain may reference only its **own** variables and the variables of components it **renders or extends** (inward). It must never reference a **consumer's** variable (outward). +> +> `InputBase` must not mention `--OutlinedInput-*` or `--TextField-*`. +> `OutlinedInput` (extends InputBase) **may** reference `--InputBase-*`. +> `TextField` (renders OutlinedInput) **may** map down to `--OutlinedInput-*`. + +Break this rule and the base component carries dead knowledge of every wrapper that might one day use it — `InputBase`'s CSS would hard-code `--TextField-padding-block` even when no TextField is present. That is the coupling we refuse. + +--- + +## 2. How it works + +Two CSS mechanics carry the whole design. + +### 2.1 `var()` with a fallback + +```css +padding-block: var(--InputBase-padding-block, 4px); +``` + +If `--InputBase-padding-block` is set anywhere up the inheritance chain, it wins; otherwise the literal `4px` (today's value) applies. This is what makes **defaults byte-identical** while still being overridable. + +### 2.2 The guaranteed-invalid value (how downward mapping stays safe) + +A wrapper maps its public knob onto the inner component's variable: + +```css +/* TextField root, variant=outlined */ +--OutlinedInput-padding-block: var(--TextField-padding-block); +``` + +When the user sets nothing, `var(--TextField-padding-block)` (no fallback, property unset) resolves to the **guaranteed-invalid value**. A custom property holding that value behaves as unset _to downstream `var()` lookups_ — so the inner `var(--OutlinedInput-padding-block, )` correctly **falls through to its literal**. + +This is the key trick: the mapping declaration is **harmless when the user sets nothing**, so it can always be present without disturbing defaults. + +### 2.3 Resolution order + +The inner consumer reads, innermost-knob first, then inward fallbacks, then literal: + +```css +/* OutlinedInputInput (the Input slot of OutlinedInput) */ +padding-block: var( + --OutlinedInput-padding-block, + var(--InputBase-padding-block, 16.5px) +); +``` + +So precedence is: `--OutlinedInput-*` (variant knob) → `--InputBase-*` (base knob shared across input flavors) → literal default. Outer-most (`--TextField-*`) only enters via the wrapper's downward mapping into `--OutlinedInput-*`. + +--- + +## 3. Worked example — input padding + +Pseudo-CSS for the three layers (real selectors elided; `${spacing(n)}` renders to `calc(var(--mui-spacing) * n)` under `cssVariables: true`): + +```css +/* --- InputBase: base consumer (standard variant) --- */ +.InputBaseInput { + padding-block: var(--InputBase-padding-block, 4px); + padding-inline: var(--InputBase-padding-inline, 0px); +} + +/* --- OutlinedInput: extends InputBase, may reference --InputBase-* (inward) --- */ +.OutlinedInputInput { + /* vertical: height-driven, padding DERIVED to keep text centered. + Height chain: --OutlinedInput-height (variant) → --InputBase-height (base) → spacing(7). */ + padding-block: var( + --OutlinedInput-padding-block, + var( + --InputBase-padding-block, + calc( + ( + var(--OutlinedInput-height, var(--InputBase-height, ${spacing(7)})) + /* 56px medium / spacing(5)=40px small */ - + var(--InputBase-line-height, 1.4375) * var(--InputBase-font-size, 1rem) + ) + /* text line-box = 23px */ / 2 + ) + ) + ); + /* horizontal: spacing-driven, independent of height */ + padding-inline: var( + --OutlinedInput-padding-inline, + var(--InputBase-padding-inline, ${spacing(1.75)}) /* 14px */ + ); +} + +/* --- TextField: renders OutlinedInput, maps its knob DOWNWARD (inward). + Single knob is HEIGHT, mapped to the variant-level var so a page-level + --InputBase-height set by the user is NOT shadowed for wrapped inputs. --- */ +.TextField--outlined { + --OutlinedInput-height: var(--TextField-height); +} +``` + +### Traces (rendered as ``) + +| user sets | `padding-block` resolves to | path | +| :-------------------------------------------- | :-------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | +| nothing | `16.5px` | `--TextField-*` unset → `--OutlinedInput-*` guaranteed-invalid → `--InputBase-*` unset → derived `(spacing(7)−23)/2` = 16.5px. **Default preserved.** | +| `--TextField-height: 40px` (on the TextField) | `8.5px` | wrapper maps to `--OutlinedInput-height` → `(40−23)/2`; height becomes 40px | +| `--InputBase-height: 48px` | `12.5px` | `(48−23)/2` — geometry stays centered, height contract holds | +| `--InputBase-line-height: 1.2` | `18.4px` | `(56−19.2)/2` — line-height density, height stays 56px | +| `--mui-spacing: 6px` (page scope) | `9.5px`, height 42px | `--InputBase-height` defaults to `spacing(7)` → rides the global dial | +| `--InputBase-padding-block: 9px` | `9px` | base-knob override beats the derived formula | + +**Floor:** when `height < line-height·font-size` (e.g. `--mui-spacing: 3px` → `spacing(7)=21px < 23px`), the derived padding goes negative. Fixing this needs font-size to scale too (deferred) or a `max(…, lineBox)` clamp. + +### Standalone use (no TextField) + +`` alone: the `.TextField--outlined` mapping never applies, so `--OutlinedInput-padding-block` stays genuinely unset and the inward fallback chain still bottoms out correctly. Setting `--OutlinedInput-padding-block` directly now works (no wrapper to shadow it). This is exactly the decoupling the rule buys. + +--- + +## 4. Dropped alternatives (history) + +### A. Single nested-fallback chain at the consumer — **rejected (coupling)** + +```css +/* OutlinedInputInput */ +padding-block: var( + --InputBase-padding-block, + var(--OutlinedInput-padding-block, var(--TextField-padding-block, 16.5px)) +); /* ← base CSS names the wrapper */ +``` + +Shortest to write and the traces work, **but it bakes `--TextField-padding-block` into the base/Input CSS** — the inner layer now knows about a consumer it may not even have. Violates the inward rule; abandoned in favor of the layered mapping. This was the first proposal and the reason the rule was formalised. + +### B. Upward-direction declarations — **rejected (value never reaches the input)** + +```css +.OutlinedInput { + --OutlinedInput-padding-block: var(--InputBase-padding-block); +} +.TextField { + --TextField-padding-block: var(--OutlinedInput-padding-block); +} +``` + +Declares each layer's own var _from its child's_ — flow goes inner→outer. A `--TextField-padding-block` the user sets then never propagates **down** to the Input that paints padding. This was the original sketch's direction; corrected to outer-declares-inner. + +### C. Per-variant vars only, no shared base knob — **rejected (no cross-flavor dial)** + +Expose only `--OutlinedInput-*`, `--FilledInput-*`, etc., with no `--InputBase-*` in the chain. Precise, but there is then **no single knob to densify all input flavors at once** — a common need. Keeping `--InputBase-*` as the inward fallback gives that cross-variant dial for free. + +### D. Wrapper prop-drills a value (no CSS var mapping) — **rejected (loses scope cascade)** + +TextField reads a prop/theme value and passes a concrete number to the Input. Works for explicit props but **can't be overridden by an ancestor scope** (`.dense { --TextField-padding-block: … }`) or a `@media` query — which is the entire point of the density experiment. CSS-var mapping preserves the cascade; prop-drilling does not. diff --git a/docs/pages/experiments/agnostic-variables.tsx b/docs/pages/experiments/agnostic-variables.tsx new file mode 100644 index 00000000000000..be4edcec2f935c --- /dev/null +++ b/docs/pages/experiments/agnostic-variables.tsx @@ -0,0 +1,489 @@ +'use client'; +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import Slider from '@mui/material/Slider'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { createTheme, ThemeProvider, type Theme } from '@mui/material/styles'; +import { HighlightedCode } from '@mui/internal-core-docs/HighlightedCode'; + +// Agnostic variables experiment. +// Two themes target the SAME look. Left re-fights Material in every variant × +// state via styleOverrides; right sets one agnostic var per property and lets +// the component honor it in every state. See CONTEXT.md + docs/adr/0002. +const BLUE = '#2C6CA3'; + +const beforeTheme = createTheme({ + cssVariables: { + nativeColor: true, + colorSchemeSelector: 'data-mui-color-scheme', + cssVarPrefix: 'demo1', + }, + colorSchemes: { + light: { + palette: { primary: { main: BLUE } }, + }, + dark: { + palette: { primary: { main: BLUE } }, + }, + }, + components: { + MuiButtonBase: { defaultProps: { disableRipple: true } }, + MuiButton: { + defaultProps: { disableElevation: true }, + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 600, + borderRadius: 6, + '&.Mui-focusVisible': { outline: `2px solid ${BLUE}` }, + '&.Mui-disabled': { opacity: 0.4 }, + }, + }, + variants: [ + { + props: { variant: 'outlined' }, + style: ({ theme }: { theme: Theme }) => ({ + color: (theme.vars || theme).palette.text.primary, + borderColor: (theme.vars || theme).palette.divider, + '@media (hover:hover)': { + '&:hover': { + backgroundColor: (theme.vars || theme).palette.action.hover, + borderColor: (theme.vars || theme).palette.divider, + }, + }, + '&:active': { backgroundColor: (theme.vars || theme).palette.action.selected }, + '&.Mui-disabled': { + color: (theme.vars || theme).palette.text.primary, + borderColor: (theme.vars || theme).palette.divider, + }, + }), + }, + { + props: { variant: 'text' }, + style: ({ theme }: { theme: Theme }) => ({ + color: (theme.vars || theme).palette.text.primary, + '@media (hover:hover)': { + '&:hover': { backgroundColor: (theme.vars || theme).palette.action.hover }, + }, + '&:active': { backgroundColor: (theme.vars || theme).palette.action.selected }, + '&.Mui-disabled': { color: (theme.vars || theme).palette.text.primary }, + }), + }, + { + props: { variant: 'contained' }, + style: ({ theme }: { theme: Theme }) => ({ + backgroundColor: (theme.vars || theme).palette.primary.main, + color: (theme.vars || theme).palette.primary.contrastText, + '@media (hover:hover)': { + '&:hover': { + backgroundColor: theme.darken((theme.vars || theme).palette.primary.main, 0.15), + }, + }, + '&:active': { + backgroundColor: theme.darken((theme.vars || theme).palette.primary.main, 0.3), + }, + '&.Mui-disabled': { + backgroundColor: (theme.vars || theme).palette.primary.main, + color: (theme.vars || theme).palette.primary.contrastText, + }, + }), + }, + ], + }, + }, +}); + +const afterTheme = createTheme({ + cssVariables: { + nativeColor: true, + colorSchemeSelector: 'data-mui-color-scheme', + cssVarPrefix: 'demo2', + }, + colorSchemes: { + light: { + palette: { primary: { main: BLUE } }, + }, + dark: { + palette: { primary: { main: BLUE } }, + }, + }, + components: { + MuiButtonBase: { defaultProps: { disableRipple: true } }, + MuiButton: { + defaultProps: { disableElevation: true }, + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 600, + '--Button-radius': '6px', + '--Button-ring': `2px solid ${BLUE}`, + '&.Mui-disabled': { opacity: 0.4 }, + }, + }, + variants: [ + { + props: { variant: 'outlined' }, + style: ({ theme }: { theme: Theme }) => ({ + '--Button-color': (theme.vars || theme).palette.text.primary, + '--Button-border-color': (theme.vars || theme).palette.divider, + '@media (hover:hover)': { + '&:hover': { '--Button-bg': (theme.vars || theme).palette.action.hover }, + }, + '&:active': { '--Button-bg': (theme.vars || theme).palette.action.selected }, + }), + }, + { + props: { variant: 'text' }, + style: ({ theme }: { theme: Theme }) => ({ + '--Button-color': (theme.vars || theme).palette.text.primary, + '@media (hover:hover)': { + '&:hover': { '--Button-bg': (theme.vars || theme).palette.action.hover }, + }, + '&:active': { '--Button-bg': (theme.vars || theme).palette.action.selected }, + }), + }, + { + props: { variant: 'contained' }, + style: ({ theme }: { theme: Theme }) => ({ + '--Button-bg': (theme.vars || theme).palette.primary.main, + '--Button-color': (theme.vars || theme).palette.primary.contrastText, + '@media (hover:hover)': { + '&:hover': { + '--Button-bg': theme.darken((theme.vars || theme).palette.primary.main, 0.15), + }, + }, + '&:active': { + '--Button-bg': theme.darken((theme.vars || theme).palette.primary.main, 0.3), + }, + }), + }, + ], + }, + }, +}); + +// Stock Material — no component overrides. The reference look. +const materialTheme = createTheme({ + cssVariables: { + nativeColor: true, + colorSchemeSelector: 'data-mui-color-scheme', + }, + colorSchemes: { + light: { palette: { primary: { main: BLUE } } }, + dark: { palette: { primary: { main: BLUE } } }, + }, +}); + +// Root-only agnostic vars → the same component takes a different shape: a soft, +// pill baseline. Geometry, density, elevation, font-size, and the ring all come +// from vars, flattened across every variant; colors follow the teal palette. +const TEAL = '#0F766E'; +const baselineTheme = createTheme({ + cssVariables: { + nativeColor: true, + colorSchemeSelector: 'data-mui-color-scheme', + cssVarPrefix: 'demo4', + }, + colorSchemes: { + light: { palette: { primary: { main: TEAL } } }, + dark: { palette: { primary: { main: TEAL } } }, + }, + components: { + MuiButton: { + defaultProps: { disableRipple: true }, + styleOverrides: { + root: ({ theme }: { theme: Theme }) => ({ + '--Button-radius': '999px', + '--Button-padding-block': '10px', + '--Button-padding-inline': '24px', + '--Button-font-size': '0.9375rem', + '--Button-border-width': '1.5px', + '--Button-shadow': 'none', + '--Button-ring': `3px solid ${theme.alpha((theme.vars || theme).palette.primary.main, 0.35)}`, + }), + }, + }, + }, +}); + +const baselineCode = `MuiButton: { + defaultProps: { disableRipple: true }, + styleOverrides: { + root: ({ theme }) => ({ + '--Button-radius': '999px', + '--Button-padding-block': '10px', + '--Button-padding-inline': '24px', + '--Button-font-size': '0.9375rem', + '--Button-border-width': '1.5px', + '--Button-shadow': 'none', + '--Button-ring': \`3px solid \${theme.alpha(theme.palette.primary.main, 0.35)}\`, + }), + }, +}`; + +const beforeCode = `MuiButton: { + defaultProps: { disableElevation: true }, + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 600, + borderRadius: 6, + '&.Mui-focusVisible': { outline: '2px solid #2C6CA3' }, + '&.Mui-disabled': { opacity: 0.4 }, + }, + }, + variants: [ + { props: { variant: 'outlined' }, style: ({ theme }) => ({ + color: theme.palette.text.primary, + borderColor: theme.palette.divider, + '&:hover': { backgroundColor: theme.palette.action.hover }, + '&.Mui-disabled': { // re-state to stop gray-out + color: theme.palette.text.primary, + borderColor: theme.palette.divider, + }, + }) }, + { props: { variant: 'text' }, style: ({ theme }) => ({ + color: theme.palette.text.primary, + '&:hover': { backgroundColor: theme.palette.action.hover }, + '&.Mui-disabled': { color: theme.palette.text.primary }, // re-state + }) }, + { props: { variant: 'contained' }, style: ({ theme }) => ({ + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + '&:hover': { backgroundColor: theme.darken(theme.palette.primary.main, 0.15) }, + '&.Mui-disabled': { // re-state + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + }) }, + ], +}`; + +const afterCode = `MuiButton: { + defaultProps: { disableElevation: true }, + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 600, + '--Button-radius': '6px', + '--Button-ring': '2px solid #2C6CA3', + '&.Mui-disabled': { opacity: 0.4 }, + }, + }, + variants: [ + { props: { variant: 'outlined' }, style: ({ theme }) => ({ + '--Button-color': theme.palette.text.primary, + '--Button-border-color': theme.palette.divider, + '&:hover': { '--Button-bg': theme.palette.action.hover }, + // no .Mui-disabled block — color + border cascade through the vars + }) }, + { props: { variant: 'text' }, style: ({ theme }) => ({ + '--Button-color': theme.palette.text.primary, + '&:hover': { '--Button-bg': theme.palette.action.hover }, + }) }, + { props: { variant: 'contained' }, style: ({ theme }) => ({ + '--Button-bg': theme.palette.primary.main, + '--Button-color': theme.palette.primary.contrastText, + '&:hover': { '--Button-bg': theme.darken(theme.palette.primary.main, 0.15) }, + }) }, + ], +}`; + +function Demo() { + return ( + + + + + + + + + + + + + Hover / focus / disabled — identity preserved + + + ); +} + +function Column({ + title, + subtitle, + theme, + code, + badge, +}: { + title: string; + subtitle: string; + theme: Theme; + code: string; + badge?: string; +}) { + return ( + + + + + {title} + + + {badge ?? `${code.split('\n').length} lines`} + + + + {subtitle} + + + + + + + + + + + + ); +} + +// Density: override --mui-spacing at a scope. Button padding is spacing-derived, +// the outlined TextField's height defaults to a spacing multiple — both reflow. +function DensityShowcase() { + const [spacing, setSpacing] = React.useState(8); + return ( + + + + --mui-spacing + + setSpacing(value as number)} + aria-label="--mui-spacing" + /> + + {spacing}px + + + + + + Sign in + + + + + + + + + ); +} + +export default function App() { + return ( + + + Agnostic variables + + + Opt out of Material Design by setting one var per property. Both columns render identically + — the right does it with far less code, and the customization survives every state. + + + + + + + + Re-express as a different design language + + + Set the agnostic vars on the root and the same component takes a different shape — a soft, + pill-shaped baseline. Geometry, density, elevation, font-size, and the focus ring all come + from vars, set once and flattened across every variant and state; colors follow the teal + palette via Material's own variant mapping. + + + + + + + Still Material: uppercase text (typography axis — no var yet); the ripple is turned off via + a prop (anatomy, not a var). The var layer expresses look, not anatomy. + + + + Adjust density with one dial + + + Spacing-derived properties ride a single runtime variable, --mui-spacing. + Override it at any scope and the whole control set reflows: Button padding scales directly; + the outlined TextField scales by height (its --InputBase-height defaults to a + spacing multiple) with padding re-derived to keep text centered. One dial — no per-component + knobs. + + + + ); +} diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index cbed494f1e7399..db392d258dcddc 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -14,6 +14,7 @@ import CircularProgress from '../CircularProgress'; import capitalize from '../utils/capitalize'; import createSimplePaletteValueFilter from '../utils/createSimplePaletteValueFilter'; import buttonClasses, { getButtonUtilityClass } from './buttonClasses'; +import buttonVars from './ButtonVars'; import ButtonGroupContext from '../ButtonGroup/ButtonGroupContext'; import ButtonGroupButtonContext from '../ButtonGroup/ButtonGroupButtonContext'; @@ -100,9 +101,14 @@ const ButtonRoot = styled(ButtonBase, { return { ...theme.typography.button, minWidth: 64, - padding: '6px 16px', + fontSize: `var(${buttonVars.fontSize},${theme.typography.button.fontSize})`, + padding: `var(${buttonVars.paddingBlock},${theme.spacing(0.75)}) var(${ + buttonVars.paddingInline + },${theme.spacing(2)})`, border: 0, - borderRadius: (theme.vars || theme).shape.borderRadius, + borderRadius: `var(${buttonVars.radius},${(theme.vars || theme).shape.borderRadius}${ + theme.vars ? '' : 'px' + })`, transition: theme.transitions.create( ['background-color', 'box-shadow', 'border-color', 'color'], { @@ -113,54 +119,70 @@ const ButtonRoot = styled(ButtonBase, { textDecoration: 'none', }, [`&.${buttonClasses.disabled}`]: { - color: (theme.vars || theme).palette.action.disabled, + color: `var(${buttonVars.color},${(theme.vars || theme).palette.action.disabled})`, + }, + // Opt-in focus ring; default `0` = no ring (ButtonBase already resets outline). + [`&.${buttonClasses.focusVisible}`]: { + outline: `var(${buttonVars.ring},0)`, + outlineOffset: 2, }, variants: [ { props: { variant: 'contained' }, style: { - color: `var(--variant-containedColor)`, - backgroundColor: `var(--variant-containedBg)`, - boxShadow: (theme.vars || theme).shadows[2], + color: `var(${buttonVars.color},var(--variant-containedColor))`, + backgroundColor: `var(${buttonVars.bg},var(--variant-containedBg))`, + boxShadow: `var(${buttonVars.shadow},${(theme.vars || theme).shadows[2]})`, '&:hover': { - boxShadow: (theme.vars || theme).shadows[4], + boxShadow: `var(${buttonVars.shadow},${(theme.vars || theme).shadows[4]})`, // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { - boxShadow: (theme.vars || theme).shadows[2], + boxShadow: `var(${buttonVars.shadow},${(theme.vars || theme).shadows[2]})`, }, }, '&:active': { - boxShadow: (theme.vars || theme).shadows[8], + boxShadow: `var(${buttonVars.shadow},${(theme.vars || theme).shadows[8]})`, }, [`&.${buttonClasses.focusVisible}`]: { - boxShadow: (theme.vars || theme).shadows[6], + boxShadow: `var(${buttonVars.shadow},${(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, + color: `var(${buttonVars.color},${(theme.vars || theme).palette.action.disabled})`, + boxShadow: `var(${buttonVars.shadow},${(theme.vars || theme).shadows[0]})`, + backgroundColor: `var(${buttonVars.bg},${ + (theme.vars || theme).palette.action.disabledBackground + })`, }, }, }, { props: { variant: 'outlined' }, style: { - padding: '5px 15px', - border: '1px solid currentColor', - borderColor: `var(--variant-outlinedBorder, currentColor)`, - backgroundColor: `var(--variant-outlinedBg)`, - color: `var(--variant-outlinedColor)`, + padding: `calc(var(${buttonVars.paddingBlock},${theme.spacing(0.75)}) - var(${ + buttonVars.borderWidth + },1px)) calc(var(${buttonVars.paddingInline},${theme.spacing(2)}) - var(${ + buttonVars.borderWidth + },1px))`, + borderStyle: 'solid', + borderWidth: `var(${buttonVars.borderWidth},1px)`, + borderColor: `var(${buttonVars.borderColor},var(--variant-outlinedBorder, currentColor))`, + backgroundColor: `var(${buttonVars.bg},var(--variant-outlinedBg))`, + color: `var(${buttonVars.color},var(--variant-outlinedColor))`, [`&.${buttonClasses.disabled}`]: { - border: `1px solid ${(theme.vars || theme).palette.action.disabledBackground}`, + borderColor: `var(${buttonVars.borderColor},${ + (theme.vars || theme).palette.action.disabledBackground + })`, }, }, }, { props: { variant: 'text' }, style: { - padding: '6px 8px', - color: `var(--variant-textColor)`, - backgroundColor: `var(--variant-textBg)`, + padding: `var(${buttonVars.paddingBlock},${theme.spacing(0.75)}) var(${ + buttonVars.paddingInline + },${theme.spacing(1)})`, + color: `var(${buttonVars.color},var(--variant-textColor))`, + backgroundColor: `var(${buttonVars.bg},var(--variant-textBg))`, }, }, ...Object.entries(theme.palette) @@ -197,8 +219,8 @@ const ButtonRoot = styled(ButtonBase, { color: 'inherit', }, style: { - color: 'inherit', - borderColor: 'currentColor', + color: `var(${buttonVars.color},inherit)`, + borderColor: `var(${buttonVars.borderColor},currentColor)`, '--variant-containedBg': theme.vars ? theme.vars.palette.Button.inheritContainedBg : inheritContainedBackgroundColor, @@ -225,8 +247,10 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: '4px 5px', - fontSize: theme.typography.pxToRem(13), + padding: `var(${buttonVars.paddingBlock},${theme.spacing(0.5)}) var(${ + buttonVars.paddingInline + },${theme.spacing(0.625)})`, + fontSize: `var(${buttonVars.fontSize},${theme.typography.pxToRem(13)})`, }, }, { @@ -235,8 +259,10 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: '8px 11px', - fontSize: theme.typography.pxToRem(15), + padding: `var(${buttonVars.paddingBlock},${theme.spacing(1)}) var(${ + buttonVars.paddingInline + },${theme.spacing(1.375)})`, + fontSize: `var(${buttonVars.fontSize},${theme.typography.pxToRem(15)})`, }, }, { @@ -245,8 +271,12 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: '3px 9px', - fontSize: theme.typography.pxToRem(13), + padding: `calc(var(${buttonVars.paddingBlock},${theme.spacing(0.5)}) - var(${ + buttonVars.borderWidth + },1px)) calc(var(${buttonVars.paddingInline},${theme.spacing(1.25)}) - var(${ + buttonVars.borderWidth + },1px))`, + fontSize: `var(${buttonVars.fontSize},${theme.typography.pxToRem(13)})`, }, }, { @@ -255,8 +285,12 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: '7px 21px', - fontSize: theme.typography.pxToRem(15), + padding: `calc(var(${buttonVars.paddingBlock},${theme.spacing(1)}) - var(${ + buttonVars.borderWidth + },1px)) calc(var(${buttonVars.paddingInline},${theme.spacing(2.75)}) - var(${ + buttonVars.borderWidth + },1px))`, + fontSize: `var(${buttonVars.fontSize},${theme.typography.pxToRem(15)})`, }, }, { @@ -265,8 +299,10 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: '4px 10px', - fontSize: theme.typography.pxToRem(13), + padding: `var(${buttonVars.paddingBlock},${theme.spacing(0.5)}) var(${ + buttonVars.paddingInline + },${theme.spacing(1.25)})`, + fontSize: `var(${buttonVars.fontSize},${theme.typography.pxToRem(13)})`, }, }, { @@ -275,8 +311,10 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: '8px 22px', - fontSize: theme.typography.pxToRem(15), + padding: `var(${buttonVars.paddingBlock},${theme.spacing(1)}) var(${ + buttonVars.paddingInline + },${theme.spacing(2.75)})`, + fontSize: `var(${buttonVars.fontSize},${theme.typography.pxToRem(15)})`, }, }, { @@ -284,18 +322,18 @@ const ButtonRoot = styled(ButtonBase, { disableElevation: true, }, style: { - boxShadow: 'none', + boxShadow: `var(${buttonVars.shadow},none)`, '&:hover': { - boxShadow: 'none', + boxShadow: `var(${buttonVars.shadow},none)`, }, [`&.${buttonClasses.focusVisible}`]: { - boxShadow: 'none', + boxShadow: `var(${buttonVars.shadow},none)`, }, '&:active': { - boxShadow: 'none', + boxShadow: `var(${buttonVars.shadow},none)`, }, [`&.${buttonClasses.disabled}`]: { - boxShadow: 'none', + boxShadow: `var(${buttonVars.shadow},none)`, }, }, }, @@ -446,7 +484,7 @@ const ButtonLoadingIndicator = styled('span', { style: { left: '50%', transform: 'translate(-50%)', - color: (theme.vars || theme).palette.action.disabled, + color: `var(${buttonVars.color},${(theme.vars || theme).palette.action.disabled})`, }, }, { diff --git a/packages/mui-material/src/Button/ButtonVars.ts b/packages/mui-material/src/Button/ButtonVars.ts new file mode 100644 index 00000000000000..20370fba57b7a3 --- /dev/null +++ b/packages/mui-material/src/Button/ButtonVars.ts @@ -0,0 +1,20 @@ +/** + * Public, agnostic CSS variables for Button — the supported theming surface. + * Single source of truth for the variable names; fallbacks live at each usage + * site (the Material Design spec defaults). Overriding any of these opts that + * property out of the spec. See docs/adr/0002-agnostic-public-css-vars.md. + */ +const buttonVars = { + paddingBlock: '--Button-padding-block', + paddingInline: '--Button-padding-inline', + fontSize: '--Button-font-size', + bg: '--Button-bg', + color: '--Button-color', + borderColor: '--Button-border-color', + borderWidth: '--Button-border-width', + radius: '--Button-radius', + shadow: '--Button-shadow', + ring: '--Button-ring', +} as const; + +export default buttonVars; diff --git a/packages/mui-material/src/InputBase/InputBase.js b/packages/mui-material/src/InputBase/InputBase.js index 7bbe9ca97e94c9..40e8d4f9db3c4a 100644 --- a/packages/mui-material/src/InputBase/InputBase.js +++ b/packages/mui-material/src/InputBase/InputBase.js @@ -19,6 +19,7 @@ import ownerDocument from '../utils/ownerDocument'; import getActiveElement from '../utils/getActiveElement'; import { isFilled } from './utils'; import inputBaseClasses, { getInputBaseUtilityClass } from './inputBaseClasses'; +import inputBaseVars from './InputBaseVars'; const MUI_AUTO_FILL = 'mui-auto-fill'; const MUI_AUTO_FILL_CANCEL = 'mui-auto-fill-cancel'; @@ -97,15 +98,16 @@ export const InputBaseRoot = styled('div', { })( memoTheme(({ theme }) => ({ ...theme.typography.body1, - color: (theme.vars || theme).palette.text.primary, - lineHeight: '1.4375em', // 23px + color: `var(${inputBaseVars.color},${(theme.vars || theme).palette.text.primary})`, + fontSize: `var(${inputBaseVars.fontSize},${theme.typography.body1.fontSize})`, + lineHeight: `var(${inputBaseVars.lineHeight},1.4375)`, // 23px (unitless = multiplier of font-size) boxSizing: 'border-box', // Prevent padding issue with fullWidth. position: 'relative', cursor: 'text', display: 'inline-flex', alignItems: 'center', [`&.${inputBaseClasses.disabled}`]: { - color: (theme.vars || theme).palette.text.disabled, + color: `var(${inputBaseVars.color},${(theme.vars || theme).palette.text.disabled})`, cursor: 'default', }, variants: [ @@ -170,7 +172,7 @@ export const InputBaseInput = styled('input', { border: 0, boxSizing: 'content-box', background: 'none', - height: '1.4375em', // Reset 23pxthe native input line-height + height: `calc(var(${inputBaseVars.lineHeight},1.4375) * 1em)`, // Reset native input height to the line box (tracks --InputBase-line-height) margin: 0, // Reset for Safari WebkitTapHighlightColor: 'transparent', display: 'block', diff --git a/packages/mui-material/src/InputBase/InputBaseVars.ts b/packages/mui-material/src/InputBase/InputBaseVars.ts new file mode 100644 index 00000000000000..2f5a8a4c9381a1 --- /dev/null +++ b/packages/mui-material/src/InputBase/InputBaseVars.ts @@ -0,0 +1,15 @@ +/** + * Public, agnostic CSS variables for InputBase — the base layer of the input + * stack. Single source of truth for the names; fallbacks (Material spec + * defaults) live at each usage site. See docs/adr/0002-agnostic-public-css-vars.md. + */ +const inputBaseVars = { + color: '--InputBase-color', + fontSize: '--InputBase-font-size', + lineHeight: '--InputBase-line-height', + height: '--InputBase-height', + paddingBlock: '--InputBase-padding-block', + paddingInline: '--InputBase-padding-inline', +} as const; + +export default inputBaseVars; diff --git a/packages/mui-material/src/InputLabel/InputLabel.js b/packages/mui-material/src/InputLabel/InputLabel.js index e523c16453a762..caf038dc3ba82c 100644 --- a/packages/mui-material/src/InputLabel/InputLabel.js +++ b/packages/mui-material/src/InputLabel/InputLabel.js @@ -144,7 +144,7 @@ const InputLabelRoot = styled(FormLabel, { // see comment above on filled.zIndex zIndex: 1, pointerEvents: 'none', - transform: 'translate(14px, 16px) scale(1)', + transform: `translate(14px, calc(${theme.spacing(3.5)} - 12px)) scale(1)`, maxWidth: 'calc(100% - 24px)', }, }, @@ -154,7 +154,7 @@ const InputLabelRoot = styled(FormLabel, { size: 'small', }, style: { - transform: 'translate(14px, 9px) scale(1)', + transform: `translate(14px, calc(${theme.spacing(2.5)} - 11px)) scale(1)`, }, }, { diff --git a/packages/mui-material/src/OutlinedInput/NotchedOutline.js b/packages/mui-material/src/OutlinedInput/NotchedOutline.js index ac8e235a49cef2..41d3aa8f0b7b05 100644 --- a/packages/mui-material/src/OutlinedInput/NotchedOutline.js +++ b/packages/mui-material/src/OutlinedInput/NotchedOutline.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import rootShouldForwardProp from '../styles/rootShouldForwardProp'; import { styled } from '../zero-styled'; import memoTheme from '../utils/memoTheme'; +import outlinedInputVars from './OutlinedInputVars'; const NotchedOutlineRoot = styled('fieldset', { name: 'MuiNotchedOutlined', @@ -19,7 +20,7 @@ const NotchedOutlineRoot = styled('fieldset', { pointerEvents: 'none', borderRadius: 'inherit', borderStyle: 'solid', - borderWidth: 1, + borderWidth: `var(${outlinedInputVars.borderWidth},1px)`, overflow: 'hidden', minWidth: '0%', }); diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index bb851995a8224d..2e3cae73ea45f2 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -11,6 +11,8 @@ import memoTheme from '../utils/memoTheme'; import createSimplePaletteValueFilter from '../utils/createSimplePaletteValueFilter'; import { useDefaultProps } from '../DefaultPropsProvider'; import outlinedInputClasses, { getOutlinedInputUtilityClass } from './outlinedInputClasses'; +import outlinedInputVars from './OutlinedInputVars'; +import inputBaseVars from '../InputBase/InputBaseVars'; import InputBase, { rootOverridesResolver as inputBaseRootOverridesResolver, inputOverridesResolver as inputBaseInputOverridesResolver, @@ -36,6 +38,18 @@ const useUtilityClasses = (ownerState) => { }; }; +// Derived input padding-block keeps text centered as the target height changes: +// (height − line-height·1em) / 2. Height chain: --OutlinedInput-height → --InputBase-height → fallback. +// padding-inline rides --mui-spacing independently. See docs/design/public-css-var-layering.md. +const derivedInputPadding = (theme, heightFallback) => + `var(${outlinedInputVars.paddingBlock},var(${inputBaseVars.paddingBlock},calc((var(${ + outlinedInputVars.height + },var(${inputBaseVars.height},${heightFallback})) - var(${ + inputBaseVars.lineHeight + },1.4375) * 1em) / 2))) var(${outlinedInputVars.paddingInline},var(${ + inputBaseVars.paddingInline + },${theme.spacing(1.75)}))`; + const OutlinedInputRoot = styled(InputBaseRoot, { shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === 'classes', name: 'MuiOutlinedInput', @@ -47,20 +61,29 @@ const OutlinedInputRoot = styled(InputBaseRoot, { theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)'; return { position: 'relative', - borderRadius: (theme.vars || theme).shape.borderRadius, + // Variant-level font-size knob; inward fallback to the base --InputBase-font-size. + fontSize: `var(${outlinedInputVars.fontSize},var(${inputBaseVars.fontSize},${theme.typography.body1.fontSize}))`, + color: `var(${outlinedInputVars.color},var(${inputBaseVars.color},${ + (theme.vars || theme).palette.text.primary + }))`, + borderRadius: `var(${outlinedInputVars.radius},${(theme.vars || theme).shape.borderRadius}${ + theme.vars ? '' : 'px' + })`, [`&:hover .${outlinedInputClasses.notchedOutline}`]: { - borderColor: (theme.vars || theme).palette.text.primary, + borderColor: `var(${outlinedInputVars.borderColor},${ + (theme.vars || theme).palette.text.primary + })`, }, // Reset on touch devices, it doesn't add specificity '@media (hover: none)': { [`&:hover .${outlinedInputClasses.notchedOutline}`]: { - borderColor: theme.vars - ? theme.alpha(theme.vars.palette.common.onBackground, 0.23) - : borderColor, + borderColor: `var(${outlinedInputVars.borderColor},${ + theme.vars ? theme.alpha(theme.vars.palette.common.onBackground, 0.23) : borderColor + })`, }, }, [`&.${outlinedInputClasses.focused} .${outlinedInputClasses.notchedOutline}`]: { - borderWidth: 2, + borderWidth: `var(${outlinedInputVars.borderWidth},2px)`, }, variants: [ ...Object.entries(theme.palette) @@ -69,7 +92,9 @@ const OutlinedInputRoot = styled(InputBaseRoot, { props: { color }, style: { [`&.${outlinedInputClasses.focused} .${outlinedInputClasses.notchedOutline}`]: { - borderColor: (theme.vars || theme).palette[color].main, + borderColor: `var(${outlinedInputVars.borderColor},${ + (theme.vars || theme).palette[color].main + })`, }, }, })), @@ -77,10 +102,14 @@ const OutlinedInputRoot = styled(InputBaseRoot, { props: {}, // to override the above style style: { [`&.${outlinedInputClasses.error} .${outlinedInputClasses.notchedOutline}`]: { - borderColor: (theme.vars || theme).palette.error.main, + borderColor: `var(${outlinedInputVars.borderColor},${ + (theme.vars || theme).palette.error.main + })`, }, [`&.${outlinedInputClasses.disabled} .${outlinedInputClasses.notchedOutline}`]: { - borderColor: (theme.vars || theme).palette.action.disabled, + borderColor: `var(${outlinedInputVars.borderColor},${ + (theme.vars || theme).palette.action.disabled + })`, }, }, }, @@ -121,9 +150,9 @@ const NotchedOutlineRoot = styled(NotchedOutline, { const borderColor = theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)'; return { - borderColor: theme.vars - ? theme.alpha(theme.vars.palette.common.onBackground, 0.23) - : borderColor, + borderColor: `var(${outlinedInputVars.borderColor},${ + theme.vars ? theme.alpha(theme.vars.palette.common.onBackground, 0.23) : borderColor + })`, }; }), ); @@ -134,7 +163,8 @@ const OutlinedInputInput = styled(InputBaseInput, { overridesResolver: inputBaseInputOverridesResolver, })( memoTheme(({ theme }) => ({ - padding: '16.5px 14px', + // medium target height defaults to spacing(7) = 56px + padding: derivedInputPadding(theme, theme.spacing(7)), '&:-webkit-autofill': { ...(!theme.vars && { WebkitBoxShadow: theme.palette.mode === 'light' ? null : '0 0 0 100px #266798 inset', @@ -155,7 +185,8 @@ const OutlinedInputInput = styled(InputBaseInput, { size: 'small', }, style: { - padding: '8.5px 14px', + // small target height defaults to spacing(5) = 40px + padding: derivedInputPadding(theme, theme.spacing(5)), }, }, { diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInputVars.ts b/packages/mui-material/src/OutlinedInput/OutlinedInputVars.ts new file mode 100644 index 00000000000000..8944bd5df07c29 --- /dev/null +++ b/packages/mui-material/src/OutlinedInput/OutlinedInputVars.ts @@ -0,0 +1,18 @@ +/** + * Public, agnostic CSS variables for OutlinedInput — the variant layer. + * Each falls back inward to the base `--InputBase-*` then the Material spec + * default. See docs/adr/0002-agnostic-public-css-vars.md and + * docs/design/public-css-var-layering.md. + */ +const outlinedInputVars = { + color: '--OutlinedInput-color', + fontSize: '--OutlinedInput-font-size', + height: '--OutlinedInput-height', + paddingBlock: '--OutlinedInput-padding-block', + paddingInline: '--OutlinedInput-padding-inline', + borderColor: '--OutlinedInput-border-color', + borderWidth: '--OutlinedInput-border-width', + radius: '--OutlinedInput-radius', +} as const; + +export default outlinedInputVars; diff --git a/packages/mui-material/src/TextField/TextField.js b/packages/mui-material/src/TextField/TextField.js index 889d824b24dc5e..2678e92b503d39 100644 --- a/packages/mui-material/src/TextField/TextField.js +++ b/packages/mui-material/src/TextField/TextField.js @@ -15,6 +15,8 @@ import FormControl from '../FormControl'; import FormHelperText from '../FormHelperText'; import Select from '../Select'; import { getTextFieldUtilityClass } from './textFieldClasses'; +import textFieldVars from './TextFieldVars'; +import outlinedInputVars from '../OutlinedInput/OutlinedInputVars'; import useSlot from '../utils/useSlot'; const variantComponent = { @@ -36,7 +38,23 @@ const useUtilityClasses = (ownerState) => { const TextFieldRoot = styled(FormControl, { name: 'MuiTextField', slot: 'Root', -})({}); +})({ + variants: [ + { + // Single TextField knob: maps to the variant-level height var so a page-level + // --InputBase-height is not shadowed for wrapped inputs. See docs/design/public-css-var-layering.md. + props: { variant: 'outlined' }, + style: { + [outlinedInputVars.height]: `var(${textFieldVars.height})`, + [outlinedInputVars.fontSize]: `var(${textFieldVars.fontSize})`, + [outlinedInputVars.color]: `var(${textFieldVars.color})`, + [outlinedInputVars.borderColor]: `var(${textFieldVars.borderColor})`, + [outlinedInputVars.borderWidth]: `var(${textFieldVars.borderWidth})`, + [outlinedInputVars.radius]: `var(${textFieldVars.radius})`, + }, + }, + ], +}); /** * The `TextField` is a convenience wrapper for the most common cases (80%). diff --git a/packages/mui-material/src/TextField/TextFieldVars.ts b/packages/mui-material/src/TextField/TextFieldVars.ts new file mode 100644 index 00000000000000..da53b91a1bc583 --- /dev/null +++ b/packages/mui-material/src/TextField/TextFieldVars.ts @@ -0,0 +1,16 @@ +/** + * Public, agnostic CSS variables for TextField — the single-knob wrapper layer. + * Each maps down to the variant-level `--OutlinedInput-*` (inward rule). + * See docs/adr/0002-agnostic-public-css-vars.md and + * docs/design/public-css-var-layering.md. + */ +const textFieldVars = { + height: '--TextField-height', + fontSize: '--TextField-font-size', + color: '--TextField-color', + borderColor: '--TextField-border-color', + borderWidth: '--TextField-border-width', + radius: '--TextField-radius', +} as const; + +export default textFieldVars;