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..e9a50391a870 --- /dev/null +++ b/projects/js-packages/components/changelog/update-placement-card-shared-component @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a CheckboxCard component. diff --git a/projects/js-packages/components/components/checkbox-card/index.tsx b/projects/js-packages/components/components/checkbox-card/index.tsx new file mode 100644 index 000000000000..d16028fe72f3 --- /dev/null +++ b/projects/js-packages/components/components/checkbox-card/index.tsx @@ -0,0 +1,79 @@ +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Card, Checkbox, Field, Link, Stack, Text } from '@wordpress/ui'; +import clsx from 'clsx'; +import './style.scss'; +import type { CheckboxCardGridProps, CheckboxCardProps } from './types.ts'; +import type { JSX } from 'react'; + +/** + * Lays out checkbox cards in two columns, or one on narrow screens. + * + * @param {CheckboxCardGridProps} props - Component props. + * @return {JSX.Element} The grid. + */ +export function CheckboxCardGrid( { children, className }: CheckboxCardGridProps ): JSX.Element { + return
{ children }
; +} + +/** + * A card with a checkbox, toggled by a click on its illustration. + * + * @param {CheckboxCardProps} props - Component props. + * @return {JSX.Element} The card. + */ +export default function CheckboxCard( { + id, + name, + title, + illustration, + checked, + onChange, + previewUrl, + onPreviewClick, + disabled, +}: CheckboxCardProps ): 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. */ } + { illustration } + + + + + + + { title } + + { previewUrl && ( + + { __( 'Preview and edit', 'jetpack-components' ) } + + ) } + + + ); +} + +export { CheckboxCard }; +export type { CheckboxCardProps, CheckboxCardGridProps }; diff --git a/projects/js-packages/components/components/checkbox-card/stories/index.stories.tsx b/projects/js-packages/components/components/checkbox-card/stories/index.stories.tsx new file mode 100644 index 000000000000..bab83dc42ccf --- /dev/null +++ b/projects/js-packages/components/components/checkbox-card/stories/index.stories.tsx @@ -0,0 +1,90 @@ +import { useCallback, useState } from 'react'; +import CheckboxCard, { CheckboxCardGrid } from '../index.tsx'; +import type { Meta, StoryFn } from '@storybook/react'; + +const meta: Meta< typeof CheckboxCard > = { + title: 'JS Packages/Components/Checkbox Card', + component: CheckboxCard, + 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 CheckboxCard > = 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 CheckboxCard > = () => { + 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/checkbox-card/style.scss b/projects/js-packages/components/components/checkbox-card/style.scss new file mode 100644 index 000000000000..015741a4e540 --- /dev/null +++ b/projects/js-packages/components/components/checkbox-card/style.scss @@ -0,0 +1,54 @@ +@use "@automattic/jetpack-base-styles/gutenberg-base-styles" as gb; + +.jp-checkbox-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-checkbox-card { + + &[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(:focus-visible) { + + @include gb.outset-ring__focus(); + } + + @media (prefers-reduced-motion: no-preference) { + transition: border-color 0.15s ease-out; + } +} + +.jp-checkbox-card__surface { + position: relative; +} + +.jp-checkbox-card__illustration { + display: flex; + aspect-ratio: 294 / 192; + background: var(--wpds-color-background-surface-neutral-weak); + cursor: var(--wpds-cursor-control); + + > svg { + inline-size: 100%; + block-size: 100%; + } +} + +.jp-checkbox-card__checkbox { + position: absolute; + inset-block-start: var(--wpds-dimension-padding-md); + inset-inline-start: var(--wpds-dimension-padding-md); +} diff --git a/projects/js-packages/components/components/checkbox-card/test/component.tsx b/projects/js-packages/components/components/checkbox-card/test/component.tsx new file mode 100644 index 000000000000..1e2567d53306 --- /dev/null +++ b/projects/js-packages/components/components/checkbox-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 CheckboxCard from '../index.tsx'; +import type { CheckboxCardProps } from '../types.ts'; + +const renderCard = ( props: Partial< CheckboxCardProps > = {} ) => { + const onChange = jest.fn(); + const onPreviewClick = jest.fn(); + render( + Wireframe } + checked={ false } + onChange={ onChange } + onPreviewClick={ onPreviewClick } + { ...props } + /> + ); + return { onChange, onPreviewClick }; +}; + +describe( 'CheckboxCard', () => { + // 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 card 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 the link rendering without a `previewUrl`, or going missing when one is passed. + 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 cards 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/checkbox-card/types.ts b/projects/js-packages/components/components/checkbox-card/types.ts new file mode 100644 index 000000000000..eb6beb325de2 --- /dev/null +++ b/projects/js-packages/components/components/checkbox-card/types.ts @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; + +export type CheckboxCardProps = { + /** + * Id of the checkbox; the title's id is built from 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; + /** + * Picture filling the card, drawn at a 294×192 aspect ratio. + */ + illustration?: ReactNode; + /** + * Whether the card is checked. + */ + 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 CheckboxCardGridProps = { + /** + * The `CheckboxCard`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..8b32e6bf4396 100644 --- a/projects/js-packages/components/index.ts +++ b/projects/js-packages/components/index.ts @@ -24,6 +24,8 @@ 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 CheckboxCard, CheckboxCardGrid } from './components/checkbox-card/index.tsx'; +export type { CheckboxCardProps, CheckboxCardGridProps } from './components/checkbox-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..6fc5aadd8ff4 100644 --- a/projects/js-packages/components/package.json +++ b/projects/js-packages/components/package.json @@ -33,6 +33,11 @@ "types": "./build/components/button/index.d.ts", "default": "./build/components/button/index.js" }, + "./checkbox-card": { + "jetpack:src": "./components/checkbox-card/index.tsx", + "types": "./build/components/checkbox-card/index.d.ts", + "default": "./build/components/checkbox-card/index.js" + }, "./global-notices": { "jetpack:src": "./components/global-notices/index.ts", "types": "./build/components/global-notices/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..6bd2d6bb951b --- /dev/null +++ b/projects/packages/newsletter/changelog/update-placement-card-shared-component @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Settings: Use the shared CheckboxCard 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