Skip to content

Commit f6338d2

Browse files
committed
[preview card] Integrate inline positioning
1 parent d23ecb8 commit f6338d2

File tree

7 files changed

+257
-5
lines changed

7 files changed

+257
-5
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
.Paragraph {
2+
margin: 0;
3+
font-size: 1rem;
4+
line-height: 1.5rem;
5+
color: var(--color-gray-900);
6+
text-wrap: balance;
7+
max-width: 20rem;
8+
margin-top: 10rem;
9+
}
10+
11+
.Link {
12+
outline: 0;
13+
color: var(--color-blue);
14+
text-decoration-line: none;
15+
text-decoration-thickness: 1px;
16+
text-decoration-color: color-mix(in oklab, var(--color-blue), transparent 40%);
17+
text-underline-offset: 2px;
18+
19+
@media (hover: hover) {
20+
&:hover {
21+
text-decoration-line: underline;
22+
}
23+
}
24+
25+
&[data-popup-open] {
26+
text-decoration-line: underline;
27+
}
28+
29+
&:focus-visible {
30+
border-radius: 0.125rem;
31+
outline: 2px solid var(--color-blue);
32+
text-decoration-line: none;
33+
}
34+
}
35+
36+
.Popup {
37+
box-sizing: border-box;
38+
width: 240px;
39+
display: flex;
40+
flex-direction: column;
41+
gap: 0.5rem;
42+
padding: 0.5rem;
43+
border-radius: 0.5rem;
44+
background-color: canvas;
45+
transform-origin: var(--transform-origin);
46+
transition:
47+
transform 150ms,
48+
opacity 150ms;
49+
50+
&[data-starting-style],
51+
&[data-ending-style] {
52+
opacity: 0;
53+
transform: scale(0.9);
54+
}
55+
56+
@media (prefers-color-scheme: light) {
57+
outline: 1px solid var(--color-gray-200);
58+
box-shadow:
59+
0 10px 15px -3px var(--color-gray-200),
60+
0 4px 6px -4px var(--color-gray-200);
61+
}
62+
63+
@media (prefers-color-scheme: dark) {
64+
outline: 1px solid var(--color-gray-300);
65+
outline-offset: -1px;
66+
}
67+
}
68+
69+
.Arrow {
70+
display: flex;
71+
72+
&[data-side='top'] {
73+
bottom: -8px;
74+
rotate: 180deg;
75+
}
76+
77+
&[data-side='bottom'] {
78+
top: -8px;
79+
rotate: 0deg;
80+
}
81+
82+
&[data-side='left'] {
83+
right: -13px;
84+
rotate: 90deg;
85+
}
86+
87+
&[data-side='right'] {
88+
left: -13px;
89+
rotate: -90deg;
90+
}
91+
}
92+
93+
.ArrowFill {
94+
fill: canvas;
95+
}
96+
97+
.ArrowOuterStroke {
98+
@media (prefers-color-scheme: light) {
99+
fill: var(--color-gray-200);
100+
}
101+
}
102+
103+
.ArrowInnerStroke {
104+
@media (prefers-color-scheme: dark) {
105+
fill: var(--color-gray-300);
106+
}
107+
}
108+
109+
.Image {
110+
display: block;
111+
width: 100%;
112+
height: auto;
113+
border-radius: 0.25rem;
114+
}
115+
116+
.Summary {
117+
margin: 0;
118+
font-size: 0.875rem;
119+
line-height: 1.25rem;
120+
color: var(--color-gray-900);
121+
text-wrap: pretty;
122+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as React from 'react';
2+
import { PreviewCard } from '@base-ui-components/react/preview-card';
3+
import styles from './inline-positioning.module.css';
4+
5+
export default function ExamplePreviewCard() {
6+
return (
7+
<PreviewCard.Root>
8+
<p className={styles.Paragraph}>
9+
The principles of good{' '}
10+
<PreviewCard.Trigger
11+
className={styles.Link}
12+
href="https://en.wikipedia.org/wiki/Typography"
13+
>
14+
typography that remain into
15+
</PreviewCard.Trigger>{' '}
16+
the digital age.
17+
</p>
18+
19+
<PreviewCard.Portal>
20+
<PreviewCard.Positioner sideOffset={8}>
21+
<PreviewCard.Popup className={styles.Popup}>
22+
<PreviewCard.Arrow className={styles.Arrow}>
23+
<ArrowSvg />
24+
</PreviewCard.Arrow>
25+
<img
26+
width="448"
27+
height="300"
28+
className={styles.Image}
29+
src="https://images.unsplash.com/photo-1619615391095-dfa29e1672ef?q=80&w=448&h=300"
30+
alt="Station Hofplein signage in Rotterdam, Netherlands"
31+
/>
32+
<p className={styles.Summary}>
33+
<strong>Typography</strong> is the art and science of arranging type to
34+
make written language clear, visually appealing, and effective in
35+
communication.
36+
</p>
37+
</PreviewCard.Popup>
38+
</PreviewCard.Positioner>
39+
</PreviewCard.Portal>
40+
</PreviewCard.Root>
41+
);
42+
}
43+
44+
function ArrowSvg(props: React.ComponentProps<'svg'>) {
45+
return (
46+
<svg width="20" height="10" viewBox="0 0 20 10" fill="none" {...props}>
47+
<path
48+
d="M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
49+
className={styles.ArrowFill}
50+
/>
51+
<path
52+
d="M8.99542 1.85876C9.75604 1.17425 10.9106 1.17422 11.6713 1.85878L16.5281 6.22989C17.0789 6.72568 17.7938 7.00001 18.5349 7.00001L15.89 7L11.0023 2.60207C10.622 2.2598 10.0447 2.2598 9.66436 2.60207L4.77734 7L2.13171 7.00001C2.87284 7.00001 3.58774 6.72568 4.13861 6.22989L8.99542 1.85876Z"
53+
className={styles.ArrowOuterStroke}
54+
/>
55+
<path
56+
d="M10.3333 3.34539L5.47654 7.71648C4.55842 8.54279 3.36693 9 2.13172 9H0V8H2.13172C3.11989 8 4.07308 7.63423 4.80758 6.97318L9.66437 2.60207C10.0447 2.25979 10.622 2.2598 11.0023 2.60207L15.8591 6.97318C16.5936 7.63423 17.5468 8 18.5349 8H20V9H18.5349C17.2998 9 16.1083 8.54278 15.1901 7.71648L10.3333 3.34539Z"
57+
className={styles.ArrowInnerStroke}
58+
/>
59+
</svg>
60+
);
61+
}

packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use client';
22
import * as React from 'react';
3+
import { inline } from '@floating-ui/react';
4+
import { isHTMLElement } from '@floating-ui/utils/dom';
35
import { usePreviewCardRootContext } from '../root/PreviewCardContext';
46
import { usePreviewCardPositioner } from './usePreviewCardPositioner';
57
import { PreviewCardPositionerContext } from './PreviewCardPositionerContext';
@@ -38,7 +40,8 @@ export const PreviewCardPositioner = React.forwardRef(function PreviewCardPositi
3840
...elementProps
3941
} = componentProps;
4042

41-
const { open, mounted, floatingRootContext, setPositionerElement } = usePreviewCardRootContext();
43+
const { open, mounted, floatingRootContext, setPositionerElement, coordsRef } =
44+
usePreviewCardRootContext();
4245
const keepMounted = usePreviewCardPortalContext();
4346

4447
const positioning = useAnchorPositioning({
@@ -57,6 +60,26 @@ export const PreviewCardPositioner = React.forwardRef(function PreviewCardPositi
5760
trackAnchor,
5861
keepMounted,
5962
collisionAvoidance,
63+
inline: inline((state) => {
64+
const trigger = state.elements.reference;
65+
if (!isHTMLElement(trigger) || !coordsRef.current) {
66+
return {};
67+
}
68+
69+
const rects = Array.from(trigger.getClientRects());
70+
const rect = rects[coordsRef.current.rectIndex];
71+
const x = coordsRef.current.x;
72+
const y = coordsRef.current.y;
73+
74+
if (!rect) {
75+
return {};
76+
}
77+
78+
return {
79+
x: rect.left + x,
80+
y: rect.top + y,
81+
};
82+
}),
6083
});
6184

6285
const defaultProps: HTMLProps = React.useMemo(() => {

packages/react/src/preview-card/root/PreviewCardContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface PreviewCardRootContext {
2121
transitionStatus: TransitionStatus;
2222
popupRef: React.RefObject<HTMLElement | null>;
2323
onOpenChangeComplete: ((open: boolean) => void) | undefined;
24+
coordsRef: React.RefObject<{ x: number; y: number; rectIndex: number } | undefined>;
2425
}
2526

2627
export const PreviewCardRootContext = React.createContext<PreviewCardRootContext | undefined>(

packages/react/src/preview-card/root/PreviewCardRoot.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
useHover,
88
useInteractions,
99
useFloatingRootContext,
10+
useClientPoint,
1011
} from '@floating-ui/react';
1112
import { PreviewCardRootContext } from './PreviewCardContext';
1213
import { CLOSE_DELAY, OPEN_DELAY } from '../utils/constants';
@@ -43,6 +44,7 @@ export function PreviewCardRoot(props: PreviewCardRoot.Props) {
4344
const [triggerElement, setTriggerElement] = React.useState<Element | null>(null);
4445
const [positionerElement, setPositionerElement] = React.useState<HTMLElement | null>(null);
4546
const [instantTypeState, setInstantTypeState] = React.useState<'dismiss' | 'focus'>();
47+
const coordsRef: PreviewCardRootContext['coordsRef'] = React.useRef(undefined);
4648

4749
const popupRef = React.useRef<HTMLDivElement | null>(null);
4850

@@ -148,6 +150,7 @@ export function PreviewCardRoot(props: PreviewCardRoot.Props) {
148150
onOpenChangeComplete,
149151
delay: delayWithDefault,
150152
closeDelay: closeDelayWithDefault,
153+
coordsRef,
151154
}),
152155
[
153156
open,

packages/react/src/preview-card/trigger/PreviewCardTrigger.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,44 @@ export const PreviewCardTrigger = React.forwardRef(function PreviewCardTrigger(
1717
) {
1818
const { render, className, ...elementProps } = componentProps;
1919

20-
const { open, triggerProps, setTriggerElement } = usePreviewCardRootContext();
20+
const { open, triggerProps, setTriggerElement, coordsRef } = usePreviewCardRootContext();
2121

2222
const state: PreviewCardTrigger.State = React.useMemo(() => ({ open }), [open]);
2323

2424
const element = useRenderElement('a', componentProps, {
2525
ref: [setTriggerElement, forwardedRef],
2626
state,
27-
props: [triggerProps, elementProps],
27+
props: [
28+
triggerProps,
29+
{
30+
onFocus() {
31+
coordsRef.current = undefined;
32+
},
33+
onMouseMove(event) {
34+
if (open) return;
35+
36+
const rects = Array.from(event.currentTarget.getClientRects());
37+
38+
const hovered = rects.reduce(
39+
(best, rect, i) => {
40+
const d = Math.hypot(
41+
event.clientX - (rect.left + rect.width / 2),
42+
event.clientY - (rect.top + rect.height / 2),
43+
);
44+
return d < best.d ? { i, rect, d } : best;
45+
},
46+
{ i: 0, rect: rects[0], d: Number.POSITIVE_INFINITY },
47+
);
48+
49+
coordsRef.current = {
50+
rectIndex: hovered.i,
51+
x: event.clientX - hovered.rect.left,
52+
y: event.clientY - hovered.rect.top,
53+
};
54+
},
55+
},
56+
elementProps,
57+
],
2858
customStyleHookMapping: triggerOpenStateMapping,
2959
});
3060

packages/react/src/utils/useAnchorPositioning.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export function useAnchorPositioning(
117117
sticky = false,
118118
arrowPadding = 5,
119119
trackAnchor = true,
120+
inline: inlineMiddleware,
120121
// Private parameters
121122
keepMounted = false,
122123
floatingRootContext,
@@ -168,7 +169,13 @@ export function useAnchorPositioning(
168169
const sideOffsetDep = typeof sideOffset !== 'function' ? sideOffset : 0;
169170
const alignOffsetDep = typeof alignOffset !== 'function' ? alignOffset : 0;
170171

171-
const middleware: UseFloatingOptions['middleware'] = [
172+
const middleware: UseFloatingOptions['middleware'] = [];
173+
174+
if (inlineMiddleware) {
175+
middleware.push(inlineMiddleware);
176+
}
177+
178+
middleware.push(
172179
offset(
173180
(state) => {
174181
const data = getOffsetData(state, sideParam, isRtl);
@@ -190,7 +197,7 @@ export function useAnchorPositioning(
190197
},
191198
[sideOffsetDep, alignOffsetDep, isRtl, sideParam],
192199
),
193-
];
200+
);
194201

195202
const shiftDisabled = collisionAvoidanceAlign === 'none' && collisionAvoidanceSide !== 'shift';
196203
const crossAxisShiftEnabled =
@@ -543,6 +550,11 @@ export namespace useAnchorPositioning {
543550
* Determines how to handle collisions when positioning the popup.
544551
*/
545552
collisionAvoidance?: CollisionAvoidance;
553+
/**
554+
* Improves positioning for inline reference elements that span over multiple lines.
555+
* Pass mouse coordinates from mouse events to position the popup relative to the cursor.
556+
*/
557+
inline?: Middleware;
546558
}
547559

548560
export interface Parameters extends SharedParameters {

0 commit comments

Comments
 (0)