diff --git a/.changeset/silent-cameras-care.md b/.changeset/silent-cameras-care.md new file mode 100644 index 00000000000..a42c1e7fb20 --- /dev/null +++ b/.changeset/silent-cameras-care.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +fix(SelectPanel): Correctly recalculate position on overflow diff --git a/.playwright/snapshots/components/Overlay.test.ts-snapshots/Overlay-SX-Props-dark-high-contrast-linux.png b/.playwright/snapshots/components/Overlay.test.ts-snapshots/Overlay-SX-Props-dark-high-contrast-linux.png index f75800514b5..71b730a871f 100644 Binary files a/.playwright/snapshots/components/Overlay.test.ts-snapshots/Overlay-SX-Props-dark-high-contrast-linux.png and b/.playwright/snapshots/components/Overlay.test.ts-snapshots/Overlay-SX-Props-dark-high-contrast-linux.png differ diff --git a/.playwright/snapshots/components/Overlay.test.ts-snapshots/Overlay-SX-Props-light-high-contrast-linux.png b/.playwright/snapshots/components/Overlay.test.ts-snapshots/Overlay-SX-Props-light-high-contrast-linux.png index 411ea701363..cc72b49a4d4 100644 Binary files a/.playwright/snapshots/components/Overlay.test.ts-snapshots/Overlay-SX-Props-light-high-contrast-linux.png and b/.playwright/snapshots/components/Overlay.test.ts-snapshots/Overlay-SX-Props-light-high-contrast-linux.png differ diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx new file mode 100644 index 00000000000..db33ac2b7ef --- /dev/null +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.dev.stories.tsx @@ -0,0 +1,110 @@ +import type {Meta} from '@storybook/react' +import React, {useState} from 'react' + +import {Button} from '../Button' +import {AnchoredOverlay} from '.' +import {Stack} from '../Stack' +import {Dialog, Spinner} from '..' + +const meta = { + title: 'Components/AnchoredOverlay/Dev', + component: AnchoredOverlay, +} satisfies Meta + +export default meta + +export const RepositionAfterContentGrows = () => { + const [open, setOpen] = useState(false) + + const [loading, setLoading] = useState(true) + + React.useEffect(() => { + window.setTimeout(() => { + if (open) setLoading(false) + }, 2000) + }, [open]) + + return ( + +
+ What to expect: +
    +
  • The anchored overlay should open below the anchor (default position)
  • +
  • After 2000ms, the amount of content in the overlay grows
  • +
  • the overlay should reposition itself above the anchor so that it stays inside the window
  • +
+
+ ( + + )} + open={open} + onOpen={() => setOpen(true)} + onClose={() => { + setOpen(false) + setLoading(true) + }} + > + {loading ? ( + <> + + loading for 2000ms + + ) : ( +
content with 300px height
+ )} +
+
+ ) +} + +export const RepositionAfterContentGrowsWithinDialog = () => { + const [open, setOpen] = useState(false) + + const [loading, setLoading] = useState(true) + + React.useEffect(() => { + window.setTimeout(() => { + if (open) setLoading(false) + }, 2000) + }, [open]) + + return ( + {}}> + +
+ What to expect: +
    +
  • The anchored overlay should open below the anchor (default position)
  • +
  • After 2000ms, the amount of content in the overlay grows
  • +
  • the overlay should reposition itself above the anchor so that it stays inside the window
  • +
+
+ ( + + )} + open={open} + onOpen={() => setOpen(true)} + onClose={() => { + setOpen(false) + setLoading(true) + }} + > + {loading ? ( + <> + + loading for 2000ms + + ) : ( +
content with 300px height
+ )} +
+
+
+ ) +} diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json b/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json index 699fd82c050..4ecfb62eb2a 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.docs.json @@ -147,6 +147,12 @@ "required": false, "description": "", "defaultValue": "" + }, { + "name": "pinPosition", + "type": "boolean", + "required": false, + "description": "If true, the overlay will attempt to prevent position shifting when sitting at the top of the anchor.", + "defaultValue": "false" } ] } diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index e046e301938..5f69fcb168f 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -89,6 +89,10 @@ interface AnchoredOverlayBaseProps extends Pick { const anchorRef = useProvidedRefOrCreate(externalAnchorRef) @@ -155,6 +160,7 @@ export const AnchoredOverlay: React.FC props.maxWidth && widthMap[props.maxWidth]}; height: ${props => heightMap[props.height || 'auto']}; - max-height: ${props => props.maxHeight && heightMap[props.maxHeight]}; + max-height: ${props => (props.maxHeight ? heightMap[props.maxHeight] : '100vh')}; width: ${props => widthMap[props.width || 'auto']}; border-radius: 12px; overflow: ${props => (props.overflow ? props.overflow : 'hidden')}; diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index 75d638f24bd..85e2251fb48 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -8,6 +8,8 @@ import type {OverlayProps} from '../Overlay' import {TriangleDownIcon} from '@primer/octicons-react' import {ActionList} from '../deprecated/ActionList' import FormControl from '../FormControl' +import {Stack} from '../Stack' +import {Dialog} from '../experimental' const meta = { title: 'Components/SelectPanel/Examples', @@ -442,3 +444,96 @@ export const ItemsInScope = () => { ) } + +export const RepositionAfterLoading = () => { + const [selected, setSelected] = React.useState([items[0], items[1]]) + const [open, setOpen] = useState(false) + const [filter, setFilter] = React.useState('') + const [filteredItems, setFilteredItems] = React.useState([]) + + const [loading, setLoading] = useState(true) + + React.useEffect(() => { + if (!open) setLoading(true) + window.setTimeout(() => { + if (open) { + setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) + setLoading(false) + } + }, 2000) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + React.useEffect(() => { + if (!loading) { + setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filter]) + + return ( + <> + +

Reposition panel after loading

+ +
+ + ) +} + +export const SelectPanelRepositionInsideDialog = () => { + const [selected, setSelected] = React.useState([items[0], items[1]]) + const [open, setOpen] = useState(false) + const [filter, setFilter] = React.useState('') + const [filteredItems, setFilteredItems] = React.useState([]) + + const [loading, setLoading] = useState(true) + + React.useEffect(() => { + if (!open) setLoading(true) + window.setTimeout(() => { + if (open) { + setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) + setLoading(false) + } + }, 2000) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + React.useEffect(() => { + if (!loading) { + setFilteredItems(items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filter]) + + return ( + {}}> + +

other content

+ +
+
+ ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index 2aab76589c8..30658f1feaf 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -132,7 +132,7 @@ interface SelectPanelBaseProps { export type SelectPanelProps = SelectPanelBaseProps & Omit & - Pick & + Pick & AnchoredOverlayWrapperAnchorProps & (SelectPanelSingleSelection | SelectPanelMultiSelection) @@ -185,8 +185,9 @@ export function SelectPanel({ sx, loading, initialLoadingType = 'spinner', - height, className, + height, + width, id, ...listProps }: SelectPanelProps): JSX.Element { @@ -451,7 +452,9 @@ export function SelectPanel({ focusTrapSettings={focusTrapSettings} focusZoneSettings={focusZoneSettings} height={height} + width={width} anchorId={id} + pinPosition={!height} > {usingModernActionList ? null : ( diff --git a/packages/react/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap b/packages/react/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap index 1c8391c7328..169fa86efef 100644 --- a/packages/react/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap +++ b/packages/react/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap @@ -13,6 +13,7 @@ exports[`AnchoredOverlay should render consistently when open 1`] = ` position: absolute; min-width: 192px; height: auto; + max-height: 100vh; width: auto; border-radius: 12px; overflow: hidden; diff --git a/packages/react/src/hooks/useAnchoredPosition.ts b/packages/react/src/hooks/useAnchoredPosition.ts index 6365a23cd27..95aaae72fb9 100644 --- a/packages/react/src/hooks/useAnchoredPosition.ts +++ b/packages/react/src/hooks/useAnchoredPosition.ts @@ -8,6 +8,7 @@ import useLayoutEffect from '../utils/useIsomorphicLayoutEffect' export interface AnchoredPositionHookSettings extends Partial { floatingElementRef?: React.RefObject anchorElementRef?: React.RefObject + pinPosition?: boolean } /** @@ -30,14 +31,52 @@ export function useAnchoredPosition( const floatingElementRef = useProvidedRefOrCreate(settings?.floatingElementRef) const anchorElementRef = useProvidedRefOrCreate(settings?.anchorElementRef) const [position, setPosition] = React.useState(undefined) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, setPrevHeight] = React.useState(undefined) + + const topPositionChanged = (prevPosition: AnchorPosition | undefined, newPosition: AnchorPosition) => { + return ( + prevPosition && + ['outside-top', 'inside-top'].includes(prevPosition.anchorSide) && + // either the anchor changed or the element is trying to shrink in height + (prevPosition.anchorSide !== newPosition.anchorSide || prevPosition.top < newPosition.top) + ) + } + + const updateElementHeight = () => { + let heightUpdated = false + setPrevHeight(prevHeight => { + // if the element is trying to shrink in height, restore to old height to prevent it from jumping + if (prevHeight && prevHeight > (floatingElementRef.current?.clientHeight ?? 0)) { + requestAnimationFrame(() => { + ;(floatingElementRef.current as HTMLElement).style.height = `${prevHeight}px` + }) + heightUpdated = true + } + return prevHeight + }) + return heightUpdated + } const updatePosition = React.useCallback( () => { if (floatingElementRef.current instanceof Element && anchorElementRef.current instanceof Element) { - setPosition(getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings)) + const newPosition = getAnchoredPosition(floatingElementRef.current, anchorElementRef.current, settings) + setPosition(prev => { + if (settings?.pinPosition && topPositionChanged(prev, newPosition)) { + const anchorTop = anchorElementRef.current?.getBoundingClientRect().top ?? 0 + const elementStillFitsOnTop = anchorTop > (floatingElementRef.current?.clientHeight ?? 0) + + if (elementStillFitsOnTop && updateElementHeight()) { + return prev + } + } + return newPosition + }) } else { setPosition(undefined) } + setPrevHeight(floatingElementRef.current?.clientHeight) }, // eslint-disable-next-line react-hooks/exhaustive-deps [floatingElementRef, anchorElementRef, ...dependencies], @@ -45,7 +84,8 @@ export function useAnchoredPosition( useLayoutEffect(updatePosition, [updatePosition]) - useResizeObserver(updatePosition) + useResizeObserver(updatePosition) // watches for changes in window size + useResizeObserver(updatePosition, floatingElementRef as React.RefObject) // watches for changes in floating element size return { floatingElementRef, diff --git a/packages/react/src/hooks/useResizeObserver.ts b/packages/react/src/hooks/useResizeObserver.ts index fca25666d98..c170fa5478e 100644 --- a/packages/react/src/hooks/useResizeObserver.ts +++ b/packages/react/src/hooks/useResizeObserver.ts @@ -59,5 +59,5 @@ export function useResizeObserver( } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [target, ...depsArray]) + }, [target?.current, ...depsArray]) }