Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down Expand Up @@ -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",
Expand Down
100 changes: 100 additions & 0 deletions packages/react/src/AnchorPosition/AnchorPosition.docs.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -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<typeof Root>

export default meta

type Story = StoryObj<typeof Root>

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: () => (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(12rem, 1fr))',
gap: '10rem',
padding: '8rem',
}}
>
{placements.map(placement => (
<Root key={placement}>
<Anchor render={<Button>{placement}</Button>} />
<Target alignment="center" placement={placement} style={targetStyle}>
{placement}
</Target>
</Root>
))}
</div>
),
}

export const WithBasePopover: Story = {
render: () => (
<div style={{padding: '8rem'}}>
<BasePopoverRoot>
<Root>
<Anchor render={<Trigger>Toggle popover</Trigger>} />
<Target
render={
<Popover style={targetStyle}>
Anchored popover
<div>
<Close>Close popover</Close>
</div>
</Popover>
}
/>
</Root>
</BasePopoverRoot>
</div>
),
}
104 changes: 104 additions & 0 deletions packages/react/src/AnchorPosition/AnchorPosition.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
76 changes: 76 additions & 0 deletions packages/react/src/AnchorPosition/AnchorPosition.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Root>

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 = () => (
<Root>
<Anchor render={<Button>Anchor</Button>} />
<Target style={targetStyle}>Target</Target>
</Root>
)

type PlaygroundArgs = {
alignment: Alignment
fallbackStrategy: FallbackStrategy
gap: number
placement: Placement
}

export const Playground: StoryFn<PlaygroundArgs> = ({alignment, fallbackStrategy, gap, placement}) => (
<div style={{padding: '8rem'}}>
<Root>
<Anchor render={<Button>Anchor</Button>} />
<Target
alignment={alignment}
fallbackStrategy={fallbackStrategy}
gap={gap}
placement={placement}
style={targetStyle}
>
Target
</Target>
</Root>
</div>
)

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'],
},
}
Loading
Loading