Skip to content

Commit 41aae8b

Browse files
committed
docs(react-headless-components-preview): add Positioning concept Storybook stories
1 parent 2135364 commit 41aae8b

13 files changed

Lines changed: 458 additions & 8 deletions

packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { usePositioning, useFocusTrap, useInert, resolvePositioningShorthand } f
1414
import { findFirstFocusable } from '../../utils';
1515
import type { PopoverProps, PopoverState, PopoverContextValue, OpenPopoverEvents } from './Popover.types';
1616

17+
const SUPPORTS_POPOVER_OPEN_SELECTOR =
18+
typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)');
19+
1720
/**
1821
* Returns the state for a Popover component, given its props and ref.
1922
*/
@@ -134,10 +137,6 @@ export const usePopover = (props: PopoverProps, ref: React.Ref<HTMLElement>): Po
134137
return;
135138
}
136139

137-
// Feature-detect the native Popover API — it's only available in
138-
// Chrome 114+, Safari 17+, Firefox 125+. Older browsers fall back to
139-
// in-flow rendering (the surface is still rendered in the DOM; top-layer
140-
// elevation just isn't available).
141140
if (typeof surface.showPopover !== 'function') {
142141
return;
143142
}
@@ -146,11 +145,11 @@ export const usePopover = (props: PopoverProps, ref: React.Ref<HTMLElement>): Po
146145
surface.setAttribute('popover', 'manual');
147146
}
148147

149-
try {
150-
surface.showPopover();
151-
} catch {
152-
// Already showing — no-op.
148+
if (SUPPORTS_POPOVER_OPEN_SELECTOR && surface.matches(':popover-open')) {
149+
return;
153150
}
151+
152+
surface.showPopover();
154153
}, [open, inline]);
155154

