Skip to content
Open
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
105 changes: 105 additions & 0 deletions packages/components/src/card-form/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# 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 the form is open |
| `className` | `string` | — | Additional class name applied to the card element |
| `children` | `React.ReactNode` | — | Form content rendered inside the card body when `isOpen` is `true` |

### `BadgeLevel`

```ts
type BadgeLevel = 'default' | 'info' | 'success' | 'warning' | 'error';
```
88 changes: 88 additions & 0 deletions packages/components/src/card-form/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* 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 } from '@wordpress/element';
import { __experimentalHStack as HStack, __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis

/**
* Internal dependencies
*/
import Badge from '../badge';
import Card from '../card';
import './style.scss';

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

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;
className?: string;
children?: React.ReactNode;
};

const CardForm = ( { title, description, badge, actions, isOpen = false, onRequestClose, className, children }: CardFormProps ) => {
useEffect( () => {
if ( ! isOpen || ! onRequestClose ) {
return;
}
const handleKeyDown = ( event: KeyboardEvent ) => {
if ( event.key === 'Escape' ) {
onRequestClose();
}
};
document.addEventListener( 'keydown', handleKeyDown );
return () => document.removeEventListener( 'keydown', handleKeyDown );
}, [ isOpen, onRequestClose ] );

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 } }>
<h3 className="newspack-card-form__title">{ title }</h3>
{ 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 && children }
</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
45 changes: 30 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,14 @@
}
}
}
&__header--no-border {
border-bottom: none !important;
}
&__body--no-header-border {
> div:not(.components-card__body):not(.components-card__media):not(.components-card__divider) {
padding-top: 0 !important;
}
}
&__header--has-actions {
.newspack-card--core__header {
padding-right: 70px;
Expand Down Expand Up @@ -143,7 +158,7 @@
h6 {
align-items: center;
color: wp-colors.$gray-900;
font-weight: 500;
font-weight: 600;
display: flex;
gap: 8px;
a {
Expand Down
7 changes: 6 additions & 1 deletion packages/components/src/grid/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@
grid-template-columns: repeat(6, 1fr);
}

// Columns 12
&__columns-12 {
grid-template-columns: repeat(12, 1fr);
}

// Gutter 48
&__gutter-48 {
grid-gap: 48px;
Expand Down Expand Up @@ -217,4 +222,4 @@
&__tbody + &__tbody {
margin-top: -32px;
}
}
}
1 change: 1 addition & 0 deletions packages/components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { default as Button } from './button';
export { default as BoxContrast } from './box-contrast';
export { default as Card } from './card';
export { default as CardFeature } from './card-feature';
export { default as CardForm } from './card-form';
export { default as CardSettingsGroup } from './card-settings-group';
export { default as CardSortableList } from './card-sortable-list';
export { default as CategoryAutocomplete } from './category-autocomplete';
Expand Down
Loading
Loading