Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

- `Card`: Set default foreground color on `Card.Root` so content and `currentColor` icons (for example the `CollapsibleCard` chevron) are themeable by default ([#77013](https://github.com/WordPress/gutenberg/pull/77013)).

### Enhancements

- Add defensive styles against global WordPress stylesheets like common.css and forms.css ([#76783](https://github.com/WordPress/gutenberg/pull/76783)).

## 0.10.0 (2026-04-01)

### New Features
Expand Down
25 changes: 25 additions & 0 deletions packages/ui/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,28 @@ If a higher layer sets `.special-button { --button-bg: red; }`, that override wi
### Disabled State Styling

For components built on Base UI, use the `data-disabled` attribute when styling disabled states rather than targeting `disabled` or `aria-disabled` directly. Base UI applies `data-disabled` consistently regardless of whether the underlying implementation uses the native `disabled` attribute or `aria-disabled` (which depends on the `focusableWhenDisabled` prop). This keeps styles decoupled from the specific HTML attribute and avoids verbose selectors that would need to target both.

### Global CSS defense (wp-admin)

WordPress loads broad, **unlayered** global styles in the admin (`common.css`, `forms.css`) that target bare elements (`input`, `button`, `a`, `p`, headings, and so on). In the cascade, **unlayered rules always win over layered rules**, no matter how specific the layered selector is. That means normal `@wordpress/ui` styles, which live in `@layer wp-ui-*`, can be overridden by admin CSS and components may look wrong in wp-admin (borders, focus rings, typography, colors, and more).

**What it is:** [`global-css-defense.module.css`](src/utils/css/global-css-defense.module.css) is a shared, **unlayered** stylesheet that defines small, class-scoped rules (for example `.input`, `.textarea`, `.button`, `.a`, `p.p`, `:is(h1,…,h6).heading`, `.div`). Those classes are applied on the actual DOM nodes that need protection. Because the declarations are unlayered and use classes, they compete with admin styles on similar footing and can win by specificity.

**How it works (custom property bridge):** Each defended property is expressed with an internal custom property and a fallback, for example:

```css
.input {
font-size: var( --_gcd-input-font-size, inherit );
}
```

- The **fallback** encodes the default that `@wordpress/ui` wants when nothing else is set, so many components need no extra declarations.
- A component’s **layered** module can set `--_gcd-*` on a wrapper or the element itself. Custom property resolution is not blocked by cascade layers the same way longhand properties are, so the layered stylesheet can still supply the real token values while the unlayered rule applies them in a context where admin CSS would otherwise win.

Use the `--_gcd-*` prefix only inside this package; treat these variables as implementation details, not public theming API.

**When to use it:** Whenever you introduce or change a primitive that renders a native element commonly styled by wp-admin globals, wire in the matching defense class from `global-css-defense.module.css` (see existing usages in `Button`, `Input`, `Textarea`, `Link`, `Text`, field descriptions, and related form primitives). If admin styles affect a new element type, extend the defense module with a new class and the same bridge pattern rather than duplicating unlayered overrides inside individual components.

**Testing:** In Storybook, enable **WordPress global CSS** from the toolbar and compare stories with that mode on and off; appearance should match aside from intentional differences.

Long term, Core may reduce the need for this by [scoping](https://core.trac.wordpress.org/ticket/64939). Until then, this module is the supported way to keep components predictable inside wp-admin.
2 changes: 2 additions & 0 deletions packages/ui/src/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { type ButtonProps } from './types';
import styles from './style.module.css';
import resetStyles from '../utils/css/resets.module.css';
import focusStyles from '../utils/css/focus.module.css';
import defenseStyles from '../utils/css/global-css-defense.module.css';

export const Button = forwardRef< HTMLButtonElement, ButtonProps >(
function Button(
Expand All @@ -25,6 +26,7 @@ export const Button = forwardRef< HTMLButtonElement, ButtonProps >(
ref
) {
const mergedClassName = clsx(
defenseStyles.button,
resetStyles[ 'box-sizing' ],
focusStyles[ 'outset-ring--focus-except-active' ],
variant !== 'unstyled' && styles.button,
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/button/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
--wp-ui-button-border-color-active: var(--wp-ui-button-background-color-active);
--wp-ui-button-border-color-disabled: var(--wp-ui-button-background-color-disabled);

--_gcd-button-font-family: var(--wpds-font-family-body);
--_gcd-button-font-size: var(--wp-ui-button-font-size);
--_gcd-button-font-weight: var(--wp-ui-button-font-weight);

/* Styles */
position: relative;
display: inline-flex;
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/form/primitives/field/description.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import { Field as _Field } from '@base-ui/react/field';
import { forwardRef } from '@wordpress/element';
import defenseStyles from '../../../utils/css/global-css-defense.module.css';
import fieldStyles from '../../../utils/css/field.module.css';
import type { FieldDescriptionProps } from './types';

Expand All @@ -11,7 +12,11 @@ export const Description = forwardRef<
return (
<_Field.Description
ref={ ref }
className={ clsx( fieldStyles.description, className ) }
className={ clsx(
defenseStyles.p,
fieldStyles.description,
className
) }
{ ...restProps }
/>
);
Expand Down
10 changes: 9 additions & 1 deletion packages/ui/src/form/primitives/fieldset/description.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import { mergeProps, useRender } from '@base-ui/react';
import { forwardRef, useEffect, useId } from '@wordpress/element';
import defenseStyles from '../../../utils/css/global-css-defense.module.css';
import fieldStyles from '../../../utils/css/field.module.css';
import { useFieldsetContext } from './context';
import type { FieldsetDescriptionProps } from './types';
Expand All @@ -27,7 +28,14 @@ export const FieldsetDescription = forwardRef<
render,
ref,
props: mergeProps< 'p' >(
{ className: clsx( fieldStyles.description, className ), id },
{
className: clsx(
defenseStyles.p,
fieldStyles.description,
className
),
id,
},
restProps
),
} );
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import { Children, forwardRef } from '@wordpress/element';
import defenseStyles from '../../../utils/css/global-css-defense.module.css';
import resetStyles from '../../../utils/css/resets.module.css';
import styles from './style.module.css';
import type { InputLayoutProps } from './types';
Expand All @@ -26,6 +27,7 @@ export const InputLayout = forwardRef< HTMLDivElement, InputLayoutProps >(
<div
ref={ ref }
className={ clsx(
defenseStyles.div,
resetStyles[ 'box-sizing' ],
styles[ 'input-layout' ],
styles[ `is-size-${ size }` ],
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/form/primitives/input/input.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Input as _Input } from '@base-ui/react/input';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import defenseStyles from '../../../utils/css/global-css-defense.module.css';
import focusStyles from '../../../utils/css/focus.module.css';
import styles from './style.module.css';
import type { InputProps } from './types';
Expand All @@ -22,7 +23,11 @@ export const Input = forwardRef< HTMLElement, InputProps >( function Input(
prefix={ prefix }
suffix={ suffix }
>
<_Input ref={ ref } className={ styles.input } { ...restProps } />
<_Input
ref={ ref }
className={ clsx( defenseStyles.input, styles.input ) }
{ ...restProps }
/>
</InputLayout>
);
} );
4 changes: 4 additions & 0 deletions packages/ui/src/form/primitives/input/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

@layer wp-ui-components {
.input {
--_gcd-input-padding:
var(--wp-ui-input-padding-block, 0)
var(--wp-ui-input-layout-padding-inline, 0);

padding-block: var(--wp-ui-input-padding-block, 0);
padding-inline: var(--wp-ui-input-layout-padding-inline, 0);
width: 100%;
Expand Down
11 changes: 10 additions & 1 deletion packages/ui/src/form/primitives/textarea/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cloneElement, forwardRef } from '@wordpress/element';
import styles from './style.module.css';
import type { TextareaProps } from './types';
import { Input } from '../input';
import defenseStyles from '../../../utils/css/global-css-defense.module.css';

const wrappedRender = (
render: NonNullable< TextareaProps[ 'render' ] >,
Expand Down Expand Up @@ -39,7 +40,15 @@ export const Textarea = forwardRef< HTMLTextAreaElement, TextareaProps >(
style={ style }
render={ wrappedRender(
render || ( ( props ) => <textarea { ...props } /> ),
{ className: styles.textarea, ref, rows, ...restProps }
{
className: clsx(
defenseStyles.textarea,
styles.textarea
),
ref,
rows,
...restProps,
}
) }
value={ value }
defaultValue={ defaultValue }
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/link/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { type LinkProps } from './types';
import resetStyles from '../utils/css/resets.module.css';
import focusStyles from '../utils/css/focus.module.css';
import styles from './style.module.css';
import defenseStyles from '../utils/css/global-css-defense.module.css';

/**
* A styled anchor element with support for semantic color tones and an
Expand Down Expand Up @@ -39,6 +40,7 @@ export const Link = forwardRef< HTMLAnchorElement, LinkProps >( function Link(
ref,
props: mergeProps< 'a' >( props, {
className: clsx(
defenseStyles.a,
resetStyles[ 'box-sizing' ],
focusStyles[ 'outset-ring--focus' ],
variant !== 'unstyled' && styles.link,
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/src/link/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,38 @@
/* Brand tone */
.is-brand,
.is-brand:visited {
--_gcd-a-color: var(--wpds-color-fg-interactive-brand);

color: var(--wpds-color-fg-interactive-brand);
}

.is-brand:hover,
.is-brand:active {
--_gcd-a-color: var(--wpds-color-fg-interactive-brand-active);

color: var(--wpds-color-fg-interactive-brand-active);
}

/* Neutral tone */
.is-neutral,
.is-neutral:visited {
--_gcd-a-color: var(--wpds-color-fg-interactive-neutral);

color: var(--wpds-color-fg-interactive-neutral);
text-decoration-color: var(--wpds-color-stroke-interactive-neutral);
}

.is-neutral:hover,
.is-neutral:active {
--_gcd-a-color: var(--wpds-color-fg-interactive-neutral-active);

color: var(--wpds-color-fg-interactive-neutral-active);
}

/* Unstyled variant */
.is-unstyled {
--_gcd-a-color: inherit;

color: inherit;
text-decoration: none;
}
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/text/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,44 @@
}

.heading-2xl {
--_gcd-heading-font-size: var(--wpds-font-size-2xl);

font-family: var(--wpds-font-family-heading);
font-size: var(--wpds-font-size-2xl);
line-height: var(--wpds-font-line-height-2xl);
font-weight: var(--wpds-font-weight-medium);
}

.heading-xl {
--_gcd-heading-font-size: var(--wpds-font-size-xl);

font-family: var(--wpds-font-family-heading);
font-size: var(--wpds-font-size-xl);
line-height: var(--wpds-font-line-height-md);
font-weight: var(--wpds-font-weight-medium);
}

.heading-lg {
--_gcd-heading-font-size: var(--wpds-font-size-lg);

font-family: var(--wpds-font-family-heading);
font-size: var(--wpds-font-size-lg);
line-height: var(--wpds-font-line-height-sm);
font-weight: var(--wpds-font-weight-medium);
}

.heading-md {
--_gcd-heading-font-size: var(--wpds-font-size-md);

font-family: var(--wpds-font-family-heading);
font-size: var(--wpds-font-size-md);
line-height: var(--wpds-font-line-height-sm);
font-weight: var(--wpds-font-weight-medium);
}

.heading-sm {
--_gcd-heading-font-size: var(--wpds-font-size-xs);

font-family: var(--wpds-font-family-heading);
font-size: var(--wpds-font-size-xs);
line-height: var(--wpds-font-line-height-xs);
Expand All @@ -42,27 +52,39 @@
}

.body-xl {
--_gcd-p-font-size: var(--wpds-font-size-xl);
--_gcd-p-line-height: var(--wpds-font-line-height-xl);

font-family: var(--wpds-font-family-body);
font-size: var(--wpds-font-size-xl);
line-height: var(--wpds-font-line-height-xl);
font-weight: var(--wpds-font-weight-regular);
}

.body-lg {
--_gcd-p-font-size: var(--wpds-font-size-lg);
--_gcd-p-line-height: var(--wpds-font-line-height-md);

font-family: var(--wpds-font-family-body);
font-size: var(--wpds-font-size-lg);
line-height: var(--wpds-font-line-height-md);
font-weight: var(--wpds-font-weight-regular);
}

.body-md {
--_gcd-p-font-size: var(--wpds-font-size-md);
--_gcd-p-line-height: var(--wpds-font-line-height-sm);

font-family: var(--wpds-font-family-body);
font-size: var(--wpds-font-size-md);
line-height: var(--wpds-font-line-height-sm);
font-weight: var(--wpds-font-weight-regular);
}

.body-sm {
--_gcd-p-font-size: var(--wpds-font-size-sm);
--_gcd-p-line-height: var(--wpds-font-line-height-xs);

font-family: var(--wpds-font-family-body);
font-size: var(--wpds-font-size-sm);
line-height: var(--wpds-font-line-height-xs);
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/src/text/text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import { type TextProps } from './types';
import styles from './style.module.css';
import defenseStyles from '../utils/css/global-css-defense.module.css';

/**
* A text component for rendering content with predefined typographic variants.
Expand All @@ -17,7 +18,13 @@ export const Text = forwardRef< HTMLSpanElement, TextProps >( function Text(
defaultTagName: 'span',
ref,
props: mergeProps< 'span' >( props, {
className: clsx( styles.text, styles[ variant ], className ),
className: clsx(
styles.text,
variant.startsWith( 'heading-' ) && defenseStyles.heading,
variant.startsWith( 'body-' ) && defenseStyles.p,
styles[ variant ],
className
),
} ),
} );

Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/utils/css/field.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
}

.description {
margin: 0;
--_gcd-p-font-size: var(--wpds-font-size-sm);
--_gcd-p-line-height: var(--wpds-font-line-height-xs);
--_gcd-p-margin: 0;

font-family: var(--wpds-font-family-body);
font-size: var(--wpds-font-size-sm);
line-height: var(--wpds-font-line-height-xs);
Expand Down
12 changes: 7 additions & 5 deletions packages/ui/src/utils/css/focus.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
.outset-ring--focus-within-visible,
.outset-ring--focus-parent-visible {
@media not ( prefers-reduced-motion ) {
--_gcd-a-transition: outline 0.1s ease-out;

transition: outline 0.1s ease-out;
}

/* Outline width must be kept at 0 even with a transparent color,
or else the outline will be visible in forced-colors mode. */
outline-width: 0;
outline-style: solid;
outline-color: transparent;
outline: 0 solid transparent;
Comment thread
ciampo marked this conversation as resolved.
outline-offset: 1px;
}

Expand All @@ -27,7 +27,9 @@
.outset-ring--focus-within-except-active:focus-within:not(:has(:active)),
.outset-ring--focus-within-visible:focus-within:has(:focus-visible),
:focus-visible .outset-ring--focus-parent-visible {
outline-width: var(--wpds-border-width-focus);
outline-color: var(--wpds-color-stroke-focus-brand);
--_gcd-a-outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
--_gcd-div-outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
Comment thread
ciampo marked this conversation as resolved.

outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
}
}
Loading
Loading