From 3fe8c06a38b3d83b840f4e95038e933a48100a0d Mon Sep 17 00:00:00 2001 From: dzucconi Date: Wed, 3 Jan 2024 08:47:50 -0500 Subject: [PATCH] --- .../src/elements/Dropdown/Dropdown.story.tsx | 34 ++++ .../src/elements/Popover/Popover.story.tsx | 52 +++++ .../src/utils/__tests__/usePosition.test.ts | 9 +- packages/palette/src/utils/usePosition.ts | 188 ++++++++++++++---- 4 files changed, 231 insertions(+), 52 deletions(-) diff --git a/packages/palette/src/elements/Dropdown/Dropdown.story.tsx b/packages/palette/src/elements/Dropdown/Dropdown.story.tsx index 21e70b288..43677da81 100644 --- a/packages/palette/src/elements/Dropdown/Dropdown.story.tsx +++ b/packages/palette/src/elements/Dropdown/Dropdown.story.tsx @@ -255,3 +255,37 @@ export const FilterExample = () => { ) } + +export const StickyExample = () => { + return ( +
+
+
+ + Example content + + } + placement="bottom-start" + openDropdownByClick + > + {({ anchorRef, anchorProps }) => { + return ( + + Example + + ) + }} + +
+ +
+
+ ) +} diff --git a/packages/palette/src/elements/Popover/Popover.story.tsx b/packages/palette/src/elements/Popover/Popover.story.tsx index 6ee5a3038..38c1bf8dd 100644 --- a/packages/palette/src/elements/Popover/Popover.story.tsx +++ b/packages/palette/src/elements/Popover/Popover.story.tsx @@ -8,6 +8,7 @@ import { Flex } from "../Flex" import { Spacer } from "../Spacer" import { Text } from "../Text" import { Popover, PopoverProps } from "./Popover" +import { Pill } from "../Pill" const CONTENT = "Lorem ipsum dolor sit amet consectetur adipisicing elit. Modi eius autem aliquid cumque, mollitia incidunt totam. Id ut quae hic in quisquam, cupiditate iure nobis, provident minus voluptatem tenetur consequatur." @@ -143,6 +144,57 @@ Placement.story = { parameters: { chromatic: { disable: true } }, } +const ExamplePopover: React.FC = ({ children }) => { + return ( + + + New filters + + + Choose artist and alert criteria to accurately narrow your results. + + + } + width={250} + variant="defaultDark" + pointer + visible + ignoreClickOutside + manageFocus={false} + > + {({ anchorRef }) => { + return ( + + {children} + + ) + }} + + ) +} + +export const InternalScrollContainer = () => { + return ( + + + + + + + Popover should remain anchored to this element + + + + + ) +} + +InternalScrollContainer.story = { + parameters: { chromatic: { disable: true } }, +} + export const ManageFocus = () => { return ( > diff --git a/packages/palette/src/utils/__tests__/usePosition.test.ts b/packages/palette/src/utils/__tests__/usePosition.test.ts index 6e7a2b70d..baaaf2315 100644 --- a/packages/palette/src/utils/__tests__/usePosition.test.ts +++ b/packages/palette/src/utils/__tests__/usePosition.test.ts @@ -34,19 +34,12 @@ describe("placeTooltip", () => { style: {}, } as HTMLElement - const boundaryRect = { - top: 0, - right: 500, - bottom: 600, - left: 0, - } as DOMRect - placeTooltip({ anchor, tooltip, position: "top", offset: 0, - boundaryRect, + scrollableParents: [], }) expect(tooltip.style.display).toEqual("block") diff --git a/packages/palette/src/utils/usePosition.ts b/packages/palette/src/utils/usePosition.ts index f0c1df3a9..48f8ea32d 100644 --- a/packages/palette/src/utils/usePosition.ts +++ b/packages/palette/src/utils/usePosition.ts @@ -68,7 +68,19 @@ export const usePosition = ({ const { current: tooltip } = tooltipRef const { current: anchor } = anchorRef - setState(placeTooltip({ anchor, tooltip, position, offset, flip, clamp })) + const scrollableParents = getScrollableParents(anchor) + + setState( + placeTooltip({ + anchor, + tooltip, + position, + offset, + flip, + clamp, + scrollableParents, + }) + ) } // Re-position when there's any change to the tooltip @@ -90,25 +102,56 @@ export const usePosition = ({ tooltip.style.top = "0" tooltip.style.left = "0" - const handleScroll = () => { - setState(placeTooltip({ anchor, tooltip, position, offset, flip, clamp })) + const handle = () => { + setState( + placeTooltip({ + anchor, + tooltip, + position, + offset, + flip, + clamp, + scrollableParents, + }) + ) } - document.addEventListener("scroll", handleScroll, { - passive: true, + const scrollableParents = getScrollableParents(anchor) + const handlers = scrollableParents.map((scrollableParent) => { + return { + scrollableParent, + handlerScroll: () => { + setState( + placeTooltip({ + anchor, + tooltip, + position, + offset, + flip, + clamp, + scrollableParents, + }) + ) + }, + } }) - const handleResize = () => { - setState(placeTooltip({ anchor, tooltip, position, offset, flip, clamp })) - } + handlers.forEach(({ scrollableParent, handlerScroll }) => { + scrollableParent.addEventListener("scroll", handlerScroll, { + passive: true, + }) + }) - window.addEventListener("resize", handleResize, { passive: true }) + window.addEventListener("resize", handle, { passive: true }) - setState(placeTooltip({ anchor, tooltip, position, offset, flip, clamp })) + // Initialize + handle() return () => { - document.removeEventListener("scroll", handleScroll) - window.removeEventListener("resize", handleResize) + window.removeEventListener("resize", handle) + handlers.forEach(({ scrollableParent, handlerScroll }) => { + scrollableParent.removeEventListener("scroll", handlerScroll) + }) } }, [active, tooltipRef, anchorRef, position]) @@ -126,9 +169,9 @@ interface PlaceTooltip { tooltip: HTMLElement position: Position offset?: number - boundaryRect?: DOMRect flip?: boolean clamp?: boolean + scrollableParents: Array } export const placeTooltip = ({ @@ -136,9 +179,9 @@ export const placeTooltip = ({ tooltip, position, offset = 0, - boundaryRect = getDocumentBoundingRect(), flip = true, clamp = true, + scrollableParents, }: PlaceTooltip) => { const elementRect = anchor.getBoundingClientRect() const tooltipRect = tooltip.getBoundingClientRect() @@ -148,11 +191,13 @@ export const placeTooltip = ({ // Flip to avoid edges const isFlipped = flip && - shouldFlip({ - targetPosition, - position, - boundaryRect, - tooltipRect, + scrollableParents.some((scrollableParent) => { + return shouldFlip({ + targetPosition, + position, + boundaryRect: getBoundingRect(scrollableParent), + tooltipRect, + }) }) if (isFlipped) { @@ -162,20 +207,32 @@ export const placeTooltip = ({ // Clamp position within boundary if (clamp) { - targetPosition.x = Math.max(boundaryRect.left, targetPosition.x) - targetPosition.x = Math.min( - boundaryRect.right - tooltipRect.width, - targetPosition.x - ) - targetPosition.y = Math.max(boundaryRect.top, targetPosition.y) - targetPosition.y = Math.min( - boundaryRect.bottom - tooltipRect.height, - targetPosition.y - ) + scrollableParents.forEach((scrollableParent) => { + const boundaryRect = getBoundingRect(scrollableParent) + + targetPosition.x = Math.max(boundaryRect.left, targetPosition.x) + targetPosition.x = Math.min( + boundaryRect.right - tooltipRect.width, + targetPosition.x + ) + targetPosition.y = Math.max(boundaryRect.top, targetPosition.y) + targetPosition.y = Math.min( + boundaryRect.bottom - tooltipRect.height, + targetPosition.y + ) + }) } // Should hide entirely if it scrolls out of view - const shouldHide = !isWithin(elementRect, boundaryRect) + let shouldHide = false + for (const scrollableParent of scrollableParents) { + const boundaryRect = getBoundingRect(scrollableParent) + if (!isWithin(elementRect, boundaryRect)) { + shouldHide = true + break + } + } + tooltip.style.display = shouldHide ? "none" : "block" tooltip.style.transform = translateWithOffset( @@ -323,20 +380,6 @@ const getOppositePosition = (position: Position) => { } } -export const getDocumentBoundingRect = () => { - const width = document.body.clientWidth - const height = document.body.clientHeight - - return { - top: 0, - left: 0, - right: width, - bottom: height, - width, - height, - } as DOMRect -} - interface ShouldFlip { targetPosition: TargetPosition position: Position @@ -378,3 +421,60 @@ const isWithin = (elementRect: DOMRect, boundaryRect: DOMRect) => { boundaryRect.right > elementRect.left ) } + +const getScrollableParents = (element: HTMLElement) => { + let parent = element.parentElement + const scrollableParents: Array = [] + + while (parent) { + const computedStyle = getComputedStyle(parent) + if ( + isOverflowSet(computedStyle.overflow) || + isOverflowSet(computedStyle.overflowY) || + isOverflowSet(computedStyle.overflowX) + ) { + scrollableParents.push(parent) + } + parent = parent.parentElement + } + + scrollableParents.push(document) + + return scrollableParents +} + +const isOverflowSet = (overflowValue: string) => { + return ( + overflowValue === "auto" || + overflowValue === "hidden" || + overflowValue === "scroll" || + overflowValue === "overlay" + ) +} + +const isDocument = (element: HTMLElement | Document): element is Document => { + return element.ownerDocument === null +} + +export const getDocumentBoundingRect = () => { + const width = document.body.clientWidth + const height = document.body.clientHeight + + return { + top: 0, + left: 0, + right: width, + bottom: height, + width, + height, + x: 0, + y: 0, + toJSON: () => null, + } +} + +export const getBoundingRect = (element: HTMLElement | Document): DOMRect => { + return isDocument(element) + ? getDocumentBoundingRect() + : element.getBoundingClientRect() +}