From 365a7ec21c330da03230e28fcb42681a5589c82c Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 15 Jul 2026 12:19:36 -0500 Subject: [PATCH 1/4] feat: add BasePopover and useBasePopover --- package-lock.json | 8 + packages/react/package.json | 3 + .../src/BasePopover/BasePopover.docs.json | 76 +++++++++ .../BasePopover.features.stories.tsx | 50 ++++++ .../src/BasePopover/BasePopover.stories.tsx | 45 ++++++ .../src/BasePopover/BasePopover.test.tsx | 151 ++++++++++++++++++ .../react/src/BasePopover/BasePopover.tsx | 50 ++++++ .../src/BasePopover/BasePopoverContext.ts | 17 ++ packages/react/src/BasePopover/index.ts | 4 + .../react/src/BasePopover/useBasePopover.ts | 64 ++++++++ .../react/src/polyfills/invoker-commands.ts | 6 + packages/react/src/utils/mergeProps.ts | 60 +++++++ 12 files changed, 534 insertions(+) create mode 100644 packages/react/src/BasePopover/BasePopover.docs.json create mode 100644 packages/react/src/BasePopover/BasePopover.features.stories.tsx create mode 100644 packages/react/src/BasePopover/BasePopover.stories.tsx create mode 100644 packages/react/src/BasePopover/BasePopover.test.tsx create mode 100644 packages/react/src/BasePopover/BasePopover.tsx create mode 100644 packages/react/src/BasePopover/BasePopoverContext.ts create mode 100644 packages/react/src/BasePopover/index.ts create mode 100644 packages/react/src/BasePopover/useBasePopover.ts create mode 100644 packages/react/src/polyfills/invoker-commands.ts create mode 100644 packages/react/src/utils/mergeProps.ts diff --git a/package-lock.json b/package-lock.json index e763e69eb71..79ee5601e43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16294,6 +16294,13 @@ "node": ">= 0.4" } }, + "node_modules/invokers-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/invokers-polyfill/-/invokers-polyfill-1.0.3.tgz", + "integrity": "sha512-T8zVavFQu5GlWlpkjghmaCaTx6Wk+9tWwJ2BC4oNsNAnar6AIrvSJQFZUcYDfrxzK9thBIyZvAkeLfdN9hTzGA==", + "dev": true, + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -28437,6 +28444,7 @@ "filesize": "10.1.6", "front-matter": "4.0.2", "gzip-size": "6.0.0", + "invokers-polyfill": "^1.0.3", "jscodeshift": "0.15.0", "lodash.groupby": "4.6.0", "lodash.keyby": "4.6.0", diff --git a/packages/react/package.json b/packages/react/package.json index 2ab015f6430..8d62f43ba87 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -32,6 +32,8 @@ "typings": "dist/index.d.ts", "sideEffects": [ "dist/**/*.css", + "src/polyfills/*.tsx", + "dist/polyfills/*.tsx", "src/**/test-helpers.tsx", "dist/**/test-helpers.js" ], @@ -152,6 +154,7 @@ "filesize": "10.1.6", "front-matter": "4.0.2", "gzip-size": "6.0.0", + "invokers-polyfill": "^1.0.3", "jscodeshift": "0.15.0", "lodash.groupby": "4.6.0", "lodash.keyby": "4.6.0", diff --git a/packages/react/src/BasePopover/BasePopover.docs.json b/packages/react/src/BasePopover/BasePopover.docs.json new file mode 100644 index 00000000000..deeb71c8df9 --- /dev/null +++ b/packages/react/src/BasePopover/BasePopover.docs.json @@ -0,0 +1,76 @@ +{ + "id": "base-popover", + "name": "BasePopover", + "status": "alpha", + "a11yReviewed": false, + "stories": [ + { + "id": "components-basepopover--default" + }, + { + "id": "components-basepopover-features--auto" + }, + { + "id": "components-basepopover-features--hint" + }, + { + "id": "components-basepopover-features--manual" + } + ], + "importPath": "@primer/react", + "props": [ + { + "name": "children", + "type": "React.ReactNode", + "required": true, + "description": "The trigger, popover, and optional close elements." + }, + { + "name": "id", + "type": "string", + "defaultValue": "A generated string", + "description": "The id used to associate the trigger with the popover." + }, + { + "name": "popover", + "type": "'auto' | 'hint' | 'manual'", + "defaultValue": "'auto'", + "description": "Sets the native popover behavior. Auto and hint popovers support light dismiss, while manual popovers must be dismissed explicitly." + } + ], + "subcomponents": [ + { + "name": "BasePopover.Trigger", + "props": [ + { + "name": "children", + "type": "React.ReactNode", + "required": true, + "description": "The content of the button that toggles the popover." + } + ] + }, + { + "name": "BasePopover.Popover", + "props": [ + { + "name": "children", + "type": "React.ReactNode", + "required": true, + "description": "The content displayed in the popover, including an optional close control." + } + ] + }, + { + "name": "BasePopover.Close", + "props": [ + { + "name": "children", + "type": "React.ReactNode", + "required": true, + "description": "The content of the button that closes the popover." + } + ] + } + ] +} diff --git a/packages/react/src/BasePopover/BasePopover.features.stories.tsx b/packages/react/src/BasePopover/BasePopover.features.stories.tsx new file mode 100644 index 00000000000..3d67c29e92c --- /dev/null +++ b/packages/react/src/BasePopover/BasePopover.features.stories.tsx @@ -0,0 +1,50 @@ +import type {Meta, StoryObj} from '@storybook/react-vite' +import {Close, Popover, Root, Trigger} from './BasePopover' + +const meta = { + title: 'Components/BasePopover/Features', + component: Root, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Auto: Story = { + render: () => ( + <> + + Toggle popover + Popover content + + + + ), +} + +export const Hint: Story = { + render: () => ( + <> + + Toggle popover + Popover content + + + + ), +} + +export const Manual: Story = { + render: () => ( + <> + + Toggle popover + + Popover content + Close popover + + + + + ), +} diff --git a/packages/react/src/BasePopover/BasePopover.stories.tsx b/packages/react/src/BasePopover/BasePopover.stories.tsx new file mode 100644 index 00000000000..71c980d46fc --- /dev/null +++ b/packages/react/src/BasePopover/BasePopover.stories.tsx @@ -0,0 +1,45 @@ +import type {Meta, StoryObj} from '@storybook/react-vite' +import {Close, Popover, Root, Trigger} from './BasePopover' + +const meta = { + title: 'Components/BasePopover', + component: Root, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: args => ( + + Toggle popover + Popover content + + ), +} + +export const Playground: Story = { + render: args => ( + + Toggle popover + + Popover content + Close popover + + + ), + args: { + id: 'base-popover-playground', + popover: 'auto', + }, + argTypes: { + id: { + control: 'text', + }, + popover: { + control: 'radio', + options: ['auto', 'hint', 'manual'], + }, + }, +} diff --git a/packages/react/src/BasePopover/BasePopover.test.tsx b/packages/react/src/BasePopover/BasePopover.test.tsx new file mode 100644 index 00000000000..f5b4575db9b --- /dev/null +++ b/packages/react/src/BasePopover/BasePopover.test.tsx @@ -0,0 +1,151 @@ +import {render, screen} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {describe, expect, it, vi} from 'vitest' +import {Close, Popover, Root, Trigger} from './BasePopover' + +describe('BasePopover', () => { + it('connects the trigger and popover with a generated id', () => { + render( + + Toggle popover + + Popover content + Close popover + + , + ) + + const trigger = screen.getByRole('button', {name: 'Toggle popover'}) + const close = screen.getByRole('button', {name: 'Close popover', hidden: true}) + const popover = screen.getByTestId('popover') + + expect(popover.id).not.toBe('') + expect(trigger).toHaveAttribute('commandfor', popover.id) + expect(trigger).toHaveAttribute('command', 'toggle-popover') + expect(close).toHaveAttribute('commandfor', popover.id) + expect(close).toHaveAttribute('command', 'hide-popover') + }) + + it('uses a custom id to connect the trigger and popover', () => { + render( + + Toggle popover + + Popover content + Close popover + + , + ) + + expect(screen.getByRole('button', {name: 'Toggle popover'})).toHaveAttribute('commandfor', 'custom-popover') + expect(screen.getByRole('button', {name: 'Close popover', hidden: true})).toHaveAttribute( + 'commandfor', + 'custom-popover', + ) + expect(screen.getByText('Popover content')).toHaveAttribute('id', 'custom-popover') + }) + + it('uses an auto popover by default', () => { + render( + + Toggle popover + Popover content + , + ) + + expect(screen.getByText('Popover content')).toHaveAttribute('popover', 'auto') + }) + + it.each(['auto', 'hint', 'manual'] as const)('supports popover="%s"', popover => { + render( + + Toggle popover + Popover content + , + ) + + expect(screen.getByText('Popover content')).toHaveAttribute('popover', popover) + }) + + it('forwards props to the trigger, popover, and close elements', () => { + render( + + + Toggle popover + + + Popover content + + Close popover + + + , + ) + + const trigger = screen.getByRole('button', {name: 'Custom trigger'}) + const close = screen.getByRole('button', {name: 'Custom close', hidden: true}) + const popover = screen.getByTestId('popover') + + expect(trigger).toHaveClass('custom-trigger') + expect(trigger).toHaveAttribute('type', 'button') + expect(popover).toHaveClass('custom-popover') + expect(popover).toHaveAttribute('data-variant', 'custom') + expect(close).toHaveClass('custom-close') + expect(close).toHaveAttribute('type', 'button') + }) + + it('opens the popover from the trigger', async () => { + render( + + Toggle popover + Popover content + , + ) + + const trigger = screen.getByRole('button', {name: 'Toggle popover'}) + const popover = screen.getByTestId('popover') + + expect(popover).not.toBeVisible() + await userEvent.click(trigger) + expect(popover).toBeVisible() + }) + + it('closes a manual popover from the close button', async () => { + render( + + Toggle popover + + Popover content + Close popover + + , + ) + + const trigger = screen.getByRole('button', {name: 'Toggle popover'}) + const popover = screen.getByTestId('popover') + + await userEvent.click(trigger) + expect(popover).toBeVisible() + + await userEvent.click(screen.getByRole('button', {name: 'Close popover'})) + expect(popover).not.toBeVisible() + }) + + it('throws when compound components are rendered outside of Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + expect(() => render(Toggle popover)).toThrow( + 'useBasePopover must be used within a BasePopoverContext.Provider', + ) + expect(() => render(Popover content)).toThrow( + 'useBasePopover must be used within a BasePopoverContext.Provider', + ) + expect(() => render(Close popover)).toThrow( + 'useBasePopover must be used within a BasePopoverContext.Provider', + ) + } finally { + consoleError.mockRestore() + } + }) +}) diff --git a/packages/react/src/BasePopover/BasePopover.tsx b/packages/react/src/BasePopover/BasePopover.tsx new file mode 100644 index 00000000000..4f7cbc6aeb7 --- /dev/null +++ b/packages/react/src/BasePopover/BasePopover.tsx @@ -0,0 +1,50 @@ +import type {HTMLAttributes, PropsWithChildren} from 'react' +import {BasePopoverContext, useBasePopoverContext} from './BasePopoverContext' +import {useBasePopover} from './useBasePopover' +import type {UseBasePopoverConfig} from './useBasePopover' +import {mergeProps} from '../utils/mergeProps' + +type RootProps = PropsWithChildren + +function Root({children, id, popover}: RootProps) { + const value = useBasePopover({ + id, + popover, + }) + return {children} +} + +type TriggerProps = HTMLAttributes & {} + +function Trigger({children, ...rest}: TriggerProps) { + const {getTriggerProps} = useBasePopoverContext() + const triggerProps = getTriggerProps() + return ( + + ) +} + +type PopoverProps = HTMLAttributes & {} + +function Popover({children, ...rest}: PopoverProps) { + const {getPopoverProps} = useBasePopoverContext() + const popoverProps = getPopoverProps() + return
{children}
+} + +type CloseProps = HTMLAttributes & {} + +function Close({children, ...rest}: CloseProps) { + const {getCloseProps} = useBasePopoverContext() + const closeProps = getCloseProps() + return ( + + ) +} + +export {Root, Trigger, Popover, Close} +export type {RootProps, TriggerProps, PopoverProps, CloseProps} diff --git a/packages/react/src/BasePopover/BasePopoverContext.ts b/packages/react/src/BasePopover/BasePopoverContext.ts new file mode 100644 index 00000000000..8411fe63ec9 --- /dev/null +++ b/packages/react/src/BasePopover/BasePopoverContext.ts @@ -0,0 +1,17 @@ +import {createContext, useContext} from 'react' +import type {UsePopoverReturn} from './usePopover' + +type BasePopoverContextValue = UsePopoverReturn + +const BasePopoverContext = createContext(null) + +function useBasePopoverContext(): BasePopoverContextValue { + const context = useContext(BasePopoverContext) + if (context) { + return context + } + throw new Error('useBasePopover must be used within a BasePopoverContext.Provider') +} + +export {BasePopoverContext, useBasePopoverContext} +export type {BasePopoverContextValue} diff --git a/packages/react/src/BasePopover/index.ts b/packages/react/src/BasePopover/index.ts new file mode 100644 index 00000000000..2bfbcf06245 --- /dev/null +++ b/packages/react/src/BasePopover/index.ts @@ -0,0 +1,4 @@ +export {Root, Trigger, Popover, Close} from './BasePopover' +export type {RootProps, TriggerProps, PopoverProps, CloseProps} from './BasePopover' +export {useBasePopover} from './useBasePopover' +export type {UseBasePopoverConfig, UseBasePopoverReturn} from './useBasePopover' diff --git a/packages/react/src/BasePopover/useBasePopover.ts b/packages/react/src/BasePopover/useBasePopover.ts new file mode 100644 index 00000000000..4c87997b8c5 --- /dev/null +++ b/packages/react/src/BasePopover/useBasePopover.ts @@ -0,0 +1,64 @@ +import '../polyfills/invoker-commands' +import {useId} from '../hooks/useId' + +type UseBasePopoverConfig = { + id?: string + popover?: PopoverValue +} + +type UseBasePopoverReturn = { + getTriggerProps: () => BaseTriggerProps + getPopoverProps: () => BasePopoverProps + getCloseProps: () => BaseCloseProps +} + +type BaseTriggerProps = { + commandfor: string + command: 'toggle-popover' +} + +type PopoverValue = 'auto' | 'hint' | 'manual' + +type BasePopoverProps = { + id: string + popover: PopoverValue +} + +type BaseCloseProps = { + commandfor: string + command: 'hide-popover' +} + +function useBasePopover({id, popover = 'auto'}: UseBasePopoverConfig = {}): UseBasePopoverReturn { + const popoverId = useId(id) + + function getTriggerProps(): BaseTriggerProps { + return { + commandfor: popoverId, + command: 'toggle-popover', + } + } + + function getPopoverProps(): BasePopoverProps { + return { + id: popoverId, + popover, + } + } + + function getCloseProps(): BaseCloseProps { + return { + commandfor: popoverId, + command: 'hide-popover', + } + } + + return { + getTriggerProps, + getPopoverProps, + getCloseProps, + } +} + +export {useBasePopover} +export type {UseBasePopoverConfig, UseBasePopoverReturn} diff --git a/packages/react/src/polyfills/invoker-commands.ts b/packages/react/src/polyfills/invoker-commands.ts new file mode 100644 index 00000000000..7f34f3c3b27 --- /dev/null +++ b/packages/react/src/polyfills/invoker-commands.ts @@ -0,0 +1,6 @@ +import {apply} from 'invokers-polyfill/fn' +import {canUseDOM} from '../utils/environment' + +if (canUseDOM) { + apply() +} diff --git a/packages/react/src/utils/mergeProps.ts b/packages/react/src/utils/mergeProps.ts new file mode 100644 index 00000000000..bd50822d07f --- /dev/null +++ b/packages/react/src/utils/mergeProps.ts @@ -0,0 +1,60 @@ +import {clsx} from 'clsx' +import type {Merge} from './types' + +type EventHandler = (event: {defaultPrevented?: boolean}) => void +type ClassValue = Parameters[number] + +function mergeProps(a: A, b: B): Merge { + const merged = {...a} as Record + + for (const [key, value] of Object.entries(b)) { + if (key in merged) { + const existing = merged[key] + + if (key === 'className') { + merged[key] = clsx(existing as ClassValue, value as ClassValue) + } else if (isEventHandlerKey(key) && isEventHandler(existing) && isEventHandler(value)) { + merged[key] = composeEventHandlers(existing, value) + } else if (key === 'style') { + merged[key] = mergeStyle(existing, value) + } else { + merged[key] = value + } + } else { + merged[key] = value + } + } + + return merged as Merge +} + +function composeEventHandlers(a: EventHandler, b: EventHandler) { + return (event: Parameters[0]) => { + a(event) + + if (!event.defaultPrevented) { + b(event) + } + } +} + +function isEventHandlerKey(key: string) { + return key.startsWith('on') +} + +function isEventHandler(value: unknown): value is EventHandler { + return typeof value === 'function' +} + +function mergeStyle(a: unknown, b: unknown) { + return { + ...(isObject(a) ? a : {}), + ...(isObject(b) ? b : {}), + } +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export {mergeProps} From 0ace3ff8029fe2218e20870cd2943c7d7e2ef064 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 15 Jul 2026 14:20:39 -0500 Subject: [PATCH 2/4] feat: add AnchorPosition component --- .../AnchorPosition/AnchorPosition.docs.json | 90 ++++++++++++++ .../AnchorPosition.features.stories.tsx | 65 ++++++++++ .../AnchorPosition/AnchorPosition.module.css | 104 ++++++++++++++++ .../AnchorPosition/AnchorPosition.stories.tsx | 76 ++++++++++++ .../AnchorPosition/AnchorPosition.test.tsx | 109 +++++++++++++++++ .../src/AnchorPosition/AnchorPosition.tsx | 60 ++++++++++ packages/react/src/AnchorPosition/index.ts | 16 +++ .../useAnchorPosition.hookDocs.json | 82 +++++++++++++ .../AnchorPosition/useAnchorPosition.test.tsx | 52 ++++++++ .../src/AnchorPosition/useAnchorPosition.ts | 111 ++++++++++++++++++ .../src/BasePopover/BasePopoverContext.ts | 4 +- 11 files changed, 767 insertions(+), 2 deletions(-) create mode 100644 packages/react/src/AnchorPosition/AnchorPosition.docs.json create mode 100644 packages/react/src/AnchorPosition/AnchorPosition.features.stories.tsx create mode 100644 packages/react/src/AnchorPosition/AnchorPosition.module.css create mode 100644 packages/react/src/AnchorPosition/AnchorPosition.stories.tsx create mode 100644 packages/react/src/AnchorPosition/AnchorPosition.test.tsx create mode 100644 packages/react/src/AnchorPosition/AnchorPosition.tsx create mode 100644 packages/react/src/AnchorPosition/index.ts create mode 100644 packages/react/src/AnchorPosition/useAnchorPosition.hookDocs.json create mode 100644 packages/react/src/AnchorPosition/useAnchorPosition.test.tsx create mode 100644 packages/react/src/AnchorPosition/useAnchorPosition.ts diff --git a/packages/react/src/AnchorPosition/AnchorPosition.docs.json b/packages/react/src/AnchorPosition/AnchorPosition.docs.json new file mode 100644 index 00000000000..f2e9d6b8db5 --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.docs.json @@ -0,0 +1,90 @@ +{ + "id": "anchor-position", + "name": "AnchorPosition", + "status": "alpha", + "a11yReviewed": false, + "stories": [ + { + "id": "components-anchorposition--default" + }, + { + "id": "components-anchorposition-features--placements" + }, + { + "id": "components-anchorposition-features--with-base-popover" + } + ], + "importPath": "@primer/react", + "props": [ + { + "name": "children", + "type": "React.ReactNode", + "required": true, + "description": "The anchor and target elements." + }, + { + "name": "anchorName", + "type": "`--${string}`", + "defaultValue": "A generated CSS anchor name", + "description": "A custom CSS anchor name shared by the anchor and target." + } + ], + "subcomponents": [ + { + "name": "AnchorPosition.Anchor", + "props": [ + { + "name": "as", + "type": "React.ElementType", + "defaultValue": "'div'", + "description": "The element or component to render as the anchor." + }, + { + "name": "children", + "type": "React.ReactNode", + "description": "The anchor content." + } + ] + }, + { + "name": "AnchorPosition.Target", + "props": [ + { + "name": "as", + "type": "React.ElementType", + "defaultValue": "'div'", + "description": "The element or component to render as the positioned target." + }, + { + "name": "children", + "type": "React.ReactNode", + "description": "The positioned content." + }, + { + "name": "placement", + "type": "'above' | 'below' | 'start' | 'end'", + "defaultValue": "'below'", + "description": "The target's logical placement relative to the anchor." + }, + { + "name": "alignment", + "type": "'start' | 'center' | 'end'", + "defaultValue": "'start'", + "description": "The target's alignment along the placement axis." + }, + { + "name": "fallbackStrategy", + "type": "'default' | 'none' | 'opposite-side'", + "defaultValue": "'default'", + "description": "Controls which CSS anchor-positioning fallbacks the browser may try when the target overflows." + }, + { + "name": "gap", + "type": "number | string", + "defaultValue": "'var(--base-size-4)'", + "description": "The space between the anchor and target. Numbers are interpreted as pixels." + } + ] + } + ] +} diff --git a/packages/react/src/AnchorPosition/AnchorPosition.features.stories.tsx b/packages/react/src/AnchorPosition/AnchorPosition.features.stories.tsx new file mode 100644 index 00000000000..f7e244b55f4 --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.features.stories.tsx @@ -0,0 +1,65 @@ +import type {Meta, StoryObj} from '@storybook/react-vite' +import {Button} from '../Button' +import {Close, Popover, Root as BasePopoverRoot, Trigger} from '../BasePopover' +import {Root, Target, Anchor} from './AnchorPosition' +import type {Placement} from './AnchorPosition' + +const meta = { + title: 'Components/AnchorPosition/Features', + component: Root, +} satisfies Meta + +export default meta + +type Story = StoryObj + +const targetStyle = { + padding: 'var(--base-size-12)', + color: 'var(--fgColor-default)', + backgroundColor: 'var(--bgColor-default)', + border: 'var(--borderWidth-thin) solid var(--borderColor-default)', + borderRadius: 'var(--borderRadius-medium)', + boxShadow: 'var(--shadow-floating-small)', +} + +const placements: Placement[] = ['above', 'below', 'start', 'end'] + +export const Placements: Story = { + render: () => ( +
+ {placements.map(placement => ( + + {placement} + + {placement} + + + ))} +
+ ), +} + +export const WithBasePopover: Story = { + render: () => ( +
+ + + Toggle popover + + Anchored popover +
+ Close popover +
+
+
+
+
+ ), +} diff --git a/packages/react/src/AnchorPosition/AnchorPosition.module.css b/packages/react/src/AnchorPosition/AnchorPosition.module.css new file mode 100644 index 00000000000..9821667f4fc --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.module.css @@ -0,0 +1,104 @@ +@supports (anchor-name: --anchor-position) { + .Anchor { + anchor-name: var(--anchor-position-name); + } + + .Target { + --anchor-position-gap: var(--base-size-4); + + position: fixed; + inset: auto; + position-anchor: var(--anchor-position-name); + position-area: self-block-end span-self-inline-end; + position-try-fallbacks: + flip-block, + flip-inline, + flip-block flip-inline; + position-visibility: anchors-visible; + } + + .Target[popover] { + inset: auto; + margin: 0; + } + + .Target[data-placement='above'][data-alignment='start'] { + position-area: self-block-start span-self-inline-end; + } + + .Target[data-placement='above'][data-alignment='center'] { + position-area: self-block-start; + } + + .Target[data-placement='above'][data-alignment='end'] { + position-area: self-block-start span-self-inline-start; + } + + .Target[data-placement='below'][data-alignment='start'] { + position-area: self-block-end span-self-inline-end; + } + + .Target[data-placement='below'][data-alignment='center'] { + position-area: self-block-end; + } + + .Target[data-placement='below'][data-alignment='end'] { + position-area: self-block-end span-self-inline-start; + } + + .Target[data-placement='start'][data-alignment='start'] { + position-area: self-inline-start span-self-block-end; + } + + .Target[data-placement='start'][data-alignment='center'] { + position-area: self-inline-start; + } + + .Target[data-placement='start'][data-alignment='end'] { + position-area: self-inline-start span-self-block-start; + } + + .Target[data-placement='end'][data-alignment='start'] { + position-area: self-inline-end span-self-block-end; + } + + .Target[data-placement='end'][data-alignment='center'] { + position-area: self-inline-end; + } + + .Target[data-placement='end'][data-alignment='end'] { + position-area: self-inline-end span-self-block-start; + } + + /* stylelint-disable primer/spacing -- supports the public custom gap value */ + .Target[data-placement='above'] { + margin-block-end: var(--anchor-position-gap); + } + + .Target[data-placement='below'] { + margin-block-start: var(--anchor-position-gap); + } + + .Target[data-placement='start'] { + margin-inline-end: var(--anchor-position-gap); + } + + .Target[data-placement='end'] { + margin-inline-start: var(--anchor-position-gap); + } + /* stylelint-enable primer/spacing */ + + .Target[data-fallback-strategy='none'] { + position-try-fallbacks: none; + } + + .Target[data-fallback-strategy='opposite-side'][data-placement='above'], + .Target[data-fallback-strategy='opposite-side'][data-placement='below'] { + position-try-fallbacks: flip-block; + } + + .Target[data-fallback-strategy='opposite-side'][data-placement='start'], + .Target[data-fallback-strategy='opposite-side'][data-placement='end'] { + position-try-fallbacks: flip-inline; + } +} diff --git a/packages/react/src/AnchorPosition/AnchorPosition.stories.tsx b/packages/react/src/AnchorPosition/AnchorPosition.stories.tsx new file mode 100644 index 00000000000..30b66a48f6f --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.stories.tsx @@ -0,0 +1,76 @@ +import type {Meta, StoryFn} from '@storybook/react-vite' +import {Button} from '../Button' +import {Root, Anchor, Target} from './AnchorPosition' +import type {Alignment, FallbackStrategy, Placement} from './AnchorPosition' + +const meta = { + title: 'Components/AnchorPosition', + component: Root, +} satisfies Meta + +export default meta + +const targetStyle = { + padding: 'var(--base-size-12)', + color: 'var(--fgColor-default)', + backgroundColor: 'var(--bgColor-default)', + border: 'var(--borderWidth-thin) solid var(--borderColor-default)', + borderRadius: 'var(--borderRadius-medium)', + boxShadow: 'var(--shadow-floating-small)', +} + +export const Default = () => ( + + Anchor + Target + +) + +type PlaygroundArgs = { + alignment: Alignment + fallbackStrategy: FallbackStrategy + gap: number + placement: Placement +} + +export const Playground: StoryFn = ({alignment, fallbackStrategy, gap, placement}) => ( +
+ + Anchor + + Target + + +
+) + +Playground.args = { + alignment: 'start', + fallbackStrategy: 'default', + gap: 4, + placement: 'below', +} + +Playground.argTypes = { + alignment: { + control: 'radio', + options: ['start', 'center', 'end'], + }, + fallbackStrategy: { + control: 'radio', + options: ['default', 'none', 'opposite-side'], + }, + gap: { + control: {type: 'number', min: 0}, + }, + placement: { + control: 'radio', + options: ['above', 'below', 'start', 'end'], + }, +} diff --git a/packages/react/src/AnchorPosition/AnchorPosition.test.tsx b/packages/react/src/AnchorPosition/AnchorPosition.test.tsx new file mode 100644 index 00000000000..612ef8ee928 --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.test.tsx @@ -0,0 +1,109 @@ +import {render, screen} from '@testing-library/react' +import {createRef} from 'react' +import {describe, expect, it, vi} from 'vitest' +import {Popover, Root as BasePopoverRoot, Trigger} from '../BasePopover' +import {Anchor, Root, Target} from './AnchorPosition' + +describe('AnchorPosition', () => { + it('connects the anchor and target with a generated CSS anchor name', () => { + render( + + Anchor + Target + , + ) + + const anchorName = screen.getByTestId('anchor').style.getPropertyValue('--anchor-position-name') + + expect(anchorName).toMatch(/^--anchor-position-/) + expect(screen.getByTestId('target').style.getPropertyValue('--anchor-position-name')).toBe(anchorName) + }) + + it('uses a custom CSS anchor name', () => { + render( + + Anchor + Target + , + ) + + expect(screen.getByTestId('anchor').style.getPropertyValue('--anchor-position-name')).toBe('--custom-anchor') + expect(screen.getByTestId('target').style.getPropertyValue('--anchor-position-name')).toBe('--custom-anchor') + }) + + it('applies positioning options to the target', () => { + render( + + Anchor + + Target + + , + ) + + const target = screen.getByTestId('target') + + expect(target).toHaveAttribute('data-alignment', 'end') + expect(target).toHaveAttribute('data-fallback-strategy', 'opposite-side') + expect(target).toHaveAttribute('data-placement', 'above') + expect(target.style.getPropertyValue('--anchor-position-gap')).toBe('12px') + }) + + it('forwards props and refs to polymorphic anchor and target elements', () => { + const anchorRef = createRef() + const targetRef = createRef() + + render( + + + Anchor + + + Target + + , + ) + + expect(screen.getByRole('button', {name: 'Anchor'})).toHaveClass('custom-anchor') + expect(screen.getByRole('region')).toHaveClass('custom-target') + expect(anchorRef.current).toBeInstanceOf(HTMLButtonElement) + expect(targetRef.current).toBeInstanceOf(HTMLElement) + }) + + it('composes with BasePopover', () => { + render( + + + Toggle popover + + Popover content + + + , + ) + + const trigger = screen.getByRole('button', {name: 'Toggle popover'}) + const popover = screen.getByTestId('popover') + + expect(trigger).toHaveAttribute('commandfor', popover.id) + expect(popover).toHaveAttribute('popover', 'auto') + expect(popover.style.getPropertyValue('--anchor-position-name')).toBe( + trigger.style.getPropertyValue('--anchor-position-name'), + ) + }) + + it('throws when Anchor or Target are rendered outside Root', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + expect(() => render(Anchor)).toThrow( + 'AnchorPosition components must be used within AnchorPosition.Root', + ) + expect(() => render(Target)).toThrow( + 'AnchorPosition components must be used within AnchorPosition.Root', + ) + } finally { + consoleError.mockRestore() + } + }) +}) diff --git a/packages/react/src/AnchorPosition/AnchorPosition.tsx b/packages/react/src/AnchorPosition/AnchorPosition.tsx new file mode 100644 index 00000000000..6a670f73e61 --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.tsx @@ -0,0 +1,60 @@ +import React, {createContext, useContext} from 'react' +import {mergeProps} from '../utils/mergeProps' +import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' +import {useAnchorPosition} from './useAnchorPosition' +import type { + Alignment, + AnchorName, + AnchorPositionTargetOptions, + FallbackStrategy, + Placement, + UseAnchorPositionConfig, + UseAnchorPositionReturn, +} from './useAnchorPosition' + +type RootProps = React.PropsWithChildren + +type AnchorProps = React.ComponentPropsWithoutRef<'div'> + +type TargetProps = React.ComponentPropsWithoutRef<'div'> & AnchorPositionTargetOptions + +const AnchorPositionContext = createContext(null) + +function useAnchorPositionContext() { + const context = useContext(AnchorPositionContext) + if (context) { + return context + } + throw new Error('AnchorPosition components must be used within AnchorPosition.Root') +} + +function Root({anchorName: customAnchorName, children}: RootProps) { + const value = useAnchorPosition({anchorName: customAnchorName}) + + return {children} +} + +const Anchor = React.forwardRef(function Anchor({as: Component = 'div', ...rest}, forwardedRef) { + const {getAnchorProps} = useAnchorPositionContext() + const anchorProps = getAnchorProps() + + return +}) as PolymorphicForwardRefComponent<'div', AnchorProps> + +const Target = React.forwardRef(function Target( + {as: Component = 'div', alignment = 'start', fallbackStrategy = 'default', gap, placement = 'below', ...rest}, + forwardedRef, +) { + const {getTargetProps} = useAnchorPositionContext() + const targetProps = getTargetProps({ + alignment, + fallbackStrategy, + gap, + placement, + }) + + return +}) as PolymorphicForwardRefComponent<'div', TargetProps> + +export {Root, Anchor, Target} +export type {RootProps, AnchorProps, TargetProps, AnchorName, Placement, Alignment, FallbackStrategy} diff --git a/packages/react/src/AnchorPosition/index.ts b/packages/react/src/AnchorPosition/index.ts new file mode 100644 index 00000000000..dfcdb786d1f --- /dev/null +++ b/packages/react/src/AnchorPosition/index.ts @@ -0,0 +1,16 @@ +import {Root, Anchor, Target} from './AnchorPosition' + +const AnchorPosition = {Root, Anchor, Target} + +export {AnchorPosition, Root, Anchor, Target} +export type { + RootProps, + AnchorProps, + TargetProps, + AnchorName, + Placement, + Alignment, + FallbackStrategy, +} from './AnchorPosition' +export {useAnchorPosition} from './useAnchorPosition' +export type {UseAnchorPositionConfig, UseAnchorPositionReturn, AnchorPositionTargetOptions} from './useAnchorPosition' diff --git a/packages/react/src/AnchorPosition/useAnchorPosition.hookDocs.json b/packages/react/src/AnchorPosition/useAnchorPosition.hookDocs.json new file mode 100644 index 00000000000..b93812715c8 --- /dev/null +++ b/packages/react/src/AnchorPosition/useAnchorPosition.hookDocs.json @@ -0,0 +1,82 @@ +{ + "name": "useAnchorPosition", + "status": "ready", + "importPath": "@primer/react", + "stories": [], + "parameters": [ + { + "name": "config", + "type": "UseAnchorPositionConfig", + "defaultValue": "{}", + "description": "Configures the CSS anchor name shared by the anchor and target." + } + ], + "returns": { + "type": "UseAnchorPositionReturn" + }, + "relatedTypes": [ + { + "name": "UseAnchorPositionConfig", + "properties": [ + { + "name": "anchorName", + "type": "`--${string}`", + "defaultValue": "A generated CSS anchor name", + "description": "A custom CSS anchor name shared by the anchor and target." + } + ] + }, + { + "name": "UseAnchorPositionReturn", + "properties": [ + { + "name": "anchorName", + "type": "`--${string}`", + "required": true, + "description": "The resolved CSS anchor name." + }, + { + "name": "getAnchorProps", + "type": "() => React.HTMLAttributes", + "required": true, + "description": "Returns the props that establish an element as the anchor." + }, + { + "name": "getTargetProps", + "type": "(options?: AnchorPositionTargetOptions) => React.HTMLAttributes", + "required": true, + "description": "Returns the props that position an element relative to the anchor." + } + ] + }, + { + "name": "AnchorPositionTargetOptions", + "properties": [ + { + "name": "placement", + "type": "'above' | 'below' | 'start' | 'end'", + "defaultValue": "'below'", + "description": "The target's logical placement relative to the anchor." + }, + { + "name": "alignment", + "type": "'start' | 'center' | 'end'", + "defaultValue": "'start'", + "description": "The target's alignment along the placement axis." + }, + { + "name": "fallbackStrategy", + "type": "'default' | 'none' | 'opposite-side'", + "defaultValue": "'default'", + "description": "Controls which overflow fallbacks the browser may try." + }, + { + "name": "gap", + "type": "number | string", + "defaultValue": "'var(--base-size-4)'", + "description": "The space between the anchor and target." + } + ] + } + ] +} diff --git a/packages/react/src/AnchorPosition/useAnchorPosition.test.tsx b/packages/react/src/AnchorPosition/useAnchorPosition.test.tsx new file mode 100644 index 00000000000..3def015584b --- /dev/null +++ b/packages/react/src/AnchorPosition/useAnchorPosition.test.tsx @@ -0,0 +1,52 @@ +import {render, screen} from '@testing-library/react' +import {describe, expect, it} from 'vitest' +import {useAnchorPosition} from './useAnchorPosition' + +function TestComponent({anchorName}: {anchorName?: `--${string}`}) { + const {getAnchorProps, getTargetProps} = useAnchorPosition({anchorName}) + + return ( + <> + +
+ Target +
+ + ) +} + +describe('useAnchorPosition', () => { + it('returns props that connect an anchor and target', () => { + render() + + const anchor = screen.getByRole('button', {name: 'Anchor'}) + const target = screen.getByText('Target') + const anchorName = anchor.style.getPropertyValue('--anchor-position-name') + + expect(anchorName).toMatch(/^--anchor-position-/) + expect(target.style.getPropertyValue('--anchor-position-name')).toBe(anchorName) + }) + + it('supports a custom anchor name and target positioning options', () => { + render() + + const anchor = screen.getByRole('button', {name: 'Anchor'}) + const target = screen.getByText('Target') + + expect(anchor.style.getPropertyValue('--anchor-position-name')).toBe('--custom-anchor') + expect(target.style.getPropertyValue('--anchor-position-name')).toBe('--custom-anchor') + expect(target.style.getPropertyValue('--anchor-position-gap')).toBe('12px') + expect(target).toHaveAttribute('data-alignment', 'end') + expect(target).toHaveAttribute('data-fallback-strategy', 'opposite-side') + expect(target).toHaveAttribute('data-placement', 'above') + }) +}) diff --git a/packages/react/src/AnchorPosition/useAnchorPosition.ts b/packages/react/src/AnchorPosition/useAnchorPosition.ts new file mode 100644 index 00000000000..bade3e873d2 --- /dev/null +++ b/packages/react/src/AnchorPosition/useAnchorPosition.ts @@ -0,0 +1,111 @@ +import type React from 'react' +import {useId} from '../hooks/useId' +import classes from './AnchorPosition.module.css' + +type AnchorName = `--${string}` +type Placement = 'above' | 'below' | 'start' | 'end' +type Alignment = 'start' | 'center' | 'end' +type FallbackStrategy = 'default' | 'none' | 'opposite-side' + +type AnchorPositionTargetOptions = { + /** + * The target's logical placement relative to the anchor. + * @default 'below' + */ + placement?: Placement + /** + * The target's alignment along the placement axis. + * @default 'start' + */ + alignment?: Alignment + /** + * Controls which CSS anchor-positioning fallbacks the browser may try. + * @default 'default' + */ + fallbackStrategy?: FallbackStrategy + /** + * The space between the anchor and target. + * @default 'var(--base-size-4)' + */ + gap?: number | string +} + +type UseAnchorPositionConfig = { + /** + * A custom CSS anchor name shared by the anchor and target. + * @default A generated anchor name + */ + anchorName?: AnchorName +} + +type AnchorPositionStyle = React.CSSProperties & { + '--anchor-position-name': AnchorName + '--anchor-position-gap'?: string +} + +type TargetDataAttributes = { + 'data-alignment': Alignment + 'data-fallback-strategy': FallbackStrategy + 'data-placement': Placement +} + +type UseAnchorPositionReturn = { + anchorName: AnchorName + getAnchorProps: () => React.HTMLAttributes + getTargetProps: (options?: AnchorPositionTargetOptions) => React.HTMLAttributes & TargetDataAttributes +} + +/** + * Connects an anchor and target with CSS anchor positioning without prescribing + * their rendered markup. + */ +function useAnchorPosition({anchorName: customAnchorName}: UseAnchorPositionConfig = {}): UseAnchorPositionReturn { + const id = useId() + const anchorName = customAnchorName ?? (`--anchor-position-${id.replaceAll(':', '')}` as AnchorName) + + function getAnchorProps(): React.HTMLAttributes { + return { + className: classes.Anchor, + style: { + '--anchor-position-name': anchorName, + } as AnchorPositionStyle, + } + } + + function getTargetProps({ + alignment = 'start', + fallbackStrategy = 'default', + gap, + placement = 'below', + }: AnchorPositionTargetOptions = {}): React.HTMLAttributes & TargetDataAttributes { + const resolvedGap = typeof gap === 'number' ? `${gap}px` : gap + + return { + className: classes.Target, + 'data-alignment': alignment, + 'data-fallback-strategy': fallbackStrategy, + 'data-placement': placement, + style: { + '--anchor-position-name': anchorName, + ...(resolvedGap === undefined ? {} : {'--anchor-position-gap': resolvedGap}), + } as AnchorPositionStyle, + } + } + + return { + anchorName, + getAnchorProps, + getTargetProps, + } +} + +export {useAnchorPosition} +export type { + UseAnchorPositionConfig, + UseAnchorPositionReturn, + AnchorPositionTargetOptions, + AnchorName, + Placement, + Alignment, + FallbackStrategy, +} diff --git a/packages/react/src/BasePopover/BasePopoverContext.ts b/packages/react/src/BasePopover/BasePopoverContext.ts index 8411fe63ec9..7c829a5ae5b 100644 --- a/packages/react/src/BasePopover/BasePopoverContext.ts +++ b/packages/react/src/BasePopover/BasePopoverContext.ts @@ -1,7 +1,7 @@ import {createContext, useContext} from 'react' -import type {UsePopoverReturn} from './usePopover' +import type {UseBasePopoverReturn} from './useBasePopover' -type BasePopoverContextValue = UsePopoverReturn +type BasePopoverContextValue = UseBasePopoverReturn const BasePopoverContext = createContext(null) From a9b83f7ece4802fb957310456627735cf6d3abf1 Mon Sep 17 00:00:00 2001 From: Josh Black Date: Wed, 15 Jul 2026 15:13:55 -0500 Subject: [PATCH 3/4] refactor: apply render prop conventions --- .changeset/render-composition-utility.md | 5 + .../AnchorPosition/AnchorPosition.docs.json | 10 ++ .../AnchorPosition.features.stories.tsx | 20 ++-- .../AnchorPosition/AnchorPosition.stories.tsx | 4 +- .../AnchorPosition/AnchorPosition.test.tsx | 22 ++++- .../src/AnchorPosition/AnchorPosition.tsx | 63 +++++++++--- .../src/BasePopover/BasePopover.docs.json | 15 +++ .../src/BasePopover/BasePopover.test.tsx | 73 +++++++++++++- .../react/src/BasePopover/BasePopover.tsx | 60 +++++++----- .../__snapshots__/exports.test.ts.snap | 6 ++ .../src/hooks/__tests__/useRender.test.tsx | 81 ++++++++++++++++ packages/react/src/hooks/index.ts | 8 ++ .../react/src/hooks/useRender.hookDocs.json | 62 ++++++++++++ packages/react/src/hooks/useRender.tsx | 96 +++++++++++++++++++ .../react/src/hooks/useRender.types.test.tsx | 66 +++++++++++++ packages/react/src/index.ts | 8 ++ 16 files changed, 543 insertions(+), 56 deletions(-) create mode 100644 .changeset/render-composition-utility.md create mode 100644 packages/react/src/hooks/__tests__/useRender.test.tsx create mode 100644 packages/react/src/hooks/useRender.hookDocs.json create mode 100644 packages/react/src/hooks/useRender.tsx create mode 100644 packages/react/src/hooks/useRender.types.test.tsx diff --git a/.changeset/render-composition-utility.md b/.changeset/render-composition-utility.md new file mode 100644 index 00000000000..3f029c350d9 --- /dev/null +++ b/.changeset/render-composition-utility.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +AnchorPosition and BasePopover: Add `render` props and a `useRender` utility for composing custom elements and components. diff --git a/packages/react/src/AnchorPosition/AnchorPosition.docs.json b/packages/react/src/AnchorPosition/AnchorPosition.docs.json index f2e9d6b8db5..6646cc630e8 100644 --- a/packages/react/src/AnchorPosition/AnchorPosition.docs.json +++ b/packages/react/src/AnchorPosition/AnchorPosition.docs.json @@ -39,6 +39,11 @@ "defaultValue": "'div'", "description": "The element or component to render as the anchor." }, + { + "name": "render", + "type": "React.ReactElement | function", + "description": "Replaces the default anchor element or composes it with another component." + }, { "name": "children", "type": "React.ReactNode", @@ -55,6 +60,11 @@ "defaultValue": "'div'", "description": "The element or component to render as the positioned target." }, + { + "name": "render", + "type": "React.ReactElement | function", + "description": "Replaces the default target element or composes it with another component." + }, { "name": "children", "type": "React.ReactNode", diff --git a/packages/react/src/AnchorPosition/AnchorPosition.features.stories.tsx b/packages/react/src/AnchorPosition/AnchorPosition.features.stories.tsx index f7e244b55f4..a2fd710ceae 100644 --- a/packages/react/src/AnchorPosition/AnchorPosition.features.stories.tsx +++ b/packages/react/src/AnchorPosition/AnchorPosition.features.stories.tsx @@ -36,7 +36,7 @@ export const Placements: Story = { > {placements.map(placement => ( - {placement} + {placement}} /> {placement} @@ -51,13 +51,17 @@ export const WithBasePopover: Story = {
- Toggle popover - - Anchored popover -
- Close popover -
-
+ Toggle popover} /> + + Anchored popover +
+ Close popover +
+ + } + />
diff --git a/packages/react/src/AnchorPosition/AnchorPosition.stories.tsx b/packages/react/src/AnchorPosition/AnchorPosition.stories.tsx index 30b66a48f6f..0373add7705 100644 --- a/packages/react/src/AnchorPosition/AnchorPosition.stories.tsx +++ b/packages/react/src/AnchorPosition/AnchorPosition.stories.tsx @@ -21,7 +21,7 @@ const targetStyle = { export const Default = () => ( - Anchor + Anchor} /> Target ) @@ -36,7 +36,7 @@ type PlaygroundArgs = { export const Playground: StoryFn = ({alignment, fallbackStrategy, gap, placement}) => (
- Anchor + Anchor} /> { render( - Toggle popover - - Popover content - + Toggle popover} /> + Popover content} /> , ) @@ -92,6 +90,22 @@ describe('AnchorPosition', () => { ) }) + it('does not accept both as and render', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + expect(() => + render( + + Anchor} /> + , + ), + ).toThrow('AnchorPosition components cannot use both `as` and `render`') + } finally { + consoleError.mockRestore() + } + }) + it('throws when Anchor or Target are rendered outside Root', () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/react/src/AnchorPosition/AnchorPosition.tsx b/packages/react/src/AnchorPosition/AnchorPosition.tsx index 6a670f73e61..aa4789ca1e0 100644 --- a/packages/react/src/AnchorPosition/AnchorPosition.tsx +++ b/packages/react/src/AnchorPosition/AnchorPosition.tsx @@ -1,4 +1,6 @@ import React, {createContext, useContext} from 'react' +import {useRender} from '../hooks/useRender' +import type {RenderComponentProps, RenderProp} from '../hooks/useRender' import {mergeProps} from '../utils/mergeProps' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {useAnchorPosition} from './useAnchorPosition' @@ -14,9 +16,9 @@ import type { type RootProps = React.PropsWithChildren -type AnchorProps = React.ComponentPropsWithoutRef<'div'> +type AnchorProps = RenderComponentProps<'div'> -type TargetProps = React.ComponentPropsWithoutRef<'div'> & AnchorPositionTargetOptions +type TargetProps = RenderComponentProps<'div'> & AnchorPositionTargetOptions const AnchorPositionContext = createContext(null) @@ -34,27 +36,60 @@ function Root({anchorName: customAnchorName, children}: RootProps) { return {children} } -const Anchor = React.forwardRef(function Anchor({as: Component = 'div', ...rest}, forwardedRef) { +type AnchorInternalProps = AnchorProps & { + as?: React.ElementType +} + +const Anchor = React.forwardRef(function Anchor( + {as: Component, render, ...rest}, + forwardedRef, +) { const {getAnchorProps} = useAnchorPositionContext() - const anchorProps = getAnchorProps() - return + return useRender({ + defaultTagName: 'div', + render: resolveRenderProp(Component, render), + props: mergeProps(getAnchorProps(), rest), + ref: forwardedRef, + }) }) as PolymorphicForwardRefComponent<'div', AnchorProps> -const Target = React.forwardRef(function Target( - {as: Component = 'div', alignment = 'start', fallbackStrategy = 'default', gap, placement = 'below', ...rest}, +type TargetInternalProps = TargetProps & { + as?: React.ElementType +} + +const Target = React.forwardRef(function Target( + {as: Component, alignment = 'start', fallbackStrategy = 'default', gap, placement = 'below', render, ...rest}, forwardedRef, ) { const {getTargetProps} = useAnchorPositionContext() - const targetProps = getTargetProps({ - alignment, - fallbackStrategy, - gap, - placement, - }) - return + return useRender({ + defaultTagName: 'div', + render: resolveRenderProp(Component, render), + props: mergeProps( + getTargetProps({ + alignment, + fallbackStrategy, + gap, + placement, + }), + rest, + ), + ref: forwardedRef, + }) }) as PolymorphicForwardRefComponent<'div', TargetProps> +function resolveRenderProp( + Component: React.ElementType | undefined, + render: RenderProp> | undefined, +) { + if (Component && render) { + throw new Error('AnchorPosition components cannot use both `as` and `render`') + } + + return render ?? (Component ? React.createElement(Component) : undefined) +} + export {Root, Anchor, Target} export type {RootProps, AnchorProps, TargetProps, AnchorName, Placement, Alignment, FallbackStrategy} diff --git a/packages/react/src/BasePopover/BasePopover.docs.json b/packages/react/src/BasePopover/BasePopover.docs.json index deeb71c8df9..9cc29d7be23 100644 --- a/packages/react/src/BasePopover/BasePopover.docs.json +++ b/packages/react/src/BasePopover/BasePopover.docs.json @@ -42,6 +42,11 @@ { "name": "BasePopover.Trigger", "props": [ + { + "name": "render", + "type": "React.ReactElement | function", + "description": "Composes the trigger props and ref with a custom button component." + }, { "name": "children", "type": "React.ReactNode", @@ -53,6 +58,11 @@ { "name": "BasePopover.Popover", "props": [ + { + "name": "render", + "type": "React.ReactElement | function", + "description": "Replaces the default popover element or composes it with another component." + }, { "name": "children", "type": "React.ReactNode", @@ -64,6 +74,11 @@ { "name": "BasePopover.Close", "props": [ + { + "name": "render", + "type": "React.ReactElement | function", + "description": "Composes the close props and ref with a custom button component." + }, { "name": "children", "type": "React.ReactNode", diff --git a/packages/react/src/BasePopover/BasePopover.test.tsx b/packages/react/src/BasePopover/BasePopover.test.tsx index f5b4575db9b..376fca21eb8 100644 --- a/packages/react/src/BasePopover/BasePopover.test.tsx +++ b/packages/react/src/BasePopover/BasePopover.test.tsx @@ -1,5 +1,6 @@ import {render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' +import {createRef} from 'react' import {describe, expect, it, vi} from 'vitest' import {Close, Popover, Root, Trigger} from './BasePopover' @@ -68,14 +69,18 @@ describe('BasePopover', () => { }) it('forwards props to the trigger, popover, and close elements', () => { + const triggerRef = createRef() + const popoverRef = createRef() + const closeRef = createRef() + render( - + Toggle popover - + Popover content - + Close popover @@ -92,6 +97,68 @@ describe('BasePopover', () => { expect(popover).toHaveAttribute('data-variant', 'custom') expect(close).toHaveClass('custom-close') expect(close).toHaveAttribute('type', 'button') + expect(triggerRef.current).toBe(trigger) + expect(popoverRef.current).toBe(popover) + expect(closeRef.current).toBe(close) + }) + + it('composes props and refs with custom rendered elements', () => { + const triggerRef = createRef() + const renderedTriggerRef = createRef() + const popoverRef = createRef() + const renderedPopoverRef = createRef() + const closeRef = createRef() + const renderedCloseRef = createRef() + + render( + + + Toggle popover + + } + /> + + Popover content + + Close popover + + } + /> + + } + /> + , + ) + + const trigger = screen.getByRole('button', {name: 'Toggle popover'}) + const popover = screen.getByTestId('popover') + const close = screen.getByRole('button', {name: 'Close popover', hidden: true}) + + expect(trigger).toHaveClass('trigger', 'rendered-trigger') + expect(trigger).toHaveAttribute('commandfor', 'custom-render-popover') + expect(popover).toHaveClass('popover', 'rendered-popover') + expect(popover).toHaveAttribute('id', 'custom-render-popover') + expect(popover).toHaveAttribute('popover', 'auto') + expect(close).toHaveClass('close', 'rendered-close') + expect(close).toHaveAttribute('commandfor', 'custom-render-popover') + expect(triggerRef.current).toBe(trigger) + expect(renderedTriggerRef.current).toBe(trigger) + expect(popoverRef.current).toBe(popover) + expect(renderedPopoverRef.current).toBe(popover) + expect(closeRef.current).toBe(close) + expect(renderedCloseRef.current).toBe(close) }) it('opens the popover from the trigger', async () => { diff --git a/packages/react/src/BasePopover/BasePopover.tsx b/packages/react/src/BasePopover/BasePopover.tsx index 4f7cbc6aeb7..fce948dd317 100644 --- a/packages/react/src/BasePopover/BasePopover.tsx +++ b/packages/react/src/BasePopover/BasePopover.tsx @@ -1,8 +1,11 @@ -import type {HTMLAttributes, PropsWithChildren} from 'react' +import React from 'react' +import type {PropsWithChildren} from 'react' +import {useRender} from '../hooks/useRender' +import type {RenderComponentProps} from '../hooks/useRender' +import {mergeProps} from '../utils/mergeProps' import {BasePopoverContext, useBasePopoverContext} from './BasePopoverContext' import {useBasePopover} from './useBasePopover' import type {UseBasePopoverConfig} from './useBasePopover' -import {mergeProps} from '../utils/mergeProps' type RootProps = PropsWithChildren @@ -14,37 +17,44 @@ function Root({children, id, popover}: RootProps) { return {children} } -type TriggerProps = HTMLAttributes & {} +type TriggerProps = RenderComponentProps<'button'> -function Trigger({children, ...rest}: TriggerProps) { +const Trigger = React.forwardRef(function Trigger({render, ...rest}, forwardedRef) { const {getTriggerProps} = useBasePopoverContext() - const triggerProps = getTriggerProps() - return ( - - ) -} -type PopoverProps = HTMLAttributes & {} + return useRender({ + defaultTagName: 'button', + render, + props: mergeProps({type: 'button', ...getTriggerProps()}, rest), + ref: forwardedRef, + }) +}) + +type PopoverProps = RenderComponentProps<'div'> -function Popover({children, ...rest}: PopoverProps) { +const Popover = React.forwardRef(function Popover({render, ...rest}, forwardedRef) { const {getPopoverProps} = useBasePopoverContext() - const popoverProps = getPopoverProps() - return
{children}
-} -type CloseProps = HTMLAttributes & {} + return useRender({ + defaultTagName: 'div', + render, + props: mergeProps(getPopoverProps(), rest), + ref: forwardedRef, + }) +}) + +type CloseProps = RenderComponentProps<'button'> -function Close({children, ...rest}: CloseProps) { +const Close = React.forwardRef(function Close({render, ...rest}, forwardedRef) { const {getCloseProps} = useBasePopoverContext() - const closeProps = getCloseProps() - return ( - - ) -} + + return useRender({ + defaultTagName: 'button', + render, + props: mergeProps({type: 'button', ...getCloseProps()}, rest), + ref: forwardedRef, + }) +}) export {Root, Trigger, Popover, Close} export type {RootProps, TriggerProps, PopoverProps, CloseProps} diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 412847b840c..96ad049022f 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -142,6 +142,10 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "registerPortalRoot", "RelativeTime", "type RelativeTimeProps", + "type RenderComponentProps", + "type RenderElementProps", + "type RenderFunction", + "type RenderProp", "ResponsiveValue", "SegmentedControl", "type SegmentedControlButtonProps", @@ -235,6 +239,8 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "useOverlay", "useProvidedRefOrCreate", "useRefObjectAsForwardedRef", + "useRender", + "type UseRenderParameters", "useResizeObserver", "useResponsiveValue", "useRovingTabIndex", diff --git a/packages/react/src/hooks/__tests__/useRender.test.tsx b/packages/react/src/hooks/__tests__/useRender.test.tsx new file mode 100644 index 00000000000..003bcc74ac9 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useRender.test.tsx @@ -0,0 +1,81 @@ +import {render, screen} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React, {createRef} from 'react' +import {describe, expect, it, vi} from 'vitest' +import {useRender} from '../useRender' + +describe('useRender', () => { + it('renders the default element with the provided props', () => { + function Example() { + return useRender({ + defaultTagName: 'button', + props: { + children: 'Default element', + type: 'button', + }, + }) + } + + render() + + expect(screen.getByRole('button', {name: 'Default element'})).toHaveAttribute('type', 'button') + }) + + it('merges props and refs with a rendered element', async () => { + const internalClick = vi.fn() + const renderedClick = vi.fn() + const forwardedRef = createRef() + const renderedRef = createRef() + + function Example() { + return useRender({ + defaultTagName: 'button', + props: { + children: 'Default content', + className: 'internal', + onClick: internalClick, + style: {color: 'red', display: 'block'}, + type: 'button', + }, + ref: forwardedRef, + render: ( + + ), + }) + } + + render() + + const button = screen.getByRole('button', {name: 'Rendered element'}) + expect(button).toHaveClass('internal', 'rendered') + expect(button).toHaveStyle({display: 'block'}) + expect(button.style.color).toBe('blue') + expect(forwardedRef.current).toBe(button) + expect(renderedRef.current).toBe(button) + + await userEvent.click(button) + + expect(internalClick).toHaveBeenCalledOnce() + expect(renderedClick).toHaveBeenCalledOnce() + }) + + it('passes props and state to a render function', () => { + function Example() { + return useRender({ + defaultTagName: 'button', + props: { + children: 'Render function', + type: 'button', + }, + render: (props, state) =>