diff --git a/packages/components/src/card-form/README.md b/packages/components/src/card-form/README.md new file mode 100644 index 0000000000..4bda9be812 --- /dev/null +++ b/packages/components/src/card-form/README.md @@ -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 `` — 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 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'; +``` diff --git a/packages/components/src/card-form/index.tsx b/packages/components/src/card-form/index.tsx new file mode 100644 index 0000000000..c499a95e8c --- /dev/null +++ b/packages/components/src/card-form/index.tsx @@ -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 ( + + +

{ 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..b3ee839d01 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,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; @@ -143,7 +158,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..efb25a9dbd 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( () => { @@ -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..6e551feb51 100644 --- a/src/wizards/advertising/views/placements/index.js +++ b/src/wizards/advertising/views/placements/index.js @@ -6,21 +6,19 @@ * External dependencies */ import classnames from 'classnames'; -import set from 'lodash/set'; /** * 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 +30,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 [ notices, setNotices ] = useState( [] ); const placementsApiFetch = async options => { try { @@ -46,13 +47,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 +92,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 +134,215 @@ const Placements = () => { fetchData(); }, [] ); - // Silently refetch placements data when exiting edit modal. + const cancelEditing = async () => { + if ( isEnabling && editingPlacement ) { + await handlePlacementToggle( editingPlacement )( false ); + } + 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 = JSON.stringify( placement.data ) !== JSON.stringify( 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 && ( + + ) } + + + + ); + } ) } + + + { notices.length > 0 && + createPortal( +
+ { notices.map( notice => ( + setNotices( prev => prev.filter( n => n.id !== notice.id ) ) }> + { 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' ) }