From ea726792e349dc4557a8aeb9bb2341c2801114bb Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Fri, 22 May 2026 14:56:55 +0700 Subject: [PATCH 01/12] [material-ui] POC public CSS vars + density for Button & inputs Expose hand-authorable padding/height CSS vars on Button, InputBase, OutlinedInput, TextField; rewire to theme.spacing() so --mui-spacing drives holistic density. Inputs are height-driven (padding derived). Adds CONTEXT.md, ADR-0001, layering design doc, experiment page. --- CONTEXT.md | 45 +++++ .../0001-public-css-var-inward-dependency.md | 16 ++ docs/design/public-css-var-layering.md | 168 ++++++++++++++++++ docs/pages/experiments/css-vars-density.tsx | 108 +++++++++++ packages/mui-material/src/Button/Button.js | 18 +- .../mui-material/src/InputBase/InputBase.js | 4 +- .../src/OutlinedInput/OutlinedInput.js | 8 +- .../mui-material/src/TextField/TextField.js | 13 +- 8 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-public-css-var-inward-dependency.md create mode 100644 docs/design/public-css-var-layering.md create mode 100644 docs/pages/experiments/css-vars-density.tsx diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000000000..41e034536b1a82 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,45 @@ +# 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**: +A component-scoped CSS custom property (e.g. `--Button-padding-block`) that consumers may set directly in their own stylesheets. It is a fixed, supported part of the component's API — component style implementations are written to consume it. No `--mui-` prefix. +_Avoid_: "internal var", "computed var" (those are private, like Badge's `--Badge-translate`). + +**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** 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 and font-weight deferred. Color deferred. +- **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..01ec74870ee25f --- /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/design/public-css-var-layering.md b/docs/design/public-css-var-layering.md new file mode 100644 index 00000000000000..ca5ad38c31454a --- /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/css-vars-density.tsx b/docs/pages/experiments/css-vars-density.tsx new file mode 100644 index 00000000000000..49ccd352ec4920 --- /dev/null +++ b/docs/pages/experiments/css-vars-density.tsx @@ -0,0 +1,108 @@ +"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 } from "@mui/material/styles"; + +// Public CSS variables + density POC. +// See CONTEXT.md, docs/adr/0001-public-css-var-inward-dependency.md, +// docs/design/public-css-var-layering.md. +const theme = createTheme({ cssVariables: true }); + +function Controls() { + return ( + + + + + + + + + + + + + + ); +} + +function Scope({ title, sx, children }: { title: string; sx?: object; children: React.ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + +export default function App() { + const [spacing, setSpacing] = React.useState(8); + return ( + + + Public CSS variables & density + + {/* --- Different Apps: drive --mui-spacing live at a class scope --- */} + + + {`App density — --mui-spacing: ${spacing}px`} + + setSpacing(value as number)} + min={4} + max={12} + step={1} + marks + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}px`} + /> + + + + + + {/* --- Different Viewports: override --mui-spacing inside a media query --- */} + + + + + {/* --- Fine-grained per-component knobs --- */} + + + + + + + + + + + ); +} diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index cbed494f1e7399..c6ec8b301169a8 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -100,7 +100,7 @@ const ButtonRoot = styled(ButtonBase, { return { ...theme.typography.button, minWidth: 64, - padding: '6px 16px', + padding: `var(--Button-padding-block, ${theme.spacing(0.75)}) var(--Button-padding-inline, ${theme.spacing(2)})`, border: 0, borderRadius: (theme.vars || theme).shape.borderRadius, transition: theme.transitions.create( @@ -145,7 +145,7 @@ const ButtonRoot = styled(ButtonBase, { { props: { variant: 'outlined' }, style: { - padding: '5px 15px', + padding: `calc(var(--Button-padding-block, ${theme.spacing(0.75)}) - 1px) calc(var(--Button-padding-inline, ${theme.spacing(2)}) - 1px)`, border: '1px solid currentColor', borderColor: `var(--variant-outlinedBorder, currentColor)`, backgroundColor: `var(--variant-outlinedBg)`, @@ -158,7 +158,7 @@ const ButtonRoot = styled(ButtonBase, { { props: { variant: 'text' }, style: { - padding: '6px 8px', + padding: `var(--Button-padding-block, ${theme.spacing(0.75)}) var(--Button-padding-inline, ${theme.spacing(1)})`, color: `var(--variant-textColor)`, backgroundColor: `var(--variant-textBg)`, }, @@ -225,7 +225,7 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: '4px 5px', + padding: `var(--Button-padding-block, ${theme.spacing(0.5)}) var(--Button-padding-inline, ${theme.spacing(0.625)})`, fontSize: theme.typography.pxToRem(13), }, }, @@ -235,7 +235,7 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: '8px 11px', + padding: `var(--Button-padding-block, ${theme.spacing(1)}) var(--Button-padding-inline, ${theme.spacing(1.375)})`, fontSize: theme.typography.pxToRem(15), }, }, @@ -245,7 +245,7 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: '3px 9px', + padding: `calc(var(--Button-padding-block, ${theme.spacing(0.5)}) - 1px) calc(var(--Button-padding-inline, ${theme.spacing(1.25)}) - 1px)`, fontSize: theme.typography.pxToRem(13), }, }, @@ -255,7 +255,7 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: '7px 21px', + padding: `calc(var(--Button-padding-block, ${theme.spacing(1)}) - 1px) calc(var(--Button-padding-inline, ${theme.spacing(2.75)}) - 1px)`, fontSize: theme.typography.pxToRem(15), }, }, @@ -265,7 +265,7 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: '4px 10px', + padding: `var(--Button-padding-block, ${theme.spacing(0.5)}) var(--Button-padding-inline, ${theme.spacing(1.25)})`, fontSize: theme.typography.pxToRem(13), }, }, @@ -275,7 +275,7 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: '8px 22px', + padding: `var(--Button-padding-block, ${theme.spacing(1)}) var(--Button-padding-inline, ${theme.spacing(2.75)})`, fontSize: theme.typography.pxToRem(15), }, }, diff --git a/packages/mui-material/src/InputBase/InputBase.js b/packages/mui-material/src/InputBase/InputBase.js index 7bbe9ca97e94c9..97ba5f0ed9efc9 100644 --- a/packages/mui-material/src/InputBase/InputBase.js +++ b/packages/mui-material/src/InputBase/InputBase.js @@ -98,7 +98,7 @@ export const InputBaseRoot = styled('div', { memoTheme(({ theme }) => ({ ...theme.typography.body1, color: (theme.vars || theme).palette.text.primary, - lineHeight: '1.4375em', // 23px + lineHeight: 'var(--InputBase-line-height, 1.4375)', // 23px (unitless = multiplier of font-size) boxSizing: 'border-box', // Prevent padding issue with fullWidth. position: 'relative', cursor: 'text', @@ -170,7 +170,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(--InputBase-line-height, 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/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index bb851995a8224d..026a8024405cc5 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -134,7 +134,10 @@ const OutlinedInputInput = styled(InputBaseInput, { overridesResolver: inputBaseInputOverridesResolver, })( memoTheme(({ theme }) => ({ - padding: '16.5px 14px', + // padding-block is derived from the target height so geometry stays centered: + // (height − line-height·1em) / 2. Height chain: --OutlinedInput-height → --InputBase-height → spacing(7). + // padding-inline rides --mui-spacing independently. See docs/design/public-css-var-layering.md. + padding: `var(--OutlinedInput-padding-block, var(--InputBase-padding-block, calc((var(--OutlinedInput-height, var(--InputBase-height, ${theme.spacing(7)})) - var(--InputBase-line-height, 1.4375) * 1em) / 2))) var(--OutlinedInput-padding-inline, var(--InputBase-padding-inline, ${theme.spacing(1.75)}))`, '&:-webkit-autofill': { ...(!theme.vars && { WebkitBoxShadow: theme.palette.mode === 'light' ? null : '0 0 0 100px #266798 inset', @@ -155,7 +158,8 @@ const OutlinedInputInput = styled(InputBaseInput, { size: 'small', }, style: { - padding: '8.5px 14px', + // small target height defaults to spacing(5) = 40px + padding: `var(--OutlinedInput-padding-block, var(--InputBase-padding-block, calc((var(--OutlinedInput-height, var(--InputBase-height, ${theme.spacing(5)})) - var(--InputBase-line-height, 1.4375) * 1em) / 2))) var(--OutlinedInput-padding-inline, var(--InputBase-padding-inline, ${theme.spacing(1.75)}))`, }, }, { diff --git a/packages/mui-material/src/TextField/TextField.js b/packages/mui-material/src/TextField/TextField.js index 889d824b24dc5e..2b6e45d0c50b4d 100644 --- a/packages/mui-material/src/TextField/TextField.js +++ b/packages/mui-material/src/TextField/TextField.js @@ -36,7 +36,18 @@ 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: { + '--OutlinedInput-height': 'var(--TextField-height)', + }, + }, + ], +}); /** * The `TextField` is a convenience wrapper for the most common cases (80%). From a6cede3a750b37fb0eef10fe522e8756f27f8cb1 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Fri, 22 May 2026 15:07:15 +0700 Subject: [PATCH 02/12] [material-ui] POC expose font-size CSS vars on Button & inputs --Button-font-size on all sizes; inputs via --InputBase-font-size with variant chain --OutlinedInput-font-size and TextField mapping --TextField-font-size. Input height formula's 1em tracks font-size, so text re-centers in the fixed height automatically. Adds responsive font-size experiment section; switch demo rows to useFlexGap. --- CONTEXT.md | 3 ++- docs/pages/experiments/css-vars-density.tsx | 26 ++++++++++++++++--- packages/mui-material/src/Button/Button.js | 13 +++++----- .../mui-material/src/InputBase/InputBase.js | 1 + .../src/OutlinedInput/OutlinedInput.js | 2 ++ .../mui-material/src/TextField/TextField.js | 1 + 6 files changed, 36 insertions(+), 10 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 41e034536b1a82..9dc9133d67a979 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,7 +33,8 @@ A component's CSS-var fallback chain may reference only its own var and the vars - **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 and font-weight deferred. Color deferred. +- **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. - **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. diff --git a/docs/pages/experiments/css-vars-density.tsx b/docs/pages/experiments/css-vars-density.tsx index 49ccd352ec4920..31c7b252a3b3f1 100644 --- a/docs/pages/experiments/css-vars-density.tsx +++ b/docs/pages/experiments/css-vars-density.tsx @@ -16,7 +16,7 @@ const theme = createTheme({ cssVariables: true }); function Controls() { return ( - + @@ -27,7 +27,7 @@ function Controls() { - + @@ -83,7 +83,12 @@ export default function App() { {/* --- Fine-grained per-component knobs --- */} - + @@ -102,6 +107,21 @@ export default function App() { /> + + {/* --- Responsive typography: font-size 1rem on mobile, 0.875rem on desktop --- */} + + + ); diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index c6ec8b301169a8..4aea93b138d08e 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -100,6 +100,7 @@ const ButtonRoot = styled(ButtonBase, { return { ...theme.typography.button, minWidth: 64, + fontSize: `var(--Button-font-size, ${theme.typography.button.fontSize})`, padding: `var(--Button-padding-block, ${theme.spacing(0.75)}) var(--Button-padding-inline, ${theme.spacing(2)})`, border: 0, borderRadius: (theme.vars || theme).shape.borderRadius, @@ -226,7 +227,7 @@ const ButtonRoot = styled(ButtonBase, { }, style: { padding: `var(--Button-padding-block, ${theme.spacing(0.5)}) var(--Button-padding-inline, ${theme.spacing(0.625)})`, - fontSize: theme.typography.pxToRem(13), + fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(13)})`, }, }, { @@ -236,7 +237,7 @@ const ButtonRoot = styled(ButtonBase, { }, style: { padding: `var(--Button-padding-block, ${theme.spacing(1)}) var(--Button-padding-inline, ${theme.spacing(1.375)})`, - fontSize: theme.typography.pxToRem(15), + fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(15)})`, }, }, { @@ -246,7 +247,7 @@ const ButtonRoot = styled(ButtonBase, { }, style: { padding: `calc(var(--Button-padding-block, ${theme.spacing(0.5)}) - 1px) calc(var(--Button-padding-inline, ${theme.spacing(1.25)}) - 1px)`, - fontSize: theme.typography.pxToRem(13), + fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(13)})`, }, }, { @@ -256,7 +257,7 @@ const ButtonRoot = styled(ButtonBase, { }, style: { padding: `calc(var(--Button-padding-block, ${theme.spacing(1)}) - 1px) calc(var(--Button-padding-inline, ${theme.spacing(2.75)}) - 1px)`, - fontSize: theme.typography.pxToRem(15), + fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(15)})`, }, }, { @@ -266,7 +267,7 @@ const ButtonRoot = styled(ButtonBase, { }, style: { padding: `var(--Button-padding-block, ${theme.spacing(0.5)}) var(--Button-padding-inline, ${theme.spacing(1.25)})`, - fontSize: theme.typography.pxToRem(13), + fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(13)})`, }, }, { @@ -276,7 +277,7 @@ const ButtonRoot = styled(ButtonBase, { }, style: { padding: `var(--Button-padding-block, ${theme.spacing(1)}) var(--Button-padding-inline, ${theme.spacing(2.75)})`, - fontSize: theme.typography.pxToRem(15), + fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(15)})`, }, }, { diff --git a/packages/mui-material/src/InputBase/InputBase.js b/packages/mui-material/src/InputBase/InputBase.js index 97ba5f0ed9efc9..97cd99fb159cd3 100644 --- a/packages/mui-material/src/InputBase/InputBase.js +++ b/packages/mui-material/src/InputBase/InputBase.js @@ -98,6 +98,7 @@ export const InputBaseRoot = styled('div', { memoTheme(({ theme }) => ({ ...theme.typography.body1, color: (theme.vars || theme).palette.text.primary, + fontSize: `var(--InputBase-font-size, ${theme.typography.body1.fontSize})`, lineHeight: 'var(--InputBase-line-height, 1.4375)', // 23px (unitless = multiplier of font-size) boxSizing: 'border-box', // Prevent padding issue with fullWidth. position: 'relative', diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index 026a8024405cc5..ab4bff2d1f1523 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -47,6 +47,8 @@ const OutlinedInputRoot = styled(InputBaseRoot, { theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)'; return { position: 'relative', + // Variant-level font-size knob; inward fallback to the base --InputBase-font-size. + fontSize: `var(--OutlinedInput-font-size, var(--InputBase-font-size, ${theme.typography.body1.fontSize}))`, borderRadius: (theme.vars || theme).shape.borderRadius, [`&:hover .${outlinedInputClasses.notchedOutline}`]: { borderColor: (theme.vars || theme).palette.text.primary, diff --git a/packages/mui-material/src/TextField/TextField.js b/packages/mui-material/src/TextField/TextField.js index 2b6e45d0c50b4d..0a054824d5733d 100644 --- a/packages/mui-material/src/TextField/TextField.js +++ b/packages/mui-material/src/TextField/TextField.js @@ -44,6 +44,7 @@ const TextFieldRoot = styled(FormControl, { props: { variant: 'outlined' }, style: { '--OutlinedInput-height': 'var(--TextField-height)', + '--OutlinedInput-font-size': 'var(--TextField-font-size)', }, }, ], From 50d4471f35f2eb7337b278bb568a6409e48cc525 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Fri, 22 May 2026 17:57:53 +0700 Subject: [PATCH 03/12] [material-ui][Button] POC expose agnostic color/border/radius/shadow vars --Button-bg/color/border-color/border-width/radius/shadow, layered over private --variant-* spec source; resting-only. --Button-border-width also drives outlined padding compensation. Adds ADR-0002 (agnostic public vars are the public API contract). --- CONTEXT.md | 8 ++- docs/adr/0002-agnostic-public-css-vars.md | 13 ++++ docs/pages/experiments/css-vars-density.tsx | 27 +++++++++ packages/mui-material/src/Button/Button.js | 67 +++++++++++++++------ 4 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 docs/adr/0002-agnostic-public-css-vars.md diff --git a/CONTEXT.md b/CONTEXT.md index 9dc9133d67a979..33923dcf850c40 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,9 +4,9 @@ POC exploring whether Material UI components should expose hand-authorable CSS v ## Language -**Public CSS variable**: -A component-scoped CSS custom property (e.g. `--Button-padding-block`) that consumers may set directly in their own stylesheets. It is a fixed, supported part of the component's API — component style implementations are written to consume it. No `--mui-` prefix. -_Avoid_: "internal var", "computed var" (those are private, like Badge's `--Badge-translate`). +**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. 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`). **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. @@ -35,6 +35,8 @@ A component's CSS-var fallback chain may reference only its own var and the vars - **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) 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. +- **Color is resting-only (state caveat).** Hover/active rules reassign `--variant-*`, which sits *below* a user-set `--Button-bg` in the chain, so a custom `--Button-bg` does **not** darken on hover/active. By design: if you customize color, you own the states. The global remap path is unchanged — override `--mui-palette-*` to recolor with states intact. - **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. 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..0b1fd40da24aa4 --- /dev/null +++ b/docs/adr/0002-agnostic-public-css-vars.md @@ -0,0 +1,13 @@ +# 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**. 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. + +## 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. +- **Resting-only** — state styling (hover/active/focus) is reassigned on the private `--variant-*` layer beneath the public var, so a custom color/shadow does not animate on hover. If you customize, you own the states. The spec-preserving global path is unchanged: remap `--mui-palette-*` / `--mui-spacing` to restyle *with* states intact. +- 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/pages/experiments/css-vars-density.tsx b/docs/pages/experiments/css-vars-density.tsx index 31c7b252a3b3f1..84fb0dac1e50be 100644 --- a/docs/pages/experiments/css-vars-density.tsx +++ b/docs/pages/experiments/css-vars-density.tsx @@ -122,6 +122,33 @@ export default function App() { > + + {/* --- Color knobs (agnostic, resting-only) --- */} + + + + + + + + + + ); diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index 4aea93b138d08e..9061175de3a126 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -101,9 +101,13 @@ const ButtonRoot = styled(ButtonBase, { ...theme.typography.button, minWidth: 64, fontSize: `var(--Button-font-size, ${theme.typography.button.fontSize})`, - padding: `var(--Button-padding-block, ${theme.spacing(0.75)}) var(--Button-padding-inline, ${theme.spacing(2)})`, + padding: `var(--Button-padding-block, ${theme.spacing( + 0.75, + )}) var(--Button-padding-inline, ${theme.spacing(2)})`, border: 0, - borderRadius: (theme.vars || theme).shape.borderRadius, + borderRadius: `var(--Button-radius, ${(theme.vars || theme).shape.borderRadius}${ + theme.vars ? '' : 'px' + })`, transition: theme.transitions.create( ['background-color', 'box-shadow', 'border-color', 'color'], { @@ -120,9 +124,9 @@ const ButtonRoot = styled(ButtonBase, { { props: { variant: 'contained' }, style: { - color: `var(--variant-containedColor)`, - backgroundColor: `var(--variant-containedBg)`, - boxShadow: (theme.vars || theme).shadows[2], + color: `var(--Button-color, var(--variant-containedColor))`, + backgroundColor: `var(--Button-bg, var(--variant-containedBg))`, + boxShadow: `var(--Button-shadow, ${(theme.vars || theme).shadows[2]})`, '&:hover': { boxShadow: (theme.vars || theme).shadows[4], // Reset on touch devices, it doesn't add specificity @@ -146,22 +150,29 @@ const ButtonRoot = styled(ButtonBase, { { props: { variant: 'outlined' }, style: { - padding: `calc(var(--Button-padding-block, ${theme.spacing(0.75)}) - 1px) calc(var(--Button-padding-inline, ${theme.spacing(2)}) - 1px)`, - border: '1px solid currentColor', - borderColor: `var(--variant-outlinedBorder, currentColor)`, - backgroundColor: `var(--variant-outlinedBg)`, - color: `var(--variant-outlinedColor)`, + padding: `calc(var(--Button-padding-block, ${theme.spacing( + 0.75, + )}) - var(--Button-border-width, 1px)) calc(var(--Button-padding-inline, ${theme.spacing( + 2, + )}) - var(--Button-border-width, 1px))`, + borderStyle: 'solid', + borderWidth: 'var(--Button-border-width, 1px)', + borderColor: `var(--Button-border-color, var(--variant-outlinedBorder, currentColor))`, + backgroundColor: `var(--Button-bg, var(--variant-outlinedBg))`, + color: `var(--Button-color, var(--variant-outlinedColor))`, [`&.${buttonClasses.disabled}`]: { - border: `1px solid ${(theme.vars || theme).palette.action.disabledBackground}`, + borderColor: (theme.vars || theme).palette.action.disabledBackground, }, }, }, { props: { variant: 'text' }, style: { - padding: `var(--Button-padding-block, ${theme.spacing(0.75)}) var(--Button-padding-inline, ${theme.spacing(1)})`, - color: `var(--variant-textColor)`, - backgroundColor: `var(--variant-textBg)`, + padding: `var(--Button-padding-block, ${theme.spacing( + 0.75, + )}) var(--Button-padding-inline, ${theme.spacing(1)})`, + color: `var(--Button-color, var(--variant-textColor))`, + backgroundColor: `var(--Button-bg, var(--variant-textBg))`, }, }, ...Object.entries(theme.palette) @@ -226,7 +237,9 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: `var(--Button-padding-block, ${theme.spacing(0.5)}) var(--Button-padding-inline, ${theme.spacing(0.625)})`, + padding: `var(--Button-padding-block, ${theme.spacing( + 0.5, + )}) var(--Button-padding-inline, ${theme.spacing(0.625)})`, fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(13)})`, }, }, @@ -236,7 +249,9 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: `var(--Button-padding-block, ${theme.spacing(1)}) var(--Button-padding-inline, ${theme.spacing(1.375)})`, + padding: `var(--Button-padding-block, ${theme.spacing( + 1, + )}) var(--Button-padding-inline, ${theme.spacing(1.375)})`, fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(15)})`, }, }, @@ -246,7 +261,11 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: `calc(var(--Button-padding-block, ${theme.spacing(0.5)}) - 1px) calc(var(--Button-padding-inline, ${theme.spacing(1.25)}) - 1px)`, + padding: `calc(var(--Button-padding-block, ${theme.spacing( + 0.5, + )}) - var(--Button-border-width, 1px)) calc(var(--Button-padding-inline, ${theme.spacing( + 1.25, + )}) - var(--Button-border-width, 1px))`, fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(13)})`, }, }, @@ -256,7 +275,11 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: `calc(var(--Button-padding-block, ${theme.spacing(1)}) - 1px) calc(var(--Button-padding-inline, ${theme.spacing(2.75)}) - 1px)`, + padding: `calc(var(--Button-padding-block, ${theme.spacing( + 1, + )}) - var(--Button-border-width, 1px)) calc(var(--Button-padding-inline, ${theme.spacing( + 2.75, + )}) - var(--Button-border-width, 1px))`, fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(15)})`, }, }, @@ -266,7 +289,9 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: `var(--Button-padding-block, ${theme.spacing(0.5)}) var(--Button-padding-inline, ${theme.spacing(1.25)})`, + padding: `var(--Button-padding-block, ${theme.spacing( + 0.5, + )}) var(--Button-padding-inline, ${theme.spacing(1.25)})`, fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(13)})`, }, }, @@ -276,7 +301,9 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: `var(--Button-padding-block, ${theme.spacing(1)}) var(--Button-padding-inline, ${theme.spacing(2.75)})`, + padding: `var(--Button-padding-block, ${theme.spacing( + 1, + )}) var(--Button-padding-inline, ${theme.spacing(2.75)})`, fontSize: `var(--Button-font-size, ${theme.typography.pxToRem(15)})`, }, }, From e4f07eaa104ead89fc464dce3bf666d5ed53a8ce Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 25 May 2026 14:17:57 +0700 Subject: [PATCH 04/12] [material-ui][TextField] POC expose agnostic color/border/radius vars --TextField-color/border-color/border-width/radius mapped to variant-level --OutlinedInput-* (inward rule), consumed by NotchedOutline slot + root. Border-width focus uses var(--OutlinedInput-border-width, 2px): default 1px->2px preserved, focus = resting when customized. No --TextField-bg (InputBase styles no background). Resting-only. --- CONTEXT.md | 1 + docs/pages/experiments/css-vars-density.tsx | 31 +++++++++++++++++++ .../mui-material/src/InputBase/InputBase.js | 2 +- .../src/OutlinedInput/NotchedOutline.js | 2 +- .../src/OutlinedInput/OutlinedInput.js | 11 ++++--- .../mui-material/src/TextField/TextField.js | 4 +++ 6 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 33923dcf850c40..f6ba531681624f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -25,6 +25,7 @@ A component's CSS-var fallback chain may reference only its own var and the vars - 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). Resting-only otherwise (focus/hover/error border-color still reassign beneath). - **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). diff --git a/docs/pages/experiments/css-vars-density.tsx b/docs/pages/experiments/css-vars-density.tsx index 84fb0dac1e50be..b46ef1c812c5b4 100644 --- a/docs/pages/experiments/css-vars-density.tsx +++ b/docs/pages/experiments/css-vars-density.tsx @@ -149,6 +149,37 @@ export default function App() { + + {/* --- TextField knobs (outlined; resting-only, focus width keeps emphasis) --- */} + + + + + + + + ); diff --git a/packages/mui-material/src/InputBase/InputBase.js b/packages/mui-material/src/InputBase/InputBase.js index 97cd99fb159cd3..6777bb31b1f7b9 100644 --- a/packages/mui-material/src/InputBase/InputBase.js +++ b/packages/mui-material/src/InputBase/InputBase.js @@ -97,7 +97,7 @@ export const InputBaseRoot = styled('div', { })( memoTheme(({ theme }) => ({ ...theme.typography.body1, - color: (theme.vars || theme).palette.text.primary, + color: `var(--InputBase-color, ${(theme.vars || theme).palette.text.primary})`, fontSize: `var(--InputBase-font-size, ${theme.typography.body1.fontSize})`, lineHeight: 'var(--InputBase-line-height, 1.4375)', // 23px (unitless = multiplier of font-size) boxSizing: 'border-box', // Prevent padding issue with fullWidth. diff --git a/packages/mui-material/src/OutlinedInput/NotchedOutline.js b/packages/mui-material/src/OutlinedInput/NotchedOutline.js index ac8e235a49cef2..25bb85a5adf6b3 100644 --- a/packages/mui-material/src/OutlinedInput/NotchedOutline.js +++ b/packages/mui-material/src/OutlinedInput/NotchedOutline.js @@ -19,7 +19,7 @@ const NotchedOutlineRoot = styled('fieldset', { pointerEvents: 'none', borderRadius: 'inherit', borderStyle: 'solid', - borderWidth: 1, + borderWidth: 'var(--OutlinedInput-border-width, 1px)', overflow: 'hidden', minWidth: '0%', }); diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index ab4bff2d1f1523..4503427179aa4f 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -49,7 +49,8 @@ const OutlinedInputRoot = styled(InputBaseRoot, { position: 'relative', // Variant-level font-size knob; inward fallback to the base --InputBase-font-size. fontSize: `var(--OutlinedInput-font-size, var(--InputBase-font-size, ${theme.typography.body1.fontSize}))`, - borderRadius: (theme.vars || theme).shape.borderRadius, + color: `var(--OutlinedInput-color, var(--InputBase-color, ${(theme.vars || theme).palette.text.primary}))`, + borderRadius: `var(--OutlinedInput-radius, ${(theme.vars || theme).shape.borderRadius}${theme.vars ? '' : 'px'})`, [`&:hover .${outlinedInputClasses.notchedOutline}`]: { borderColor: (theme.vars || theme).palette.text.primary, }, @@ -62,7 +63,7 @@ const OutlinedInputRoot = styled(InputBaseRoot, { }, }, [`&.${outlinedInputClasses.focused} .${outlinedInputClasses.notchedOutline}`]: { - borderWidth: 2, + borderWidth: 'var(--OutlinedInput-border-width, 2px)', }, variants: [ ...Object.entries(theme.palette) @@ -123,9 +124,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(--OutlinedInput-border-color, ${ + theme.vars ? theme.alpha(theme.vars.palette.common.onBackground, 0.23) : borderColor + })`, }; }), ); diff --git a/packages/mui-material/src/TextField/TextField.js b/packages/mui-material/src/TextField/TextField.js index 0a054824d5733d..04950d314b6ec0 100644 --- a/packages/mui-material/src/TextField/TextField.js +++ b/packages/mui-material/src/TextField/TextField.js @@ -45,6 +45,10 @@ const TextFieldRoot = styled(FormControl, { style: { '--OutlinedInput-height': 'var(--TextField-height)', '--OutlinedInput-font-size': 'var(--TextField-font-size)', + '--OutlinedInput-color': 'var(--TextField-color)', + '--OutlinedInput-border-color': 'var(--TextField-border-color)', + '--OutlinedInput-border-width': 'var(--TextField-border-width)', + '--OutlinedInput-radius': 'var(--TextField-radius)', }, }, ], From 6c8eb2b15ae5163f2318cbf0b500052e2610cee8 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 25 May 2026 14:39:09 +0700 Subject: [PATCH 05/12] [material-ui][Button] POC expose --Button-ring focus-visible outline var Opt-in focus ring via outline (default 0 = no change), separate from the elevation box-shadow. --- CONTEXT.md | 2 +- docs/pages/experiments/css-vars-density.tsx | 79 +++++++++++---------- packages/mui-material/src/Button/Button.js | 5 ++ 3 files changed, 49 insertions(+), 37 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f6ba531681624f..f60726cf076828 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,7 +36,7 @@ A component's CSS-var fallback chain may reference only its own var and the vars - **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) 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. +- **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. - **Color is resting-only (state caveat).** Hover/active rules reassign `--variant-*`, which sits *below* a user-set `--Button-bg` in the chain, so a custom `--Button-bg` does **not** darken on hover/active. By design: if you customize color, you own the states. The global remap path is unchanged — override `--mui-palette-*` to recolor with states intact. - **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. diff --git a/docs/pages/experiments/css-vars-density.tsx b/docs/pages/experiments/css-vars-density.tsx index b46ef1c812c5b4..319527eab30ea5 100644 --- a/docs/pages/experiments/css-vars-density.tsx +++ b/docs/pages/experiments/css-vars-density.tsx @@ -1,12 +1,12 @@ -"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 } from "@mui/material/styles"; +'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 } from '@mui/material/styles'; // Public CSS variables + density POC. // See CONTEXT.md, docs/adr/0001-public-css-var-inward-dependency.md, @@ -16,7 +16,7 @@ const theme = createTheme({ cssVariables: true }); function Controls() { return ( - + @@ -27,7 +27,7 @@ function Controls() { - + @@ -37,7 +37,7 @@ function Controls() { function Scope({ title, sx, children }: { title: string; sx?: object; children: React.ReactNode }) { return ( - + {title} @@ -50,7 +50,7 @@ export default function App() { const [spacing, setSpacing] = React.useState(8); return ( - + Public CSS variables & density {/* --- Different Apps: drive --mui-spacing live at a class scope --- */} @@ -69,14 +69,14 @@ export default function App() { valueLabelFormat={(value) => `${value}px`} /> - + {/* --- Different Viewports: override --mui-spacing inside a media query --- */} @@ -87,23 +87,23 @@ export default function App() { direction="row" spacing={2} useFlexGap - sx={{ alignItems: "center", flexWrap: "wrap" }} + sx={{ alignItems: 'center', flexWrap: 'wrap' }} > - - @@ -112,11 +112,11 @@ export default function App() { @@ -129,24 +129,31 @@ export default function App() { direction="row" spacing={2} useFlexGap - sx={{ alignItems: "center", flexWrap: "wrap", "--Button-radius": "16px" }} + sx={{ alignItems: 'center', flexWrap: 'wrap', '--Button-radius': '16px' }} > - - - - - + @@ -156,27 +163,27 @@ export default function App() { direction="row" spacing={2} useFlexGap - sx={{ alignItems: "start", flexWrap: "wrap" }} + sx={{ alignItems: 'start', flexWrap: 'wrap' }} > diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index 9061175de3a126..900167d3872d1c 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -120,6 +120,11 @@ const ButtonRoot = styled(ButtonBase, { [`&.${buttonClasses.disabled}`]: { color: (theme.vars || theme).palette.action.disabled, }, + // Opt-in focus ring; default `0` = no ring (ButtonBase already resets outline). + [`&.${buttonClasses.focusVisible}`]: { + outline: 'var(--Button-ring, 0)', + outlineOffset: 2, + }, variants: [ { props: { variant: 'contained' }, From 70bfaeaecbf3cee96d896b1a6aeabe459abb3d8c Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 25 May 2026 14:51:26 +0700 Subject: [PATCH 06/12] [material-ui] POC extract agnostic CSS vars to {Component}Vars.ts Centralize public var names in ButtonVars/InputBaseVars/OutlinedInputVars/ TextFieldVars; components reference them instead of literals. Private --variant-* left inline. Dedupe input padding into derivedInputPadding helper. --- packages/mui-material/src/Button/Button.js | 103 +++---- .../mui-material/src/Button/ButtonVars.ts | 20 ++ .../mui-material/src/InputBase/InputBase.js | 263 +++++++++--------- .../src/InputBase/InputBaseVars.ts | 15 + .../src/OutlinedInput/NotchedOutline.js | 71 ++--- .../src/OutlinedInput/OutlinedInput.js | 150 +++++----- .../src/OutlinedInput/OutlinedInputVars.ts | 18 ++ .../mui-material/src/TextField/TextField.js | 100 +++---- .../src/TextField/TextFieldVars.ts | 16 ++ 9 files changed, 423 insertions(+), 333 deletions(-) create mode 100644 packages/mui-material/src/Button/ButtonVars.ts create mode 100644 packages/mui-material/src/InputBase/InputBaseVars.ts create mode 100644 packages/mui-material/src/OutlinedInput/OutlinedInputVars.ts create mode 100644 packages/mui-material/src/TextField/TextFieldVars.ts diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index 900167d3872d1c..b8d174a423226e 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,12 +101,12 @@ const ButtonRoot = styled(ButtonBase, { return { ...theme.typography.button, minWidth: 64, - fontSize: `var(--Button-font-size, ${theme.typography.button.fontSize})`, - padding: `var(--Button-padding-block, ${theme.spacing( - 0.75, - )}) var(--Button-padding-inline, ${theme.spacing(2)})`, + 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: `var(--Button-radius, ${(theme.vars || theme).shape.borderRadius}${ + borderRadius: `var(${buttonVars.radius},${(theme.vars || theme).shape.borderRadius}${ theme.vars ? '' : 'px' })`, transition: theme.transitions.create( @@ -122,16 +123,16 @@ const ButtonRoot = styled(ButtonBase, { }, // Opt-in focus ring; default `0` = no ring (ButtonBase already resets outline). [`&.${buttonClasses.focusVisible}`]: { - outline: 'var(--Button-ring, 0)', + outline: `var(${buttonVars.ring},0)`, outlineOffset: 2, }, variants: [ { props: { variant: 'contained' }, style: { - color: `var(--Button-color, var(--variant-containedColor))`, - backgroundColor: `var(--Button-bg, var(--variant-containedBg))`, - boxShadow: `var(--Button-shadow, ${(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], // Reset on touch devices, it doesn't add specificity @@ -155,16 +156,16 @@ const ButtonRoot = styled(ButtonBase, { { props: { variant: 'outlined' }, style: { - padding: `calc(var(--Button-padding-block, ${theme.spacing( - 0.75, - )}) - var(--Button-border-width, 1px)) calc(var(--Button-padding-inline, ${theme.spacing( - 2, - )}) - var(--Button-border-width, 1px))`, + 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(--Button-border-width, 1px)', - borderColor: `var(--Button-border-color, var(--variant-outlinedBorder, currentColor))`, - backgroundColor: `var(--Button-bg, var(--variant-outlinedBg))`, - color: `var(--Button-color, var(--variant-outlinedColor))`, + 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}`]: { borderColor: (theme.vars || theme).palette.action.disabledBackground, }, @@ -173,11 +174,11 @@ const ButtonRoot = styled(ButtonBase, { { props: { variant: 'text' }, style: { - padding: `var(--Button-padding-block, ${theme.spacing( - 0.75, - )}) var(--Button-padding-inline, ${theme.spacing(1)})`, - color: `var(--Button-color, var(--variant-textColor))`, - backgroundColor: `var(--Button-bg, 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) @@ -242,10 +243,10 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: `var(--Button-padding-block, ${theme.spacing( - 0.5, - )}) var(--Button-padding-inline, ${theme.spacing(0.625)})`, - fontSize: `var(--Button-font-size, ${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)})`, }, }, { @@ -254,10 +255,10 @@ const ButtonRoot = styled(ButtonBase, { variant: 'text', }, style: { - padding: `var(--Button-padding-block, ${theme.spacing( - 1, - )}) var(--Button-padding-inline, ${theme.spacing(1.375)})`, - fontSize: `var(--Button-font-size, ${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)})`, }, }, { @@ -266,12 +267,12 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: `calc(var(--Button-padding-block, ${theme.spacing( - 0.5, - )}) - var(--Button-border-width, 1px)) calc(var(--Button-padding-inline, ${theme.spacing( - 1.25, - )}) - var(--Button-border-width, 1px))`, - fontSize: `var(--Button-font-size, ${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)})`, }, }, { @@ -280,12 +281,12 @@ const ButtonRoot = styled(ButtonBase, { variant: 'outlined', }, style: { - padding: `calc(var(--Button-padding-block, ${theme.spacing( - 1, - )}) - var(--Button-border-width, 1px)) calc(var(--Button-padding-inline, ${theme.spacing( - 2.75, - )}) - var(--Button-border-width, 1px))`, - fontSize: `var(--Button-font-size, ${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)})`, }, }, { @@ -294,10 +295,10 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: `var(--Button-padding-block, ${theme.spacing( - 0.5, - )}) var(--Button-padding-inline, ${theme.spacing(1.25)})`, - fontSize: `var(--Button-font-size, ${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)})`, }, }, { @@ -306,10 +307,10 @@ const ButtonRoot = styled(ButtonBase, { variant: 'contained', }, style: { - padding: `var(--Button-padding-block, ${theme.spacing( - 1, - )}) var(--Button-padding-inline, ${theme.spacing(2.75)})`, - fontSize: `var(--Button-font-size, ${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)})`, }, }, { diff --git a/packages/mui-material/src/Button/ButtonVars.ts b/packages/mui-material/src/Button/ButtonVars.ts new file mode 100644 index 00000000000000..500d927287ef57 --- /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 6777bb31b1f7b9..1ff88f5742817f 100644 --- a/packages/mui-material/src/InputBase/InputBase.js +++ b/packages/mui-material/src/InputBase/InputBase.js @@ -1,27 +1,28 @@ -'use client'; -import * as React from 'react'; -import PropTypes from 'prop-types'; -import clsx from 'clsx'; -import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef'; -import refType from '@mui/utils/refType'; -import composeClasses from '@mui/utils/composeClasses'; -import isHostComponent from '@mui/utils/isHostComponent'; -import TextareaAutosize from '../TextareaAutosize'; -import FormControlContext from '../FormControl/FormControlContext'; -import { useFormControlState } from '../FormControl/useFormControl'; -import { styled, globalCss } from '../zero-styled'; -import memoTheme from '../utils/memoTheme'; -import { useDefaultProps } from '../DefaultPropsProvider'; -import capitalize from '../utils/capitalize'; -import useForkRef from '../utils/useForkRef'; -import useEnhancedEffect from '../utils/useEnhancedEffect'; -import ownerDocument from '../utils/ownerDocument'; -import getActiveElement from '../utils/getActiveElement'; -import { isFilled } from './utils'; -import inputBaseClasses, { getInputBaseUtilityClass } from './inputBaseClasses'; - -const MUI_AUTO_FILL = 'mui-auto-fill'; -const MUI_AUTO_FILL_CANCEL = 'mui-auto-fill-cancel'; +"use client"; +import * as React from "react"; +import PropTypes from "prop-types"; +import clsx from "clsx"; +import elementTypeAcceptingRef from "@mui/utils/elementTypeAcceptingRef"; +import refType from "@mui/utils/refType"; +import composeClasses from "@mui/utils/composeClasses"; +import isHostComponent from "@mui/utils/isHostComponent"; +import TextareaAutosize from "../TextareaAutosize"; +import FormControlContext from "../FormControl/FormControlContext"; +import { useFormControlState } from "../FormControl/useFormControl"; +import { styled, globalCss } from "../zero-styled"; +import memoTheme from "../utils/memoTheme"; +import { useDefaultProps } from "../DefaultPropsProvider"; +import capitalize from "../utils/capitalize"; +import useForkRef from "../utils/useForkRef"; +import useEnhancedEffect from "../utils/useEnhancedEffect"; +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"; export const rootOverridesResolver = (props, styles) => { const { ownerState } = props; @@ -32,7 +33,7 @@ export const rootOverridesResolver = (props, styles) => { ownerState.startAdornment && styles.adornedStart, ownerState.endAdornment && styles.adornedEnd, ownerState.error && styles.error, - ownerState.size === 'small' && styles.sizeSmall, + ownerState.size === "small" && styles.sizeSmall, ownerState.multiline && styles.multiline, ownerState.color && styles[`color${capitalize(ownerState.color)}`], ownerState.fullWidth && styles.fullWidth, @@ -43,7 +44,7 @@ export const rootOverridesResolver = (props, styles) => { export const inputOverridesResolver = (props, styles) => { const { ownerState } = props; - return [styles.input, ownerState.type === 'search' && styles.inputTypeSearch]; + return [styles.input, ownerState.type === "search" && styles.inputTypeSearch]; }; const useUtilityClasses = (ownerState) => { @@ -65,59 +66,59 @@ const useUtilityClasses = (ownerState) => { } = ownerState; const slots = { root: [ - 'root', + "root", `color${capitalize(color)}`, - disabled && 'disabled', - error && 'error', - fullWidth && 'fullWidth', - focused && 'focused', - formControl && 'formControl', - size && size !== 'medium' && `size${capitalize(size)}`, - multiline && 'multiline', - startAdornment && 'adornedStart', - endAdornment && 'adornedEnd', - hiddenLabel && 'hiddenLabel', - readOnly && 'readOnly', + disabled && "disabled", + error && "error", + fullWidth && "fullWidth", + focused && "focused", + formControl && "formControl", + size && size !== "medium" && `size${capitalize(size)}`, + multiline && "multiline", + startAdornment && "adornedStart", + endAdornment && "adornedEnd", + hiddenLabel && "hiddenLabel", + readOnly && "readOnly", ], input: [ - 'input', - disabled && 'disabled', - type === 'search' && 'inputTypeSearch', - readOnly && 'readOnly', + "input", + disabled && "disabled", + type === "search" && "inputTypeSearch", + readOnly && "readOnly", ], }; return composeClasses(slots, getInputBaseUtilityClass, classes); }; -export const InputBaseRoot = styled('div', { - name: 'MuiInputBase', - slot: 'Root', +export const InputBaseRoot = styled("div", { + name: "MuiInputBase", + slot: "Root", overridesResolver: rootOverridesResolver, })( memoTheme(({ theme }) => ({ ...theme.typography.body1, - color: `var(--InputBase-color, ${(theme.vars || theme).palette.text.primary})`, - fontSize: `var(--InputBase-font-size, ${theme.typography.body1.fontSize})`, - lineHeight: 'var(--InputBase-line-height, 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', + 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, - cursor: 'default', + cursor: "default", }, variants: [ { props: ({ ownerState }) => ownerState.multiline, style: { - padding: '4px 0 5px', + padding: "4px 0 5px", }, }, { - props: ({ ownerState, size }) => ownerState.multiline && size === 'small', + props: ({ ownerState, size }) => ownerState.multiline && size === "small", style: { paddingTop: 1, }, @@ -125,22 +126,22 @@ export const InputBaseRoot = styled('div', { { props: ({ ownerState }) => ownerState.fullWidth, style: { - width: '100%', + width: "100%", }, }, ], - })), + })) ); -export const InputBaseInput = styled('input', { - name: 'MuiInputBase', - slot: 'Input', +export const InputBaseInput = styled("input", { + name: "MuiInputBase", + slot: "Input", overridesResolver: inputOverridesResolver, })( memoTheme(({ theme }) => { - const light = theme.palette.mode === 'light'; + const light = theme.palette.mode === "light"; const placeholder = { - color: 'currentColor', + color: "currentColor", ...(theme.vars ? { opacity: theme.vars.opacity.inputPlaceholder, @@ -148,12 +149,12 @@ export const InputBaseInput = styled('input', { : { opacity: light ? 0.42 : 0.5, }), - transition: theme.transitions.create('opacity', { + transition: theme.transitions.create("opacity", { duration: theme.transitions.duration.shorter, }), }; const placeholderHidden = { - opacity: '0 !important', + opacity: "0 !important", }; const placeholderVisible = theme.vars ? { @@ -164,42 +165,42 @@ export const InputBaseInput = styled('input', { }; return { - font: 'inherit', - letterSpacing: 'inherit', - color: 'currentColor', - padding: '4px 0 5px', + font: "inherit", + letterSpacing: "inherit", + color: "currentColor", + padding: "4px 0 5px", border: 0, - boxSizing: 'content-box', - background: 'none', - height: 'calc(var(--InputBase-line-height, 1.4375) * 1em)', // Reset native input height to the line box (tracks --InputBase-line-height) + boxSizing: "content-box", + background: "none", + 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', + WebkitTapHighlightColor: "transparent", + display: "block", // Make the flex item shrink with Firefox minWidth: 0, - width: '100%', - '&::-webkit-input-placeholder': placeholder, - '&::-moz-placeholder': placeholder, // Firefox 19+ - '&::-ms-input-placeholder': placeholder, // Edge - '&:focus': { + width: "100%", + "&::-webkit-input-placeholder": placeholder, + "&::-moz-placeholder": placeholder, // Firefox 19+ + "&::-ms-input-placeholder": placeholder, // Edge + "&:focus": { outline: 0, }, // Reset Firefox invalid required input style - '&:invalid': { - boxShadow: 'none', + "&:invalid": { + boxShadow: "none", }, - '&::-webkit-search-decoration': { + "&::-webkit-search-decoration": { // Remove the padding when type=search. - WebkitAppearance: 'none', + WebkitAppearance: "none", }, // Show and hide the placeholder logic [`label[data-shrink=false] + .${inputBaseClasses.formControl} &`]: { - '&::-webkit-input-placeholder': placeholderHidden, - '&::-moz-placeholder': placeholderHidden, // Firefox 19+ - '&::-ms-input-placeholder': placeholderHidden, // Edge - '&:focus::-webkit-input-placeholder': placeholderVisible, - '&:focus::-moz-placeholder': placeholderVisible, // Firefox 19+ - '&:focus::-ms-input-placeholder': placeholderVisible, // Edge + "&::-webkit-input-placeholder": placeholderHidden, + "&::-moz-placeholder": placeholderHidden, // Firefox 19+ + "&::-ms-input-placeholder": placeholderHidden, // Edge + "&:focus::-webkit-input-placeholder": placeholderVisible, + "&:focus::-moz-placeholder": placeholderVisible, // Firefox 19+ + "&:focus::-ms-input-placeholder": placeholderVisible, // Edge }, [`&.${inputBaseClasses.disabled}`]: { opacity: 1, // Reset iOS opacity @@ -210,16 +211,16 @@ export const InputBaseInput = styled('input', { props: ({ ownerState }) => !ownerState.disableInjectingGlobalStyles, style: { animationName: MUI_AUTO_FILL_CANCEL, - animationDuration: '10ms', - '&:-webkit-autofill': { - animationDuration: '5000s', + animationDuration: "10ms", + "&:-webkit-autofill": { + animationDuration: "5000s", animationName: MUI_AUTO_FILL, }, }, }, { props: { - size: 'small', + size: "small", }, style: { paddingTop: 1, @@ -228,23 +229,23 @@ export const InputBaseInput = styled('input', { { props: ({ ownerState }) => ownerState.multiline, style: { - height: 'auto', - resize: 'none', + height: "auto", + resize: "none", padding: 0, paddingTop: 0, }, }, { props: { - type: 'search', + type: "search", }, style: { - MozAppearance: 'textfield', // Improve type search style. + MozAppearance: "textfield", // Improve type search style. }, }, ], }; - }), + }) ); const InputGlobalStyles = globalCss({ @@ -260,10 +261,10 @@ const InputGlobalStyles = globalCss({ * It contains a load of style reset and some state logic. */ const InputBase = React.forwardRef(function InputBase(inProps, ref) { - const props = useDefaultProps({ props: inProps, name: 'MuiInputBase' }); + const props = useDefaultProps({ props: inProps, name: "MuiInputBase" }); const { - 'aria-describedby': ariaDescribedby, - 'aria-label': ariaLabel, + "aria-describedby": ariaDescribedby, + "aria-label": ariaLabel, autoComplete, autoFocus, className, @@ -275,7 +276,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { error, fullWidth = false, id, - inputComponent = 'input', + inputComponent = "input", inputProps: inputPropsProp = {}, inputRef: inputRefProp, margin, @@ -297,7 +298,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { slotProps = {}, slots = {}, startAdornment, - type = 'text', + type = "text", value: valueProp, ...other } = props; @@ -307,14 +308,14 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const inputRef = React.useRef(); const handleInputRefWarning = React.useCallback((instance) => { - if (process.env.NODE_ENV !== 'production') { - if (instance && instance.nodeName !== 'INPUT' && !instance.focus) { + if (process.env.NODE_ENV !== "production") { + if (instance && instance.nodeName !== "INPUT" && !instance.focus) { console.error( [ - 'MUI: You have provided a `inputComponent` to the input component', - 'that does not correctly handle the `ref` prop.', - 'Make sure the `ref` prop is called with a HTMLInputElement.', - ].join('\n'), + "MUI: You have provided a `inputComponent` to the input component", + "that does not correctly handle the `ref` prop.", + "Make sure the `ref` prop is called with a HTMLInputElement.", + ].join("\n") ); } } @@ -324,16 +325,16 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { inputRef, inputRefProp, inputPropsProp.ref, - handleInputRefWarning, + handleInputRefWarning ); const [focused, setFocused] = React.useState(false); const [fcs, muiFormControl] = useFormControlState({ props, - states: ['color', 'disabled', 'error', 'hiddenLabel', 'size', 'required', 'filled'], + states: ["color", "disabled", "error", "hiddenLabel", "size", "required", "filled"], }); - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV !== "production") { // TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { @@ -371,7 +372,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { onEmpty(); } }, - [onFilled, onEmpty], + [onFilled, onEmpty] ); useEnhancedEffect(() => { @@ -445,9 +446,9 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const element = event.target || inputRef.current; if (element == null) { throw /* minify-error */ new Error( - 'MUI: Expected valid input target. ' + - 'Did you use a custom `inputComponent` and forget to forward refs? ' + - 'See https://mui.com/r/input-component-ref-interface for more info.', + "MUI: Expected valid input target. " + + "Did you use a custom `inputComponent` and forget to forward refs? " + + "See https://mui.com/r/input-component-ref-interface for more info." ); } @@ -486,12 +487,12 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { let InputComponent = inputComponent; let inputProps = inputPropsProp; - if (multiline && InputComponent === 'input') { + if (multiline && InputComponent === "input") { if (rows) { - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV !== "production") { if (minRows || maxRows) { console.warn( - 'MUI: You can not use the `minRows` or `maxRows` props when the input `rows` prop is set.', + "MUI: You can not use the `minRows` or `maxRows` props when the input `rows` prop is set." ); } } @@ -515,7 +516,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const handleAutoFill = (event) => { // Provide a fake value as Chrome might not let you access it for security reasons. - checkDirty(event.animationName === MUI_AUTO_FILL_CANCEL ? inputRef.current : { value: 'x' }); + checkDirty(event.animationName === MUI_AUTO_FILL_CANCEL ? inputRef.current : { value: "x" }); }; React.useEffect(() => { @@ -526,7 +527,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const ownerState = { ...props, - color: fcs.color || 'primary', + color: fcs.color || "primary", disabled: fcs.disabled, endAdornment, error: fcs.error, @@ -550,7 +551,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { return ( - {!disableInjectingGlobalStyles && typeof InputGlobalStyles === 'function' && ( + {!disableInjectingGlobalStyles && typeof InputGlobalStyles === "function" && ( // For Emotion/Styled-components, InputGlobalStyles will be a function // For Pigment CSS, this has no effect because the InputGlobalStyles will be null. @@ -568,10 +569,10 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { classes.root, { // TODO v6: remove this class as it duplicates with the global state class Mui-readOnly - 'MuiInputBase-readOnly': readOnly, + "MuiInputBase-readOnly": readOnly, }, rootProps.className, - className, + className )} > {startAdornment} @@ -605,9 +606,9 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { classes.input, { // TODO v6: remove this class as it duplicates with the global state class Mui-readOnly - 'MuiInputBase-readOnly': readOnly, + "MuiInputBase-readOnly": readOnly, }, - inputProps.className, + inputProps.className )} onBlur={handleBlur} onChange={handleChange} @@ -634,11 +635,11 @@ InputBase.propTypes /* remove-proptypes */ = { /** * @ignore */ - 'aria-describedby': PropTypes.string, + "aria-describedby": PropTypes.string, /** * @ignore */ - 'aria-label': PropTypes.string, + "aria-label": PropTypes.string, /** * This prop helps users to fill forms faster, especially on mobile devices. * The name can be confusing, as it's more like an autofill. @@ -664,7 +665,7 @@ InputBase.propTypes /* remove-proptypes */ = { * The prop defaults to the value (`'primary'`) inherited from the parent FormControl component. */ color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(['primary', 'secondary', 'error', 'info', 'success', 'warning']), + PropTypes.oneOf(["primary", "secondary", "error", "info", "success", "warning"]), PropTypes.string, ]), /** @@ -720,7 +721,7 @@ InputBase.propTypes /* remove-proptypes */ = { * FormControl. * The prop defaults to the value (`'none'`) inherited from the parent FormControl component. */ - margin: PropTypes.oneOf(['dense', 'none']), + margin: PropTypes.oneOf(["dense", "none"]), /** * Maximum number of rows to display when multiline option is set to true. */ @@ -797,7 +798,7 @@ InputBase.propTypes /* remove-proptypes */ = { * The size of the component. */ size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(['medium', 'small']), + PropTypes.oneOf(["medium", "small"]), PropTypes.string, ]), /** diff --git a/packages/mui-material/src/InputBase/InputBaseVars.ts b/packages/mui-material/src/InputBase/InputBaseVars.ts new file mode 100644 index 00000000000000..b2d25ece38098c --- /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/OutlinedInput/NotchedOutline.js b/packages/mui-material/src/OutlinedInput/NotchedOutline.js index 25bb85a5adf6b3..6659d73b27be47 100644 --- a/packages/mui-material/src/OutlinedInput/NotchedOutline.js +++ b/packages/mui-material/src/OutlinedInput/NotchedOutline.js @@ -1,44 +1,45 @@ -'use client'; -import PropTypes from 'prop-types'; -import rootShouldForwardProp from '../styles/rootShouldForwardProp'; -import { styled } from '../zero-styled'; -import memoTheme from '../utils/memoTheme'; +"use client"; +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', +const NotchedOutlineRoot = styled("fieldset", { + name: "MuiNotchedOutlined", shouldForwardProp: rootShouldForwardProp, })({ - textAlign: 'left', - position: 'absolute', + textAlign: "left", + position: "absolute", bottom: 0, right: 0, top: -5, left: 0, margin: 0, - padding: '0 8px', - pointerEvents: 'none', - borderRadius: 'inherit', - borderStyle: 'solid', - borderWidth: 'var(--OutlinedInput-border-width, 1px)', - overflow: 'hidden', - minWidth: '0%', + padding: "0 8px", + pointerEvents: "none", + borderRadius: "inherit", + borderStyle: "solid", + borderWidth: `var(${outlinedInputVars.borderWidth},1px)`, + overflow: "hidden", + minWidth: "0%", }); -const NotchedOutlineLegend = styled('legend', { - name: 'MuiNotchedOutlined', +const NotchedOutlineLegend = styled("legend", { + name: "MuiNotchedOutlined", shouldForwardProp: rootShouldForwardProp, })( memoTheme(({ theme }) => ({ - float: 'unset', // Fix conflict with bootstrap - width: 'auto', // Fix conflict with bootstrap - overflow: 'hidden', // Fix Horizontal scroll when label too long + float: "unset", // Fix conflict with bootstrap + width: "auto", // Fix conflict with bootstrap + overflow: "hidden", // Fix Horizontal scroll when label too long variants: [ { props: ({ ownerState }) => !ownerState.withLabel, style: { padding: 0, - lineHeight: '11px', // sync with `height` in `legend` styles - transition: theme.transitions.create('width', { + lineHeight: "11px", // sync with `height` in `legend` styles + transition: theme.transitions.create("width", { duration: 150, easing: theme.transitions.easing.easeOut, }), @@ -47,31 +48,31 @@ const NotchedOutlineLegend = styled('legend', { { props: ({ ownerState }) => ownerState.withLabel, style: { - display: 'block', // Fix conflict with normalize.css and sanitize.css + display: "block", // Fix conflict with normalize.css and sanitize.css padding: 0, height: 11, // sync with `lineHeight` in `legend` styles - fontSize: '0.75em', - visibility: 'hidden', + fontSize: "0.75em", + visibility: "hidden", maxWidth: 0.01, - transition: theme.transitions.create('max-width', { + transition: theme.transitions.create("max-width", { duration: 50, easing: theme.transitions.easing.easeOut, }), - whiteSpace: 'nowrap', - '& > span': { + whiteSpace: "nowrap", + "& > span": { paddingLeft: 5, paddingRight: 5, - display: 'inline-block', + display: "inline-block", opacity: 0, - visibility: 'visible', + visibility: "visible", }, }, }, { props: ({ ownerState }) => ownerState.withLabel && ownerState.notched, style: { - maxWidth: '100%', - transition: theme.transitions.create('max-width', { + maxWidth: "100%", + transition: theme.transitions.create("max-width", { duration: 100, easing: theme.transitions.easing.easeOut, delay: 50, @@ -79,7 +80,7 @@ const NotchedOutlineLegend = styled('legend', { }, }, ], - })), + })) ); /** @@ -87,7 +88,7 @@ const NotchedOutlineLegend = styled('legend', { */ export default function NotchedOutline(props) { const { children, classes, className, label, notched, ...other } = props; - const withLabel = label != null && label !== ''; + const withLabel = label != null && label !== ""; const ownerState = { ...props, notched, diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index 4503427179aa4f..3fa2dc61264bed 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -1,31 +1,33 @@ -'use client'; -import * as React from 'react'; -import PropTypes from 'prop-types'; -import refType from '@mui/utils/refType'; -import composeClasses from '@mui/utils/composeClasses'; -import NotchedOutline from './NotchedOutline'; -import { useFormControlState } from '../FormControl/useFormControl'; -import rootShouldForwardProp from '../styles/rootShouldForwardProp'; -import { styled } from '../zero-styled'; -import memoTheme from '../utils/memoTheme'; -import createSimplePaletteValueFilter from '../utils/createSimplePaletteValueFilter'; -import { useDefaultProps } from '../DefaultPropsProvider'; -import outlinedInputClasses, { getOutlinedInputUtilityClass } from './outlinedInputClasses'; +"use client"; +import * as React from "react"; +import PropTypes from "prop-types"; +import refType from "@mui/utils/refType"; +import composeClasses from "@mui/utils/composeClasses"; +import NotchedOutline from "./NotchedOutline"; +import { useFormControlState } from "../FormControl/useFormControl"; +import rootShouldForwardProp from "../styles/rootShouldForwardProp"; +import { styled } from "../zero-styled"; +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, InputBaseRoot, InputBaseInput, -} from '../InputBase/InputBase'; -import useSlot from '../utils/useSlot'; +} from "../InputBase/InputBase"; +import useSlot from "../utils/useSlot"; const useUtilityClasses = (ownerState) => { const { classes } = ownerState; const slots = { - root: ['root'], - notchedOutline: ['notchedOutline'], - input: ['input'], + root: ["root"], + notchedOutline: ["notchedOutline"], + input: ["input"], }; const composedClasses = composeClasses(slots, getOutlinedInputUtilityClass, classes); @@ -36,26 +38,42 @@ 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', - slot: 'Root', + shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === "classes", + name: "MuiOutlinedInput", + slot: "Root", overridesResolver: inputBaseRootOverridesResolver, })( memoTheme(({ theme }) => { const borderColor = - theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)'; + theme.palette.mode === "light" ? "rgba(0, 0, 0, 0.23)" : "rgba(255, 255, 255, 0.23)"; return { - position: 'relative', + position: "relative", // Variant-level font-size knob; inward fallback to the base --InputBase-font-size. - fontSize: `var(--OutlinedInput-font-size, var(--InputBase-font-size, ${theme.typography.body1.fontSize}))`, - color: `var(--OutlinedInput-color, var(--InputBase-color, ${(theme.vars || theme).palette.text.primary}))`, - borderRadius: `var(--OutlinedInput-radius, ${(theme.vars || theme).shape.borderRadius}${theme.vars ? '' : 'px'})`, + 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, }, // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { + "@media (hover: none)": { [`&:hover .${outlinedInputClasses.notchedOutline}`]: { borderColor: theme.vars ? theme.alpha(theme.vars.palette.common.onBackground, 0.23) @@ -63,7 +81,7 @@ const OutlinedInputRoot = styled(InputBaseRoot, { }, }, [`&.${outlinedInputClasses.focused} .${outlinedInputClasses.notchedOutline}`]: { - borderWidth: 'var(--OutlinedInput-border-width, 2px)', + borderWidth: `var(${outlinedInputVars.borderWidth},2px)`, }, variants: [ ...Object.entries(theme.palette) @@ -102,67 +120,65 @@ const OutlinedInputRoot = styled(InputBaseRoot, { { props: ({ ownerState }) => ownerState.multiline, style: { - padding: '16.5px 14px', + padding: "16.5px 14px", }, }, { - props: ({ ownerState, size }) => ownerState.multiline && size === 'small', + props: ({ ownerState, size }) => ownerState.multiline && size === "small", style: { - padding: '8.5px 14px', + padding: "8.5px 14px", }, }, ], }; - }), + }) ); const NotchedOutlineRoot = styled(NotchedOutline, { - name: 'MuiOutlinedInput', - slot: 'NotchedOutline', + name: "MuiOutlinedInput", + slot: "NotchedOutline", })( memoTheme(({ theme }) => { const borderColor = - theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)'; + theme.palette.mode === "light" ? "rgba(0, 0, 0, 0.23)" : "rgba(255, 255, 255, 0.23)"; return { - borderColor: `var(--OutlinedInput-border-color, ${ + borderColor: `var(${outlinedInputVars.borderColor},${ theme.vars ? theme.alpha(theme.vars.palette.common.onBackground, 0.23) : borderColor })`, }; - }), + }) ); const OutlinedInputInput = styled(InputBaseInput, { - name: 'MuiOutlinedInput', - slot: 'Input', + name: "MuiOutlinedInput", + slot: "Input", overridesResolver: inputBaseInputOverridesResolver, })( memoTheme(({ theme }) => ({ - // padding-block is derived from the target height so geometry stays centered: - // (height − line-height·1em) / 2. Height chain: --OutlinedInput-height → --InputBase-height → spacing(7). - // padding-inline rides --mui-spacing independently. See docs/design/public-css-var-layering.md. - padding: `var(--OutlinedInput-padding-block, var(--InputBase-padding-block, calc((var(--OutlinedInput-height, var(--InputBase-height, ${theme.spacing(7)})) - var(--InputBase-line-height, 1.4375) * 1em) / 2))) var(--OutlinedInput-padding-inline, var(--InputBase-padding-inline, ${theme.spacing(1.75)}))`, - '&:-webkit-autofill': { + // 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', - WebkitTextFillColor: theme.palette.mode === 'light' ? null : '#fff', - caretColor: theme.palette.mode === 'light' ? null : '#fff', + WebkitBoxShadow: theme.palette.mode === "light" ? null : "0 0 0 100px #266798 inset", + WebkitTextFillColor: theme.palette.mode === "light" ? null : "#fff", + caretColor: theme.palette.mode === "light" ? null : "#fff", }), - borderRadius: 'inherit', + borderRadius: "inherit", ...(theme.vars && - theme.applyStyles('dark', { - WebkitBoxShadow: '0 0 0 100px #266798 inset', - WebkitTextFillColor: '#fff', - caretColor: '#fff', + theme.applyStyles("dark", { + WebkitBoxShadow: "0 0 0 100px #266798 inset", + WebkitTextFillColor: "#fff", + caretColor: "#fff", })), }, variants: [ { props: { - size: 'small', + size: "small", }, style: { // small target height defaults to spacing(5) = 40px - padding: `var(--OutlinedInput-padding-block, var(--InputBase-padding-block, calc((var(--OutlinedInput-height, var(--InputBase-height, ${theme.spacing(5)})) - var(--InputBase-line-height, 1.4375) * 1em) / 2))) var(--OutlinedInput-padding-inline, var(--InputBase-padding-inline, ${theme.spacing(1.75)}))`, + padding: derivedInputPadding(theme, theme.spacing(5)), }, }, { @@ -184,20 +200,20 @@ const OutlinedInputInput = styled(InputBaseInput, { }, }, ], - })), + })) ); const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { - const props = useDefaultProps({ props: inProps, name: 'MuiOutlinedInput' }); + const props = useDefaultProps({ props: inProps, name: "MuiOutlinedInput" }); const { fullWidth = false, - inputComponent = 'input', + inputComponent = "input", label, multiline = false, notched, slots = {}, slotProps = {}, - type = 'text', + type = "text", ...other } = props; @@ -205,12 +221,12 @@ const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { const [fcs, muiFormControl] = useFormControlState({ props, - states: ['color', 'disabled', 'error', 'focused', 'hiddenLabel', 'size', 'required'], + states: ["color", "disabled", "error", "focused", "hiddenLabel", "size", "required"], }); const ownerState = { ...props, - color: fcs.color || 'primary', + color: fcs.color || "primary", disabled: fcs.disabled, error: fcs.error, focused: fcs.focused, @@ -225,7 +241,7 @@ const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { const RootSlot = slots.root ?? OutlinedInputRoot; const InputSlot = slots.input ?? OutlinedInputInput; - const [NotchedSlot, notchedProps] = useSlot('notchedOutline', { + const [NotchedSlot, notchedProps] = useSlot("notchedOutline", { elementType: NotchedOutlineRoot, className: classes.notchedOutline, shouldForwardComponentProp: true, @@ -236,10 +252,10 @@ const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { }, additionalProps: { label: - label != null && label !== '' && fcs.required ? ( + label != null && label !== "" && fcs.required ? ( {label} -  {'*'} +  {"*"} ) : ( label @@ -255,7 +271,7 @@ const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { { const { classes } = ownerState; const slots = { - root: ['root'], + root: ["root"], }; return composeClasses(slots, getTextFieldUtilityClass, classes); }; const TextFieldRoot = styled(FormControl, { - name: 'MuiTextField', - slot: 'Root', + 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' }, + props: { variant: "outlined" }, style: { - '--OutlinedInput-height': 'var(--TextField-height)', - '--OutlinedInput-font-size': 'var(--TextField-font-size)', - '--OutlinedInput-color': 'var(--TextField-color)', - '--OutlinedInput-border-color': 'var(--TextField-border-color)', - '--OutlinedInput-border-width': 'var(--TextField-border-width)', - '--OutlinedInput-radius': 'var(--TextField-radius)', + [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})`, }, }, ], @@ -89,13 +91,13 @@ const TextFieldRoot = styled(FormControl, { * - using the underlying components directly as shown in the demos */ const TextField = React.forwardRef(function TextField(inProps, ref) { - const props = useDefaultProps({ props: inProps, name: 'MuiTextField' }); + const props = useDefaultProps({ props: inProps, name: "MuiTextField" }); const { autoComplete, autoFocus = false, children, className, - color = 'primary', + color = "primary", defaultValue, disabled = false, error = false, @@ -119,7 +121,7 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { slotProps = {}, type, value, - variant = 'outlined', + variant = "outlined", ...other } = props; @@ -138,10 +140,10 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { const classes = useUtilityClasses(ownerState); - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV !== "production") { if (select && !children) { console.error( - 'MUI: `children` must be passed when using the `TextField` component with `select`.', + "MUI: `children` must be passed when using the `TextField` component with `select`." ); } } @@ -156,7 +158,7 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { slotProps, }; - const [SelectSlot, selectProps] = useSlot('select', { + const [SelectSlot, selectProps] = useSlot("select", { elementType: Select, externalForwardedProps, ownerState, @@ -167,8 +169,8 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { const inputAdditionalProps = {}; const inputLabelSlotProps = externalForwardedProps.slotProps.inputLabel; - if (variant === 'outlined') { - if (inputLabelSlotProps && typeof inputLabelSlotProps.shrink !== 'undefined') { + if (variant === "outlined") { + if (inputLabelSlotProps && typeof inputLabelSlotProps.shrink !== "undefined") { inputAdditionalProps.notched = inputLabelSlotProps.shrink; } inputAdditionalProps.label = label; @@ -178,10 +180,10 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { if (!nativeSelect) { inputAdditionalProps.id = undefined; } - inputAdditionalProps['aria-describedby'] = undefined; + inputAdditionalProps["aria-describedby"] = undefined; } - const [RootSlot, rootProps] = useSlot('root', { + const [RootSlot, rootProps] = useSlot("root", { elementType: TextFieldRoot, shouldForwardComponentProp: true, externalForwardedProps: { @@ -201,26 +203,26 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { }, }); - const [InputSlot, inputProps] = useSlot('input', { + const [InputSlot, inputProps] = useSlot("input", { elementType: InputComponent, externalForwardedProps, additionalProps: inputAdditionalProps, ownerState, }); - const [InputLabelSlot, inputLabelProps] = useSlot('inputLabel', { + const [InputLabelSlot, inputLabelProps] = useSlot("inputLabel", { elementType: InputLabel, externalForwardedProps, ownerState, }); - const [HtmlInputSlot, htmlInputProps] = useSlot('htmlInput', { - elementType: 'input', + const [HtmlInputSlot, htmlInputProps] = useSlot("htmlInput", { + elementType: "input", externalForwardedProps, ownerState, }); - const [FormHelperTextSlot, formHelperTextProps] = useSlot('formHelperText', { + const [FormHelperTextSlot, formHelperTextProps] = useSlot("formHelperText", { elementType: FormHelperText, externalForwardedProps, ownerState, @@ -256,11 +258,11 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { return ( - {label != null && label !== '' && ( + {label != null && label !== "" && ( {label} @@ -326,7 +328,7 @@ TextField.propTypes /* remove-proptypes */ = { * @default 'primary' */ color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(['primary', 'secondary', 'error', 'info', 'success', 'warning']), + PropTypes.oneOf(["primary", "secondary", "error", "info", "success", "warning"]), PropTypes.string, ]), /** @@ -369,7 +371,7 @@ TextField.propTypes /* remove-proptypes */ = { * If `dense` or `normal`, will adjust vertical spacing of this and contained components. * @default 'none' */ - margin: PropTypes.oneOf(['dense', 'none', 'normal']), + margin: PropTypes.oneOf(["dense", "none", "normal"]), /** * Maximum number of rows to display when multiline option is set to true. */ @@ -426,7 +428,7 @@ TextField.propTypes /* remove-proptypes */ = { * @default 'medium' */ size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(['medium', 'small']), + PropTypes.oneOf(["medium", "small"]), PropTypes.string, ]), /** @@ -472,7 +474,7 @@ TextField.propTypes /* remove-proptypes */ = { * The variant to use. * @default 'outlined' */ - variant: PropTypes.oneOf(['filled', 'outlined', 'standard']), + variant: PropTypes.oneOf(["filled", "outlined", "standard"]), }; export default TextField; diff --git a/packages/mui-material/src/TextField/TextFieldVars.ts b/packages/mui-material/src/TextField/TextFieldVars.ts new file mode 100644 index 00000000000000..510f5901478325 --- /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; From 1b9fdc1fdc7bbb2a8dd939e4405f1d3fbe7ff2d3 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 25 May 2026 15:09:12 +0700 Subject: [PATCH 07/12] [docs] Reframe css-vars experiment around agnostic-var defection Lead with 'leaving Material' triptych (stock | vars-only | +raw CSS), Steel UI matrix across variants/palettes, disabled shown going grey as documented limitation. Demote everyday knobs, fence density as separate axis. --- docs/pages/experiments/css-vars-density.tsx | 332 +++++++++++++++----- 1 file changed, 262 insertions(+), 70 deletions(-) diff --git a/docs/pages/experiments/css-vars-density.tsx b/docs/pages/experiments/css-vars-density.tsx index 319527eab30ea5..fe1d2f59414edc 100644 --- a/docs/pages/experiments/css-vars-density.tsx +++ b/docs/pages/experiments/css-vars-density.tsx @@ -1,22 +1,118 @@ -'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 } from '@mui/material/styles'; +"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 } from "@mui/material/styles"; // Public CSS variables + density POC. -// See CONTEXT.md, docs/adr/0001-public-css-var-inward-dependency.md, +// See CONTEXT.md, docs/adr/0002-agnostic-public-css-vars.md, // docs/design/public-css-var-layering.md. const theme = createTheme({ cssVariables: true }); +// --- "Steel UI": an alternate design language built only from agnostic vars. --- +// Agnostic vars carry color / shape / elevation. They flatten across variants +// (overriding = opting out of the Material spec), so color is applied per button. +const steelVars = { + "--Button-bg": "#3c6997", + "--Button-color": "#ffffff", + "--Button-radius": "8px", + "--Button-shadow": + "0 1px 1px rgba(15,32,56,0.10), 0 3px 6px rgba(15,32,56,0.14), 0 10px 24px rgba(15,32,56,0.22), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -1px 0 rgba(15,32,56,0.20)", + "--Button-padding-block": "5px", + "--Button-padding-inline": "14px", + "--Button-ring": "2px solid #2f5377", +} as const; + +// No var exists for typographic identity — the loudest Material tells. Plain CSS. +const steelType = { + textTransform: "none", + fontFamily: "ui-sans-serif, system-ui, sans-serif", + fontWeight: 500, + letterSpacing: "normal", +} as const; + +// Per-variant agnostic color, parameterised by palette (Steel | Neutral). +const palettes = { + Steel: { fill: "#3c6997", line: "#2f5377" }, + Neutral: { fill: "#6c757d", line: "#495057" }, +} as const; +type PaletteKey = keyof typeof palettes; + +const variantVars = (p: PaletteKey) => ({ + contained: { + "--Button-bg": palettes[p].fill, + "--Button-color": "#fff", + "--Button-shadow": "none", + }, + outlined: { "--Button-border-color": palettes[p].line, "--Button-color": palettes[p].line }, + text: { "--Button-color": palettes[p].line }, +}); + +function Caption({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function fmt(obj: Record) { + return Object.entries(obj) + .map( + ([k, v]) => ` ${k.startsWith("--") ? `'${k}'` : k}: ${typeof v === "string" ? `'${v}'` : v},` + ) + .join("\n"); +} + +// Renders the exact objects applied below — auditable, cannot drift from what renders. +function CodeBlock() { + return ( + + {`sx={{ + // agnostic vars — color / shape / elevation +${fmt(steelVars)} + // no var yet — raw CSS for type +${fmt(steelType)} +}}`} + + ); +} + +function Scope({ title, sx, children }: { title: string; sx?: object; children: React.ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + function Controls() { return ( - + @@ -27,7 +123,7 @@ function Controls() { - + @@ -35,14 +131,43 @@ function Controls() { ); } -function Scope({ title, sx, children }: { title: string; sx?: object; children: React.ReactNode }) { +// Matrix: one palette × {contained, outlined, text} × {enabled, disabled}. +function MatrixGroup({ palette }: { palette: PaletteKey }) { + const vv = variantVars(palette); + const row = (disabled: boolean) => ( + + + + + + ); return ( - - - {title} - - {children} - + + {palette} + {row(false)} + {row(true)} + + Disabled (2nd row) reverts to theme greys — agnostic color is resting-only (ADR-0002). + Matching it needs &.Mui-disabled CSS or a future state layer. + + ); } @@ -50,142 +175,209 @@ export default function App() { const [spacing, setSpacing] = React.useState(8); return ( - - Public CSS variables & density - - {/* --- Different Apps: drive --mui-spacing live at a class scope --- */} - - - {`App density — --mui-spacing: ${spacing}px`} + +
+ Agnostic CSS variables — leaving Material Design + + One agnostic var per property (no variant/size/color in the name). Overriding opts that + property out of the spec — so the same Button can host a different design language. - setSpacing(value as number)} - min={4} - max={12} - step={1} - marks - valueLabelDisplay="auto" - valueLabelFormat={(value) => `${value}px`} - /> - - - +
+ + {/* === HERO: static triptych — what we escaped, what vars carry, what raw CSS finishes === */} + + +
+ + + Stock Material. Uppercase, Roboto, MD blue, 4px radius — what MUI ships. + +
+
+ + + Agnostic vars only. Steel-blue, 8px, custom shadow + focus ring (tab to see) + — but still UPPERCASE Roboto and still ripples. Vars carry color/shape/elevation; + type & behaviour are the gap. + +
+
+ + + + raw-CSS type & disableRipple. No var for type or ripple → plain CSS + a + prop. No longer Material. + +
+
+ + +
- {/* --- Different Viewports: override --mui-spacing inside a media query --- */} - - + {/* === Matrix: Steel UI as a coherent system across variants + palettes === */} + + + + + + + Everyday knobs — the same vars for small tweaks + + {/* --- Fine-grained per-component knobs --- */} - - - {/* --- Responsive typography: font-size 1rem on mobile, 0.875rem on desktop --- */} + {/* --- Responsive typography --- */} - {/* --- Color knobs (agnostic, resting-only) --- */} + {/* --- Color knobs --- */} - - - - - - {/* --- TextField knobs (outlined; resting-only, focus width keeps emphasis) --- */} + {/* --- TextField knobs (partial escape) --- */} + + Partial escape: color/border/radius leave the spec, but the notched outline + floating + label are structural — still Material. Full structural escape needs future vars. + + + + + Density — a different axis (one --mui-spacing dial) + + + {/* --- Different Apps: drive --mui-spacing live --- */} + + + {`App density — --mui-spacing: ${spacing}px`} + + setSpacing(value as number)} + min={4} + max={12} + step={1} + marks + valueLabelDisplay="auto" + valueLabelFormat={(value) => `${value}px`} + /> + + + + + + {/* --- Different Viewports --- */} + +
From f96d0523878139d05be672c3825da64f8ea57951 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 25 May 2026 15:30:25 +0700 Subject: [PATCH 08/12] [material-ui] POC honor agnostic vars in interactive states + fix quote style Fix bug: hover/active/focus boxShadow ignored --Button-shadow; OutlinedInput hover/focus borderColor ignored --OutlinedInput-border-color. State rules now route through the var (spec value as fallback); disabled/error/disableElevation stay literal. Reformat files to repo single-quote style. Update ADR-0002/CONTEXT. --- CONTEXT.md | 8 +- .../0001-public-css-var-inward-dependency.md | 2 +- docs/adr/0002-agnostic-public-css-vars.md | 4 +- docs/design/public-css-var-layering.md | 16 +- docs/pages/experiments/css-vars-density.tsx | 5 +- packages/mui-material/src/Button/Button.js | 8 +- .../mui-material/src/Button/ButtonVars.ts | 20 +- .../mui-material/src/InputBase/InputBase.js | 256 +++++++++--------- .../src/InputBase/InputBaseVars.ts | 12 +- .../src/OutlinedInput/NotchedOutline.js | 70 ++--- .../src/OutlinedInput/OutlinedInput.js | 134 ++++----- .../src/OutlinedInput/OutlinedInputVars.ts | 16 +- .../mui-material/src/TextField/TextField.js | 90 +++--- .../src/TextField/TextFieldVars.ts | 12 +- 14 files changed, 328 insertions(+), 325 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f60726cf076828..cd780877b789cc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -25,9 +25,9 @@ A component's CSS-var fallback chain may reference only its own var and the vars - 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). Resting-only otherwise (focus/hover/error border-color still reassign beneath). +- **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 hover/focus also route through `var(--OutlinedInput-border-color, )`, so a custom border color persists through hover/focus; `error`/`disabled` borders stay literal (affordances). - **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). +- **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 @@ -36,8 +36,8 @@ A component's CSS-var fallback chain may reference only its own var and the vars - **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. -- **Color is resting-only (state caveat).** Hover/active rules reassign `--variant-*`, which sits *below* a user-set `--Button-bg` in the chain, so a custom `--Button-bg` does **not** darken on hover/active. By design: if you customize color, you own the states. The global remap path is unchanged — override `--mui-palette-*` to recolor with states intact. +- **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. Net: a custom value persists through hover/active/focus (spec delta is the fallback); customizing forgoes the spec's per-state delta (remap `--mui-palette-*` to keep it). **Exceptions (stay literal, component-controlled):** `disabled`, `error`, `disableElevation`. - **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. diff --git a/docs/adr/0001-public-css-var-inward-dependency.md b/docs/adr/0001-public-css-var-inward-dependency.md index 01ec74870ee25f..867cbbe96e929e 100644 --- a/docs/adr/0001-public-css-var-inward-dependency.md +++ b/docs/adr/0001-public-css-var-inward-dependency.md @@ -11,6 +11,6 @@ The rejected alternative was a single nested-fallback chain at the consumer — ## 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. +- 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 index 0b1fd40da24aa4..3b95b2d5f91482 100644 --- a/docs/adr/0002-agnostic-public-css-vars.md +++ b/docs/adr/0002-agnostic-public-css-vars.md @@ -1,6 +1,6 @@ # 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. +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 @@ -9,5 +9,5 @@ The intent is to keep the public surface small and **unopinionated about design ## 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. -- **Resting-only** — state styling (hover/active/focus) is reassigned on the private `--variant-*` layer beneath the public var, so a custom color/shadow does not animate on hover. If you customize, you own the states. The spec-preserving global path is unchanged: remap `--mui-palette-*` / `--mui-spacing` to restyle *with* states intact. +- **The var is honored in every state.** A property that has an agnostic var must consume it at _every_ assignment, including interactive-state rules, written as `var(--X, )`. So a custom value persists through hover/active/focus (the spec's per-state delta is only the fallback, used when the var is unset). A direct literal in a state rule — e.g. `boxShadow: shadows[4]` on hover — is a **bug**: it clobbers the var. Component-controlled affordances (`disabled`, `error`, `disableElevation`) intentionally stay literal so they always read as that state. Consequence: a customized property won't show the spec's hover/active delta (a custom `--Button-bg` won't darken on hover); to restyle _with_ the spec's state deltas, remap `--mui-palette-*` / `--mui-spacing` instead. - 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 index ca5ad38c31454a..dd3e5c52cd1ff0 100644 --- a/docs/design/public-css-var-layering.md +++ b/docs/design/public-css-var-layering.md @@ -115,14 +115,14 @@ Pseudo-CSS for the three layers (real selectors elided; `${spacing(n)}` renders ### 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 | +| 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. diff --git a/docs/pages/experiments/css-vars-density.tsx b/docs/pages/experiments/css-vars-density.tsx index fe1d2f59414edc..2f1bd6d4505895 100644 --- a/docs/pages/experiments/css-vars-density.tsx +++ b/docs/pages/experiments/css-vars-density.tsx @@ -19,9 +19,8 @@ const theme = createTheme({ cssVariables: true }); const steelVars = { "--Button-bg": "#3c6997", "--Button-color": "#ffffff", - "--Button-radius": "8px", - "--Button-shadow": - "0 1px 1px rgba(15,32,56,0.10), 0 3px 6px rgba(15,32,56,0.14), 0 10px 24px rgba(15,32,56,0.22), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -1px 0 rgba(15,32,56,0.20)", + "--Button-radius": "12px", + "--Button-shadow": "inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -5px 8px rgba(15,32,56,0.40)", "--Button-padding-block": "5px", "--Button-padding-inline": "14px", "--Button-ring": "2px solid #2f5377", diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index b8d174a423226e..1ba412c5cbd0cc 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -134,17 +134,17 @@ const ButtonRoot = styled(ButtonBase, { 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, diff --git a/packages/mui-material/src/Button/ButtonVars.ts b/packages/mui-material/src/Button/ButtonVars.ts index 500d927287ef57..20370fba57b7a3 100644 --- a/packages/mui-material/src/Button/ButtonVars.ts +++ b/packages/mui-material/src/Button/ButtonVars.ts @@ -5,16 +5,16 @@ * 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", + 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 1ff88f5742817f..96e45da27008f8 100644 --- a/packages/mui-material/src/InputBase/InputBase.js +++ b/packages/mui-material/src/InputBase/InputBase.js @@ -1,28 +1,28 @@ -"use client"; -import * as React from "react"; -import PropTypes from "prop-types"; -import clsx from "clsx"; -import elementTypeAcceptingRef from "@mui/utils/elementTypeAcceptingRef"; -import refType from "@mui/utils/refType"; -import composeClasses from "@mui/utils/composeClasses"; -import isHostComponent from "@mui/utils/isHostComponent"; -import TextareaAutosize from "../TextareaAutosize"; -import FormControlContext from "../FormControl/FormControlContext"; -import { useFormControlState } from "../FormControl/useFormControl"; -import { styled, globalCss } from "../zero-styled"; -import memoTheme from "../utils/memoTheme"; -import { useDefaultProps } from "../DefaultPropsProvider"; -import capitalize from "../utils/capitalize"; -import useForkRef from "../utils/useForkRef"; -import useEnhancedEffect from "../utils/useEnhancedEffect"; -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"; +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import elementTypeAcceptingRef from '@mui/utils/elementTypeAcceptingRef'; +import refType from '@mui/utils/refType'; +import composeClasses from '@mui/utils/composeClasses'; +import isHostComponent from '@mui/utils/isHostComponent'; +import TextareaAutosize from '../TextareaAutosize'; +import FormControlContext from '../FormControl/FormControlContext'; +import { useFormControlState } from '../FormControl/useFormControl'; +import { styled, globalCss } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import capitalize from '../utils/capitalize'; +import useForkRef from '../utils/useForkRef'; +import useEnhancedEffect from '../utils/useEnhancedEffect'; +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'; export const rootOverridesResolver = (props, styles) => { const { ownerState } = props; @@ -33,7 +33,7 @@ export const rootOverridesResolver = (props, styles) => { ownerState.startAdornment && styles.adornedStart, ownerState.endAdornment && styles.adornedEnd, ownerState.error && styles.error, - ownerState.size === "small" && styles.sizeSmall, + ownerState.size === 'small' && styles.sizeSmall, ownerState.multiline && styles.multiline, ownerState.color && styles[`color${capitalize(ownerState.color)}`], ownerState.fullWidth && styles.fullWidth, @@ -44,7 +44,7 @@ export const rootOverridesResolver = (props, styles) => { export const inputOverridesResolver = (props, styles) => { const { ownerState } = props; - return [styles.input, ownerState.type === "search" && styles.inputTypeSearch]; + return [styles.input, ownerState.type === 'search' && styles.inputTypeSearch]; }; const useUtilityClasses = (ownerState) => { @@ -66,34 +66,34 @@ const useUtilityClasses = (ownerState) => { } = ownerState; const slots = { root: [ - "root", + 'root', `color${capitalize(color)}`, - disabled && "disabled", - error && "error", - fullWidth && "fullWidth", - focused && "focused", - formControl && "formControl", - size && size !== "medium" && `size${capitalize(size)}`, - multiline && "multiline", - startAdornment && "adornedStart", - endAdornment && "adornedEnd", - hiddenLabel && "hiddenLabel", - readOnly && "readOnly", + disabled && 'disabled', + error && 'error', + fullWidth && 'fullWidth', + focused && 'focused', + formControl && 'formControl', + size && size !== 'medium' && `size${capitalize(size)}`, + multiline && 'multiline', + startAdornment && 'adornedStart', + endAdornment && 'adornedEnd', + hiddenLabel && 'hiddenLabel', + readOnly && 'readOnly', ], input: [ - "input", - disabled && "disabled", - type === "search" && "inputTypeSearch", - readOnly && "readOnly", + 'input', + disabled && 'disabled', + type === 'search' && 'inputTypeSearch', + readOnly && 'readOnly', ], }; return composeClasses(slots, getInputBaseUtilityClass, classes); }; -export const InputBaseRoot = styled("div", { - name: "MuiInputBase", - slot: "Root", +export const InputBaseRoot = styled('div', { + name: 'MuiInputBase', + slot: 'Root', overridesResolver: rootOverridesResolver, })( memoTheme(({ theme }) => ({ @@ -101,24 +101,24 @@ export const InputBaseRoot = styled("div", { 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", + 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, - cursor: "default", + cursor: 'default', }, variants: [ { props: ({ ownerState }) => ownerState.multiline, style: { - padding: "4px 0 5px", + padding: '4px 0 5px', }, }, { - props: ({ ownerState, size }) => ownerState.multiline && size === "small", + props: ({ ownerState, size }) => ownerState.multiline && size === 'small', style: { paddingTop: 1, }, @@ -126,22 +126,22 @@ export const InputBaseRoot = styled("div", { { props: ({ ownerState }) => ownerState.fullWidth, style: { - width: "100%", + width: '100%', }, }, ], - })) + })), ); -export const InputBaseInput = styled("input", { - name: "MuiInputBase", - slot: "Input", +export const InputBaseInput = styled('input', { + name: 'MuiInputBase', + slot: 'Input', overridesResolver: inputOverridesResolver, })( memoTheme(({ theme }) => { - const light = theme.palette.mode === "light"; + const light = theme.palette.mode === 'light'; const placeholder = { - color: "currentColor", + color: 'currentColor', ...(theme.vars ? { opacity: theme.vars.opacity.inputPlaceholder, @@ -149,12 +149,12 @@ export const InputBaseInput = styled("input", { : { opacity: light ? 0.42 : 0.5, }), - transition: theme.transitions.create("opacity", { + transition: theme.transitions.create('opacity', { duration: theme.transitions.duration.shorter, }), }; const placeholderHidden = { - opacity: "0 !important", + opacity: '0 !important', }; const placeholderVisible = theme.vars ? { @@ -165,42 +165,42 @@ export const InputBaseInput = styled("input", { }; return { - font: "inherit", - letterSpacing: "inherit", - color: "currentColor", - padding: "4px 0 5px", + font: 'inherit', + letterSpacing: 'inherit', + color: 'currentColor', + padding: '4px 0 5px', border: 0, - boxSizing: "content-box", - background: "none", + boxSizing: 'content-box', + background: 'none', 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", + WebkitTapHighlightColor: 'transparent', + display: 'block', // Make the flex item shrink with Firefox minWidth: 0, - width: "100%", - "&::-webkit-input-placeholder": placeholder, - "&::-moz-placeholder": placeholder, // Firefox 19+ - "&::-ms-input-placeholder": placeholder, // Edge - "&:focus": { + width: '100%', + '&::-webkit-input-placeholder': placeholder, + '&::-moz-placeholder': placeholder, // Firefox 19+ + '&::-ms-input-placeholder': placeholder, // Edge + '&:focus': { outline: 0, }, // Reset Firefox invalid required input style - "&:invalid": { - boxShadow: "none", + '&:invalid': { + boxShadow: 'none', }, - "&::-webkit-search-decoration": { + '&::-webkit-search-decoration': { // Remove the padding when type=search. - WebkitAppearance: "none", + WebkitAppearance: 'none', }, // Show and hide the placeholder logic [`label[data-shrink=false] + .${inputBaseClasses.formControl} &`]: { - "&::-webkit-input-placeholder": placeholderHidden, - "&::-moz-placeholder": placeholderHidden, // Firefox 19+ - "&::-ms-input-placeholder": placeholderHidden, // Edge - "&:focus::-webkit-input-placeholder": placeholderVisible, - "&:focus::-moz-placeholder": placeholderVisible, // Firefox 19+ - "&:focus::-ms-input-placeholder": placeholderVisible, // Edge + '&::-webkit-input-placeholder': placeholderHidden, + '&::-moz-placeholder': placeholderHidden, // Firefox 19+ + '&::-ms-input-placeholder': placeholderHidden, // Edge + '&:focus::-webkit-input-placeholder': placeholderVisible, + '&:focus::-moz-placeholder': placeholderVisible, // Firefox 19+ + '&:focus::-ms-input-placeholder': placeholderVisible, // Edge }, [`&.${inputBaseClasses.disabled}`]: { opacity: 1, // Reset iOS opacity @@ -211,16 +211,16 @@ export const InputBaseInput = styled("input", { props: ({ ownerState }) => !ownerState.disableInjectingGlobalStyles, style: { animationName: MUI_AUTO_FILL_CANCEL, - animationDuration: "10ms", - "&:-webkit-autofill": { - animationDuration: "5000s", + animationDuration: '10ms', + '&:-webkit-autofill': { + animationDuration: '5000s', animationName: MUI_AUTO_FILL, }, }, }, { props: { - size: "small", + size: 'small', }, style: { paddingTop: 1, @@ -229,23 +229,23 @@ export const InputBaseInput = styled("input", { { props: ({ ownerState }) => ownerState.multiline, style: { - height: "auto", - resize: "none", + height: 'auto', + resize: 'none', padding: 0, paddingTop: 0, }, }, { props: { - type: "search", + type: 'search', }, style: { - MozAppearance: "textfield", // Improve type search style. + MozAppearance: 'textfield', // Improve type search style. }, }, ], }; - }) + }), ); const InputGlobalStyles = globalCss({ @@ -261,10 +261,10 @@ const InputGlobalStyles = globalCss({ * It contains a load of style reset and some state logic. */ const InputBase = React.forwardRef(function InputBase(inProps, ref) { - const props = useDefaultProps({ props: inProps, name: "MuiInputBase" }); + const props = useDefaultProps({ props: inProps, name: 'MuiInputBase' }); const { - "aria-describedby": ariaDescribedby, - "aria-label": ariaLabel, + 'aria-describedby': ariaDescribedby, + 'aria-label': ariaLabel, autoComplete, autoFocus, className, @@ -276,7 +276,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { error, fullWidth = false, id, - inputComponent = "input", + inputComponent = 'input', inputProps: inputPropsProp = {}, inputRef: inputRefProp, margin, @@ -298,7 +298,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { slotProps = {}, slots = {}, startAdornment, - type = "text", + type = 'text', value: valueProp, ...other } = props; @@ -308,14 +308,14 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const inputRef = React.useRef(); const handleInputRefWarning = React.useCallback((instance) => { - if (process.env.NODE_ENV !== "production") { - if (instance && instance.nodeName !== "INPUT" && !instance.focus) { + if (process.env.NODE_ENV !== 'production') { + if (instance && instance.nodeName !== 'INPUT' && !instance.focus) { console.error( [ - "MUI: You have provided a `inputComponent` to the input component", - "that does not correctly handle the `ref` prop.", - "Make sure the `ref` prop is called with a HTMLInputElement.", - ].join("\n") + 'MUI: You have provided a `inputComponent` to the input component', + 'that does not correctly handle the `ref` prop.', + 'Make sure the `ref` prop is called with a HTMLInputElement.', + ].join('\n'), ); } } @@ -325,16 +325,16 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { inputRef, inputRefProp, inputPropsProp.ref, - handleInputRefWarning + handleInputRefWarning, ); const [focused, setFocused] = React.useState(false); const [fcs, muiFormControl] = useFormControlState({ props, - states: ["color", "disabled", "error", "hiddenLabel", "size", "required", "filled"], + states: ['color', 'disabled', 'error', 'hiddenLabel', 'size', 'required', 'filled'], }); - if (process.env.NODE_ENV !== "production") { + if (process.env.NODE_ENV !== 'production') { // TODO: uncomment once we enable eslint-plugin-react-compiler // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/rules-of-hooks React.useEffect(() => { @@ -372,7 +372,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { onEmpty(); } }, - [onFilled, onEmpty] + [onFilled, onEmpty], ); useEnhancedEffect(() => { @@ -446,9 +446,9 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const element = event.target || inputRef.current; if (element == null) { throw /* minify-error */ new Error( - "MUI: Expected valid input target. " + - "Did you use a custom `inputComponent` and forget to forward refs? " + - "See https://mui.com/r/input-component-ref-interface for more info." + 'MUI: Expected valid input target. ' + + 'Did you use a custom `inputComponent` and forget to forward refs? ' + + 'See https://mui.com/r/input-component-ref-interface for more info.', ); } @@ -487,12 +487,12 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { let InputComponent = inputComponent; let inputProps = inputPropsProp; - if (multiline && InputComponent === "input") { + if (multiline && InputComponent === 'input') { if (rows) { - if (process.env.NODE_ENV !== "production") { + if (process.env.NODE_ENV !== 'production') { if (minRows || maxRows) { console.warn( - "MUI: You can not use the `minRows` or `maxRows` props when the input `rows` prop is set." + 'MUI: You can not use the `minRows` or `maxRows` props when the input `rows` prop is set.', ); } } @@ -516,7 +516,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const handleAutoFill = (event) => { // Provide a fake value as Chrome might not let you access it for security reasons. - checkDirty(event.animationName === MUI_AUTO_FILL_CANCEL ? inputRef.current : { value: "x" }); + checkDirty(event.animationName === MUI_AUTO_FILL_CANCEL ? inputRef.current : { value: 'x' }); }; React.useEffect(() => { @@ -527,7 +527,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { const ownerState = { ...props, - color: fcs.color || "primary", + color: fcs.color || 'primary', disabled: fcs.disabled, endAdornment, error: fcs.error, @@ -551,7 +551,7 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { return ( - {!disableInjectingGlobalStyles && typeof InputGlobalStyles === "function" && ( + {!disableInjectingGlobalStyles && typeof InputGlobalStyles === 'function' && ( // For Emotion/Styled-components, InputGlobalStyles will be a function // For Pigment CSS, this has no effect because the InputGlobalStyles will be null. @@ -569,10 +569,10 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { classes.root, { // TODO v6: remove this class as it duplicates with the global state class Mui-readOnly - "MuiInputBase-readOnly": readOnly, + 'MuiInputBase-readOnly': readOnly, }, rootProps.className, - className + className, )} > {startAdornment} @@ -606,9 +606,9 @@ const InputBase = React.forwardRef(function InputBase(inProps, ref) { classes.input, { // TODO v6: remove this class as it duplicates with the global state class Mui-readOnly - "MuiInputBase-readOnly": readOnly, + 'MuiInputBase-readOnly': readOnly, }, - inputProps.className + inputProps.className, )} onBlur={handleBlur} onChange={handleChange} @@ -635,11 +635,11 @@ InputBase.propTypes /* remove-proptypes */ = { /** * @ignore */ - "aria-describedby": PropTypes.string, + 'aria-describedby': PropTypes.string, /** * @ignore */ - "aria-label": PropTypes.string, + 'aria-label': PropTypes.string, /** * This prop helps users to fill forms faster, especially on mobile devices. * The name can be confusing, as it's more like an autofill. @@ -665,7 +665,7 @@ InputBase.propTypes /* remove-proptypes */ = { * The prop defaults to the value (`'primary'`) inherited from the parent FormControl component. */ color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(["primary", "secondary", "error", "info", "success", "warning"]), + PropTypes.oneOf(['primary', 'secondary', 'error', 'info', 'success', 'warning']), PropTypes.string, ]), /** @@ -721,7 +721,7 @@ InputBase.propTypes /* remove-proptypes */ = { * FormControl. * The prop defaults to the value (`'none'`) inherited from the parent FormControl component. */ - margin: PropTypes.oneOf(["dense", "none"]), + margin: PropTypes.oneOf(['dense', 'none']), /** * Maximum number of rows to display when multiline option is set to true. */ @@ -798,7 +798,7 @@ InputBase.propTypes /* remove-proptypes */ = { * The size of the component. */ size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(["medium", "small"]), + PropTypes.oneOf(['medium', 'small']), PropTypes.string, ]), /** diff --git a/packages/mui-material/src/InputBase/InputBaseVars.ts b/packages/mui-material/src/InputBase/InputBaseVars.ts index b2d25ece38098c..2f5a8a4c9381a1 100644 --- a/packages/mui-material/src/InputBase/InputBaseVars.ts +++ b/packages/mui-material/src/InputBase/InputBaseVars.ts @@ -4,12 +4,12 @@ * 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", + 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/OutlinedInput/NotchedOutline.js b/packages/mui-material/src/OutlinedInput/NotchedOutline.js index 6659d73b27be47..41d3aa8f0b7b05 100644 --- a/packages/mui-material/src/OutlinedInput/NotchedOutline.js +++ b/packages/mui-material/src/OutlinedInput/NotchedOutline.js @@ -1,45 +1,45 @@ -"use client"; -import PropTypes from "prop-types"; -import rootShouldForwardProp from "../styles/rootShouldForwardProp"; -import { styled } from "../zero-styled"; -import memoTheme from "../utils/memoTheme"; -import outlinedInputVars from "./OutlinedInputVars"; +'use client'; +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", +const NotchedOutlineRoot = styled('fieldset', { + name: 'MuiNotchedOutlined', shouldForwardProp: rootShouldForwardProp, })({ - textAlign: "left", - position: "absolute", + textAlign: 'left', + position: 'absolute', bottom: 0, right: 0, top: -5, left: 0, margin: 0, - padding: "0 8px", - pointerEvents: "none", - borderRadius: "inherit", - borderStyle: "solid", + padding: '0 8px', + pointerEvents: 'none', + borderRadius: 'inherit', + borderStyle: 'solid', borderWidth: `var(${outlinedInputVars.borderWidth},1px)`, - overflow: "hidden", - minWidth: "0%", + overflow: 'hidden', + minWidth: '0%', }); -const NotchedOutlineLegend = styled("legend", { - name: "MuiNotchedOutlined", +const NotchedOutlineLegend = styled('legend', { + name: 'MuiNotchedOutlined', shouldForwardProp: rootShouldForwardProp, })( memoTheme(({ theme }) => ({ - float: "unset", // Fix conflict with bootstrap - width: "auto", // Fix conflict with bootstrap - overflow: "hidden", // Fix Horizontal scroll when label too long + float: 'unset', // Fix conflict with bootstrap + width: 'auto', // Fix conflict with bootstrap + overflow: 'hidden', // Fix Horizontal scroll when label too long variants: [ { props: ({ ownerState }) => !ownerState.withLabel, style: { padding: 0, - lineHeight: "11px", // sync with `height` in `legend` styles - transition: theme.transitions.create("width", { + lineHeight: '11px', // sync with `height` in `legend` styles + transition: theme.transitions.create('width', { duration: 150, easing: theme.transitions.easing.easeOut, }), @@ -48,31 +48,31 @@ const NotchedOutlineLegend = styled("legend", { { props: ({ ownerState }) => ownerState.withLabel, style: { - display: "block", // Fix conflict with normalize.css and sanitize.css + display: 'block', // Fix conflict with normalize.css and sanitize.css padding: 0, height: 11, // sync with `lineHeight` in `legend` styles - fontSize: "0.75em", - visibility: "hidden", + fontSize: '0.75em', + visibility: 'hidden', maxWidth: 0.01, - transition: theme.transitions.create("max-width", { + transition: theme.transitions.create('max-width', { duration: 50, easing: theme.transitions.easing.easeOut, }), - whiteSpace: "nowrap", - "& > span": { + whiteSpace: 'nowrap', + '& > span': { paddingLeft: 5, paddingRight: 5, - display: "inline-block", + display: 'inline-block', opacity: 0, - visibility: "visible", + visibility: 'visible', }, }, }, { props: ({ ownerState }) => ownerState.withLabel && ownerState.notched, style: { - maxWidth: "100%", - transition: theme.transitions.create("max-width", { + maxWidth: '100%', + transition: theme.transitions.create('max-width', { duration: 100, easing: theme.transitions.easing.easeOut, delay: 50, @@ -80,7 +80,7 @@ const NotchedOutlineLegend = styled("legend", { }, }, ], - })) + })), ); /** @@ -88,7 +88,7 @@ const NotchedOutlineLegend = styled("legend", { */ export default function NotchedOutline(props) { const { children, classes, className, label, notched, ...other } = props; - const withLabel = label != null && label !== ""; + const withLabel = label != null && label !== ''; const ownerState = { ...props, notched, diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index 3fa2dc61264bed..01a433acf691cf 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -1,33 +1,33 @@ -"use client"; -import * as React from "react"; -import PropTypes from "prop-types"; -import refType from "@mui/utils/refType"; -import composeClasses from "@mui/utils/composeClasses"; -import NotchedOutline from "./NotchedOutline"; -import { useFormControlState } from "../FormControl/useFormControl"; -import rootShouldForwardProp from "../styles/rootShouldForwardProp"; -import { styled } from "../zero-styled"; -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"; +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import refType from '@mui/utils/refType'; +import composeClasses from '@mui/utils/composeClasses'; +import NotchedOutline from './NotchedOutline'; +import { useFormControlState } from '../FormControl/useFormControl'; +import rootShouldForwardProp from '../styles/rootShouldForwardProp'; +import { styled } from '../zero-styled'; +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, InputBaseRoot, InputBaseInput, -} from "../InputBase/InputBase"; -import useSlot from "../utils/useSlot"; +} from '../InputBase/InputBase'; +import useSlot from '../utils/useSlot'; const useUtilityClasses = (ownerState) => { const { classes } = ownerState; const slots = { - root: ["root"], - notchedOutline: ["notchedOutline"], - input: ["input"], + root: ['root'], + notchedOutline: ['notchedOutline'], + input: ['input'], }; const composedClasses = composeClasses(slots, getOutlinedInputUtilityClass, classes); @@ -51,33 +51,35 @@ const derivedInputPadding = (theme, heightFallback) => },${theme.spacing(1.75)}))`; const OutlinedInputRoot = styled(InputBaseRoot, { - shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === "classes", - name: "MuiOutlinedInput", - slot: "Root", + shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === 'classes', + name: 'MuiOutlinedInput', + slot: 'Root', overridesResolver: inputBaseRootOverridesResolver, })( memoTheme(({ theme }) => { const borderColor = - theme.palette.mode === "light" ? "rgba(0, 0, 0, 0.23)" : "rgba(255, 255, 255, 0.23)"; + theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)'; return { - position: "relative", + position: 'relative', // 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" + 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)": { + '@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}`]: { @@ -90,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 + })`, }, }, })), @@ -120,61 +124,61 @@ const OutlinedInputRoot = styled(InputBaseRoot, { { props: ({ ownerState }) => ownerState.multiline, style: { - padding: "16.5px 14px", + padding: '16.5px 14px', }, }, { - props: ({ ownerState, size }) => ownerState.multiline && size === "small", + props: ({ ownerState, size }) => ownerState.multiline && size === 'small', style: { - padding: "8.5px 14px", + padding: '8.5px 14px', }, }, ], }; - }) + }), ); const NotchedOutlineRoot = styled(NotchedOutline, { - name: "MuiOutlinedInput", - slot: "NotchedOutline", + name: 'MuiOutlinedInput', + slot: 'NotchedOutline', })( memoTheme(({ theme }) => { const borderColor = - theme.palette.mode === "light" ? "rgba(0, 0, 0, 0.23)" : "rgba(255, 255, 255, 0.23)"; + theme.palette.mode === 'light' ? 'rgba(0, 0, 0, 0.23)' : 'rgba(255, 255, 255, 0.23)'; return { borderColor: `var(${outlinedInputVars.borderColor},${ theme.vars ? theme.alpha(theme.vars.palette.common.onBackground, 0.23) : borderColor })`, }; - }) + }), ); const OutlinedInputInput = styled(InputBaseInput, { - name: "MuiOutlinedInput", - slot: "Input", + name: 'MuiOutlinedInput', + slot: 'Input', overridesResolver: inputBaseInputOverridesResolver, })( memoTheme(({ theme }) => ({ // medium target height defaults to spacing(7) = 56px padding: derivedInputPadding(theme, theme.spacing(7)), - "&:-webkit-autofill": { + '&:-webkit-autofill': { ...(!theme.vars && { - WebkitBoxShadow: theme.palette.mode === "light" ? null : "0 0 0 100px #266798 inset", - WebkitTextFillColor: theme.palette.mode === "light" ? null : "#fff", - caretColor: theme.palette.mode === "light" ? null : "#fff", + WebkitBoxShadow: theme.palette.mode === 'light' ? null : '0 0 0 100px #266798 inset', + WebkitTextFillColor: theme.palette.mode === 'light' ? null : '#fff', + caretColor: theme.palette.mode === 'light' ? null : '#fff', }), - borderRadius: "inherit", + borderRadius: 'inherit', ...(theme.vars && - theme.applyStyles("dark", { - WebkitBoxShadow: "0 0 0 100px #266798 inset", - WebkitTextFillColor: "#fff", - caretColor: "#fff", + theme.applyStyles('dark', { + WebkitBoxShadow: '0 0 0 100px #266798 inset', + WebkitTextFillColor: '#fff', + caretColor: '#fff', })), }, variants: [ { props: { - size: "small", + size: 'small', }, style: { // small target height defaults to spacing(5) = 40px @@ -200,20 +204,20 @@ const OutlinedInputInput = styled(InputBaseInput, { }, }, ], - })) + })), ); const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { - const props = useDefaultProps({ props: inProps, name: "MuiOutlinedInput" }); + const props = useDefaultProps({ props: inProps, name: 'MuiOutlinedInput' }); const { fullWidth = false, - inputComponent = "input", + inputComponent = 'input', label, multiline = false, notched, slots = {}, slotProps = {}, - type = "text", + type = 'text', ...other } = props; @@ -221,12 +225,12 @@ const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { const [fcs, muiFormControl] = useFormControlState({ props, - states: ["color", "disabled", "error", "focused", "hiddenLabel", "size", "required"], + states: ['color', 'disabled', 'error', 'focused', 'hiddenLabel', 'size', 'required'], }); const ownerState = { ...props, - color: fcs.color || "primary", + color: fcs.color || 'primary', disabled: fcs.disabled, error: fcs.error, focused: fcs.focused, @@ -241,7 +245,7 @@ const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { const RootSlot = slots.root ?? OutlinedInputRoot; const InputSlot = slots.input ?? OutlinedInputInput; - const [NotchedSlot, notchedProps] = useSlot("notchedOutline", { + const [NotchedSlot, notchedProps] = useSlot('notchedOutline', { elementType: NotchedOutlineRoot, className: classes.notchedOutline, shouldForwardComponentProp: true, @@ -252,10 +256,10 @@ const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { }, additionalProps: { label: - label != null && label !== "" && fcs.required ? ( + label != null && label !== '' && fcs.required ? ( {label} -  {"*"} +  {'*'} ) : ( label @@ -271,7 +275,7 @@ const OutlinedInput = React.forwardRef(function OutlinedInput(inProps, ref) { { const { classes } = ownerState; const slots = { - root: ["root"], + root: ['root'], }; return composeClasses(slots, getTextFieldUtilityClass, classes); }; const TextFieldRoot = styled(FormControl, { - name: "MuiTextField", - slot: "Root", + 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" }, + props: { variant: 'outlined' }, style: { [outlinedInputVars.height]: `var(${textFieldVars.height})`, [outlinedInputVars.fontSize]: `var(${textFieldVars.fontSize})`, @@ -91,13 +91,13 @@ const TextFieldRoot = styled(FormControl, { * - using the underlying components directly as shown in the demos */ const TextField = React.forwardRef(function TextField(inProps, ref) { - const props = useDefaultProps({ props: inProps, name: "MuiTextField" }); + const props = useDefaultProps({ props: inProps, name: 'MuiTextField' }); const { autoComplete, autoFocus = false, children, className, - color = "primary", + color = 'primary', defaultValue, disabled = false, error = false, @@ -121,7 +121,7 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { slotProps = {}, type, value, - variant = "outlined", + variant = 'outlined', ...other } = props; @@ -140,10 +140,10 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { const classes = useUtilityClasses(ownerState); - if (process.env.NODE_ENV !== "production") { + if (process.env.NODE_ENV !== 'production') { if (select && !children) { console.error( - "MUI: `children` must be passed when using the `TextField` component with `select`." + 'MUI: `children` must be passed when using the `TextField` component with `select`.', ); } } @@ -158,7 +158,7 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { slotProps, }; - const [SelectSlot, selectProps] = useSlot("select", { + const [SelectSlot, selectProps] = useSlot('select', { elementType: Select, externalForwardedProps, ownerState, @@ -169,8 +169,8 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { const inputAdditionalProps = {}; const inputLabelSlotProps = externalForwardedProps.slotProps.inputLabel; - if (variant === "outlined") { - if (inputLabelSlotProps && typeof inputLabelSlotProps.shrink !== "undefined") { + if (variant === 'outlined') { + if (inputLabelSlotProps && typeof inputLabelSlotProps.shrink !== 'undefined') { inputAdditionalProps.notched = inputLabelSlotProps.shrink; } inputAdditionalProps.label = label; @@ -180,10 +180,10 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { if (!nativeSelect) { inputAdditionalProps.id = undefined; } - inputAdditionalProps["aria-describedby"] = undefined; + inputAdditionalProps['aria-describedby'] = undefined; } - const [RootSlot, rootProps] = useSlot("root", { + const [RootSlot, rootProps] = useSlot('root', { elementType: TextFieldRoot, shouldForwardComponentProp: true, externalForwardedProps: { @@ -203,26 +203,26 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { }, }); - const [InputSlot, inputProps] = useSlot("input", { + const [InputSlot, inputProps] = useSlot('input', { elementType: InputComponent, externalForwardedProps, additionalProps: inputAdditionalProps, ownerState, }); - const [InputLabelSlot, inputLabelProps] = useSlot("inputLabel", { + const [InputLabelSlot, inputLabelProps] = useSlot('inputLabel', { elementType: InputLabel, externalForwardedProps, ownerState, }); - const [HtmlInputSlot, htmlInputProps] = useSlot("htmlInput", { - elementType: "input", + const [HtmlInputSlot, htmlInputProps] = useSlot('htmlInput', { + elementType: 'input', externalForwardedProps, ownerState, }); - const [FormHelperTextSlot, formHelperTextProps] = useSlot("formHelperText", { + const [FormHelperTextSlot, formHelperTextProps] = useSlot('formHelperText', { elementType: FormHelperText, externalForwardedProps, ownerState, @@ -258,11 +258,11 @@ const TextField = React.forwardRef(function TextField(inProps, ref) { return ( - {label != null && label !== "" && ( + {label != null && label !== '' && ( {label} @@ -328,7 +328,7 @@ TextField.propTypes /* remove-proptypes */ = { * @default 'primary' */ color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(["primary", "secondary", "error", "info", "success", "warning"]), + PropTypes.oneOf(['primary', 'secondary', 'error', 'info', 'success', 'warning']), PropTypes.string, ]), /** @@ -371,7 +371,7 @@ TextField.propTypes /* remove-proptypes */ = { * If `dense` or `normal`, will adjust vertical spacing of this and contained components. * @default 'none' */ - margin: PropTypes.oneOf(["dense", "none", "normal"]), + margin: PropTypes.oneOf(['dense', 'none', 'normal']), /** * Maximum number of rows to display when multiline option is set to true. */ @@ -428,7 +428,7 @@ TextField.propTypes /* remove-proptypes */ = { * @default 'medium' */ size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ - PropTypes.oneOf(["medium", "small"]), + PropTypes.oneOf(['medium', 'small']), PropTypes.string, ]), /** @@ -474,7 +474,7 @@ TextField.propTypes /* remove-proptypes */ = { * The variant to use. * @default 'outlined' */ - variant: PropTypes.oneOf(["filled", "outlined", "standard"]), + variant: PropTypes.oneOf(['filled', 'outlined', 'standard']), }; export default TextField; diff --git a/packages/mui-material/src/TextField/TextFieldVars.ts b/packages/mui-material/src/TextField/TextFieldVars.ts index 510f5901478325..da53b91a1bc583 100644 --- a/packages/mui-material/src/TextField/TextFieldVars.ts +++ b/packages/mui-material/src/TextField/TextFieldVars.ts @@ -5,12 +5,12 @@ * 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", + 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; From a06602d2cec232d2a27451fc1b49d4dcbd32a1c9 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Mon, 25 May 2026 15:54:51 +0700 Subject: [PATCH 09/12] [material-ui] POC route disabled/error/inherit/disableElevation through agnostic vars Apply the agnostic-var rule to ALL assignments with no exceptions: Button disabled color/bg/shadow, outlined disabled border, color=inherit color/border, disableElevation shadow; InputBase disabled color; OutlinedInput error/disabled notched-outline border. A custom var now wins in every state. Update ADR-0002/CONTEXT. --- CONTEXT.md | 4 +- docs/adr/0002-agnostic-public-css-vars.md | 2 +- docs/pages/experiments/css-vars-density.tsx | 162 ++++++++++-------- packages/mui-material/src/Button/Button.js | 30 ++-- .../mui-material/src/InputBase/InputBase.js | 2 +- .../src/OutlinedInput/OutlinedInput.js | 8 +- 6 files changed, 116 insertions(+), 92 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index cd780877b789cc..3c1818facc1fb8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -25,7 +25,7 @@ A component's CSS-var fallback chain may reference only its own var and the vars - 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 hover/focus also route through `var(--OutlinedInput-border-color, )`, so a custom border color persists through hover/focus; `error`/`disabled` borders stay literal (affordances). +- **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). @@ -37,7 +37,7 @@ A component's CSS-var fallback chain may reference only its own var and the vars - **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. Net: a custom value persists through hover/active/focus (spec delta is the fallback); customizing forgoes the spec's per-state delta (remap `--mui-palette-*` to keep it). **Exceptions (stay literal, component-controlled):** `disabled`, `error`, `disableElevation`. +- **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. - **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. diff --git a/docs/adr/0002-agnostic-public-css-vars.md b/docs/adr/0002-agnostic-public-css-vars.md index 3b95b2d5f91482..1f8b604cd7b9f8 100644 --- a/docs/adr/0002-agnostic-public-css-vars.md +++ b/docs/adr/0002-agnostic-public-css-vars.md @@ -9,5 +9,5 @@ The intent is to keep the public surface small and **unopinionated about design ## 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 state.** A property that has an agnostic var must consume it at _every_ assignment, including interactive-state rules, written as `var(--X, )`. So a custom value persists through hover/active/focus (the spec's per-state delta is only the fallback, used when the var is unset). A direct literal in a state rule — e.g. `boxShadow: shadows[4]` on hover — is a **bug**: it clobbers the var. Component-controlled affordances (`disabled`, `error`, `disableElevation`) intentionally stay literal so they always read as that state. Consequence: a customized property won't show the spec's hover/active delta (a custom `--Button-bg` won't darken on hover); to restyle _with_ the spec's state deltas, remap `--mui-palette-*` / `--mui-spacing` instead. +- **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/pages/experiments/css-vars-density.tsx b/docs/pages/experiments/css-vars-density.tsx index 2f1bd6d4505895..dd9461b021fb88 100644 --- a/docs/pages/experiments/css-vars-density.tsx +++ b/docs/pages/experiments/css-vars-density.tsx @@ -1,12 +1,12 @@ -"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 } from "@mui/material/styles"; +'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 } from '@mui/material/styles'; // Public CSS variables + density POC. // See CONTEXT.md, docs/adr/0002-agnostic-public-css-vars.md, @@ -17,38 +17,52 @@ const theme = createTheme({ cssVariables: true }); // Agnostic vars carry color / shape / elevation. They flatten across variants // (overriding = opting out of the Material spec), so color is applied per button. const steelVars = { - "--Button-bg": "#3c6997", - "--Button-color": "#ffffff", - "--Button-radius": "12px", - "--Button-shadow": "inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -5px 8px rgba(15,32,56,0.40)", - "--Button-padding-block": "5px", - "--Button-padding-inline": "14px", - "--Button-ring": "2px solid #2f5377", + '--Button-bg': '#3c6997', + '--Button-color': '#ffffff', + '--Button-radius': '12px', + '--Button-shadow': 'inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -5px 8px rgba(15,32,56,0.40)', + '--Button-padding-block': '5px', + '--Button-padding-inline': '14px', + '--Button-ring': '2px solid #2f5377', } as const; // No var exists for typographic identity — the loudest Material tells. Plain CSS. const steelType = { - textTransform: "none", - fontFamily: "ui-sans-serif, system-ui, sans-serif", + textTransform: 'none', + fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontWeight: 500, - letterSpacing: "normal", + letterSpacing: 'normal', } as const; // Per-variant agnostic color, parameterised by palette (Steel | Neutral). const palettes = { - Steel: { fill: "#3c6997", line: "#2f5377" }, - Neutral: { fill: "#6c757d", line: "#495057" }, + Steel: { fill: '#3c6997', line: '#2f5377' }, + Neutral: { fill: '#6c757d', line: '#495057' }, } as const; type PaletteKey = keyof typeof palettes; const variantVars = (p: PaletteKey) => ({ contained: { - "--Button-bg": palettes[p].fill, - "--Button-color": "#fff", - "--Button-shadow": "none", + '--Button-bg': palettes[p].fill, + '--Button-color': '#fff', + '--Button-shadow': 'none', + '&.Mui-disabled': { + opacity: 0.5, + }, + }, + outlined: { + '--Button-border-color': palettes[p].line, + '--Button-color': palettes[p].line, + '&.Mui-disabled': { + opacity: 0.5, + }, + }, + text: { + '--Button-color': palettes[p].line, + '&.Mui-disabled': { + opacity: 0.5, + }, }, - outlined: { "--Button-border-color": palettes[p].line, "--Button-color": palettes[p].line }, - text: { "--Button-color": palettes[p].line }, }); function Caption({ children }: { children: React.ReactNode }) { @@ -56,7 +70,7 @@ function Caption({ children }: { children: React.ReactNode }) { {children} @@ -66,9 +80,10 @@ function Caption({ children }: { children: React.ReactNode }) { function fmt(obj: Record) { return Object.entries(obj) .map( - ([k, v]) => ` ${k.startsWith("--") ? `'${k}'` : k}: ${typeof v === "string" ? `'${v}'` : v},` + ([k, v]) => + ` ${k.startsWith('--') ? `'${k}'` : k}: ${typeof v === 'string' ? `'${v}'` : v},`, ) - .join("\n"); + .join('\n'); } // Renders the exact objects applied below — auditable, cannot drift from what renders. @@ -79,12 +94,12 @@ function CodeBlock() { sx={{ m: 0, p: 2, - bgcolor: "grey.900", - color: "grey.100", + bgcolor: 'grey.900', + color: 'grey.100', borderRadius: 1, fontSize: 12, lineHeight: 1.6, - overflow: "auto", + overflow: 'auto', }} > {`sx={{ @@ -99,7 +114,7 @@ ${fmt(steelType)} function Scope({ title, sx, children }: { title: string; sx?: object; children: React.ReactNode }) { return ( - + {title} @@ -111,7 +126,7 @@ function Scope({ title, sx, children }: { title: string; sx?: object; children: function Controls() { return ( - + @@ -122,7 +137,7 @@ function Controls() { - + @@ -139,10 +154,10 @@ function MatrixGroup({ palette }: { palette: PaletteKey }) { spacing={2} useFlexGap sx={{ - alignItems: "center", - flexWrap: "wrap", - "--Button-radius": "8px", - "--Button-ring": "2px solid #2f5377", + alignItems: 'center', + flexWrap: 'wrap', + '--Button-radius': '8px', + '--Button-ring': '2px solid #2f5377', ...steelType, }} > @@ -163,8 +178,9 @@ function MatrixGroup({ palette }: { palette: PaletteKey }) { {row(false)} {row(true)} - Disabled (2nd row) reverts to theme greys — agnostic color is resting-only (ADR-0002). - Matching it needs &.Mui-disabled CSS or a future state layer. + Disabled (2nd row) keeps the custom color — an agnostic var opts that property out of the + spec in every state, so the default disabled grey-out is gone too (ADR-0002). The + cue is now yours: here we re-add one with &.Mui-disabled opacity 0.5. ); @@ -174,7 +190,7 @@ export default function App() { const [spacing, setSpacing] = React.useState(8); return ( - +
Agnostic CSS variables — leaving Material Design @@ -187,10 +203,10 @@ export default function App() {
@@ -200,13 +216,13 @@ export default function App() {
- - Agnostic vars only. Steel-blue, 8px, custom shadow + focus ring (tab to see) - — but still UPPERCASE Roboto and still ripples. Vars carry color/shape/elevation; - type & behaviour are the gap. + Agnostic vars only. Steel-blue, rounded, custom shadow + focus ring (tab to + see) — but still UPPERCASE Roboto and still ripples. Vars carry + color/shape/elevation; type & behaviour are the gap.
@@ -242,23 +258,23 @@ export default function App() { direction="row" spacing={2} useFlexGap - sx={{ alignItems: "center", flexWrap: "wrap" }} + sx={{ alignItems: 'center', flexWrap: 'wrap' }} > - - @@ -267,11 +283,11 @@ export default function App() { @@ -284,28 +300,28 @@ export default function App() { direction="row" spacing={2} useFlexGap - sx={{ alignItems: "center", flexWrap: "wrap", "--Button-radius": "16px" }} + sx={{ alignItems: 'center', flexWrap: 'wrap', '--Button-radius': '16px' }} > - - - - - @@ -318,27 +334,27 @@ export default function App() { direction="row" spacing={2} useFlexGap - sx={{ alignItems: "start", flexWrap: "wrap" }} + sx={{ alignItems: 'start', flexWrap: 'wrap' }} > @@ -367,14 +383,14 @@ export default function App() { valueLabelFormat={(value) => `${value}px`} /> - + {/* --- Different Viewports --- */} diff --git a/packages/mui-material/src/Button/Button.js b/packages/mui-material/src/Button/Button.js index 1ba412c5cbd0cc..db392d258dcddc 100644 --- a/packages/mui-material/src/Button/Button.js +++ b/packages/mui-material/src/Button/Button.js @@ -119,7 +119,7 @@ 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}`]: { @@ -147,9 +147,11 @@ const ButtonRoot = styled(ButtonBase, { 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 + })`, }, }, }, @@ -167,7 +169,9 @@ const ButtonRoot = styled(ButtonBase, { backgroundColor: `var(${buttonVars.bg},var(--variant-outlinedBg))`, color: `var(${buttonVars.color},var(--variant-outlinedColor))`, [`&.${buttonClasses.disabled}`]: { - borderColor: (theme.vars || theme).palette.action.disabledBackground, + borderColor: `var(${buttonVars.borderColor},${ + (theme.vars || theme).palette.action.disabledBackground + })`, }, }, }, @@ -215,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, @@ -318,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)`, }, }, }, @@ -480,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/InputBase/InputBase.js b/packages/mui-material/src/InputBase/InputBase.js index 96e45da27008f8..40e8d4f9db3c4a 100644 --- a/packages/mui-material/src/InputBase/InputBase.js +++ b/packages/mui-material/src/InputBase/InputBase.js @@ -107,7 +107,7 @@ export const InputBaseRoot = styled('div', { 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: [ diff --git a/packages/mui-material/src/OutlinedInput/OutlinedInput.js b/packages/mui-material/src/OutlinedInput/OutlinedInput.js index 01a433acf691cf..2e3cae73ea45f2 100644 --- a/packages/mui-material/src/OutlinedInput/OutlinedInput.js +++ b/packages/mui-material/src/OutlinedInput/OutlinedInput.js @@ -102,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 + })`, }, }, }, From ec4192924316312da5e6e14c66eeac3748dfbc85 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Tue, 26 May 2026 11:50:07 +0700 Subject: [PATCH 10/12] [material-ui][InputLabel] POC simplify outlined label density formula --- docs/pages/experiments/css-vars-density.tsx | 400 ------------------ .../mui-material/src/InputLabel/InputLabel.js | 4 +- 2 files changed, 2 insertions(+), 402 deletions(-) delete mode 100644 docs/pages/experiments/css-vars-density.tsx diff --git a/docs/pages/experiments/css-vars-density.tsx b/docs/pages/experiments/css-vars-density.tsx deleted file mode 100644 index dd9461b021fb88..00000000000000 --- a/docs/pages/experiments/css-vars-density.tsx +++ /dev/null @@ -1,400 +0,0 @@ -'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 } from '@mui/material/styles'; - -// Public CSS variables + density POC. -// See CONTEXT.md, docs/adr/0002-agnostic-public-css-vars.md, -// docs/design/public-css-var-layering.md. -const theme = createTheme({ cssVariables: true }); - -// --- "Steel UI": an alternate design language built only from agnostic vars. --- -// Agnostic vars carry color / shape / elevation. They flatten across variants -// (overriding = opting out of the Material spec), so color is applied per button. -const steelVars = { - '--Button-bg': '#3c6997', - '--Button-color': '#ffffff', - '--Button-radius': '12px', - '--Button-shadow': 'inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -5px 8px rgba(15,32,56,0.40)', - '--Button-padding-block': '5px', - '--Button-padding-inline': '14px', - '--Button-ring': '2px solid #2f5377', -} as const; - -// No var exists for typographic identity — the loudest Material tells. Plain CSS. -const steelType = { - textTransform: 'none', - fontFamily: 'ui-sans-serif, system-ui, sans-serif', - fontWeight: 500, - letterSpacing: 'normal', -} as const; - -// Per-variant agnostic color, parameterised by palette (Steel | Neutral). -const palettes = { - Steel: { fill: '#3c6997', line: '#2f5377' }, - Neutral: { fill: '#6c757d', line: '#495057' }, -} as const; -type PaletteKey = keyof typeof palettes; - -const variantVars = (p: PaletteKey) => ({ - contained: { - '--Button-bg': palettes[p].fill, - '--Button-color': '#fff', - '--Button-shadow': 'none', - '&.Mui-disabled': { - opacity: 0.5, - }, - }, - outlined: { - '--Button-border-color': palettes[p].line, - '--Button-color': palettes[p].line, - '&.Mui-disabled': { - opacity: 0.5, - }, - }, - text: { - '--Button-color': palettes[p].line, - '&.Mui-disabled': { - opacity: 0.5, - }, - }, -}); - -function Caption({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function fmt(obj: Record) { - return Object.entries(obj) - .map( - ([k, v]) => - ` ${k.startsWith('--') ? `'${k}'` : k}: ${typeof v === 'string' ? `'${v}'` : v},`, - ) - .join('\n'); -} - -// Renders the exact objects applied below — auditable, cannot drift from what renders. -function CodeBlock() { - return ( - - {`sx={{ - // agnostic vars — color / shape / elevation -${fmt(steelVars)} - // no var yet — raw CSS for type -${fmt(steelType)} -}}`} - - ); -} - -function Scope({ title, sx, children }: { title: string; sx?: object; children: React.ReactNode }) { - return ( - - - {title} - - {children} - - ); -} - -function Controls() { - return ( - - - - - - - - - - - - - - ); -} - -// Matrix: one palette × {contained, outlined, text} × {enabled, disabled}. -function MatrixGroup({ palette }: { palette: PaletteKey }) { - const vv = variantVars(palette); - const row = (disabled: boolean) => ( - - - - - - ); - return ( - - {palette} - {row(false)} - {row(true)} - - Disabled (2nd row) keeps the custom color — an agnostic var opts that property out of the - spec in every state, so the default disabled grey-out is gone too (ADR-0002). The - cue is now yours: here we re-add one with &.Mui-disabled opacity 0.5. - - - ); -} - -export default function App() { - const [spacing, setSpacing] = React.useState(8); - return ( - - -
- Agnostic CSS variables — leaving Material Design - - One agnostic var per property (no variant/size/color in the name). Overriding opts that - property out of the spec — so the same Button can host a different design language. - -
- - {/* === HERO: static triptych — what we escaped, what vars carry, what raw CSS finishes === */} - - -
- - - Stock Material. Uppercase, Roboto, MD blue, 4px radius — what MUI ships. - -
-
- - - Agnostic vars only. Steel-blue, rounded, custom shadow + focus ring (tab to - see) — but still UPPERCASE Roboto and still ripples. Vars carry - color/shape/elevation; type & behaviour are the gap. - -
-
- - - + raw-CSS type & disableRipple. No var for type or ripple → plain CSS + a - prop. No longer Material. - -
-
- - - -
- - {/* === Matrix: Steel UI as a coherent system across variants + palettes === */} - - - - - - - - - Everyday knobs — the same vars for small tweaks - - - {/* --- Fine-grained per-component knobs --- */} - - - - - - - - - - {/* --- Responsive typography --- */} - - - - - {/* --- Color knobs --- */} - - - - - - - - - - - - - {/* --- TextField knobs (partial escape) --- */} - - - - - - - - - Partial escape: color/border/radius leave the spec, but the notched outline + floating - label are structural — still Material. Full structural escape needs future vars. - - - - - Density — a different axis (one --mui-spacing dial) - - - {/* --- Different Apps: drive --mui-spacing live --- */} - - - {`App density — --mui-spacing: ${spacing}px`} - - setSpacing(value as number)} - min={4} - max={12} - step={1} - marks - valueLabelDisplay="auto" - valueLabelFormat={(value) => `${value}px`} - /> - - - - - - {/* --- Different Viewports --- */} - - - -
-
- ); -} 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)`, }, }, { From 0ab1f422ba09c1ff5504b74e5ec59f5819e863cc Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Tue, 26 May 2026 11:50:21 +0700 Subject: [PATCH 11/12] [docs] POC add agnostic-variables experiment page + record five-axis taxonomy --- CONTEXT.md | 14 +- docs/adr/0002-agnostic-public-css-vars.md | 18 +- docs/pages/experiments/agnostic-variables.tsx | 486 ++++++++++++++++++ 3 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 docs/pages/experiments/agnostic-variables.tsx diff --git a/CONTEXT.md b/CONTEXT.md index 3c1818facc1fb8..f94954937d6b03 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,9 +5,20 @@ POC exploring whether Material UI components should expose hand-authorable CSS v ## 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. 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`. +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. @@ -38,6 +49,7 @@ A component's CSS-var fallback chain may reference only its own var and the vars - **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. diff --git a/docs/adr/0002-agnostic-public-css-vars.md b/docs/adr/0002-agnostic-public-css-vars.md index 1f8b604cd7b9f8..0c694a008ff993 100644 --- a/docs/adr/0002-agnostic-public-css-vars.md +++ b/docs/adr/0002-agnostic-public-css-vars.md @@ -4,7 +4,23 @@ The public CSS-variable API exposes exactly **one variable per styleable propert ## Why -The intent is to keep the public surface small and **unopinionated about design language**. 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. +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 diff --git a/docs/pages/experiments/agnostic-variables.tsx b/docs/pages/experiments/agnostic-variables.tsx new file mode 100644 index 00000000000000..618ad1baa819a1 --- /dev/null +++ b/docs/pages/experiments/agnostic-variables.tsx @@ -0,0 +1,486 @@ +'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 + + + + + + + + + + + + + + + + ); +} + +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. + + + + ); +} From d9b69a4ef983634637e67ce2a06da9c07c53e037 Mon Sep 17 00:00:00 2001 From: siriwatknp Date: Wed, 27 May 2026 09:23:55 +0700 Subject: [PATCH 12/12] [docs] POC density showcase as login form --- docs/pages/experiments/agnostic-variables.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/pages/experiments/agnostic-variables.tsx b/docs/pages/experiments/agnostic-variables.tsx index 618ad1baa819a1..be4edcec2f935c 100644 --- a/docs/pages/experiments/agnostic-variables.tsx +++ b/docs/pages/experiments/agnostic-variables.tsx @@ -400,15 +400,18 @@ function DensityShowcase() { sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }} style={{ '--mui-spacing': `${spacing}px` } as React.CSSProperties} > - - - - - - - - - + + + Sign in + + + + +