import {
attrs,
createMixin,
css,
on,
ref,
type Handle,
type Props,
type RemixNode,
} from 'remix/component'
export type Side = 'top' | 'right' | 'bottom' | 'left'
export type Align = 'start' | 'center' | 'end'
export type DismissReason = 'escape-key' | 'outside-click' | 'focus-outside'
// ---------------------------------------------------------------------------------------------
// Floating layer: the Radix Popper + DismissableLayer + Presence behaviour shared by every
// floating surface (popover, tooltip, hover card, select, combobox). The host renders in the top
// layer (`popover="manual"`), so `overflow: hidden` ancestors never clip it.
// ---------------------------------------------------------------------------------------------
export interface FloatingLayerOptions {
/** Whether the layer is shown. While `false` the host plays its exit animation, then hides. */
open: boolean
/** Element the layer is positioned against. */
anchor: () => HTMLElement | null | undefined
/**
* Name used for the Radix-compatible CSS variables set on the host:
* `--radix-<name>-content-transform-origin`, `--radix-<name>-content-available-width|height`
* and `--radix-<name>-trigger-width|height`.
*/
name: string
side?: Side
align?: Align
sideOffset?: number
alignOffset?: number
/** Arrow element inside the layer; it is centred on the anchor like Radix's `<Arrow>`. */
arrow?: () => HTMLElement | null | undefined
/** Positions the host itself instead of anchoring it (select's item-aligned mode). */
position?: (node: HTMLElement) => (() => void) | void
/** Targets that do not count as "outside" (e.g. the trigger). */
inside?: (target: Node) => boolean
/** Called on Escape (topmost layer only), outside pointerdown and, if enabled, outside focus. */
onDismiss?: (reason: DismissReason) => void
dismissOnFocusOutside?: boolean
/** Moves focus to the first tabbable descendant (or the host) once shown. */
autoFocus?: boolean
/** Called when the layer starts closing (before the exit animation). */
onClose?: () => void
/** Wait for the `data-state="closed"` exit animation before hiding. Defaults to `true`. */
exitAnimation?: boolean
}
const layerStack: object[] = []
const tabbableSelector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',')
function focusFirst(container: HTMLElement) {
for (let candidate of container.querySelectorAll<HTMLElement>(tabbableSelector)) {
if (candidate.tabIndex < 0 || candidate.closest('[hidden], [inert]')) continue
candidate.focus({ preventScroll: true })
if (document.activeElement !== candidate) continue
if (candidate instanceof HTMLInputElement) candidate.select()
return
}
container.focus({ preventScroll: true })
}
const oppositeSide: Record<Side, Side> = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }
const arrowTransformOrigin: Record<Side, string> = {
top: '',
right: '0 0',
bottom: 'center 0',
left: '100% 0',
}
const arrowTransform: Record<Side, string> = {
top: 'translateY(100%)',
right: 'translateY(50%) rotate(90deg) translateX(-50%)',
bottom: 'rotate(180deg)',
left: 'translateY(50%) rotate(-90deg) translateX(50%)',
}
function setStyle(node: HTMLElement, property: string, value: string) {
if (node.style.getPropertyValue(property) !== value) node.style.setProperty(property, value)
}
function setAttribute(node: HTMLElement, name: string, value: string) {
if (node.getAttribute(name) !== value) node.setAttribute(name, value)
}
/**
* Turns the host into a floating layer: top-layer rendering, anchor positioning with flipping,
* `data-side`/`data-align`, Escape/outside dismissal, and exit animations keyed off
* `data-state="closed"` (the host keeps rendering until its animation ends).
*/
export const floatingLayer = createMixin<HTMLElement, [FloatingLayerOptions]>((handle) => {
let options: FloatingLayerOptions
let node: HTMLElement | null = null
let token = {}
let shown = false
let closing = false
let stopPositioning = () => {}
let stopListening = () => {}
let stopExit = () => {}
let placementKey = ''
/**
* Radix Popper defaults: offset → shift (alignment axis, limited so the content stays attached
* to the anchor) → flip (to the opposite side when it overflows the viewport) → arrow.
* Collision padding is 0, like Radix.
*/
function place() {
if (!node) return
let target = options.anchor()
if (!target) return
let { align = 'center', sideOffset = 0, alignOffset = 0, name } = options
let arrow = options.arrow?.()
let arrowWidth = arrow?.offsetWidth ?? 0
let arrowHeight = arrow?.offsetHeight ?? 0
let rect = target.getBoundingClientRect()
let width = node.offsetWidth
let height = node.offsetHeight
let viewportWidth = document.documentElement.clientWidth
let viewportHeight = document.documentElement.clientHeight
let offset = sideOffset + arrowHeight
let cross = align === 'center' ? 0 : align === 'end' ? -alignOffset : alignOffset
let coordsFor = (side: Side) => {
if (side === 'top' || side === 'bottom') {
let x = align === 'start' ? rect.left : align === 'end' ? rect.right - width : rect.left + (rect.width - width) / 2
return { x: x + cross, y: side === 'bottom' ? rect.bottom + offset : rect.top - height - offset }
}
let y = align === 'start' ? rect.top : align === 'end' ? rect.bottom - height : rect.top + (rect.height - height) / 2
return { x: side === 'right' ? rect.right + offset : rect.left - width - offset, y: y + cross }
}
let overflowOf = (side: Side, { x, y }: { x: number; y: number }) =>
side === 'bottom' ? y + height - viewportHeight : side === 'top' ? -y : side === 'right' ? x + width - viewportWidth : -x
let side = options.side ?? 'bottom'
let coords = coordsFor(side)
let overflow = overflowOf(side, coords)
if (overflow > 0) {
let flipped = oppositeSide[side]
let flippedCoords = coordsFor(flipped)
if (overflowOf(flipped, flippedCoords) < overflow) {
side = flipped
coords = flippedCoords
}
}
let vertical = side === 'top' || side === 'bottom'
let { x, y } = coords
if (vertical) {
x = Math.max(0, Math.min(x, viewportWidth - width))
x = Math.max(rect.left - width, Math.min(x, rect.right))
} else {
y = Math.max(0, Math.min(y, viewportHeight - height))
y = Math.max(rect.top - height, Math.min(y, rect.bottom))
}
setStyle(node, 'position', 'fixed')
setStyle(node, 'right', 'auto')
setStyle(node, 'bottom', 'auto')
setStyle(node, 'left', `${x}px`)
setStyle(node, 'top', `${y}px`)
setAttribute(node, 'data-side', side)
setAttribute(node, 'data-align', align)
setStyle(node, `--radix-${name}-trigger-width`, `${rect.width}px`)
setStyle(node, `--radix-${name}-trigger-height`, `${rect.height}px`)
let availableWidth = side === 'left' ? rect.left - offset : side === 'right' ? viewportWidth - rect.right - offset : viewportWidth
let availableHeight =
side === 'top' ? rect.top - offset : side === 'bottom' ? viewportHeight - rect.bottom - offset : viewportHeight
setStyle(node, `--radix-${name}-content-available-width`, `${Math.max(0, availableWidth)}px`)
setStyle(node, `--radix-${name}-content-available-height`, `${Math.max(0, availableHeight)}px`)
let alignOrigin = { start: '0%', center: '50%', end: '100%' }[align]
let originX = alignOrigin
let originY = alignOrigin
if (arrow) {
let style = arrow.style
style.position = 'absolute'
for (let edge of ['top', 'right', 'bottom', 'left'] as const) style[edge] = ''
if (vertical) {
let arrowX = Math.max(0, Math.min(rect.left + rect.width / 2 - x - arrowWidth / 2, width - arrowWidth))
style.left = `${arrowX}px`
originX = `${arrowX + arrowWidth / 2}px`
} else {
let arrowY = Math.max(0, Math.min(rect.top + rect.height / 2 - y - arrowWidth / 2, height - arrowWidth))
style.top = `${arrowY}px`
originY = `${arrowY + arrowWidth / 2}px`
}
style[oppositeSide[side]] = '0px'
style.transformOrigin = arrowTransformOrigin[side]
style.transform = arrowTransform[side]
}
let origin =
side === 'bottom'
? `${originX} ${-arrowHeight}px`
: side === 'top'
? `${originX} ${height + arrowHeight}px`
: side === 'right'
? `${-arrowHeight}px ${originY}`
: `${width + arrowHeight}px ${originY}`
setStyle(node, `--radix-${name}-content-transform-origin`, origin)
}
function position() {
stopPositioning()
stopPositioning = () => {}
if (!node) return
if (options.position) {
stopPositioning = options.position(node) ?? (() => {})
return
}
// Like Radix's `autoUpdate`, follow the anchor while shown (scroll, resize, layout shifts).
let frame = 0
let loop = () => {
place()
frame = requestAnimationFrame(loop)
}
loop()
stopPositioning = () => cancelAnimationFrame(frame)
}
function listen() {
let onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || layerStack[layerStack.length - 1] !== token) return
if (event.defaultPrevented) return
event.preventDefault()
options.onDismiss?.('escape-key')
}
let onOutside = (reason: DismissReason, target: EventTarget | null) => {
if (!(target instanceof Node) || !node || node.contains(target) || options.inside?.(target)) return
if (reason === 'focus-outside' && !options.dismissOnFocusOutside) return
options.onDismiss?.(reason)
}
let onPointerDown = (event: PointerEvent) => onOutside('outside-click', event.target)
let onFocusIn = (event: FocusEvent) => onOutside('focus-outside', event.target)
layerStack.push(token)
document.addEventListener('keydown', onKeyDown)
document.addEventListener('focusin', onFocusIn)
// Like Radix, start listening for outside pointerdowns after the opening event settles.
let timer = window.setTimeout(() => document.addEventListener('pointerdown', onPointerDown, true))
stopListening = () => {
clearTimeout(timer)
let index = layerStack.indexOf(token)
if (index !== -1) layerStack.splice(index, 1)
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('pointerdown', onPointerDown, true)
stopListening = () => {}
}
}
function show() {
if (!node?.isConnected) return
shown = true
if (!node.matches(':popover-open')) node.showPopover()
position()
listen()
if (options.autoFocus) focusFirst(node)
}
function hide() {
stopExit()
stopListening()
stopPositioning()
stopPositioning = () => {}
shown = false
closing = false
if (node?.isConnected && node.matches(':popover-open')) node.hidePopover()
}
function close() {
closing = true
stopListening()
options.onClose?.()
if (!node || options.exitAnimation === false) return hide()
let animationName = getComputedStyle(node).animationName
if (!animationName || animationName === 'none') return hide()
let host = node
let onEnd = (event: AnimationEvent) => {
if (event.target !== host) return
if (closing) hide()
}
host.addEventListener('animationend', onEnd)
host.addEventListener('animationcancel', onEnd)
stopExit = () => {
host.removeEventListener('animationend', onEnd)
host.removeEventListener('animationcancel', onEnd)
stopExit = () => {}
}
}
handle.addEventListener('remove', hide)
return (nextOptions) => {
options = nextOptions
let key = [options.side, options.align, options.sideOffset, options.alignOffset].join()
handle.queueTask((host) => {
node = host
if (options.open) {
if (!shown) {
placementKey = key
show()
} else if (closing) {
closing = false
stopExit()
listen()
} else if (key !== placementKey) {
placementKey = key
position()
}
} else if (shown && !closing) {
close()
}
})
return attrs({ popover: 'manual' })
}
})
// ---------------------------------------------------------------------------------------------
// Popover
// ---------------------------------------------------------------------------------------------
interface PopoverContextValue {
readonly open: boolean
readonly contentId: string
trigger: HTMLElement | null
anchor: HTMLElement | null
setOpen(open: boolean): void
}
export interface PopoverProps {
children?: RemixNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export function Popover(handle: Handle<PopoverProps, PopoverContextValue>) {
let uncontrolledOpen = handle.props.defaultOpen ?? false
let context: PopoverContextValue = {
get open() {
return handle.props.open ?? uncontrolledOpen
},
contentId: `${handle.id}-content`,
trigger: null,
anchor: null,
setOpen(open) {
if (open === context.open) return
if (handle.props.open === undefined) {
uncontrolledOpen = open
void handle.update()
}
handle.props.onOpenChange?.(open)
},
}
handle.context.set(context)
return () => handle.props.children
}
export interface PopoverTriggerProps extends Props<'button'> {}
/**
* Renders a `<button>` that toggles the popover. shadcn's `asChild` maps to `mix`:
* `<PopoverTrigger mix={buttonVariants({ variant: 'outline' })}>Open</PopoverTrigger>`.
*/
export function PopoverTrigger(handle: Handle<PopoverTriggerProps>) {
let context = handle.context.get(Popover)
return () => {
let { mix, ...props } = handle.props
return (
<button
type="button"
data-slot="popover-trigger"
aria-haspopup="dialog"
aria-expanded={context.open ? 'true' : 'false'}
aria-controls={context.contentId}
data-state={context.open ? 'open' : 'closed'}
{...props}
mix={[
ref((node, signal) => {
context.trigger = node
signal.addEventListener('abort', () => {
if (context.trigger === node) context.trigger = null
})
}),
on('click', () => context.setOpen(!context.open)),
mix,
]}
/>
)
}
}
export interface PopoverAnchorProps extends Props<'div'> {}
/** An optional element to position the content against instead of the trigger. */
export function PopoverAnchor(handle: Handle<PopoverAnchorProps>) {
let context = handle.context.get(Popover)
return () => {
let { mix, ...props } = handle.props
return (
<div
data-slot="popover-anchor"
{...props}
mix={[
ref((node, signal) => {
context.anchor = node
signal.addEventListener('abort', () => {
if (context.anchor === node) context.anchor = null
})
}),
mix,
]}
/>
)
}
}
export interface PopoverContentProps extends Props<'div'> {
side?: Side
align?: Align
sideOffset?: number
alignOffset?: number
}
/** tw-animate-css `slide-in-from-*-2`, keyed off the side the content was placed on. */
export const slideInFromSide = {
'&[data-side="bottom"]': { '--tw-enter-translate-y': '-0.5rem' },
'&[data-side="left"]': { '--tw-enter-translate-x': '0.5rem' },
'&[data-side="right"]': { '--tw-enter-translate-x': '-0.5rem' },
'&[data-side="top"]': { '--tw-enter-translate-y': '0.5rem' },
}
/** `data-[state=open]:animate-in fade-in-0 zoom-in-95` + `data-[state=closed]:animate-out fade-out-0 zoom-out-95`. */
export const fadeZoomByState = {
'&[data-state="closed"]': {
animation: 'exit 150ms ease forwards',
'--tw-exit-opacity': '0',
'--tw-exit-scale': '0.95',
},
'&[data-state="open"]': {
animation: 'enter 150ms ease',
'--tw-enter-opacity': '0',
'--tw-enter-scale': '0.95',
},
}
const contentStyles = css({
zIndex: '50',
width: '18rem',
transformOrigin: 'var(--radix-popover-content-transform-origin)',
borderRadius: 'calc(var(--radius) - 2px)',
borderWidth: '1px',
backgroundColor: 'var(--popover)',
padding: '1rem',
color: 'var(--popover-foreground)',
'--tw-shadow': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
outline: '2px solid transparent',
outlineOffset: '2px',
...slideInFromSide,
...fadeZoomByState,
})
export function PopoverContent(handle: Handle<PopoverContentProps>) {
let context = handle.context.get(Popover)
let interactedOutside = false
return () => {
let { side = 'bottom', align = 'center', sideOffset = 4, alignOffset = 0, mix, ...props } = handle.props
return (
<div
data-slot="popover-content"
id={context.contentId}
role="dialog"
tabIndex={-1}
data-state={context.open ? 'open' : 'closed'}
{...props}
mix={[
contentStyles,
floatingLayer({
open: context.open,
anchor: () => context.anchor ?? context.trigger,
name: 'popover',
side,
align,
sideOffset,
alignOffset,
inside: (target) => !!context.trigger?.contains(target),
dismissOnFocusOutside: true,
autoFocus: true,
onDismiss(reason) {
interactedOutside = reason !== 'escape-key'
context.setOpen(false)
},
onClose() {
if (!interactedOutside) context.trigger?.focus()
interactedOutside = false
},
}),
mix,
]}
/>
)
}
}
const headerStyles = css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
fontSize: '0.875rem',
lineHeight: 'calc(1.25 / 0.875)',
})
export function PopoverHeader(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return <div data-slot="popover-header" {...props} mix={[headerStyles, mix]} />
}
}
const titleStyles = css({ fontWeight: 500 })
export function PopoverTitle(handle: Handle<Props<'div'>>) {
return () => {
let { mix, ...props } = handle.props
return <div data-slot="popover-title" {...props} mix={[titleStyles, mix]} />
}
}
const descriptionStyles = css({ color: 'var(--muted-foreground)' })
export function PopoverDescription(handle: Handle<Props<'p'>>) {
return () => {
let { mix, ...props } = handle.props
return <p data-slot="popover-description" {...props} mix={[descriptionStyles, mix]} />
}
}