diff --git a/packages/components/src/badge/index.tsx b/packages/components/src/badge/index.tsx index d54649d788..a782861ab4 100644 --- a/packages/components/src/badge/index.tsx +++ b/packages/components/src/badge/index.tsx @@ -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; }; /** diff --git a/packages/components/src/card-form/README.md b/packages/components/src/card-form/README.md new file mode 100644 index 0000000000..6a7636b025 --- /dev/null +++ b/packages/components/src/card-form/README.md @@ -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 `` — 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 ); + + isOpen ? handleClose() : setIsOpen( true ) }> + { isOpen ? __( 'Cancel', 'newspack-plugin' ) : __( 'Edit', 'newspack-plugin' ) } + + ) : ( + + ) + } + isOpen={ isOpen } + onRequestClose={ handleClose } +> + { /* form controls */ } + + +``` + +## With a custom badge level + +The `badge` prop accepts any `BadgeLevel`. Use `warning` or `error` to communicate a degraded state. + +```tsx +{ __( 'Edit', 'newspack-plugin' ) } } + isOpen={ false } +/> +``` + +## Without a badge + +Omit `badge` (or pass `undefined`) to show no badge at all. + +```tsx + + { __( 'Enable', 'newspack-plugin' ) } + + } + 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'; +``` diff --git a/packages/components/src/card-form/index.tsx b/packages/components/src/card-form/index.tsx new file mode 100644 index 0000000000..8376a742eb --- /dev/null +++ b/packages/components/src/card-form/index.tsx @@ -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 ( + + + { createElement( titleTag, { id: titleId, className: 'newspack-card-form__title' }, title ) } + { description &&

