8c7cbb3b09
* 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
583 lines
20 KiB
TypeScript
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
|
|
}
|