Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/hover-card-inline-positioning.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 90 additions & 0 deletions examples/next-ts/pages/hover-card/inline-positioning.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main
style={{
display: "grid",
minHeight: "100vh",
padding: "80px",
placeItems: "center",
}}
>
<article
style={{
border: "1px solid #e5e7eb",
borderRadius: "16px",
boxShadow: "0 12px 30px rgba(15, 23, 42, 0.08)",
maxWidth: "360px",
padding: "24px",
}}
>
<h1 style={{ fontSize: "20px", marginBottom: "16px" }}>Inline positioning</h1>
<p style={{ lineHeight: 1.7, marginBottom: "16px" }}>
Hover this:{" "}
<a
href="https://zagjs.com"
rel="noreferrer"
target="_blank"
{...api.getTriggerProps()}
style={{
color: "#2563eb",
fontWeight: 600,
textDecoration: "underline",
textUnderlineOffset: "3px",
}}
>
Zag.js inline preview
<br />
API
</a>
, which keeps the preview anchored to the visible text fragment.
</p>
<p style={{ color: "#64748b", fontSize: "14px", lineHeight: 1.6 }}>
The trigger is intentionally split across two inline lines so you can compare how the card anchors to each
fragment.
</p>
</article>

{api.open && (
<Portal>
<div {...api.getPositionerProps()}>
<div
{...api.getContentProps()}
style={{
background: "white",
border: "1px solid #cbd5e1",
borderRadius: "12px",
boxShadow: "0 16px 40px rgba(15, 23, 42, 0.16)",
padding: "14px",
width: "260px",
}}
>
<div {...api.getArrowProps()}>
<div {...api.getArrowTipProps()} />
</div>
<strong>Anchored to inline text</strong>
<p style={{ color: "#475569", fontSize: "14px", lineHeight: 1.5, marginTop: "8px" }}>
The inline middleware picks the text rect closest to the pointer, instead of the whole wrapped link box.
</p>
</div>
</div>
</Portal>
)}
</main>
)
}
2 changes: 2 additions & 0 deletions packages/machines/hover-card/src/hover-card.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ export function connect<T extends PropTypes>(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) {
Expand Down
51 changes: 37 additions & 14 deletions packages/machines/hover-card/src/hover-card.machine.ts
Original file line number Diff line number Diff line change
@@ -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<HoverCardSchema>()

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<HoverCardSchema>({
props({ props }) {
return {
Expand All @@ -15,6 +23,7 @@ export const machine = createMachine<HoverCardSchema>({
...props,
positioning: {
placement: "bottom",
inline: false,
...props.positioning,
},
}
Expand All @@ -37,6 +46,9 @@ export const machine = createMachine<HoverCardSchema>({
isPointer: bindable<boolean>(() => ({
defaultValue: false,
})),
pointerPoint: bindable<Point | null>(() => ({
defaultValue: null,
})),
triggerValue: bindable<string | null>(() => ({
defaultValue: prop("defaultTriggerValue") ?? null,
value: prop("triggerValue"),
Expand All @@ -63,29 +75,29 @@ export const machine = createMachine<HoverCardSchema>({

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"],
},
},
},
Expand Down Expand Up @@ -146,7 +158,7 @@ export const machine = createMachine<HoverCardSchema>({
],
"TRIGGER_VALUE.SET": {
// Stay in opening state but update trigger value (will reposition when opened)
actions: ["setTriggerValue"],
actions: ["setPointerPoint", "setTriggerValue"],
},
},
},
Expand All @@ -159,7 +171,7 @@ export const machine = createMachine<HoverCardSchema>({
target: "closed",
},
POINTER_ENTER: {
actions: ["setIsPointer"],
actions: ["setIsPointer", "setPointerPoint"],
},
POINTER_LEAVE: {
target: "closing",
Expand Down Expand Up @@ -214,15 +226,15 @@ export const machine = createMachine<HoverCardSchema>({
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"],
},
},
},
Expand Down Expand Up @@ -257,8 +269,9 @@ export const machine = createMachine<HoverCardSchema>({
}
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)
Expand Down Expand Up @@ -298,12 +311,22 @@ export const machine = createMachine<HoverCardSchema>({
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) {
Expand Down
4 changes: 4 additions & 0 deletions packages/machines/hover-card/src/hover-card.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
16 changes: 13 additions & 3 deletions packages/utilities/popper/src/get-anchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
21 changes: 20 additions & 1 deletion packages/utilities/popper/src/get-placement.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -14,6 +25,7 @@ const defaultOptions: PositioningOptions = {
restoreStyles: false,
gutter: 8,
flip: true,
inline: false,
slide: true,
overlap: false,
sameWidth: false,
Expand All @@ -32,6 +44,7 @@ interface Options extends RequiredBy<
| "listeners"
| "gutter"
| "flip"
| "inline"
| "slide"
| "overlap"
| "sameWidth"
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -249,6 +267,7 @@ function getPlacementImpl(
restoreArrowStyles = options.restoreStyles ? createStyleCleanup(arrowEl, arrowStyleProps) : undefined

middleware = [
getInlineMiddleware(options),
getOffsetMiddleware(arrowEl, options),
getFlipMiddleware(options),
getShiftMiddleware(options),
Expand Down
1 change: 1 addition & 0 deletions packages/utilities/popper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
AutoUpdateOptions,
Boundary,
ComputePositionReturn,
InlineOptions,
Placement,
PlacementAlign,
PlacementSide,
Expand Down
15 changes: 13 additions & 2 deletions packages/utilities/popper/src/types.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -121,4 +132,4 @@ export interface PositioningOptions {
| undefined
}

export type { AutoUpdateOptions, Boundary, ComputePositionReturn, Placement }
export type { AutoUpdateOptions, Boundary, ComputePositionReturn, InlineOptions, Placement }
1 change: 1 addition & 0 deletions shared/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
Expand Down
Loading
Loading