Skip to content

[KZN-3415] uses native popover api in SingleSelect #5885

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a985a19
[KZN-3415] uses native popover api in SingleSelect
Kitty-Al Jul 2, 2025
c862422
[KZN-3415] fixes lint, ts, failing tests
Kitty-Al Jul 2, 2025
7b4beed
[KZN-3415] adds styling to see a11y behaviour
Kitty-Al Jul 2, 2025
566129a
[KZN-3415] adds tests for the popover
Kitty-Al Jul 3, 2025
6f62ad1
[KZN-3415] fix lint issues
Kitty-Al Jul 3, 2025
401c338
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 3, 2025
5632923
[KZN-3415] adds a note on popover in the docs
Kitty-Al Jul 3, 2025
6f84289
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 3, 2025
6b2cb5b
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 4, 2025
82f8f8e
[KZN-3415] removes classNameOverride
Kitty-Al Jul 4, 2025
2744914
[KZN-3415] change trigger to type
Kitty-Al Jul 8, 2025
11f606b
[KZN-3415] updates positioning logic
Kitty-Al Jul 9, 2025
c80d7d1
[KZN-3415] moves interaction tests to storybook
Kitty-Al Jul 9, 2025
d32fd52
[KZN-3415] guards against SSR
Kitty-Al Jul 9, 2025
c3c8afb
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 9, 2025
428052a
[KZN-3415] improves positioning function performance and handles pare…
Kitty-Al Jul 10, 2025
40cc648
[KZN-3415] removes popover toggle
Kitty-Al Jul 15, 2025
0808407
[KZN-3415] updates popover to handle flicker
Kitty-Al Jul 15, 2025
738f5f1
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 15, 2025
7788311
[KZN-3415] WIP: using css anchor position
Kitty-Al Jul 16, 2025
6f973d3
[KZN-3415] gives the popover a unique id
Kitty-Al Jul 17, 2025
d1b90fb
[KZN-3415] refactor the popover positioning
Kitty-Al Jul 17, 2025
06dd126
[KZN-3415] css anchoring position
Kitty-Al Jul 17, 2025
dc10e64
[KZN-3415] fixes lint issues
Kitty-Al Jul 18, 2025
bf6918a
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 18, 2025
f0ab4e7
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 18, 2025
239f351
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 21, 2025
e8a2bc6
Merge branch 'main' into KZN-3415/popover-api
Kitty-Al Jul 22, 2025
a4e61a7
[KZN-3415] refactoring
Kitty-Al Jul 22, 2025
9841ea8
[KZN-3415] refactors moving out positioning styles
Kitty-Al Jul 22, 2025
24ac24b
[KZN-3415] fixes lint error
Kitty-Al Jul 22, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,27 @@
.popover {
position: absolute;
position-anchor: var(--position-anchor, --trigger);
top: anchor(bottom);
left: anchor(start);
top: var(--popover-top, calc(anchor(bottom) + 4px));
bottom: var(--popover-bottom, auto);
inset-inline-start: var(--popover-inline-start, anchor(start));
max-height: var(--popover-max-height, 300px);
height: auto;

background-color: var(--color-white);
border-radius: var(--spacing-8);
padding: 0;
box-shadow: var(--shadow-small-box-shadow);
overflow-y: auto;
/* left: auto;
right: auto; */
overflow-x: hidden;

margin: 0;
box-sizing: border-box;
/* TODO: update width based on design */
width: 200px;
/* position: fixed; */
visibility: hidden

visibility: hidden;


}

.popover:popover-open {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useState, type PropsWithChildren } from 'react'
import React, { useEffect, useLayoutEffect, useMemo, useState, type PropsWithChildren } from 'react'
import { useLocale } from '@react-aria/i18n'
import { Popover as RACPopover } from 'react-aria-components'
import { useSingleSelectContext } from '../../context'
Expand All @@ -11,23 +11,116 @@ type PopoverProps = {
racPopoverRef: React.Ref<any>
}

