Files
headlessui/packages/@headlessui-react/src/internal/floating.tsx
T
Robin Malfait 8c7cbb3b09 Add string shorthand for the anchor prop (#3133)
* allow to define `anchor` as a string. E.g.: `anchor="bottom"`

* use `--anchor-gap`, `--anchor-offset` and `--anchor-padding` variables by default

This way simply adding `anchor="bottom"` to one of the anchorable
components will also use these variables defined on the component.

* update playgrounds to use new string-based `anchor` prop

+ CSS variables

* update changelog
2024-04-25 02:13:25 +02:00

583 lines
20 KiB
TypeScript

import {
autoUpdate,
flip as flipMiddleware,
inner as innerMiddleware,
offset as offsetMiddleware,
shift as shiftMiddleware,
size as sizeMiddleware,
useFloating,
useInnerOffset,
useInteractions,
type InnerProps,
type UseFloatingReturn,
} from '@floating-ui/react'
import * as React from 'react'
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
import { useDisposables } from '../hooks/use-disposables'
import { useEvent } from '../hooks/use-event'
import { useIsoMorphicEffect } from '../hooks/use-iso-morphic-effect'
type Align = 'start' | 'end'
type Placement = 'top' | 'right' | 'bottom' | 'left'
type BaseAnchorProps = {
/**
* The `gap` is the space between the trigger and the panel.
*/
gap: number | string // For `var()` support
/**
* The `offset` is the amount the panel should be nudged from its original position.
*/
offset: number | string // For `var()` support
/**
* The `padding` is the minimum space between the panel and the viewport.
*/
padding: number | string // For `var()` support
}
export type AnchorProps =
| false // Disable entirely
| (`${Placement}` | `${Placement} ${Align}`) // String value to define the placement
| Partial<
BaseAnchorProps & {
/**
* The `to` value defines which side of the trigger the panel should be placed on and its
* alignment.
*/
to: `${Placement}` | `${Placement} ${Align}`
}
>
export type AnchorPropsWithSelection =
| false // Disable entirely
| (`${Placement | 'selection'}` | `${Placement | 'selection'} ${Align}`)
| Partial<
BaseAnchorProps & {
/**
* The `to` value defines which side of the trigger the panel should be placed on and its
* alignment.
*/
to: `${Placement | 'selection'}` | `${Placement | 'selection'} ${Align}`
}
>
export type InternalFloatingPanelProps = Partial<{
inner: {
listRef: InnerProps['listRef']
index: InnerProps['index']
}
}>
let FloatingContext = createContext<{
styles?: UseFloatingReturn<any>['floatingStyles']
setReference: UseFloatingReturn<any>['refs']['setReference']
setFloating: UseFloatingReturn<any>['refs']['setFloating']
getReferenceProps: ReturnType<typeof useInteractions>['getReferenceProps']
getFloatingProps: ReturnType<typeof useInteractions>['getFloatingProps']
slot: Partial<{
anchor: `${Placement | 'selection'}` | `${Placement | 'selection'} ${Align}`
}>
}>({
styles: undefined,
setReference: () => {},
setFloating: () => {},
getReferenceProps: () => ({}),
getFloatingProps: () => ({}),
slot: {},
})
FloatingContext.displayName = 'FloatingContext'
let PlacementContext = createContext<
((value: Exclude<AnchorPropsWithSelection, boolean> | null) => void) | null
>(null)
PlacementContext.displayName = 'PlacementContext'
export function useResolvedAnchor<T extends AnchorProps | AnchorPropsWithSelection>(
anchor?: T
): Exclude<T, boolean | string> | null {
return useMemo(() => {
if (!anchor) return null // Disable entirely
if (typeof anchor === 'string') return { to: anchor } as Exclude<T, boolean | string> // Simple string based value,
return anchor as Exclude<T, boolean | string> // User-provided value
}, [anchor])
}
export function useFloatingReference() {
return useContext(FloatingContext).setReference
}
export function useFloatingReferenceProps() {
return useContext(FloatingContext).getReferenceProps
}
export function useFloatingPanelProps() {
let { getFloatingProps, slot } = useContext(FloatingContext)
return useCallback(
(...args: Parameters<typeof getFloatingProps>) => {
return Object.assign({}, getFloatingProps(...args), {
'data-anchor': slot.anchor,
})
},
[getFloatingProps, slot]
)
}
export function useFloatingPanel(
placement: (AnchorPropsWithSelection & InternalFloatingPanelProps) | null = null
) {
if (placement === false) placement = null // Disable entirely
if (typeof placement === 'string') placement = { to: placement } // Simple string based value
let updatePlacementConfig = useContext(PlacementContext)
let stablePlacement = useMemo(
() => placement,
[
JSON.stringify(
placement,
typeof HTMLElement !== 'undefined'
? (_, v) => {
if (v instanceof HTMLElement) {
return v.outerHTML
}
return v
}
: undefined
),
]
)
useIsoMorphicEffect(() => {
updatePlacementConfig?.(stablePlacement ?? null)
}, [updatePlacementConfig, stablePlacement])
let context = useContext(FloatingContext)
return useMemo(
() => [context.setFloating, placement ? context.styles : {}] as const,
[context.setFloating, placement, context.styles]
)
}
// TODO: Make this a config part of the `config`. Just need to decide on a name.
let MINIMUM_ITEMS_VISIBLE = 4
export function FloatingProvider({
children,
enabled = true,
}: {
children: React.ReactNode
enabled?: boolean
}) {
let [config, setConfig] = useState<
(AnchorPropsWithSelection & InternalFloatingPanelProps) | null
>(null)
let [innerOffset, setInnerOffset] = useState(0)
let overflowRef = useRef(null)
let [floatingEl, setFloatingElement] = useState<HTMLElement | null>(null)
useFixScrollingPixel(floatingEl)
let isEnabled = enabled && config !== null && floatingEl !== null
let {
to: placement = 'bottom',
gap = 0,
offset = 0,
padding = 0,
inner,
} = useResolvedConfig(config, floatingEl)
let [to, align = 'center'] = placement.split(' ') as [Placement | 'selection', Align | 'center']
// Reset
useIsoMorphicEffect(() => {
if (!isEnabled) return
setInnerOffset(0)
}, [isEnabled])
let { refs, floatingStyles, context } = useFloating({
open: isEnabled,
placement:
to === 'selection'
? align === 'center'
? 'bottom'
: `bottom-${align}`
: align === 'center'
? `${to}`
: `${to}-${align}`,
// This component will be used in combination with a `Portal`, which means the floating
// element will be rendered outside of the current DOM tree.
strategy: 'absolute',
// We use the panel in a `Dialog` which is making the page inert, therefore no re-positioning is
// needed when scrolling changes.
transform: false,
middleware: [
// - The `mainAxis` is set to `gap` which defines the gap between the panel and the
// trigger/reference.
// - The `crossAxis` is set to `offset` which nudges the panel from its original position.
//
// When we are showing the panel on top of the selected item, we don't want a gap between the
// reference and the panel, therefore setting the `mainAxis` to `0`.
offsetMiddleware({
mainAxis: to === 'selection' ? 0 : gap,
crossAxis: offset,
}),
// When the panel overflows the viewport, we will try to nudge the panel to the other side to
// ensure it's not clipped. We use the `padding` to define the minimum space between the
// panel and the viewport.
shiftMiddleware({ padding }),
// The `flip` middleware will swap the `placement` of the panel if there is not enough room.
// This is not compatible with the `inner` middleware (which is only enabled when `to` is set
// to "selection").
to !== 'selection' && flipMiddleware(),
// The `inner` middleware will ensure the panel is always fully visible on screen and
// positioned on top of the reference and moved to the currently selected item.
to === 'selection' && inner
? innerMiddleware({
...inner,
padding, // For overflow detection
overflowRef,
offset: innerOffset,
minItemsVisible: MINIMUM_ITEMS_VISIBLE,
referenceOverflowThreshold: padding,
onFallbackChange(fallback) {
if (!fallback) return
let parent = context.elements.floating
if (!parent) return
let scrollPaddingBottom =
parseFloat(getComputedStyle(parent!).scrollPaddingBottom) || 0
// We want at least X visible items, but if there are less than X items in the list,
// we want to show as many as possible.
let missing = Math.min(MINIMUM_ITEMS_VISIBLE, parent.childElementCount)
let elementHeight = 0
let elementAmountVisible = 0
for (let child of context.elements.floating?.childNodes ?? []) {
if (child instanceof HTMLElement) {
let childTop = child.offsetTop
// It can be that the child is fully visible, but we also want to keep the scroll
// padding into account to ensure the UI looks good. Therefore we fake that the
// bottom of the child is actually `scrollPaddingBottom` amount of pixels lower.
let childBottom = childTop + child.clientHeight + scrollPaddingBottom
let parentTop = parent.scrollTop
let parentBottom = parentTop + parent.clientHeight
// Figure out if the child is fully visible in the scroll parent.
if (childTop >= parentTop && childBottom <= parentBottom) {
missing--
} else {
// Not fully visible, so we will use this child to calculate the height of
// each item. We will also use this to calculate how much of the item is
// already visible.
elementAmountVisible = Math.max(
0,
Math.min(childBottom, parentBottom) - Math.max(childTop, parentTop)
)
elementHeight = child.clientHeight
break
}
}
}
// There are fewer visible items than we want, so we will try to nudge the offset
// to show more items.
if (missing >= 1) {
setInnerOffset((existingOffset) => {
let newInnerOffset =
elementHeight * missing - // `missing` amount of `elementHeight`
elementAmountVisible + // The amount of the last item that is visible
scrollPaddingBottom // The scroll padding to ensure the UI looks good
// Nudged enough already, no need to continue
if (existingOffset >= newInnerOffset) {
return existingOffset
}
return newInnerOffset
})
}
},
})
: null,
// The `size` middleware will ensure the panel is never bigger than the viewport minus the
// provided `padding` that we want.
sizeMiddleware({
apply({ availableWidth, availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${availableWidth - padding}px`,
maxHeight: `${availableHeight - padding}px`,
})
},
}),
].filter(Boolean),
whileElementsMounted: autoUpdate,
})
// Calculate placement information to expose as data attributes
let [exposedTo = to, exposedAlign = align] = context.placement.split('-')
// If user-land code is using custom styles specifically for `bottom`, but
// they chose `selection`, then we want to make sure to map it to selection
// again otherwise styles could be wrong.
if (to === 'selection') exposedTo = 'selection'
let data = useMemo(
() => ({
anchor: [exposedTo, exposedAlign].filter(Boolean).join(' ') as React.ContextType<
typeof FloatingContext
>['slot']['anchor'],
}),
[exposedTo, exposedAlign]
)
let innerOffsetConfig = useInnerOffset(context, {
overflowRef,
onChange: setInnerOffset,
})
let { getReferenceProps, getFloatingProps } = useInteractions([innerOffsetConfig])
let setFloatingRef = useEvent((el: HTMLElement | null) => {
setFloatingElement(el)
refs.setFloating(el)
})
return (
<PlacementContext.Provider value={setConfig}>
<FloatingContext.Provider
value={{
setFloating: setFloatingRef,
setReference: refs.setReference,
styles: floatingStyles,
getReferenceProps,
getFloatingProps,
slot: data,
}}
>
{children}
</FloatingContext.Provider>
</PlacementContext.Provider>
)
}
function useFixScrollingPixel(element: HTMLElement | null) {
useIsoMorphicEffect(() => {
if (!element) return
let observer = new MutationObserver(() => {
let maxHeight = element.style.maxHeight
if (parseFloat(maxHeight) !== parseInt(maxHeight)) {
element.style.maxHeight = `${Math.ceil(parseFloat(maxHeight))}px`
}
})
observer.observe(element, {
attributes: true,
attributeFilter: ['style'],
})
return () => {
observer.disconnect()
}
}, [element])
}
function useResolvedConfig(
config: (Exclude<AnchorPropsWithSelection, boolean | string> & InternalFloatingPanelProps) | null,
element?: HTMLElement | null
) {
let gap = useResolvePxValue(config?.gap ?? 'var(--anchor-gap, 0)', element)
let offset = useResolvePxValue(config?.offset ?? 'var(--anchor-offset, 0)', element)
let padding = useResolvePxValue(config?.padding ?? 'var(--anchor-padding, 0)', element)
return { ...config, gap, offset, padding }
}
function useResolvePxValue(
input?: string | number,
element?: HTMLElement | null,
defaultValue: number | undefined = undefined
) {
let d = useDisposables()
let computeValue = useEvent((value?: string | number, element?: HTMLElement | null) => {
// Nullish
if (value == null) return [defaultValue, null] as const
// Number as-is
if (typeof value === 'number') return [value, null] as const
// String values, the interesting part
if (typeof value === 'string') {
if (!element) return [defaultValue, null] as const
let result = resolveCSSVariablePxValue(value, element)
return [
result,
(setValue: (value?: number) => void) => {
let variables = resolveVariables(value)
// TODO: Improve this part and make it work
//
// Observe variables themselves. Currently the browser doesn't support this, but the
// variables we are interested in resolve to a pixel value. Which means that we can use
// this variable in the `margin` of an element. Then we can observe the `margin` of the
// element and we will be notified when the variable changes.
//
// if (typeof ResizeObserver !== 'undefined') {
// let tmpEl = document.createElement('div')
// element.appendChild(tmpEl)
//
// // Didn't use `fontSize` because a `fontSize` can't be negative.
// tmpEl.style.setProperty('margin-top', '0px', 'important')
//
// // Set the new value, if this is invalid the previous value will be used.
// tmpEl.style.setProperty('margin-top', value, 'important')
//
// let observer = new ResizeObserver(() => {
// let newResult = resolveCSSVariableValue(value, element)
//
// if (result !== newResult) {
// setValue(newResult)
// result = newResult
// }
// })
// observer.observe(tmpEl)
// d.add(() => observer.disconnect())
// return d.dispose
// }
// Works as a fallback, but not very performant because we are polling the value.
{
let history = variables.map((variable) =>
window.getComputedStyle(element!).getPropertyValue(variable)
)
d.requestAnimationFrame(function check() {
d.nextFrame(check)
// Fast path, detect if the value of the CSS Variable has changed before completely
// computing the new value. Once we use `resolveCSSVariablePxValue` we will have to
// compute the actual px value by injecting a temporary element into the DOM.
//
// This is a lot of work, so we want to avoid it if possible.
let changed = false
for (let [idx, variable] of variables.entries()) {
let value = window.getComputedStyle(element!).getPropertyValue(variable)
if (history[idx] !== value) {
history[idx] = value
changed = true
break
}
}
// Nothing changed, no need to perform the expensive computation.
if (!changed) return
let newResult = resolveCSSVariablePxValue(value, element)
if (result !== newResult) {
setValue(newResult)
result = newResult
}
})
}
return d.dispose
},
] as const
}
return [defaultValue, null] as const
})
// Calculate the value immediately when the input or element changes. Later we can setup a watcher
// to track the value changes over time.
let immediateValue = useMemo(() => computeValue(input, element)[0], [input, element])
let [value = immediateValue, setValue] = useState<number | undefined>()
useIsoMorphicEffect(() => {
let [value, watcher] = computeValue(input, element)
setValue(value)
if (!watcher) return
return watcher(setValue)
}, [input, element])
return value
}
function resolveVariables(value: string): string[] {
let matches = /var\((.*)\)/.exec(value)
if (matches) {
let idx = matches[1].indexOf(',')
if (idx === -1) {
return [matches[1]]
}
let variable = matches[1].slice(0, idx).trim()
let fallback = matches[1].slice(idx + 1).trim()
if (fallback) {
return [variable, ...resolveVariables(fallback)]
}
return [variable]
}
return []
}
function resolveCSSVariablePxValue(input: string, element: HTMLElement) {
// Resolve the value: Instead of trying to compute the value ourselves by converting rem /
// vwh / ... values to pixels or by parsing out the fallback values and evaluating it
// (because it can contain calc expressions or other variables).
//
// We will let the browser compute all of it by creating a temporary element and setting
// the value as a CSS variable. Then we can read the computed value from the browser.
//
//
// BUG REPORT ABOUT INCORRECT VALUES, look here:
// ---------------------------------------------
//
// Currently this technically contains a bug because we are rendering a new element inside of the
// current element. Which means that if the passed in element has CSS that looks like:
//
// ```css
// .the-element {
// --the-variable: 1rem
// }
//
// .the-element > * {
// --the-variable: 2rem
// }
// ```
//
// Then this will result to resolved value of `2rem`, instead of `1rem`
let tmpEl = document.createElement('div')
element.appendChild(tmpEl)
// Set the value to `0px` otherwise if an invalid value is provided later the browser will read
// out the default value.
//
// Didn't use `fontSize` because a `fontSize` can't be negative.
tmpEl.style.setProperty('margin-top', '0px', 'important')
// Set the new value, if this is invalid the previous value will be used.
tmpEl.style.setProperty('margin-top', input, 'important')
// Reading the `margin-top` will already be in pixels (e.g.: 123px).
let pxValue = parseFloat(window.getComputedStyle(tmpEl).marginTop) || 0
element.removeChild(tmpEl)
return pxValue
}