Skip to content
4 changes: 3 additions & 1 deletion packages/components/src/badge/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import './style.scss';
*/
import classnames from 'classnames';

export type BadgeLevel = 'default' | 'info' | 'success' | 'warning' | 'error';

type BadgeProps = {
text: string;
level?: 'default' | 'info' | 'success' | 'warning' | 'error';
level?: BadgeLevel;
};

/**
Expand Down
113 changes: 113 additions & 0 deletions packages/components/src/card-form/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# CardForm

A card component for presenting a named setting or feature with an expandable inline form. When open, the card body reveals children (controls, fields, action buttons) and the header border is removed for a seamless look. Intended for lists of items that can each be independently enabled, edited, or configured without leaving the page.

## Layout rules

- Stack multiple `CardForm` cards inside a `<VStack>` — they are designed to appear as a list.
- The `actions` slot sits to the right of the badge. Keep it to one button; if you need multiple actions, use an `HStack` with `expanded={ false }`.
- The form body (`children`) is only mounted when `isOpen` is `true`.

## States

| State | `isOpen` | `badge` | `actions` example |
|---|---|---|---|
| **Disabled** | `false` | None | "Enable" (secondary) |
| **Enabling** | `true` | None | "Cancel" (tertiary) |
| **Enabled** | `false` | Success badge | "Edit" (tertiary) |
| **Editing** | `true` | Success badge | "Cancel" (tertiary) |

## Basic usage — enable/edit pattern

```tsx
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { Button } from '@wordpress/components';
import { CardForm } from '../../../../../packages/components/src';

const [ isOpen, setIsOpen ] = useState( false );
const [ isEnabled, setIsEnabled ] = useState( false );

const handleClose = () => setIsOpen( false );

<CardForm
title={ __( 'Above Header', 'newspack-plugin' ) }
description={ __( 'Displays an ad above the site header.', 'newspack-plugin' ) }
badge={ isEnabled ? { level: 'success', text: __( 'Enabled', 'newspack-plugin' ) } : undefined }
actions={
isEnabled ? (
<Button variant="tertiary" size="compact" onClick={ () => isOpen ? handleClose() : setIsOpen( true ) }>
{ isOpen ? __( 'Cancel', 'newspack-plugin' ) : __( 'Edit', 'newspack-plugin' ) }
</Button>
) : (
<Button variant="secondary" size="compact" onClick={ () => setIsOpen( true ) }>
{ __( 'Enable', 'newspack-plugin' ) }
</Button>
)
}
isOpen={ isOpen }
onRequestClose={ handleClose }
>
{ /* form controls */ }
<Button variant="primary" size="compact" onClick={ handleSave }>
{ __( 'Update', 'newspack-plugin' ) }
</Button>
</CardForm>
```

## With a custom badge level

The `badge` prop accepts any `BadgeLevel`. Use `warning` or `error` to communicate a degraded state.

```tsx
<CardForm
title={ __( 'Above Header', 'newspack-plugin' ) }
badge={ { level: 'warning', text: __( 'Missing ad unit', 'newspack-plugin' ) } }
actions={ <Button variant="tertiary" size="compact">{ __( 'Edit', 'newspack-plugin' ) }</Button> }
isOpen={ false }
/>
```

## Without a badge

Omit `badge` (or pass `undefined`) to show no badge at all.

```tsx
<CardForm
title={ __( 'Sticky Footer', 'newspack-plugin' ) }
description={ __( 'Pins an ad to the bottom of the viewport.', 'newspack-plugin' ) }
actions={
<Button variant="secondary" size="compact" onClick={ handleEnable }>
{ __( 'Enable', 'newspack-plugin' ) }
</Button>
}
isOpen={ false }
/>
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `title` | `string` | — | Card heading (**required**) |
| `description` | `string` | — | Supporting text below the title |
| `badge` | `{ text: string; level?: BadgeLevel }` | — | Badge shown next to the actions slot. Omit or pass `undefined` to hide. |
| `actions` | `React.ReactNode` | — | JSX rendered in the header action area (buttons, dropdowns, etc.) |
| `isOpen` | `boolean` | `false` | When `true`, renders `children` in the card body and removes the header border |
| `onRequestClose` | `() => void` | — | Called when the user presses Escape while focus is inside the open form |
| `titleLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `3` | Heading level rendered for `title`. Pick the level that fits the surrounding document outline. |
| `className` | `string` | — | Additional class name applied to the card element |
| `children` | `React.ReactNode` | — | Form content rendered inside the card body when `isOpen` is `true` |

## Accessibility

- The body is rendered as a `role="region"` labelled by the title, so assistive tech announces it as a named region when focus enters.
- On open, focus moves to the first focusable element in the body (or to the region itself if none exist). On close, focus is restored to whatever was focused before opening — typically the trigger button.
- The Escape listener is scoped to the open form's body, so multiple open cards do not all close on a single keypress. If an inner control needs to consume Escape (for example, to close its own menu), call `event.preventDefault()` and CardForm will ignore it.


### `BadgeLevel`

```ts
type BadgeLevel = 'default' | 'info' | 'success' | 'warning' | 'error';
```
128 changes: 128 additions & 0 deletions packages/components/src/card-form/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Card Form component.
*
* A card with an expandable inline form — title, description, optional badge,
* and an actions slot in the header. When `isOpen` is true, children are
* rendered in the card body and the header border is removed for a seamless look.
*/

/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { useEffect, useRef, createElement } from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis

/**
* Internal dependencies
*/
import Badge, { BadgeLevel } from '../badge';
import Card from '../card';
import './style.scss';

type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;

type CardFormProps = {
title: string;
description?: string;
badge?: {
text: string;
level?: BadgeLevel;
};
/** JSX rendered in the header action area (buttons, etc.). */
actions?: React.ReactNode;
/** When true, children are shown and the header border is removed. */
isOpen?: boolean;
/** Called when the user presses Escape while the form is open. */
onRequestClose?: () => void;
/** Heading level for the title. Defaults to 3. */
titleLevel?: HeadingLevel;
className?: string;
children?: React.ReactNode;
};

const CardForm = ( { title, description, badge, actions, isOpen = false, onRequestClose, titleLevel = 3, className, children }: CardFormProps ) => {
const bodyRef = useRef< HTMLDivElement | null >( null );
const previousActiveRef = useRef< HTMLElement | null >( null );
const instanceId = useInstanceId( CardForm, 'newspack-card-form' );
const titleId = `${ instanceId }__title`;
const bodyId = `${ instanceId }__body`;

// Scope Escape handling to the open form's body, so multiple open CardForms
// don't all close on a single keypress, and callers can preventDefault from
// inner controls (e.g. select menus) without tripping the close.
useEffect( () => {
if ( ! isOpen || ! onRequestClose ) {
return;
}
const node = bodyRef.current;
if ( ! node ) {
return;
}
const handleKeyDown = ( event: KeyboardEvent ) => {
if ( event.key === 'Escape' && ! event.defaultPrevented ) {
onRequestClose();
}
};
node.addEventListener( 'keydown', handleKeyDown );
return () => node.removeEventListener( 'keydown', handleKeyDown );
}, [ isOpen, onRequestClose ] );

// Move focus into the body on open and restore it to the trigger on close.
useEffect( () => {
if ( ! isOpen ) {
return;
}
const node = bodyRef.current;
if ( node ) {
previousActiveRef.current = ( node.ownerDocument?.activeElement ?? null ) as HTMLElement | null;
const focusable = node.querySelector< HTMLElement >(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
( focusable ?? node ).focus();
}
return () => {
previousActiveRef.current?.focus?.();
};
}, [ isOpen ] );

const titleTag = `h${ titleLevel }`;

return (
<Card
className={ classnames( 'newspack-card-form', className, {
'newspack-card-form--open': isOpen,
} ) }
__experimentalCoreCard
isSmall
__experimentalCoreProps={ {
hasHeaderBorder: ! isOpen,
header: (
<HStack justify="space-between" style={ { width: '100%' } }>
<VStack spacing={ 0 } style={ { flex: 1, minWidth: 0 } }>
{ createElement( titleTag, { id: titleId, className: 'newspack-card-form__title' }, title ) }
{ description && <p className="newspack-card-form__description">{ description }</p> }
</VStack>
<HStack spacing={ 2 } expanded={ false }>
{ badge && <Badge text={ badge.text } level={ badge.level ?? 'success' } /> }
{ actions }
</HStack>
</HStack>
),
} }
>
{ isOpen && (
<div ref={ bodyRef } id={ bodyId } role="region" aria-labelledby={ titleId } tabIndex={ -1 } className="newspack-card-form__body">
{ children }
</div>
) }
</Card>
);
};

export default CardForm;
20 changes: 20 additions & 0 deletions packages/components/src/card-form/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* CardForm
*/

@use "~@wordpress/base-styles/colors" as wp-colors;
@use "~@wordpress/base-styles/variables" as wp;

.newspack-card-form {
&__title {
font-size: wp.$font-size-large;
font-weight: 600;
line-height: wp.$font-line-height-large;
}

&__description {
color: wp-colors.$gray-700;
font-size: wp.$font-size-medium;
line-height: wp.$font-line-height-medium;
}
}
12 changes: 10 additions & 2 deletions packages/components/src/card/core-card.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const CoreCard = ( {
noMargin,
children = null,
hasGreyHeader,
hasHeaderBorder = true,
...otherProps
} ) => {
const classes = classNames(
Expand Down Expand Up @@ -81,7 +82,11 @@ const CoreCard = ( {
{ ( header || icon ) && (
<CardHeader
as={ onHeaderClick ? 'button' : undefined }
className={ classNames( 'newspack-card--core__header', isDraggable && 'newspack-card--core__header--is-draggable' ) }
className={ classNames(
'newspack-card--core__header',
isDraggable && 'newspack-card--core__header--is-draggable',
! hasHeaderBorder && 'newspack-card--core__header--no-border'
) }
style={ headerStyle }
size={ sizeProps }
gap={ 4 }
Expand Down Expand Up @@ -157,7 +162,10 @@ const CoreCard = ( {
</CardHeader>
) }
{ children && (
<div className="newspack-card--core__body" style={ childrenStyle }>
<div
className={ classNames( 'newspack-card--core__body', ! hasHeaderBorder && 'newspack-card--core__body--no-header-border' ) }
style={ childrenStyle }
>
{ children }
</div>
) }
Expand Down
47 changes: 32 additions & 15 deletions packages/components/src/card/style-core.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,27 @@
*/

@use "~@wordpress/base-styles/colors" as wp-colors;
@use "~@wordpress/base-styles/variables" as wp-vars;
@use "../../../colors/colors.module" as colors;

.newspack-card--core,
.newspack-wizard .newspack-card--core {
background: white;
background: wp-colors.$white;
position: relative;
transition: background-color 125ms ease-in-out, box-shadow 125ms ease-in-out, color 125ms ease-in-out;
svg {
transition: fill 125ms ease-in-out;
}
&__icon {
border-radius: 50%;
border-radius: wp-vars.$radius-round;
display: grid;
height: 48px;
height: wp-vars.$grid-unit-60;
place-items: center;
width: 48px;
width: wp-vars.$grid-unit-60;
svg {
fill: var(--wp-admin-theme-color);
height: 40px;
width: 40px;
height: wp-vars.$grid-unit-50;
width: wp-vars.$grid-unit-50;
}
.newspack-card--core__has-icon-background-color & {
background: var(--wp-admin-theme-color-lighter-10);
Expand All @@ -35,15 +36,21 @@
h4,
h5,
h6 {
font-size: 14px;
font-weight: 500;
font-size: wp-vars.$font-size-large;
}
.newspack-card--core__icon {
height: 40px;
width: 40px;
svg {
height: 24px;
width: 24px;
.newspack-card--core {
&__header-content {
* {
line-height: wp-vars.$font-line-height-small;
}
}
&__icon {
height: wp-vars.$grid-unit-50;
width: wp-vars.$grid-unit-50;
svg {
height: wp-vars.$grid-unit-30;
width: wp-vars.$grid-unit-30;
}
}
}
}
Expand Down Expand Up @@ -108,6 +115,16 @@
}
}
}
// Two classes beat the single-class .components-card__header border-bottom
// rule from WP Core without needing !important.
&__header.newspack-card--core__header--no-border {
border-bottom: none;
}
&__body.newspack-card--core__body--no-header-border {
> div:not(.components-card__body):not(.components-card__media):not(.components-card__divider) {
padding-top: 0;
}
}
&__header--has-actions {
.newspack-card--core__header {
padding-right: 70px;
Expand Down Expand Up @@ -143,7 +160,7 @@
h6 {
align-items: center;
color: wp-colors.$gray-900;
font-weight: 500;
font-weight: 600;
display: flex;
gap: 8px;
a {
Expand Down
Loading
Loading