diff --git a/.changeset/hover-card-inline-positioning.md b/.changeset/hover-card-inline-positioning.md new file mode 100644 index 0000000000..bfa17b0c7a --- /dev/null +++ b/.changeset/hover-card-inline-positioning.md @@ -0,0 +1,6 @@ +--- +"@zag-js/hover-card": patch +"@zag-js/popper": patch +--- + +Add `positioning.inline` support for hover cards to improve positioning when the trigger wraps across multiple lines. diff --git a/examples/next-ts/pages/hover-card/inline-positioning.tsx b/examples/next-ts/pages/hover-card/inline-positioning.tsx new file mode 100644 index 0000000000..600aaadcb9 --- /dev/null +++ b/examples/next-ts/pages/hover-card/inline-positioning.tsx @@ -0,0 +1,90 @@ +import * as hoverCard from "@zag-js/hover-card" +import { normalizeProps, Portal, useMachine } from "@zag-js/react" +import { useId } from "react" + +export default function Page() { + const service = useMachine(hoverCard.machine, { + id: useId(), + openDelay: 100, + positioning: { + placement: "right", + inline: true, + gutter: 8, + }, + }) + + const api = hoverCard.connect(service, normalizeProps) + + return ( +
+
+

Inline positioning

+

+ Hover this:{" "} + + Zag.js inline preview +
+ API +
+ , which keeps the preview anchored to the visible text fragment. +

+

+ The trigger is intentionally split across two inline lines so you can compare how the card anchors to each + fragment. +

+
+ + {api.open && ( + +
+
+
+
+
+ Anchored to inline text +

+ The inline middleware picks the text rect closest to the pointer, instead of the whole wrapped link box. +

+
+
+ + )} +
+ ) +} diff --git a/packages/machines/hover-card/src/hover-card.connect.ts b/packages/machines/hover-card/src/hover-card.connect.ts index b308bb9458..f6fb5d1277 100644 --- a/packages/machines/hover-card/src/hover-card.connect.ts +++ b/packages/machines/hover-card/src/hover-card.connect.ts @@ -66,10 +66,12 @@ export function connect(service: HoverCardService, normaliz if (event.pointerType === "touch") return if (prop("disabled")) return const shouldSwitch = open && value != null && !current + const point = { x: event.clientX, y: event.clientY } send({ type: shouldSwitch ? "TRIGGER_VALUE.SET" : "POINTER_ENTER", src: "trigger", value, + point, }) }, onPointerLeave(event) { diff --git a/packages/machines/hover-card/src/hover-card.machine.ts b/packages/machines/hover-card/src/hover-card.machine.ts index 24be139b39..9c25341aae 100644 --- a/packages/machines/hover-card/src/hover-card.machine.ts +++ b/packages/machines/hover-card/src/hover-card.machine.ts @@ -1,11 +1,19 @@ import { createGuards, createMachine } from "@zag-js/core" import { trackDismissableElement } from "@zag-js/dismissable" -import { getPlacement } from "@zag-js/popper" +import { getPlacement, type PositioningOptions } from "@zag-js/popper" import * as dom from "./hover-card.dom" import type { HoverCardSchema, Placement } from "./hover-card.types" +type Point = { x: number; y: number } + const { not, and } = createGuards() +function getPositioningOptions(positioning: PositioningOptions, point: Point | null): PositioningOptions { + if (!positioning.inline || point == null) return positioning + if (positioning.inline === true) return { ...positioning, inline: point } + return { ...positioning, inline: { ...point, ...positioning.inline } } +} + export const machine = createMachine({ props({ props }) { return { @@ -15,6 +23,7 @@ export const machine = createMachine({ ...props, positioning: { placement: "bottom", + inline: false, ...props.positioning, }, } @@ -37,6 +46,9 @@ export const machine = createMachine({ isPointer: bindable(() => ({ defaultValue: false, })), + pointerPoint: bindable(() => ({ + defaultValue: null, + })), triggerValue: bindable(() => ({ defaultValue: prop("defaultTriggerValue") ?? null, value: prop("triggerValue"), @@ -63,29 +75,29 @@ export const machine = createMachine({ on: { "TRIGGER_VALUE.SET": { - actions: ["setTriggerValue", "reposition"], + actions: ["setPointerPoint", "setTriggerValue", "reposition"], }, }, states: { closed: { tags: ["closed"], - entry: ["clearIsPointer"], + entry: ["clearIsPointer", "clearPointerPoint"], on: { "CONTROLLED.OPEN": { target: "open", }, POINTER_ENTER: { target: "opening", - actions: ["setIsPointer", "setTriggerValue"], + actions: ["setIsPointer", "setPointerPoint", "setTriggerValue"], }, TRIGGER_FOCUS: { target: "opening", - actions: ["setTriggerValue"], + actions: ["clearPointerPoint", "setTriggerValue"], }, OPEN: { target: "opening", - actions: ["setTriggerValue"], + actions: ["clearPointerPoint", "setTriggerValue"], }, }, }, @@ -146,7 +158,7 @@ export const machine = createMachine({ ], "TRIGGER_VALUE.SET": { // Stay in opening state but update trigger value (will reposition when opened) - actions: ["setTriggerValue"], + actions: ["setPointerPoint", "setTriggerValue"], }, }, }, @@ -159,7 +171,7 @@ export const machine = createMachine({ target: "closed", }, POINTER_ENTER: { - actions: ["setIsPointer"], + actions: ["setIsPointer", "setPointerPoint"], }, POINTER_LEAVE: { target: "closing", @@ -214,15 +226,15 @@ export const machine = createMachine({ POINTER_ENTER: { target: "open", // no need to invokeOnOpen here because it's still open (but about to close) - actions: ["setIsPointer"], + actions: ["setIsPointer", "setPointerPoint"], }, TRIGGER_FOCUS: { target: "open", - actions: ["setTriggerValue"], + actions: ["clearPointerPoint", "setTriggerValue"], }, "TRIGGER_VALUE.SET": { target: "open", - actions: ["setTriggerValue", "reposition"], + actions: ["setPointerPoint", "setTriggerValue", "reposition"], }, }, }, @@ -257,8 +269,9 @@ export const machine = createMachine({ } const getPositionerEl = () => dom.getPositionerEl(scope) const getTriggerEl = () => dom.getActiveTriggerEl(scope, context.get("triggerValue")) + const positioning = getPositioningOptions(prop("positioning"), context.get("pointerPoint")) return getPlacement(getTriggerEl, getPositionerEl, { - ...prop("positioning"), + ...positioning, defer: true, onComplete(data) { context.set("currentPlacement", data.placement) @@ -298,12 +311,22 @@ export const machine = createMachine({ clearIsPointer({ context }) { context.set("isPointer", false) }, + setPointerPoint({ context, event }) { + if (!event.point) return + context.set("pointerPoint", event.point) + }, + clearPointerPoint({ context }) { + context.set("pointerPoint", null) + }, reposition({ context, prop, scope, event }) { const getPositionerEl = () => dom.getPositionerEl(scope) const getTriggerEl = () => dom.getActiveTriggerEl(scope, context.get("triggerValue")) + const positioning = getPositioningOptions( + { ...prop("positioning"), ...event.options }, + context.get("pointerPoint"), + ) getPlacement(getTriggerEl, getPositionerEl, { - ...prop("positioning"), - ...event.options, + ...positioning, defer: true, listeners: false, onComplete(data) { diff --git a/packages/machines/hover-card/src/hover-card.types.ts b/packages/machines/hover-card/src/hover-card.types.ts index 574ddea52c..f419e14f11 100644 --- a/packages/machines/hover-card/src/hover-card.types.ts +++ b/packages/machines/hover-card/src/hover-card.types.ts @@ -95,6 +95,10 @@ interface PrivateContext { * Whether the hover card is open by pointer */ isPointer: boolean + /** + * The last pointer position used to select an inline trigger rect. + */ + pointerPoint: { x: number; y: number } | null /** * Whether the hover card is open */ diff --git a/packages/utilities/popper/src/get-anchor.ts b/packages/utilities/popper/src/get-anchor.ts index b29739bc90..669730e119 100644 --- a/packages/utilities/popper/src/get-anchor.ts +++ b/packages/utilities/popper/src/get-anchor.ts @@ -29,15 +29,25 @@ export function getAnchorElement( anchorElement: MaybeRectElement, getAnchorRect?: (anchor: MaybeRectElement) => AnchorRect | null, ): VirtualElement { + const getRect = () => { + const anchor = anchorElement + const anchorRect = getAnchorRect?.(anchor) + if (anchorRect || !anchor) { + return getDOMRect(anchorRect) + } + return anchor.getBoundingClientRect() + } + return { contextElement: isHTMLElement(anchorElement) ? anchorElement : anchorElement?.contextElement, - getBoundingClientRect: () => { + getBoundingClientRect: getRect, + getClientRects: () => { const anchor = anchorElement const anchorRect = getAnchorRect?.(anchor) if (anchorRect || !anchor) { - return getDOMRect(anchorRect) + return [getDOMRect(anchorRect)] } - return anchor.getBoundingClientRect() + return "getClientRects" in anchor ? Array.from(anchor.getClientRects()) : [anchor.getBoundingClientRect()] }, } as VirtualElement } diff --git a/packages/utilities/popper/src/get-placement.ts b/packages/utilities/popper/src/get-placement.ts index a545fb661c..d2af35957d 100644 --- a/packages/utilities/popper/src/get-placement.ts +++ b/packages/utilities/popper/src/get-placement.ts @@ -1,5 +1,16 @@ import type { AutoUpdateOptions, Middleware, Placement } from "@floating-ui/dom" -import { arrow, autoUpdate, computePosition, flip, hide, limitShift, offset, shift, size } from "@floating-ui/dom" +import { + arrow, + autoUpdate, + computePosition, + flip, + hide, + inline, + limitShift, + offset, + shift, + size, +} from "@floating-ui/dom" import { getComputedStyle, getWindow, isHTMLElement, raf } from "@zag-js/dom-query" import { compact, isNull, noop } from "@zag-js/utils" import { getAnchorElement } from "./get-anchor" @@ -14,6 +25,7 @@ const defaultOptions: PositioningOptions = { restoreStyles: false, gutter: 8, flip: true, + inline: false, slide: true, overlap: false, sameWidth: false, @@ -32,6 +44,7 @@ interface Options extends RequiredBy< | "listeners" | "gutter" | "flip" + | "inline" | "slide" | "overlap" | "sameWidth" @@ -92,6 +105,11 @@ function getFlipMiddleware(opts: Options) { }) } +function getInlineMiddleware(opts: Options) { + if (!opts.inline) return + return inline(opts.inline === true ? undefined : opts.inline) +} + function getShiftMiddleware(opts: Options) { if (!opts.slide && !opts.overlap) return return shift(() => { @@ -249,6 +267,7 @@ function getPlacementImpl( restoreArrowStyles = options.restoreStyles ? createStyleCleanup(arrowEl, arrowStyleProps) : undefined middleware = [ + getInlineMiddleware(options), getOffsetMiddleware(arrowEl, options), getFlipMiddleware(options), getShiftMiddleware(options), diff --git a/packages/utilities/popper/src/index.ts b/packages/utilities/popper/src/index.ts index 3ad2e4c26e..9b9ad39cd0 100644 --- a/packages/utilities/popper/src/index.ts +++ b/packages/utilities/popper/src/index.ts @@ -6,6 +6,7 @@ export type { AutoUpdateOptions, Boundary, ComputePositionReturn, + InlineOptions, Placement, PlacementAlign, PlacementSide, diff --git a/packages/utilities/popper/src/types.ts b/packages/utilities/popper/src/types.ts index fdb12ef0aa..76df1c17d7 100644 --- a/packages/utilities/popper/src/types.ts +++ b/packages/utilities/popper/src/types.ts @@ -1,4 +1,11 @@ -import type { AutoUpdateOptions, Boundary, ComputePositionReturn, Placement, VirtualElement } from "@floating-ui/dom" +import type { + AutoUpdateOptions, + Boundary, + ComputePositionReturn, + InlineOptions, + Placement, + VirtualElement, +} from "@floating-ui/dom" export type MaybeRectElement = HTMLElement | VirtualElement | null @@ -58,6 +65,10 @@ export interface PositioningOptions { * Whether to flip the placement */ flip?: boolean | Placement[] | undefined + /** + * Whether to use the inline middleware to improve positioning for inline reference elements that span multiple lines. + */ + inline?: boolean | InlineOptions | undefined /** * Whether the popover should slide when it overflows. */ @@ -121,4 +132,4 @@ export interface PositioningOptions { | undefined } -export type { AutoUpdateOptions, Boundary, ComputePositionReturn, Placement } +export type { AutoUpdateOptions, Boundary, ComputePositionReturn, InlineOptions, Placement } diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 9aa280ac5f..0dfcc7e5f9 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -333,6 +333,7 @@ export const componentRoutes: ComponentRoute[] = [ examples: [ { slug: "basic", title: "Basic" }, { slug: "hovercard-in-dialog", title: "With Dialog" }, + { slug: "inline-positioning", title: "Inline Positioning" }, { slug: "multiple-trigger", title: "Multiple Trigger" }, ], }, diff --git a/website/data/components/hover-card.mdx b/website/data/components/hover-card.mdx index 2a6540c1c6..dde46477e5 100644 --- a/website/data/components/hover-card.mdx +++ b/website/data/components/hover-card.mdx @@ -98,6 +98,19 @@ const service = useMachine(hoverCard.machine, { }) ``` +Use `positioning.inline` when the trigger is inline text that can wrap across +multiple lines. The hover card will choose the trigger rect closest to the +pointer that opened it. + +```tsx +const service = useMachine(hoverCard.machine, { + positioning: { + placement: "top", + inline: true, + }, +}) +``` + ### Multiple triggers A single hover card instance can be shared across multiple trigger elements.