{ description }

} +
+ + { badge && } + { actions } + + + ), + } } + > + { isOpen && ( +
+ { children } +
+ ) } +
+ ); +}; + +export default CardForm; diff --git a/packages/components/src/card-form/style.scss b/packages/components/src/card-form/style.scss new file mode 100644 index 0000000000..eb246c04a4 --- /dev/null +++ b/packages/components/src/card-form/style.scss @@ -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; + } +} diff --git a/packages/components/src/card/core-card.js b/packages/components/src/card/core-card.js index 7ccbeb3413..be5a62ccea 100644 --- a/packages/components/src/card/core-card.js +++ b/packages/components/src/card/core-card.js @@ -47,6 +47,7 @@ const CoreCard = ( { noMargin, children = null, hasGreyHeader, + hasHeaderBorder = true, ...otherProps } ) => { const classes = classNames( @@ -81,7 +82,11 @@ const CoreCard = ( { { ( header || icon ) && ( ) } { children && ( -
+
{ children }
) } diff --git a/packages/components/src/card/style-core.scss b/packages/components/src/card/style-core.scss index 47aad36865..1d2b256f42 100644 --- a/packages/components/src/card/style-core.scss +++ b/packages/components/src/card/style-core.scss @@ -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); @@ -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; + } } } } @@ -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; @@ -143,7 +160,7 @@ h6 { align-items: center; color: wp-colors.$gray-900; - font-weight: 500; + font-weight: 600; display: flex; gap: 8px; a { diff --git a/packages/components/src/grid/style.scss b/packages/components/src/grid/style.scss index 164e9a7c40..43f8a94670 100644 --- a/packages/components/src/grid/style.scss +++ b/packages/components/src/grid/style.scss @@ -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; @@ -217,4 +222,4 @@ &__tbody + &__tbody { margin-top: -32px; } -} \ No newline at end of file +} diff --git a/packages/components/src/index.js b/packages/components/src/index.js index 149d89e540..c175fc0f88 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -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'; diff --git a/src/wizards/advertising/components/placement-control/index.js b/src/wizards/advertising/components/placement-control/index.js index b07f33f641..7d366b6b2b 100644 --- a/src/wizards/advertising/components/placement-control/index.js +++ b/src/wizards/advertising/components/placement-control/index.js @@ -7,11 +7,12 @@ */ import { Fragment, useState, useEffect, useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; +import { __experimentalVStack as VStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis /** * Internal dependencies */ -import { Grid, Notice, SelectControl, TextControl } from '../../../../../packages/components/src'; +import { Notice, SelectControl, TextControl } from '../../../../../packages/components/src'; /** * Get select options from object of ad units. @@ -88,13 +89,15 @@ const PlacementControl = ( { const [ biddersErrors, setBiddersErrors ] = useState( {} ); // Ensure incoming value is available otherwise reset to empty values. + const showProviderSelect = providers.length > 1; const placementProvider = useMemo( () => ( value.provider ? providers.find( provider => provider?.id === value.provider ) : null ), [ providers, value.provider ] ); + const effectiveProvider = showProviderSelect ? placementProvider : providers[ 0 ]; const placementAdUnit = useMemo( - () => ( value.ad_unit ? ( placementProvider?.units || [] ).find( u => u.value === value.ad_unit ) : null ), - [ placementProvider, value.ad_unit ] + () => ( value.ad_unit ? ( effectiveProvider?.units || [] ).find( u => u.value === value.ad_unit ) : null ), + [ effectiveProvider, value.ad_unit ] ); useEffect( () => { @@ -114,7 +117,7 @@ const PlacementControl = ( { ); } ); setBiddersErrors( errors ); - }, [ placementProvider, placementAdUnit ] ); + }, [ placementProvider, placementAdUnit, bidders ] ); if ( ! providers.length ) { return ; @@ -122,63 +125,66 @@ const PlacementControl = ( { return ( - - onChange( { ...value, provider } ) } - disabled={ disabled } - /> + + { showProviderSelect && ( + onChange( { ...value, provider } ) } + disabled={ disabled } + /> + ) } { onChange( { ...value, ad_unit: data, + ...( ! showProviderSelect && { provider: effectiveProvider?.id } ), } ); } } disabled={ disabled } { ...props } /> - - { placementProvider?.id === 'gam' && - Object.keys( bidders ).map( bidderKey => { - const bidder = bidders[ bidderKey ]; - // translators: %s: bidder name. - const bidderLabel = sprintf( __( '%s Placement ID', 'newspack-plugin' ), bidder.name ); - return ( - { - onChange( { - ...value, - bidders_ids: { - ...value.bidders_ids, - [ bidderKey ]: data, - }, - } ); - } } - { ...props } - /> - ); - } ) } - { placementProvider?.id === 'gam' && - Object.keys( biddersErrors ).map( bidderKey => { - if ( biddersErrors[ bidderKey ] ) { + { effectiveProvider?.id === 'gam' && + Object.keys( bidders ).map( bidderKey => { + const bidder = bidders[ bidderKey ]; + // translators: %s: bidder name. + const bidderLabel = sprintf( __( '%s Placement ID', 'newspack-plugin' ), bidder.name ); return ( - - { biddersErrors[ bidderKey ] } - + { + onChange( { + ...value, + bidders_ids: { + ...value.bidders_ids, + [ bidderKey ]: data, + }, + } ); + } } + { ...props } + /> ); - } - return null; - } ) } + } ) } + { effectiveProvider?.id === 'gam' && + Object.keys( biddersErrors ).map( bidderKey => { + if ( biddersErrors[ bidderKey ] ) { + return ( + + { biddersErrors[ bidderKey ] } + + ); + } + return null; + } ) } + ); }; diff --git a/src/wizards/advertising/style.scss b/src/wizards/advertising/style.scss index fd44a3141b..410f29699e 100644 --- a/src/wizards/advertising/style.scss +++ b/src/wizards/advertising/style.scss @@ -1,3 +1,11 @@ +.newspack-wizard-ads-placements__snackbar { + bottom: 16px; + left: 50%; + position: fixed; + transform: translateX( -50% ); + z-index: 99999; +} + .newspack-ads-display-ads { .newspack-button-card .newspack-notice { margin: 24px 0 0; diff --git a/src/wizards/advertising/views/placements/index.js b/src/wizards/advertising/views/placements/index.js index e5ab190b95..df820b0f71 100644 --- a/src/wizards/advertising/views/placements/index.js +++ b/src/wizards/advertising/views/placements/index.js @@ -6,21 +6,20 @@ * External dependencies */ import classnames from 'classnames'; -import set from 'lodash/set'; +import isEqual from 'lodash/isEqual'; /** * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; -import { Fragment, useState, useEffect } from '@wordpress/element'; +import { Fragment, useState, useEffect, createPortal } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { settings } from '@wordpress/icons'; -import { ToggleControl } from '@wordpress/components'; +import { __experimentalHStack as HStack, __experimentalVStack as VStack, Snackbar, ToggleControl } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis /** * Internal dependencies */ -import { ActionCard, Button, Card, Modal, Notice, withWizardScreen } from '../../../../../packages/components/src'; +import { Button, CardForm, Grid, Notice, withWizardScreen } from '../../../../../packages/components/src'; import PlacementControl from '../../components/placement-control'; /** @@ -32,9 +31,12 @@ const Placements = () => { const [ error, setError ] = useState( null ); const [ providers, setProviders ] = useState( [] ); const [ editingPlacement, setEditingPlacement ] = useState( null ); + const [ isEnabling, setIsEnabling ] = useState( false ); + const [ originalData, setOriginalData ] = useState( null ); const [ placements, setPlacements ] = useState( {} ); const [ bidders, setBidders ] = useState( {} ); const [ biddersError, setBiddersError ] = useState( null ); + const [ notice, setNotice ] = useState( null ); const placementsApiFetch = async options => { try { @@ -46,13 +48,25 @@ const Placements = () => { } }; const handlePlacementToggle = placement => async value => { - await placementsApiFetch( { - path: `/newspack-ads/v1/placements/${ placement }`, - method: value ? 'POST' : 'DELETE', - } ); - if ( value ) { + setInFlight( true ); + let success = false; + try { + const data = await apiFetch( { + path: `/newspack-ads/v1/placements/${ placement }`, + method: value ? 'POST' : 'DELETE', + } ); + setPlacements( data ); + setError( null ); + success = true; + } catch ( err ) { + setError( err ); + } + setInFlight( false ); + if ( success && value ) { + setIsEnabling( true ); setEditingPlacement( placement ); } + return success; }; const handlePlacementChange = ( placementKey, hookKey ) => value => { const placementData = placements[ placementKey ]?.data; @@ -79,12 +93,20 @@ const Placements = () => { }; const updatePlacement = async placementKey => { setInFlight( true ); - await placementsApiFetch( { - path: `/newspack-ads/v1/placements/${ placementKey }`, - method: 'POST', - data: placements[ placementKey ].data, - } ); + let success = false; + try { + await apiFetch( { + path: `/newspack-ads/v1/placements/${ placementKey }`, + method: 'POST', + data: placements[ placementKey ].data, + } ); + success = true; + setError( null ); + } catch ( err ) { + setError( err ); + } setInFlight( false ); + return success; }; const isEnabled = placementKey => { return placements[ placementKey ].data?.enabled; @@ -113,121 +135,216 @@ const Placements = () => { fetchData(); }, [] ); - // Silently refetch placements data when exiting edit modal. + const cancelEditing = async () => { + if ( isEnabling && editingPlacement ) { + const success = await handlePlacementToggle( editingPlacement )( false ); + if ( ! success ) { + return; + } + } else if ( editingPlacement && originalData ) { + // Revert dirty edits so other cards' hasChanges doesn't see them + // before the silent refetch completes. + setPlacements( { + ...placements, + [ editingPlacement ]: { + ...placements[ editingPlacement ], + data: originalData, + }, + } ); + } + setIsEnabling( false ); + setOriginalData( null ); + setEditingPlacement( null ); + }; + + // Silently refetch placements data when exiting edit panel. useEffect( () => { if ( ! editingPlacement && initialized ) { placementsApiFetch( { path: '/newspack-ads/v1/placements' } ); } }, [ editingPlacement ] ); - const placement = editingPlacement ? placements[ editingPlacement ] : null; - return ( -

