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()
+}