diff --git a/docs/src/app/(private)/experiments/inline-positioning.module.css b/docs/src/app/(private)/experiments/inline-positioning.module.css new file mode 100644 index 0000000000..f69e9ce09f --- /dev/null +++ b/docs/src/app/(private)/experiments/inline-positioning.module.css @@ -0,0 +1,122 @@ +.Paragraph { + margin: 0; + font-size: 1rem; + line-height: 1.5rem; + color: var(--color-gray-900); + text-wrap: balance; + max-width: 20rem; + margin-top: 10rem; +} + +.Link { + outline: 0; + color: var(--color-blue); + text-decoration-line: none; + text-decoration-thickness: 1px; + text-decoration-color: color-mix(in oklab, var(--color-blue), transparent 40%); + text-underline-offset: 2px; + + @media (hover: hover) { + &:hover { + text-decoration-line: underline; + } + } + + &[data-popup-open] { + text-decoration-line: underline; + } + + &:focus-visible { + border-radius: 0.125rem; + outline: 2px solid var(--color-blue); + text-decoration-line: none; + } +} + +.Popup { + box-sizing: border-box; + width: 240px; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 0.5rem; + background-color: canvas; + transform-origin: var(--transform-origin); + transition: + transform 150ms, + opacity 150ms; + + &[data-starting-style], + &[data-ending-style] { + opacity: 0; + transform: scale(0.9); + } + + @media (prefers-color-scheme: light) { + outline: 1px solid var(--color-gray-200); + box-shadow: + 0 10px 15px -3px var(--color-gray-200), + 0 4px 6px -4px var(--color-gray-200); + } + + @media (prefers-color-scheme: dark) { + outline: 1px solid var(--color-gray-300); + outline-offset: -1px; + } +} + +.Arrow { + display: flex; + + &[data-side='top'] { + bottom: -8px; + rotate: 180deg; + } + + &[data-side='bottom'] { + top: -8px; + rotate: 0deg; + } + + &[data-side='left'] { + right: -13px; + rotate: 90deg; + } + + &[data-side='right'] { + left: -13px; + rotate: -90deg; + } +} + +.ArrowFill { + fill: canvas; +} + +.ArrowOuterStroke { + @media (prefers-color-scheme: light) { + fill: var(--color-gray-200); + } +} + +.ArrowInnerStroke { + @media (prefers-color-scheme: dark) { + fill: var(--color-gray-300); + } +} + +.Image { + display: block; + width: 100%; + height: auto; + border-radius: 0.25rem; +} + +.Summary { + margin: 0; + font-size: 0.875rem; + line-height: 1.25rem; + color: var(--color-gray-900); + text-wrap: pretty; +} diff --git a/docs/src/app/(private)/experiments/inline-positioning.tsx b/docs/src/app/(private)/experiments/inline-positioning.tsx new file mode 100644 index 0000000000..783f74f5e4 --- /dev/null +++ b/docs/src/app/(private)/experiments/inline-positioning.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { PreviewCard } from '@base-ui-components/react/preview-card'; +import styles from './inline-positioning.module.css'; + +export default function ExamplePreviewCard() { + return ( + + + The principles of good{' '} + + typography that remain into + {' '} + the digital age. + + + + + + + + + + + Typography is the art and science of arranging type to + make written language clear, visually appealing, and effective in + communication. + + + + + + ); +} + +function ArrowSvg(props: React.ComponentProps<'svg'>) { + return ( + + + + + + ); +} diff --git a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx index 45894697fe..7404d70dc4 100644 --- a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx +++ b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx @@ -1,5 +1,7 @@ 'use client'; import * as React from 'react'; +import { inline } from '@floating-ui/react'; +import { isHTMLElement } from '@floating-ui/utils/dom'; import { usePreviewCardRootContext } from '../root/PreviewCardContext'; import { usePreviewCardPositioner } from './usePreviewCardPositioner'; import { PreviewCardPositionerContext } from './PreviewCardPositionerContext'; @@ -38,7 +40,8 @@ export const PreviewCardPositioner = React.forwardRef(function PreviewCardPositi ...elementProps } = componentProps; - const { open, mounted, floatingRootContext, setPositionerElement } = usePreviewCardRootContext(); + const { open, mounted, floatingRootContext, setPositionerElement, coordsRef } = + usePreviewCardRootContext(); const keepMounted = usePreviewCardPortalContext(); const positioning = useAnchorPositioning({ @@ -57,6 +60,24 @@ export const PreviewCardPositioner = React.forwardRef(function PreviewCardPositi trackAnchor, keepMounted, collisionAvoidance, + inline: inline((state) => { + const trigger = state.elements.reference; + if (!isHTMLElement(trigger) || !coordsRef.current) { + return {}; + } + + const rects = Array.from(trigger.getClientRects()); + const rect = rects[coordsRef.current.rectIndex]; + + if (!rect) { + return {}; + } + + return { + x: rect.left + coordsRef.current.x, + y: rect.top + coordsRef.current.y, + }; + }), }); const defaultProps: HTMLProps = React.useMemo(() => { diff --git a/packages/react/src/preview-card/root/PreviewCardContext.ts b/packages/react/src/preview-card/root/PreviewCardContext.ts index 0fdd17ef75..a8ec52e087 100644 --- a/packages/react/src/preview-card/root/PreviewCardContext.ts +++ b/packages/react/src/preview-card/root/PreviewCardContext.ts @@ -21,6 +21,7 @@ export interface PreviewCardRootContext { transitionStatus: TransitionStatus; popupRef: React.RefObject; onOpenChangeComplete: ((open: boolean) => void) | undefined; + coordsRef: React.RefObject<{ x: number; y: number; rectIndex: number } | undefined>; } export const PreviewCardRootContext = React.createContext( diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.tsx index d5e9c74f3c..b4f2266e41 100644 --- a/packages/react/src/preview-card/root/PreviewCardRoot.tsx +++ b/packages/react/src/preview-card/root/PreviewCardRoot.tsx @@ -43,6 +43,7 @@ export function PreviewCardRoot(props: PreviewCardRoot.Props) { const [triggerElement, setTriggerElement] = React.useState(null); const [positionerElement, setPositionerElement] = React.useState(null); const [instantTypeState, setInstantTypeState] = React.useState<'dismiss' | 'focus'>(); + const coordsRef: PreviewCardRootContext['coordsRef'] = React.useRef(undefined); const popupRef = React.useRef(null); @@ -148,6 +149,7 @@ export function PreviewCardRoot(props: PreviewCardRoot.Props) { onOpenChangeComplete, delay: delayWithDefault, closeDelay: closeDelayWithDefault, + coordsRef, }), [ open, diff --git a/packages/react/src/preview-card/trigger/PreviewCardTrigger.tsx b/packages/react/src/preview-card/trigger/PreviewCardTrigger.tsx index eb4fefe196..256a1ef409 100644 --- a/packages/react/src/preview-card/trigger/PreviewCardTrigger.tsx +++ b/packages/react/src/preview-card/trigger/PreviewCardTrigger.tsx @@ -17,14 +17,50 @@ export const PreviewCardTrigger = React.forwardRef(function PreviewCardTrigger( ) { const { render, className, ...elementProps } = componentProps; - const { open, triggerProps, setTriggerElement } = usePreviewCardRootContext(); + const { open, triggerProps, setTriggerElement, coordsRef } = usePreviewCardRootContext(); const state: PreviewCardTrigger.State = React.useMemo(() => ({ open }), [open]); const element = useRenderElement('a', componentProps, { ref: [setTriggerElement, forwardedRef], state, - props: [triggerProps, elementProps], + props: [ + triggerProps, + { + onFocus() { + coordsRef.current = undefined; + }, + onMouseMove(event) { + if (open) { + return; + } + + const rects = Array.from(event.currentTarget.getClientRects()); + + if (rects.length < 2) { + return; + } + + const hovered = rects.reduce( + (best, rect, i) => { + const d = Math.hypot( + event.clientX - (rect.left + rect.width / 2), + event.clientY - (rect.top + rect.height / 2), + ); + return d < best.d ? { i, rect, d } : best; + }, + { i: 0, rect: rects[0], d: Number.POSITIVE_INFINITY }, + ); + + coordsRef.current = { + rectIndex: hovered.i, + x: event.clientX - hovered.rect.left, + y: event.clientY - hovered.rect.top, + }; + }, + }, + elementProps, + ], customStyleHookMapping: triggerOpenStateMapping, }); diff --git a/packages/react/src/utils/useAnchorPositioning.ts b/packages/react/src/utils/useAnchorPositioning.ts index cffa03ea8c..459eba1137 100644 --- a/packages/react/src/utils/useAnchorPositioning.ts +++ b/packages/react/src/utils/useAnchorPositioning.ts @@ -117,6 +117,7 @@ export function useAnchorPositioning( sticky = false, arrowPadding = 5, trackAnchor = true, + inline: inlineMiddleware, // Private parameters keepMounted = false, floatingRootContext, @@ -168,7 +169,13 @@ export function useAnchorPositioning( const sideOffsetDep = typeof sideOffset !== 'function' ? sideOffset : 0; const alignOffsetDep = typeof alignOffset !== 'function' ? alignOffset : 0; - const middleware: UseFloatingOptions['middleware'] = [ + const middleware: UseFloatingOptions['middleware'] = []; + + if (inlineMiddleware) { + middleware.push(inlineMiddleware); + } + + middleware.push( offset( (state) => { const data = getOffsetData(state, sideParam, isRtl); @@ -190,7 +197,7 @@ export function useAnchorPositioning( }, [sideOffsetDep, alignOffsetDep, isRtl, sideParam], ), - ]; + ); const shiftDisabled = collisionAvoidanceAlign === 'none' && collisionAvoidanceSide !== 'shift'; const crossAxisShiftEnabled = @@ -555,6 +562,7 @@ export namespace useAnchorPositioning { adaptiveOrigin?: Middleware; collisionAvoidance: CollisionAvoidance; shiftCrossAxis?: boolean; + inline?: Middleware; } export interface ReturnValue {
+ The principles of good{' '} + + typography that remain into + {' '} + the digital age. +
+ Typography is the art and science of arranging type to + make written language clear, visually appealing, and effective in + communication. +