156155
const children = React.Children.toArray(props.children) as React.ReactElement[];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as React from 'react';
2+
import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview';
3+
4+
const classes = {
5+
outer: 'w-full overflow-auto',
6+
wrapper: 'flex flex-col items-start gap-4 mx-16 my-16 w-max',
7+
row: 'flex items-center gap-3 text-sm text-gray-700',
8+
input: 'w-24 px-2 py-1 border border-gray-300 rounded',
9+
checkbox: 'flex items-center gap-2',
10+
boundary: 'relative border-2 border-dashed border-red-500 w-[300px] h-[300px] flex flex-col items-center p-2',
11+
trigger:
12+
'w-[150px] inline-flex justify-center px-3 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
13+
surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-1 overflow-auto flex flex-col gap-0.5 min-w-[150px]',
14+
item: 'px-3 py-1.5 rounded text-sm text-gray-800 hover:bg-gray-100 cursor-default whitespace-nowrap',
15+
};
16+
17+
export const AutoSize = (): React.ReactNode => {
18+
const [open, setOpen] = React.useState(false);
19+
const [itemCount, setItemCount] = React.useState(10);
20+
const [boundary, setBoundary] = React.useState<HTMLDivElement | null>(null);
21+
22+
return (
23+
<div className={classes.outer}>
24+
<div className={classes.wrapper}>
25+
<label className={classes.checkbox}>
26+
<input type="checkbox" checked={open} onChange={e => setOpen(e.target.checked)} />
27+
<span className="text-sm text-gray-700">Open</span>
28+
</label>
29+
<label className={classes.row}>
30+
<span>Menu item count</span>
31+
<input
32+
type="number"
33+
className={classes.input}
34+
min={1}
35+
value={itemCount}
36+
onChange={e => setItemCount(parseInt(e.target.value, 10) || 1)}
37+
/>
38+
</label>
39+
40+
<div ref={setBoundary} className={classes.boundary}>
41+
<Popover
42+
open={open}
43+
onOpenChange={(_, data) => setOpen(data.open)}
44+
positioning={{ position: 'below', autoSize: true, overflowBoundary: boundary, strategy: 'absolute' }}
45+
>
46+
<PopoverTrigger>
47+
<button className={classes.trigger}>AutoSized popover</button>
48+
</PopoverTrigger>
49+
<PopoverSurface className={classes.surface}>
50+
{Array.from({ length: itemCount }, (_, i) => (
51+
<div key={i} className={classes.item}>
52+
Item {i + 1}
53+
</div>
54+
))}
55+
</PopoverSurface>
56+
</Popover>
57+
</div>
58+
59+
<p className="text-xs text-gray-500 max-w-lg">
60+
<code>autoSize</code> sets inline <code>max-width</code> and <code>max-height</code> styles on the surface
61+
derived from <code>overflowBoundary</code> (here, the dashed 300×300 box). As the item count grows the popover
62+
clips to the boundary and scrolls instead of bursting outside.
63+
</p>
64+
</div>
65+
</div>
66+
);
67+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Best practices
2+
3+
These examples are intended to document the `positioning` prop used in Fluent UI Headless Components; please refer to component-specific documentation for best practices for a specific component.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as React from 'react';
2+
import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview';
3+
import type { PositioningProps } from '@fluentui/react-headless-components-preview';
4+
5+
const classes = {
6+
outer: 'w-full overflow-auto',
7+
wrapper: 'grid grid-cols-[repeat(3,auto)] grid-rows-[repeat(5,auto)] gap-16 mx-32 my-16 w-max',
8+
trigger:
9+
'h-12 w-32 flex items-center justify-center px-3 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 cursor-pointer border-none',
10+
surface:
11+
'bg-white/95 rounded-lg shadow-lg border border-gray-200 px-4 py-3 text-sm w-56 h-28 flex items-center justify-center',
12+
};
13+
14+
const cells: Array<{
15+
label: string;
16+
position: NonNullable<PositioningProps['position']>;
17+
align: NonNullable<PositioningProps['align']>;
18+
gridClass: string;
19+
}> = [
20+
{ label: 'above-start', position: 'above', align: 'start', gridClass: 'row-start-1 col-start-1' },
21+
{ label: 'above', position: 'above', align: 'center', gridClass: 'row-start-1 col-start-2' },
22+
{ label: 'above-end', position: 'above', align: 'end', gridClass: 'row-start-1 col-start-3' },
23+
{ label: 'before-top', position: 'before', align: 'start', gridClass: 'row-start-2 col-start-1' },
24+
{ label: 'before', position: 'before', align: 'center', gridClass: 'row-start-3 col-start-1' },
25+
{ label: 'before-bottom', position: 'before', align: 'end', gridClass: 'row-start-4 col-start-1' },
26+
{ label: 'after-top', position: 'after', align: 'start', gridClass: 'row-start-2 col-start-3' },
27+
{ label: 'after', position: 'after', align: 'center', gridClass: 'row-start-3 col-start-3' },
28+
{ label: 'after-bottom', position: 'after', align: 'end', gridClass: 'row-start-4 col-start-3' },
29+
{ label: 'below-start', position: 'below', align: 'start', gridClass: 'row-start-5 col-start-1' },
30+
{ label: 'below', position: 'below', align: 'center', gridClass: 'row-start-5 col-start-2' },
31+
{ label: 'below-end', position: 'below', align: 'end', gridClass: 'row-start-5 col-start-3' },
32+
];
33+
34+
export const CoverTarget = (): React.ReactNode => (
35+
<div className={classes.outer}>
36+
<div className={classes.wrapper}>
37+
{cells.map(cell => (
38+
<div key={cell.label} className={cell.gridClass}>
39+
<Popover positioning={{ position: cell.position, align: cell.align, coverTarget: true }}>
40+
<PopoverTrigger>
41+
<button className={classes.trigger}>{cell.label}</button>
42+
</PopoverTrigger>
43+
<PopoverSurface className={classes.surface}>Container</PopoverSurface>
44+
</Popover>
45+
</div>
46+
))}
47+
</div>
48+
</div>
49+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as React from 'react';
2+
import {
3+
Popover,
4+
PopoverTrigger,
5+
PopoverSurface,
6+
type PositioningProps,
7+
} from '@fluentui/react-headless-components-preview';
8+
9+
const classes = {
10+
trigger:
11+
'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
12+
surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 min-w-[160px]',
13+
};
14+
15+
export const Default = (props: PositioningProps): React.ReactNode => (
16+
<Popover positioning={props}>
17+
<PopoverTrigger>
18+
<button className={classes.trigger}>Click me</button>
19+
</PopoverTrigger>
20+
<PopoverSurface className={classes.surface}>Container</PopoverSurface>
21+
</Popover>
22+
);
23+
24+
Default.argTypes = {
25+
position: {
26+
control: 'select',
27+
options: ['above', 'below', 'before', 'after'],
28+
},
29+
align: {
30+
control: 'select',
31+
options: ['start', 'center', 'end'],
32+
},
33+
offset: {
34+
control: 'number',
35+
},
36+
coverTarget: {
37+
control: 'boolean',
38+
},
39+
fallbackPositions: { control: { disable: true } },
40+
autoSize: { control: { disable: true } },
41+
};
42+
43+
Default.args = {
44+
position: 'above',
45+
align: 'center',
46+
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Headless Fluent components that make use of positioning can all be configured in the same way. In this preview package, positioning currently applies to:
2+
3+
- Popover
4+
5+
Components that have slots which are positioned will always expose a `positioning` prop where the positioning of the slot can be configured.
6+
7+
Below you can try out the different positioning options in the playground. Further examples try to explain more clearly different configuration options for the `positioning` prop.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as React from 'react';
2+
import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview';
3+
4+
const classes = {
5+
wrapper: 'flex flex-col items-start gap-4 m-16',
6+
trigger:
7+
'px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
8+
surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 max-w-xs',
9+
note: 'text-xs text-gray-500 max-w-lg',
10+
};
11+
12+
export const FallbackPositions = (): React.ReactNode => (
13+
<div className={classes.wrapper}>
14+
<Popover
15+
positioning={{
16+
position: 'above',
17+
align: 'center',
18+
fallbackPositions: ['below-start', 'after'],
19+
}}
20+
>
21+
<PopoverTrigger>
22+
<button className={classes.trigger}>Open near an edge</button>
23+
</PopoverTrigger>
24+
<PopoverSurface className={classes.surface}>
25+
When <code>above</code> overflows the viewport, the browser walks <code>fallbackPositions</code> in order —
26+
first <code>below-start</code>, then <code>after</code> — and picks the first placement that fits.
27+
</PopoverSurface>
28+
</Popover>
29+
30+
<p className={classes.note}>
31+
<code>fallbackPositions</code> accepts shorthand strings like <code>'below-start'</code>. Each entry is compiled
32+
into a <code>@position-try</code> rule so the browser can try them natively. When omitted, the default fallback is{' '}
33+
<code>flip-block, flip-inline</code>.
34+
</p>
35+
</div>
36+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as React from 'react';
2+
import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview';
3+
4+
const classes = {
5+
wrapper: 'flex flex-col items-start gap-4 m-16',
6+
trigger:
7+
'w-[350px] px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
8+
surface: 'bg-white rounded-lg shadow-lg border border-gray-200 p-4 box-border',
9+
note: 'text-xs text-gray-500 max-w-lg',
10+
};
11+
12+
export const MatchTargetSize = (): React.ReactNode => (
13+
<div className={classes.wrapper}>
14+
<Popover open positioning={{ matchTargetSize: 'width' }}>
15+
<PopoverTrigger>
16+
<button className={classes.trigger}>Click me</button>
17+
</PopoverTrigger>
18+
<PopoverSurface className={classes.surface}>This popover has the same width as its target anchor</PopoverSurface>
19+
</Popover>
20+
21+
<p className={classes.note}>
22+
<code>matchTargetSize: 'width'</code> applies <code>width: anchor-size(width)</code> to the surface so its inline
23+
size tracks the trigger's. Handy for combobox / autocomplete dropdowns that should line up with the input. Ensure
24+
the surface uses <code>box-sizing: border-box</code> so padding doesn't exceed the matched width.
25+
</p>
26+
</div>
27+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as React from 'react';
2+
import { Popover, PopoverTrigger, PopoverSurface } from '@fluentui/react-headless-components-preview';
3+
4+
const classes = {
5+
outer: 'w-full overflow-auto',
6+
wrapper: 'flex flex-col items-start gap-6 mx-32 my-16 w-max',
7+
row: 'flex items-center gap-3 text-sm text-gray-700',
8+
input: 'w-20 px-2 py-1 border border-gray-300 rounded',
9+
trigger:
10+
'inline-flex w-fit px-4 py-2 rounded-md bg-blue-600 text-white font-medium hover:bg-blue-700 data-[open]:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-500 focus-visible:outline-offset-2 cursor-pointer border-none',
11+
surface: 'bg-white rounded-lg shadow-lg border border-gray-200 px-3 py-2 text-sm w-40',
12+
group: 'flex flex-col items-start gap-2',
13+
label: 'text-xs font-semibold text-gray-500 uppercase tracking-wide',
14+
};
15+
16+
export const Offset = (): React.ReactNode => {
17+
const [mainAxis, setMainAxis] = React.useState(10);
18+
const [crossAxis, setCrossAxis] = React.useState(0);
19+
20+
return (
21+
<div className={classes.outer}>
22+
<div className={classes.wrapper}>
23+
<label className={classes.row}>
24+
<code>mainAxis</code>
25+
<input
26+
type="number"
27+
className={classes.input}
28+
value={mainAxis}
29+
onChange={e => setMainAxis(parseInt(e.target.value, 10) || 0)}
30+
/>
31+
</label>
32+
<label className={classes.row}>
33+
<code>crossAxis</code>
34+
<input
35+
type="number"
36+
className={classes.input}
37+
value={crossAxis}
38+
onChange={e => setCrossAxis(parseInt(e.target.value, 10) || 0)}
39+
/>
40+
</label>
41+
42+
<div className={classes.group}>
43+
<span className={classes.label}>offset: number (mainAxis only)</span>
44+
<Popover positioning={{ position: 'after', offset: mainAxis }}>
45+
<PopoverTrigger>
46+
<button className={classes.trigger}>Number offset</button>
47+
</PopoverTrigger>
48+
<PopoverSurface className={classes.surface}>Container</PopoverSurface>
49+
</Popover>
50+
</div>
51+
52+
<div className={classes.group}>
53+
<span className={classes.label}>offset: {'{ mainAxis, crossAxis }'}</span>
54+
<Popover positioning={{ position: 'after', offset: { mainAxis, crossAxis } }}>
55+
<PopoverTrigger>
56+
<button className={classes.trigger}>Object offset</button>
57+
</PopoverTrigger>
58+
<PopoverSurface className={classes.surface}>Container</PopoverSurface>
59+
</Popover>
60+
</div>
61+
</div>
62+
</div>
63+
);
64+
};

0 commit comments

Comments
 (0)