{ __( 'Placements', 'newspack-plugin' ) }

{ ! inFlight && ! providers.length && } -
- { Object.keys( placements ).map( key => { - return ( - setEditingPlacement( key ) } - icon={ settings } - label={ __( 'Placement settings', 'newspack-plugin' ) } - tooltipPosition="bottom center" - /> - ) : null - } - /> - ); - } ) } -
- { editingPlacement && placement && ( - setEditingPlacement( null ) } + +

{ __( 'Placements', 'newspack-plugin' ) }

+ - { error && } - { biddersError && } - { isEnabled( editingPlacement ) && placement.hook_name && ( - - ) } - { placement.hooks && - Object.keys( placement.hooks ).map( hookKey => { - const hook = { - hookKey, - ...placement.hooks[ hookKey ], - }; - return ( - - - - ); - } ) } - { placement.supports?.indexOf( 'stick_to_top' ) > -1 && ( - { - setPlacements( set( { ...placements }, [ editingPlacement, 'data', 'stick_to_top' ], value ) ); - } } - /> - ) } - - - - -
- ) } + { Object.keys( placements ).map( key => { + const placement = placements[ key ]; + const enabled = isEnabled( key ); + const isEditing = editingPlacement === key; + const hasChanges = isEditing && ! isEqual( placement.data, originalData ); + let hasAdUnit = true; + if ( placement.hook_name ) { + hasAdUnit = !! placement.data?.ad_unit; + } else if ( placement.hooks ) { + hasAdUnit = Object.keys( placement.hooks ).every( hookKey => !! placement.data?.hooks?.[ hookKey ]?.ad_unit ); + } + + return ( + { + if ( isEditing ) { + cancelEditing(); + } else { + setOriginalData( placement.data ); + setEditingPlacement( key ); + } + } } + > + { isEditing ? __( 'Cancel', 'newspack-plugin' ) : __( 'Edit', 'newspack-plugin' ) } + + ) : ( + + ) + } + isOpen={ isEditing } + onRequestClose={ cancelEditing } + className={ classnames( 'newspack-wizard-ads-placement', { + 'newspack-wizard-ads-placement--enabled': enabled, + } ) } + > + + { error && } + { biddersError && } + { ( enabled || isEnabling ) && placement.hook_name && ( + + ) } + { placement.hooks && + Object.keys( placement.hooks ).map( hookKey => { + const hook = { + hookKey, + ...placement.hooks[ hookKey ], + }; + return ( + + ); + } ) } + { placement.supports?.indexOf( 'stick_to_top' ) > -1 && ( + { + setPlacements( { + ...placements, + [ key ]: { + ...placements[ key ], + data: { + ...placements[ key ].data, + stick_to_top: value, + }, + }, + } ); + } } + /> + ) } + + + { ! isEnabling && ( + + ) } + + + + ); + } ) } + + + { notice && + createPortal( +
+ setNotice( null ) }> + { notice.content } + +
, + document.getElementById( 'wpbody' ) ?? document.body + ) }
); }; diff --git a/src/wizards/componentsDemo/index.js b/src/wizards/componentsDemo/index.js index 08be7d6703..e1611fe252 100644 --- a/src/wizards/componentsDemo/index.js +++ b/src/wizards/componentsDemo/index.js @@ -25,6 +25,7 @@ import { Button, Card, CardFeature, + CardForm, CardSettingsGroup, ColorPicker, Footer, @@ -74,6 +75,8 @@ class ComponentsDemo extends Component { settingsGroupCardActive: false, cardFeatureEnabled: false, cardFeatureCustomEnabled: false, + cardFormEnabled: false, + cardFormOpen: false, }; this.dragWrapperRef = createRef(); } @@ -991,6 +994,82 @@ class ComponentsDemo extends Component { /> + +

{ __( 'CardForm', 'newspack-plugin' ) }

+

+ { __( + 'An expandable inline form card with title, description, optional badge, and an actions slot. Handles ESC key via onRequestClose.', + 'newspack-plugin' + ) } +

+

{ __( 'Enable / Edit flow', 'newspack-plugin' ) }

+ + + this.setState( s => ( { + cardFormOpen: ! s.cardFormOpen, + } ) ) + } + > + { this.state.cardFormOpen ? __( 'Cancel', 'newspack-plugin' ) : __( 'Edit', 'newspack-plugin' ) } + + ) : ( + + ) + } + isOpen={ this.state.cardFormOpen } + onRequestClose={ () => this.setState( { cardFormOpen: false } ) } + > + + {} } /> + + + + + { __( 'Enable', 'newspack-plugin' ) } + + } + isOpen={ false } + /> + +

{ __( 'Badge levels', 'newspack-plugin' ) }

+ + { [ 'success', 'info', 'warning', 'error' ].map( level => ( + + { __( 'Edit', 'newspack-plugin' ) } + + } + isOpen={ false } + /> + ) ) } + +

{ __( 'Newspack Icons', 'newspack-plugin' ) }