diff --git a/.changeset/blankslate-ga-ready.md b/.changeset/blankslate-ga-ready.md new file mode 100644 index 00000000000..ca1e6e52b73 --- /dev/null +++ b/.changeset/blankslate-ga-ready.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Blankslate: Promote component to the main `@primer/react` entrypoint and add polymorphic action exports diff --git a/packages/react/src/Blankslate/Blankslate.docs.json b/packages/react/src/Blankslate/Blankslate.docs.json index 98babaf6230..aa8c383870f 100644 --- a/packages/react/src/Blankslate/Blankslate.docs.json +++ b/packages/react/src/Blankslate/Blankslate.docs.json @@ -1,35 +1,35 @@ { "id": "blankslate", "name": "Blankslate", - "status": "draft", + "status": "alpha", "a11yReviewed": "2025-01-08", "stories": [ { - "id": "experimental-components-blankslate--default" + "id": "components-blankslate--default" }, { - "id": "experimental-components-blankslate-features--with-visual" + "id": "components-blankslate-features--with-visual" }, { - "id": "experimental-components-blankslate-features--with-primary-action-as-link" + "id": "components-blankslate-features--with-action-as-link" }, { - "id": "experimental-components-blankslate-features--with-primary-action-as-button" + "id": "components-blankslate-features--with-action-as-button" }, { - "id": "experimental-components-blankslate-features--with-secondary-action" + "id": "components-blankslate-features--with-secondary-action" }, { - "id": "experimental-components-blankslate-features--with-border" + "id": "components-blankslate-features--with-border" }, { - "id": "experimental-components-blankslate-features--narrow" + "id": "components-blankslate-features--narrow" }, { - "id": "experimental-components-blankslate-features--spacious" + "id": "components-blankslate-features--spacious" } ], - "importPath": "@primer/react/experimental", + "importPath": "@primer/react", "props": [ { "name": "border", @@ -55,7 +55,7 @@ { "name": "size", "type": "'small' | 'medium' | 'large'", - "description": "The size of the componeont", + "description": "The size of the component", "defaultValue": "'medium'" } ], @@ -77,9 +77,39 @@ "name": "Blankslate.Description", "props": [] }, + { + "name": "Blankslate.Action", + "props": [ + { + "name": "as", + "type": "'button' | 'a'", + "required": false, + "description": "The element to render for the action." + }, + { + "name": "href", + "type": "string", + "required": false, + "description": "Link to complete the action. If defined, the action will render as an anchor." + }, + { + "name": "variant", + "type": "'primary' | 'secondary'", + "required": false, + "defaultValue": "'primary'", + "description": "The visual treatment for the action." + } + ] + }, { "name": "Blankslate.PrimaryAction", "props": [ + { + "name": "as", + "type": "'button' | 'a'", + "required": false, + "description": "The element to render for the action." + }, { "name": "href", "type": "string", @@ -91,10 +121,17 @@ { "name": "Blankslate.SecondaryAction", "props": [ + { + "name": "as", + "type": "'button' | 'a'", + "required": false, + "description": "The element to render for the action." + }, { "name": "href", "type": "string", - "description": "Link to complete secondary action" + "required": false, + "description": "Link to complete secondary action. If omitted, the action will render as a button." } ] } diff --git a/packages/react/src/Blankslate/Blankslate.features.stories.tsx b/packages/react/src/Blankslate/Blankslate.features.stories.tsx index 74224196eab..a0cd4a9cd56 100644 --- a/packages/react/src/Blankslate/Blankslate.features.stories.tsx +++ b/packages/react/src/Blankslate/Blankslate.features.stories.tsx @@ -4,12 +4,13 @@ import {Blankslate} from '../Blankslate' import {ConfirmationDialog} from '../ConfirmationDialog/ConfirmationDialog' export default { - title: 'Experimental/Components/Blankslate/Features', + title: 'Components/Blankslate/Features', component: Blankslate, subcomponents: { 'Blankslate.Visual': Blankslate.Visual, 'Blankslate.Heading': Blankslate.Heading, 'Blankslate.Description': Blankslate.Description, + 'Blankslate.Action': Blankslate.Action, 'Blankslate.PrimaryAction': Blankslate.PrimaryAction, 'Blankslate.SecondaryAction': Blankslate.SecondaryAction, }, @@ -25,18 +26,18 @@ export const WithVisual = () => ( ) -export const WithPrimaryActionAsLink = () => ( +export const WithActionAsLink = () => ( Blankslate heading Use it to provide information when no dynamic content exists. - Primary action + Primary action ) -export const WithPrimaryActionAsButton = () => { +export const WithActionAsButton = () => { const [isOpen, setIsOpen] = React.useState(false) const onDialogClose = React.useCallback(() => setIsOpen(false), []) @@ -48,7 +49,7 @@ export const WithPrimaryActionAsButton = () => { Blankslate heading Use it to provide information when no dynamic content exists. - setIsOpen(true)}>Primary action + setIsOpen(true)}>Primary action {isOpen ? ( ( Blankslate heading Use it to provide information when no dynamic content exists. - Secondary action + + Secondary action + ) @@ -113,8 +116,10 @@ export const SizeSmall = () => ( Blankslate heading Use it to provide information when no dynamic content exists. - Primary action - Secondary action + Primary action + + Secondary action + ) @@ -125,7 +130,9 @@ export const SizeLarge = () => ( Blankslate heading Use it to provide information when no dynamic content exists. - Primary action - Secondary action + Primary action + + Secondary action + ) diff --git a/packages/react/src/Blankslate/Blankslate.stories.tsx b/packages/react/src/Blankslate/Blankslate.stories.tsx index 571a68a5c53..b8fe41d3f41 100644 --- a/packages/react/src/Blankslate/Blankslate.stories.tsx +++ b/packages/react/src/Blankslate/Blankslate.stories.tsx @@ -4,12 +4,13 @@ import {Blankslate} from '../Blankslate' import type {ComponentProps} from '../utils/types' export default { - title: 'Experimental/Components/Blankslate', + title: 'Components/Blankslate', component: Blankslate, subcomponents: { 'Blankslate.Visual': Blankslate.Visual, 'Blankslate.Heading': Blankslate.Heading, 'Blankslate.Description': Blankslate.Description, + 'Blankslate.Action': Blankslate.Action, 'Blankslate.PrimaryAction': Blankslate.PrimaryAction, 'Blankslate.SecondaryAction': Blankslate.SecondaryAction, }, @@ -25,8 +26,10 @@ export const Default = () => ( Wikis provide a place in your repository to lay out the roadmap of your project, show the current status, and document software better, together. - Create the first page - Learn more about wikis + Create the first page + + Learn more about wikis + ) @@ -42,8 +45,12 @@ export const Playground: StoryFn< Wikis provide a place in your repository to lay out the roadmap of your project, show the current status, and document software better, together. - {primaryAction ? Create the first page : null} - {secondaryAction ? Learn more about wikis : null} + {primaryAction ? Create the first page : null} + {secondaryAction ? ( + + Learn more about wikis + + ) : null} ) diff --git a/packages/react/src/Blankslate/Blankslate.test.tsx b/packages/react/src/Blankslate/Blankslate.test.tsx index 46e2824c9d5..5e2163fd0b3 100644 --- a/packages/react/src/Blankslate/Blankslate.test.tsx +++ b/packages/react/src/Blankslate/Blankslate.test.tsx @@ -31,6 +31,7 @@ describe('Blankslate', () => { Test Heading Test description + Action Primary action Secondary action , @@ -47,6 +48,9 @@ describe('Blankslate', () => { expect( container.querySelector('[data-component="Blankslate"] [data-component="Blankslate.Description"]'), ).toBeInTheDocument() + expect( + container.querySelector('[data-component="Blankslate"] [data-component="Blankslate.Action"]'), + ).toBeInTheDocument() expect( container.querySelector('[data-component="Blankslate"] [data-component="Blankslate.PrimaryAction"]'), ).toBeInTheDocument() @@ -100,6 +104,80 @@ describe('Blankslate', () => { }) }) + describe('Blankslate.Action', () => { + it('should render a primary action button by default', () => { + render( + + Action + , + ) + expect(screen.getByRole('button', {name: 'Action'})).toBeInTheDocument() + expect(screen.getByRole('button', {name: 'Action'})).toHaveAttribute('data-variant', 'primary') + }) + + it('should handle click events on the button', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + render( + + Action + , + ) + + await user.click(screen.getByRole('button', {name: 'Action'})) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should render as an anchor when href is provided', () => { + render( + + Action + , + ) + const link = screen.getByRole('link', {name: 'Action'}) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', 'https://example.com') + }) + + it('should render secondary actions as links by default', () => { + render( + + + Action + + , + ) + const link = screen.getByRole('link', {name: 'Action'}) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', 'https://example.com') + expect(link).toHaveAttribute('data-component', 'Link') + }) + + it('should render secondary actions as buttons', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + render( + + + Action + + , + ) + + await user.click(screen.getByRole('button', {name: 'Action'})) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should render small primary actions when the Blankslate is small', () => { + render( + + Action + , + ) + expect(screen.getByRole('button', {name: 'Action'})).toHaveAttribute('data-size', 'small') + }) + }) + describe('Blankslate.PrimaryAction', () => { it('should render a primary action button', () => { render( @@ -146,5 +224,20 @@ describe('Blankslate', () => { expect(link).toBeInTheDocument() expect(link).toHaveAttribute('href', 'https://example.com') }) + + it('should render a secondary action button', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + render( + + + Secondary action + + , + ) + + await user.click(screen.getByRole('button', {name: 'Secondary action'})) + expect(onClick).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/packages/react/src/Blankslate/Blankslate.tsx b/packages/react/src/Blankslate/Blankslate.tsx index fa785e9991b..2c134bbf142 100644 --- a/packages/react/src/Blankslate/Blankslate.tsx +++ b/packages/react/src/Blankslate/Blankslate.tsx @@ -1,8 +1,9 @@ import {clsx} from 'clsx' -import {useMemo} from 'react' +import {forwardRef, useMemo, type JSX} from 'react' import type React from 'react' -import {Button} from '../Button' +import {ButtonBase} from '../Button' import Link from '../Link' +import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {Provider, useBlankslate} from './BlankslateContext' import classes from './Blankslate.module.css' @@ -98,50 +99,115 @@ function Description({children, className, ...rest}: BlankslateDescriptionProps) ) } -type BlankslatePrimaryActionProps = - | (React.PropsWithChildren<{ +type BlankslateActionVariant = 'primary' | 'secondary' + +type BlankslateActionElementProps = + | { + as?: 'button' href?: never - }> & - React.ComponentPropsWithoutRef<'button'>) - | React.PropsWithChildren<{ + } + | { + as?: 'a' href: string - }> + } -function PrimaryAction({children, href, ...props}: BlankslatePrimaryActionProps) { - const {size} = useBlankslate() - return ( -
- -
- ) -} +type BlankslateActionProps = React.PropsWithChildren< + BlankslateActionElementProps & { + className?: string -type BlankslateSecondaryActionProps = React.PropsWithChildren<{ - href: string -}> + /** + * Specify the visual treatment of the action + */ + variant?: BlankslateActionVariant + } +> -function SecondaryAction({children, href}: BlankslateSecondaryActionProps) { - return ( -
- {children} -
- ) +type BlankslateActionInternalProps = BlankslateActionProps & { + dataComponent?: string } -export {Blankslate, Visual, Heading, Description, PrimaryAction, SecondaryAction} +const ActionBase = forwardRef( + ( + {as, children, className, dataComponent = 'Blankslate.Action', href, variant = 'primary', ...props}, + forwardedRef, + ): JSX.Element => { + const {size} = useBlankslate() + const Component = as ?? (href ? 'a' : 'button') + const anchorRef = forwardedRef as React.ForwardedRef + const buttonRef = forwardedRef as React.ForwardedRef + + return ( +
+ {variant === 'primary' ? ( + Component === 'a' ? ( + + {children} + + ) : ( + + {children} + + ) + ) : Component === 'a' ? ( + + {children} + + ) : ( + + {children} + + )} +
+ ) + }, +) + +const Action = ActionBase as PolymorphicForwardRefComponent<'button', BlankslateActionProps> + +Action.displayName = 'Blankslate.Action' + +type BlankslatePrimaryActionProps = BlankslateActionElementProps + +const PrimaryAction = forwardRef( + (props: BlankslatePrimaryActionProps, forwardedRef: React.ForwardedRef) => { + return + }, +) as PolymorphicForwardRefComponent<'button', BlankslatePrimaryActionProps> + +PrimaryAction.displayName = 'Blankslate.PrimaryAction' + +type BlankslateSecondaryActionProps = BlankslateActionElementProps + +const SecondaryAction = forwardRef( + (props: BlankslateSecondaryActionProps, forwardedRef: React.ForwardedRef) => { + return + }, +) as PolymorphicForwardRefComponent<'button', BlankslateSecondaryActionProps> + +SecondaryAction.displayName = 'Blankslate.SecondaryAction' + +export {Blankslate, Visual, Heading, Description, Action, PrimaryAction, SecondaryAction} export type { BlankslateProps, BlankslateVisualProps, BlankslateHeadingProps, BlankslateDescriptionProps, + BlankslateActionProps, BlankslatePrimaryActionProps, BlankslateSecondaryActionProps, } diff --git a/packages/react/src/Blankslate/Blankslate.types.test.tsx b/packages/react/src/Blankslate/Blankslate.types.test.tsx new file mode 100644 index 00000000000..2d1b19b009c --- /dev/null +++ b/packages/react/src/Blankslate/Blankslate.types.test.tsx @@ -0,0 +1,74 @@ +import {useRef} from 'react' +import {Blankslate} from '../Blankslate' + +export function ActionAcceptsButtonProps() { + const buttonRef = useRef(null) + + return ( + + { + buttonRef.current = event.currentTarget + }} + > + Action + + + ) +} + +export function ActionAcceptsLinkProps() { + const linkRef = useRef(null) + + return ( + + { + linkRef.current = event.currentTarget + }} + > + Action + + + ) +} + +export function secondaryActionAcceptsButtonProps() { + return ( + + {}}> + Action + + + ) +} + +export function secondaryActionAcceptsLinkProps() { + return ( + + Action + + ) +} + +export function actionShouldOnlyAcceptValidVariants() { + return ( + + {/* @ts-expect-error variant should be either primary or secondary */} + Action + + ) +} + +export function actionShouldOnlyAcceptButtonOrAnchorElements() { + return ( + + {/* @ts-expect-error as prop should be button or anchor */} + Action + + ) +} diff --git a/packages/react/src/Blankslate/index.tsx b/packages/react/src/Blankslate/index.tsx index 7adcb70ee54..441c49f689c 100644 --- a/packages/react/src/Blankslate/index.tsx +++ b/packages/react/src/Blankslate/index.tsx @@ -1,9 +1,10 @@ -import {Blankslate, Visual, Heading, Description, PrimaryAction, SecondaryAction} from './Blankslate' +import {Blankslate, Visual, Heading, Description, Action, PrimaryAction, SecondaryAction} from './Blankslate' import type { BlankslateProps, BlankslateVisualProps, BlankslateHeadingProps, BlankslateDescriptionProps, + BlankslateActionProps, BlankslatePrimaryActionProps, BlankslateSecondaryActionProps, } from './Blankslate' @@ -12,6 +13,7 @@ const BlankslateContainer = Object.assign(Blankslate, { Visual, Heading, Description, + Action, PrimaryAction, SecondaryAction, }) @@ -22,6 +24,7 @@ export type { BlankslateVisualProps, BlankslateHeadingProps, BlankslateDescriptionProps, + BlankslateActionProps, BlankslatePrimaryActionProps, BlankslateSecondaryActionProps, } diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 8f8eb151e14..394a848eccb 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -35,6 +35,14 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type BannerProps", "BaseStyles", "type BaseStylesProps", + "Blankslate", + "type BlankslateActionProps", + "type BlankslateDescriptionProps", + "type BlankslateHeadingProps", + "type BlankslatePrimaryActionProps", + "type BlankslateProps", + "type BlankslateSecondaryActionProps", + "type BlankslateVisualProps", "BranchName", "type BranchNameProps", "Breadcrumb", @@ -284,7 +292,13 @@ exports[`@primer/react/experimental > should not update exports without a semver "AriaStatus", "type AriaStatusProps", "Blankslate", + "type BlankslateActionProps", + "type BlankslateDescriptionProps", + "type BlankslateHeadingProps", + "type BlankslatePrimaryActionProps", "type BlankslateProps", + "type BlankslateSecondaryActionProps", + "type BlankslateVisualProps", "ButtonBase", "type ButtonBaseProps", "Card", diff --git a/packages/react/src/experimental/index.ts b/packages/react/src/experimental/index.ts index f8841fec548..65d98a4a1a5 100644 --- a/packages/react/src/experimental/index.ts +++ b/packages/react/src/experimental/index.ts @@ -9,7 +9,15 @@ 'use client' export {Blankslate} from '../Blankslate' -export type {BlankslateProps} from '../Blankslate' +export type { + BlankslateActionProps, + BlankslateDescriptionProps, + BlankslateHeadingProps, + BlankslatePrimaryActionProps, + BlankslateProps, + BlankslateSecondaryActionProps, + BlankslateVisualProps, +} from '../Blankslate' export {ButtonBase} from '../Button' export type {ButtonBaseProps} from '../Button' diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0c723ddcc2e..268422e7c15 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -81,6 +81,16 @@ export {default as AvatarStack} from './AvatarStack' export type {AvatarStackProps} from './AvatarStack' export {Banner} from './Banner' export type {BannerProps} from './Banner' +export {Blankslate} from './Blankslate' +export type { + BlankslateActionProps, + BlankslateDescriptionProps, + BlankslateHeadingProps, + BlankslatePrimaryActionProps, + BlankslateProps, + BlankslateSecondaryActionProps, + BlankslateVisualProps, +} from './Blankslate' export {default as BranchName} from './BranchName' export type {BranchNameProps} from './BranchName'