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/AnchorPosition/AnchorPosition.docs.json b/packages/react/src/AnchorPosition/AnchorPosition.docs.json new file mode 100644 index 00000000000..6646cc630e8 --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.docs.json @@ -0,0 +1,100 @@ +{ + "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": "render", + "type": "React.ReactElement | function", + "description": "Replaces the default anchor element or composes it with another component." + }, + { + "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": "render", + "type": "React.ReactElement | function", + "description": "Replaces the default target element or composes it with another component." + }, + { + "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..a2fd710ceae --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.features.stories.tsx @@ -0,0 +1,69 @@ +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..0373add7705 --- /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..e398a0f870f --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.test.tsx @@ -0,0 +1,123 @@ +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('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(() => {}) + + 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..aa4789ca1e0 --- /dev/null +++ b/packages/react/src/AnchorPosition/AnchorPosition.tsx @@ -0,0 +1,95 @@ +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' +import type { + Alignment, + AnchorName, + AnchorPositionTargetOptions, + FallbackStrategy, + Placement, + UseAnchorPositionConfig, + UseAnchorPositionReturn, +} from './useAnchorPosition' + +type RootProps = React.PropsWithChildren + +type AnchorProps = RenderComponentProps<'div'> + +type TargetProps = RenderComponentProps<'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} +} + +type AnchorInternalProps = AnchorProps & { + as?: React.ElementType +} + +const Anchor = React.forwardRef(function Anchor( + {as: Component, render, ...rest}, + forwardedRef, +) { + const {getAnchorProps} = useAnchorPositionContext() + + return useRender({ + defaultTagName: 'div', + render: resolveRenderProp(Component, render), + props: mergeProps(getAnchorProps(), rest), + ref: forwardedRef, + }) +}) as PolymorphicForwardRefComponent<'div', AnchorProps> + +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() + + 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/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/BasePopover.docs.json b/packages/react/src/BasePopover/BasePopover.docs.json new file mode 100644 index 00000000000..9cc29d7be23 --- /dev/null +++ b/packages/react/src/BasePopover/BasePopover.docs.json @@ -0,0 +1,91 @@ +{ + "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": "render", + "type": "React.ReactElement | function", + "description": "Composes the trigger props and ref with a custom button component." + }, + { + "name": "children", + "type": "React.ReactNode", + "required": true, + "description": "The content of the button that toggles the popover." + } + ] + }, + { + "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", + "required": true, + "description": "The content displayed in the popover, including an optional close control." + } + ] + }, + { + "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", + "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..376fca21eb8 --- /dev/null +++ b/packages/react/src/BasePopover/BasePopover.test.tsx @@ -0,0 +1,218 @@ +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' + +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', () => { + const triggerRef = createRef() + const popoverRef = createRef() + const closeRef = createRef() + + 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') + 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 () => { + 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..fce948dd317 --- /dev/null +++ b/packages/react/src/BasePopover/BasePopover.tsx @@ -0,0 +1,60 @@ +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' + +type RootProps = PropsWithChildren + +function Root({children, id, popover}: RootProps) { + const value = useBasePopover({ + id, + popover, + }) + return {children} +} + +type TriggerProps = RenderComponentProps<'button'> + +const Trigger = React.forwardRef(function Trigger({render, ...rest}, forwardedRef) { + const {getTriggerProps} = useBasePopoverContext() + + return useRender({ + defaultTagName: 'button', + render, + props: mergeProps({type: 'button', ...getTriggerProps()}, rest), + ref: forwardedRef, + }) +}) + +type PopoverProps = RenderComponentProps<'div'> + +const Popover = React.forwardRef(function Popover({render, ...rest}, forwardedRef) { + const {getPopoverProps} = useBasePopoverContext() + + return useRender({ + defaultTagName: 'div', + render, + props: mergeProps(getPopoverProps(), rest), + ref: forwardedRef, + }) +}) + +type CloseProps = RenderComponentProps<'button'> + +const Close = React.forwardRef(function Close({render, ...rest}, forwardedRef) { + const {getCloseProps} = useBasePopoverContext() + + 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/BasePopover/BasePopoverContext.ts b/packages/react/src/BasePopover/BasePopoverContext.ts new file mode 100644 index 00000000000..7c829a5ae5b --- /dev/null +++ b/packages/react/src/BasePopover/BasePopoverContext.ts @@ -0,0 +1,17 @@ +import {createContext, useContext} from 'react' +import type {UseBasePopoverReturn} from './useBasePopover' + +type BasePopoverContextValue = UseBasePopoverReturn + +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/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) =>