// CSS custom property names for consistent usage
const CSS_PROPS = {
POSITION_ANCHOR: '--position-anchor',
POPOVER_TOP: '--popover-top',
POPOVER_BOTTOM: '--popover-bottom',
POPOVER_INLINE_START: '--popover-inline-start',
POPOVER_MAX_HEIGHT: '--popover-max-height',
} as const

// Default values
const DEFAULTS = {
MAX_HEIGHT: '300px',
OFFSET: '4px',
} as const

// Cache anchor positioning support detection (SSR-safe)
let cachedAnchorSupport: boolean | null = null

/**
* Detects if CSS anchor positioning is supported by the browser
* Detects if CSS anchor positioning is supported by the browser (cached, SSR-safe)
*/
const supportsAnchorPositioning = (): boolean => {
if (cachedAnchorSupport !== null) return cachedAnchorSupport

if (typeof window === 'undefined' || typeof CSS === 'undefined') {
cachedAnchorSupport = false
return false
}

try {
return CSS.supports('position-anchor', 'auto') || CSS.supports('position-anchor: auto')
cachedAnchorSupport =
CSS.supports('position-anchor', 'auto') || CSS.supports('position-anchor: auto')
return cachedAnchorSupport
} catch {
cachedAnchorSupport = false
return false
}
}

/**
* Hook to determine anchor positioning support and return appropriate styles
* Generates manual positioning styles for browsers without anchor positioning support or SSR
*/
const getManualPositioningStyles = (positionData: {
top: number | string | undefined
bottom: number | string | undefined
insetInlineStart: number | string | undefined
maxHeight: number | string | undefined
}): React.CSSProperties => ({
top: positionData.top,
bottom: positionData.bottom,
insetInlineStart: positionData.insetInlineStart,
maxHeight: positionData.maxHeight,
left: 'auto',
right: 'auto',
position: 'fixed',
})

/**
* Generates anchor positioning styles with CSS custom properties
*/
const getAnchorPositioningStyles = (
anchorName: string,
positionData: {
top: number | string | undefined
bottom: number | string | undefined
insetInlineStart: number | string | undefined
maxHeight: number | string | undefined
},
): React.CSSProperties => {
const { top, bottom, insetInlineStart, maxHeight } = positionData
const styles: React.CSSProperties = {
[CSS_PROPS.POSITION_ANCHOR]: anchorName,
}

// Vertical positioning
if (typeof top === 'number' && bottom === 'auto') {
// Position below trigger
styles[CSS_PROPS.POPOVER_TOP] = `calc(anchor(bottom) + ${DEFAULTS.OFFSET})`
styles[CSS_PROPS.POPOVER_BOTTOM] = 'auto'
} else if (typeof bottom === 'number' && top === 'auto') {
// Position above trigger
styles[CSS_PROPS.POPOVER_TOP] = 'auto'
styles[CSS_PROPS.POPOVER_BOTTOM] = `calc(anchor(top) + ${DEFAULTS.OFFSET})`
} else {
// Default: position below
styles[CSS_PROPS.POPOVER_TOP] = `calc(anchor(bottom) + ${DEFAULTS.OFFSET})`
styles[CSS_PROPS.POPOVER_BOTTOM] = 'auto'
}

// Horizontal positioning
if (typeof insetInlineStart === 'number') {
styles[CSS_PROPS.POPOVER_INLINE_START] = `${insetInlineStart}px`
} else {
styles[CSS_PROPS.POPOVER_INLINE_START] = 'anchor(start)'
}

// Max height
if (typeof maxHeight === 'number') {
styles[CSS_PROPS.POPOVER_MAX_HEIGHT] = `${maxHeight}px`
} else if (maxHeight) {
styles[CSS_PROPS.POPOVER_MAX_HEIGHT] = maxHeight
} else {
styles[CSS_PROPS.POPOVER_MAX_HEIGHT] = DEFAULTS.MAX_HEIGHT
}

return styles
}

