From 978b800f73532b0f262907ae3cb33e30fd649cf6 Mon Sep 17 00:00:00 2001 From: Jeremy Herve Date: Fri, 16 Oct 2026 18:35:24 +0200 Subject: [PATCH 1/6] Components: Move Newsletter's PlacementCard to jetpack-components Fixes CM-978 The Sharing & Likes screen needs the same selectable placement cards as the Subscriptions section of Jetpack > Newsletter (CM-976). Moving the card to @automattic/jetpack-components lets both screens share one component, so they look and behave the same. The card ships with a PlacementCardGrid wrapper, the two-column grid that drops to one column below 720px, so both screens lay the cards out the same way. Both are exported from the main entry point and from a ./placement-card subpath: wp-build screens import components through subpaths so they don't bundle the package's whole set of exports. Newsletter now imports the shared card and grid and drops its own copy. Its illustrations stay in the newsletter package. The props are unchanged; the classes move from jetpack-newsletter-placement__* to jp-placement-card__*, and "Preview and edit" is translated against the jetpack-components domain. --- .../update-placement-card-shared-component | 4 + .../components/placement-card/index.tsx | 81 ++++++++++++ .../placement-card/stories/index.stories.tsx | 90 +++++++++++++ .../components/placement-card/style.scss | 79 +++++++++++ .../placement-card/test/component.tsx | 73 ++++++++++ .../components/placement-card/types.ts | 51 +++++++ projects/js-packages/components/index.ts | 5 + projects/js-packages/components/package.json | 5 + .../update-placement-card-shared-component | 4 + .../src/settings/sections/placement-card.scss | 96 -------------- .../src/settings/sections/placement-card.tsx | 125 ------------------ .../sections/placement-illustrations.tsx | 6 +- .../sections/subscriptions-section.tsx | 6 +- .../newsletter/src/settings/style.scss | 1 - 14 files changed, 398 insertions(+), 228 deletions(-) create mode 100644 projects/js-packages/components/changelog/update-placement-card-shared-component create mode 100644 projects/js-packages/components/components/placement-card/index.tsx create mode 100644 projects/js-packages/components/components/placement-card/stories/index.stories.tsx create mode 100644 projects/js-packages/components/components/placement-card/style.scss create mode 100644 projects/js-packages/components/components/placement-card/test/component.tsx create mode 100644 projects/js-packages/components/components/placement-card/types.ts create mode 100644 projects/packages/newsletter/changelog/update-placement-card-shared-component delete mode 100644 projects/packages/newsletter/src/settings/sections/placement-card.scss delete mode 100644 projects/packages/newsletter/src/settings/sections/placement-card.tsx diff --git a/projects/js-packages/components/changelog/update-placement-card-shared-component b/projects/js-packages/components/changelog/update-placement-card-shared-component new file mode 100644 index 000000000000..207c6a457bdd --- /dev/null +++ b/projects/js-packages/components/changelog/update-placement-card-shared-component @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a PlacementCard component. diff --git a/projects/js-packages/components/components/placement-card/index.tsx b/projects/js-packages/components/components/placement-card/index.tsx new file mode 100644 index 000000000000..62a6d4d6e596 --- /dev/null +++ b/projects/js-packages/components/components/placement-card/index.tsx @@ -0,0 +1,81 @@ +import { CheckboxControl } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Card, Link, Stack, Text } from '@wordpress/ui'; +import clsx from 'clsx'; +import './style.scss'; +import type { PlacementCardGridProps, PlacementCardProps } from './types.ts'; +import type { JSX } from 'react'; + +/** + * Lays out placement cards in two columns, or one on narrow screens. + * + * @param {PlacementCardGridProps} props - Component props. + * @return {JSX.Element} The grid. + */ +export function PlacementCardGrid( { children, className }: PlacementCardGridProps ): JSX.Element { + return
{ children }
; +} + +/** + * A selectable card showing where something will appear on the site. + * + * @param {PlacementCardProps} props - Component props. + * @return {JSX.Element} The card. + */ +export default function PlacementCard( { + id, + name, + title, + illustration, + checked, + onChange, + previewUrl, + onPreviewClick, + disabled, +}: PlacementCardProps ): JSX.Element { + const titleId = `${ id }__title`; + + const handleChange = useCallback( + ( next: boolean ) => onChange( name, next ), + [ name, onChange ] + ); + + const handlePreviewClick = useCallback( () => { + onPreviewClick?.( name ); + }, [ name, onPreviewClick ] ); + + return ( + + + { /* Only the illustration toggles: the title and link sit outside the label. */ } + +
+ +
+
+ + + { title } + + { previewUrl && ( + + { __( 'Preview and edit', 'jetpack-components' ) } + + ) } + +
+ ); +} + +export { PlacementCard }; +export type { PlacementCardProps, PlacementCardGridProps }; diff --git a/projects/js-packages/components/components/placement-card/stories/index.stories.tsx b/projects/js-packages/components/components/placement-card/stories/index.stories.tsx new file mode 100644 index 000000000000..5764f9401d13 --- /dev/null +++ b/projects/js-packages/components/components/placement-card/stories/index.stories.tsx @@ -0,0 +1,90 @@ +import { useCallback, useState } from 'react'; +import PlacementCard, { PlacementCardGrid } from '../index.tsx'; +import type { Meta, StoryFn } from '@storybook/react'; + +const meta: Meta< typeof PlacementCard > = { + title: 'JS Packages/Components/Placement Card', + component: PlacementCard, + argTypes: { + illustration: { table: { disable: true } }, + onChange: { table: { disable: true } }, + onPreviewClick: { table: { disable: true } }, + }, +}; + +export default meta; + +const Wireframe = ( { before }: { before?: boolean } ) => { + const top = before ? 56 : 24; + return ( + + ); +}; + +const Template: StoryFn< typeof PlacementCard > = args => { + const [ checked, setChecked ] = useState( args.checked ); + const handleChange = useCallback( ( _name: string, next: boolean ) => setChecked( next ), [] ); + + return ( +
+ +
+ ); +}; + +export const _default = Template.bind( {} ); +_default.args = { + id: 'placement-after-content', + name: 'after_content', + title: 'After the post content', + illustration: , + checked: false, +}; + +export const WithPreviewLink = Template.bind( {} ); +WithPreviewLink.args = { + ..._default.args, + checked: true, + previewUrl: '#', +}; + +export const Disabled = Template.bind( {} ); +Disabled.args = { + ..._default.args, + disabled: true, +}; + +export const Grid: StoryFn< typeof PlacementCard > = () => { + const [ selected, setSelected ] = useState< Record< string, boolean > >( { + before_content: true, + } ); + const handleChange = useCallback( + ( name: string, next: boolean ) => setSelected( prev => ( { ...prev, [ name ]: next } ) ), + [] + ); + + return ( + + } + checked={ Boolean( selected.before_content ) } + onChange={ handleChange } + /> + } + checked={ Boolean( selected.after_content ) } + onChange={ handleChange } + /> + + ); +}; diff --git a/projects/js-packages/components/components/placement-card/style.scss b/projects/js-packages/components/components/placement-card/style.scss new file mode 100644 index 000000000000..6a4958b84416 --- /dev/null +++ b/projects/js-packages/components/components/placement-card/style.scss @@ -0,0 +1,79 @@ +// `Card.Root` draws the surface, border and radius; +// this adds the states and the checkbox position. + +.jp-placement-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: var(--wpds-dimension-gap-md); + row-gap: var(--wpds-dimension-gap-2xl); + + @media (max-width: 720px) { + grid-template-columns: minmax(0, 1fr); + } +} + +.jp-placement-card { + position: relative; + + &[data-checked="true"] { + border-color: var(--wpds-color-stroke-interactive-brand-active); + } + + &:hover:not([data-checked="true"]) { + border-color: var(--wpds-color-stroke-surface-neutral-strong); + } + + &:has(input:focus-visible) { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: no-preference) { + transition: border-color 0.15s ease-out; + } +} + +.jp-placement-card__surface { + display: block; + cursor: var(--wpds-cursor-control); +} + +.jp-placement-card__illustration { + aspect-ratio: 294 / 192; + background: var(--wpds-color-background-surface-neutral-weak); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + > svg { + display: block; + inline-size: 100%; + block-size: 100%; + } +} + +.jp-placement-card__checkbox { + position: absolute; + inset-block-start: var(--wpds-dimension-padding-md); + inset-inline-start: var(--wpds-dimension-padding-md); + + // Zero the BaseControl chrome until `@wordpress/ui` + // ships a checkbox of its own. + .components-base-control, + .components-base-control__field, + .components-checkbox-control, + .components-checkbox-control__input-container { + margin: 0; + padding: 0; + } + + .components-checkbox-control__input { + margin: 0; + } + + // The title under the card names the checkbox, so hide the empty label. + .components-checkbox-control__label { + display: none; + } +} diff --git a/projects/js-packages/components/components/placement-card/test/component.tsx b/projects/js-packages/components/components/placement-card/test/component.tsx new file mode 100644 index 000000000000..5091f15381ca --- /dev/null +++ b/projects/js-packages/components/components/placement-card/test/component.tsx @@ -0,0 +1,73 @@ +import { jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import PlacementCard from '../index.tsx'; +import type { PlacementCardProps } from '../types.ts'; + +const renderCard = ( props: Partial< PlacementCardProps > = {} ) => { + const onChange = jest.fn(); + const onPreviewClick = jest.fn(); + render( + Wireframe } + checked={ false } + onChange={ onChange } + onPreviewClick={ onPreviewClick } + { ...props } + /> + ); + return { onChange, onPreviewClick }; +}; + +describe( 'PlacementCard', () => { + // Catches the title id and `aria-labelledby` drifting apart, which leaves the checkbox unnamed. + it( 'names the checkbox with the title', () => { + renderCard( { checked: true } ); + + expect( screen.getByRole( 'checkbox', { name: 'After the post content' } ) ).toBeChecked(); + } ); + + // Catches the label's `htmlFor` no longer matching the checkbox, or `onChange` losing the name. + it.each( [ + [ 'ticks', false, true ], + [ 'unticks', true, false ], + ] )( '%s the placement when the illustration is clicked', async ( _, checked, next ) => { + const { onChange } = renderCard( { checked } ); + + await userEvent.click( screen.getByText( 'Wireframe' ) ); + + expect( onChange ).toHaveBeenCalledWith( 'after_content', next ); + } ); + + // Catches the title moving inside the label, where a click meant for the link toggles the card. + it( 'does not toggle when the title is clicked', async () => { + const { onChange } = renderCard(); + + await userEvent.click( screen.getByText( 'After the post content' ) ); + + expect( onChange ).not.toHaveBeenCalled(); + } ); + + // Catches a link that opens an empty preview for a placement the parent has not saved. + it.each( [ + [ 'shows', 'https://example.com/wp-admin/site-editor.php', 1 ], + [ 'hides', undefined, 0 ], + ] )( '%s the preview link', ( _, previewUrl, count ) => { + renderCard( { previewUrl } ); + + // By text: without an `href` the anchor has no link role, so a role query would miss it. + expect( screen.queryAllByText( 'Preview and edit' ) ).toHaveLength( count ); + } ); + + // Catches the preview handler losing the name the parent uses to tell placements apart. + it( 'passes its name to the preview handler', async () => { + const { onPreviewClick } = renderCard( { previewUrl: '#preview' } ); + + await userEvent.click( screen.getByRole( 'link', { name: 'Preview and edit' } ) ); + + expect( onPreviewClick ).toHaveBeenCalledWith( 'after_content' ); + } ); +} ); diff --git a/projects/js-packages/components/components/placement-card/types.ts b/projects/js-packages/components/components/placement-card/types.ts new file mode 100644 index 000000000000..1e3f16ee033c --- /dev/null +++ b/projects/js-packages/components/components/placement-card/types.ts @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; + +export type PlacementCardProps = { + /** + * Id of the checkbox; the illustration's label points at it. + */ + id: string; + /** + * Passed back to `onChange` and `onPreviewClick`, so one handler can serve every card. + */ + name: string; + /** + * Caption under the card, and the checkbox's accessible name. + */ + title: string; + /** + * Wireframe of the placement, drawn at a 294×192 aspect ratio. + */ + illustration?: ReactNode; + /** + * Whether the placement is selected. + */ + checked: boolean; + /** + * Called with the card's `name` and the next checked value. + */ + onChange: ( name: string, next: boolean ) => void; + /** + * Shows a "Preview and edit" link to this URL under the title. + */ + previewUrl?: string; + /** + * Called with the card's `name` when the "Preview and edit" link is clicked. + */ + onPreviewClick?: ( name: string ) => void; + /** + * Disables the checkbox. + */ + disabled?: boolean; +}; + +export type PlacementCardGridProps = { + /** + * The `PlacementCard`s to lay out. + */ + children: ReactNode; + /** + * Extra class for the grid. + */ + className?: string; +}; diff --git a/projects/js-packages/components/index.ts b/projects/js-packages/components/index.ts index 96fe8a3f0620..b664b4708cf2 100644 --- a/projects/js-packages/components/index.ts +++ b/projects/js-packages/components/index.ts @@ -24,6 +24,11 @@ export { default as AutomatticForAgenciesLogo } from './components/automattic-fo export { default as JetpackFooter } from './components/jetpack-footer/index.tsx'; export { default as Gravatar } from './components/gravatar/index.tsx'; export type { GravatarProps } from './components/gravatar/index.tsx'; +export { default as PlacementCard, PlacementCardGrid } from './components/placement-card/index.tsx'; +export type { + PlacementCardProps, + PlacementCardGridProps, +} from './components/placement-card/types.ts'; export { default as IconTooltip } from './components/icon-tooltip/index.tsx'; export { default as ActionButton } from './components/action-button/index.tsx'; export { default as PricingCard } from './components/pricing-card/index.tsx'; diff --git a/projects/js-packages/components/package.json b/projects/js-packages/components/package.json index 21fed2ae8173..5919170309ea 100644 --- a/projects/js-packages/components/package.json +++ b/projects/js-packages/components/package.json @@ -58,6 +58,11 @@ "types": "./build/components/jitm-slot/index.d.ts", "default": "./build/components/jitm-slot/index.js" }, + "./placement-card": { + "jetpack:src": "./components/placement-card/index.tsx", + "types": "./build/components/placement-card/index.d.ts", + "default": "./build/components/placement-card/index.js" + }, "./pricing-table": { "jetpack:src": "./components/pricing-table/index.tsx", "types": "./build/components/pricing-table/index.d.ts", diff --git a/projects/packages/newsletter/changelog/update-placement-card-shared-component b/projects/packages/newsletter/changelog/update-placement-card-shared-component new file mode 100644 index 000000000000..c657f83b952f --- /dev/null +++ b/projects/packages/newsletter/changelog/update-placement-card-shared-component @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Settings: Use the shared PlacementCard component for subscription placements. diff --git a/projects/packages/newsletter/src/settings/sections/placement-card.scss b/projects/packages/newsletter/src/settings/sections/placement-card.scss deleted file mode 100644 index 746b9a32d92a..000000000000 --- a/projects/packages/newsletter/src/settings/sections/placement-card.scss +++ /dev/null @@ -1,96 +0,0 @@ -// PlacementCard — selected / hover / focus states layered on top of the -// `Card.Root` primitive. The card surface itself (border, radius, background, -// overflow:clip, flex column) comes from `Card.Root`; this file only carries -// the interactive-state colours and the absolute checkbox positioning. - -.jetpack-newsletter-placements-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - // Tighter horizontal gap between the two columns; more breathing room - // between rows so the second row reads as a separate band. - column-gap: var(--wpds-dimension-gap-md); - row-gap: var(--wpds-dimension-gap-2xl); - - @media (max-width: 720px) { - grid-template-columns: minmax(0, 1fr); - } -} - -.jetpack-newsletter-placement__card { - // Position context for the absolute-positioned checkbox below. - position: relative; - - // Selected state: brand-toned active stroke (the WPDS primary blue). - &[data-checked="true"] { - border-color: var(--wpds-color-stroke-interactive-brand-active); - } - - &:hover:not([data-checked="true"]) { - border-color: var(--wpds-color-stroke-surface-neutral-strong); - } - - // Focus ring on the card itself (not the checkbox input), using the - // canonical brand focus token. - &:has(input:focus-visible) { - outline: - var(--wpds-border-width-focus) solid - var(--wpds-color-stroke-focus); - outline-offset: 2px; - } - - // Only animate the border-color when the user hasn't asked to reduce - // motion — respects `prefers-reduced-motion: reduce`. - @media (prefers-reduced-motion: no-preference) { - transition: border-color 0.15s ease-out; - } -} - -.jetpack-newsletter-placement__surface { - display: block; - cursor: var(--wpds-cursor-control); -} - -.jetpack-newsletter-placement__illustration { - // Match the v2 SVG's intrinsic 294×192 aspect so the wireframe - // fills the card surface edge-to-edge. - aspect-ratio: 294 / 192; - background: var(--wpds-color-background-surface-neutral-weak); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - - > svg { - display: block; - inline-size: 100%; - block-size: 100%; - } -} - -.jetpack-newsletter-placement__checkbox { - position: absolute; - inset-block-start: var(--wpds-dimension-padding-md); - inset-inline-start: var(--wpds-dimension-padding-md); - - // CheckboxControl from `@wordpress/components` wraps the native input in - // BaseControl chrome (margin/padding from `__field` + `__input-container`). - // `@wordpress/ui` doesn't yet ship a Form.Checkbox primitive, so until it - // does we zero the chrome here. Revisit once WP UI exposes a checkbox. - .components-base-control, - .components-base-control__field, - .components-checkbox-control, - .components-checkbox-control__input-container { - margin: 0; - padding: 0; - } - - .components-checkbox-control__input { - margin: 0; - } - - // The empty fallback