/**
* Hook to determine anchor positioning support and return appropriate styles (memoized)
*/
const usePopoverPositioning = (
anchorName: string,
Expand All @@ -37,29 +130,23 @@ const usePopoverPositioning = (
insetInlineStart: number | string | undefined
maxHeight: number | string | undefined
},
): { getPopoverStyle: () => React.CSSProperties } => {
): { getPopoverStyle: React.CSSProperties } => {
const [hasAnchorSupport, setHasAnchorSupport] = useState<boolean | null>(null)

useEffect(() => {
setHasAnchorSupport(supportsAnchorPositioning())
}, [])

const getPopoverStyle = (): React.CSSProperties => {
const { top, bottom, insetInlineStart, maxHeight } = positionData

// During detection or when anchor positioning is not supported, use manual positioning
// Memoize styles to prevent unnecessary recalculations
const getPopoverStyle = useMemo(() => {
// Use manual positioning during detection or when not supported
if (hasAnchorSupport === null || !hasAnchorSupport) {
return {
top,
bottom,
insetInlineStart,
maxHeight,
}
return getManualPositioningStyles(positionData)
}

// Use CSS anchor positioning when supported
return { '--position-anchor': anchorName } as React.CSSProperties
}
return getAnchorPositioningStyles(anchorName, positionData)
}, [hasAnchorSupport, anchorName, positionData])

return { getPopoverStyle }
}
Expand All @@ -73,8 +160,7 @@ export const Popover = ({
const { isOpen, setOpen, anchorName } = useSingleSelectContext()
const { direction } = useLocale()

// Manual position calculation for RTL & iframe compatibility
// RAC useOverlay doesn't work properly with positioning in the CSS top-layer
// Calculate position for RTL & iframe compatibility
const { top, bottom, insetInlineStart, maxHeight, isPositioned } = useFixedOverlayPosition({
triggerRef: buttonRef,
popoverRef,
Expand All @@ -83,26 +169,36 @@ export const Popover = ({
preferredPlacement: 'bottom',
})

const { getPopoverStyle } = usePopoverPositioning(anchorName, {
top,
bottom,
insetInlineStart,
maxHeight,
})
// Memoize position data to prevent unnecessary style recalculations
const positionData = useMemo(
() => ({
top,
bottom,
insetInlineStart,
maxHeight,
}),
[top, bottom, insetInlineStart, maxHeight],
)

// Generate appropriate styles based on browser support
const { getPopoverStyle } = usePopoverPositioning(anchorName, positionData)

// Show/hide popover based on state (memoized for performance, SSR-safe)
const shouldShowPopover = useMemo(() => isOpen && isPositioned, [isOpen, isPositioned])

useLayoutEffect(() => {
const popover = popoverRef.current

// Check if popover API is available (browser-only)
if (!popover?.showPopover || !popover?.hidePopover) return

if (isOpen) {
if (isPositioned) {
popover.showPopover()
}
if (shouldShowPopover) {
popover.showPopover()
} else {
popover.hidePopover()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, isPositioned])
}, [shouldShowPopover])

return (
<RACPopover trigger="manual" isOpen={isOpen} onOpenChange={setOpen} ref={racPopoverRef}>
Expand All @@ -112,7 +208,7 @@ export const Popover = ({
popover="manual"
ref={popoverRef}
className={styles.popover}
style={getPopoverStyle()}
style={getPopoverStyle}
>
{children}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,6 @@ type UseFixedOverlayPositionProps = {
preferredPlacement?: 'top' | 'bottom'
}

/**
* Get the nearest scrollable ancestor of an element.
*/
function getScrollParent(node: HTMLElement | null): HTMLElement | Window {
if (!node) return window
const regex = /(auto|scroll|overlay)/
const getStyle = (el: HTMLElement): CSSStyleDeclaration => getComputedStyle(el)

let parent: HTMLElement | null = node

while (parent && parent !== document.body) {
const { overflow, overflowY, overflowX } = getStyle(parent)
if (regex.test(overflow + overflowY + overflowX)) {
return parent
}
parent = parent.parentElement
}

return window
}

/**
* Hook to calculate and update the position of an overlay rendered in the top layer.
*/
Expand All @@ -48,18 +27,21 @@ export function useFixedOverlayPosition({
offset = 4,
preferredPlacement = 'bottom',
}: UseFixedOverlayPositionProps): Position & { isPositioned: boolean } {
// Provide SSR-compatible default positioning
const [position, setPosition] = useState<Position>({
top: undefined,
bottom: undefined,
top: preferredPlacement === 'bottom' ? offset : 'auto',
bottom: preferredPlacement === 'top' ? offset : 'auto',
insetInlineStart: 0,
maxHeight: undefined,
maxHeight: 300, // Reasonable default
})
const [isPositioned, setIsPositioned] = useState(false)
// Start as true for SSR compatibility - we have default positioning
const [isPositioned, setIsPositioned] = useState(true)

const mountedRef = useRef<boolean>(false)
const isSSR = typeof window === 'undefined'

const updatePosition = useCallback(() => {
if (typeof window === 'undefined') return // SSR safety
if (isSSR) return // SSR safety

const trigger = triggerRef.current
const popover = popoverRef.current
Expand All @@ -75,17 +57,11 @@ export function useFixedOverlayPosition({
const win = doc?.defaultView ?? window
const isRTL = direction === 'rtl'

const scrollContainer = getScrollParent(trigger)
const scrollY = scrollContainer instanceof HTMLElement ? scrollContainer.scrollTop : win.scrollY
const scrollX =
scrollContainer instanceof HTMLElement ? scrollContainer.scrollLeft : win.scrollX

const inlineStart = isRTL
? win.innerWidth - triggerRect.right - scrollX
: triggerRect.left + scrollX
// No scroll handling needed since popover locks scroll
const inlineStart = isRTL ? win.innerWidth - triggerRect.right : triggerRect.left

const triggerTop = triggerRect.top + scrollY
const triggerBottom = triggerRect.bottom + scrollY
const triggerTop = triggerRect.top
const triggerBottom = triggerRect.bottom
const viewportHeight = win.innerHeight

const spaceAbove = triggerTop
Expand All @@ -110,22 +86,25 @@ export function useFixedOverlayPosition({
maxHeight = Math.max(0, spaceBelow - offset)
}

setPosition({
const newPosition = {
top,
bottom,
insetInlineStart: inlineStart,
maxHeight,
})
}

setPosition(newPosition)
setIsPositioned(true)
}, [triggerRef, popoverRef, direction, offset, preferredPlacement])
}, [triggerRef, popoverRef, direction, offset, preferredPlacement, isSSR])

// Separate effect for client-side initialization and resize handling
useEffect(() => {
if (typeof window === 'undefined') return // SSR safety
// This effect only runs on the client after hydration
if (typeof window === 'undefined') return

mountedRef.current = true

const triggerEl = triggerRef.current
const scrollParent = getScrollParent(triggerEl)

updatePosition()

Expand All @@ -135,17 +114,17 @@ export function useFixedOverlayPosition({

if (triggerEl) resizeObserver.observe(triggerEl)

const onScroll = (): void => updatePosition()
scrollParent.addEventListener('scroll', onScroll, { passive: true })
// Add window resize listener for viewport changes
const onWindowResize = (): void => updatePosition()
window.addEventListener('resize', onWindowResize, { passive: true })

return () => {
mountedRef.current = false
resizeObserver.disconnect()
scrollParent.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onWindowResize)
setIsPositioned(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [updatePosition])
}, [updatePosition, triggerRef])

return { ...position, isPositioned }
}
Loading