Skip to content

Commit

Permalink
feat: open popout with anchor coordinates (#40)
Browse files Browse the repository at this point in the history
* fix: types and api of tooltip provide and popout component

* fix: auto fallback position and align

* style: move inline style to file

* feat: make popout openable with anchor cords instead of anchor element

BREAKING CHANGE: popout children forward ref and open prop is now removed with anchor prop
  • Loading branch information
ajbura authored Apr 14, 2024
1 parent 637f9be commit 86b66d9
Show file tree
Hide file tree
Showing 7 changed files with 536 additions and 165 deletions.
19 changes: 19 additions & 0 deletions src/components/pop-out/PopOut.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { style } from "@vanilla-extract/css";
import { config } from "../../theme";

export const PopOut = style({
position: "fixed",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: config.zIndex.Max,
...style,
});

export const PopOutContainer = style({
display: "inline-block",
position: "fixed",
maxWidth: "100vw",
maxHeight: "100vh",
});
54 changes: 38 additions & 16 deletions src/components/pop-out/PopOut.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,54 @@
import FocusTrap from "focus-trap-react";
import React, { useState } from "react";
import { ComponentMeta } from "@storybook/react";
import React, { MouseEventHandler, useState } from "react";
import { ComponentMeta, ComponentStory } from "@storybook/react";
import { Text } from "../text";
import { PopOut } from "./PopOut";
import { Menu, MenuItem } from "../menu";
import { Icon, Icons } from "../icon";
import { IconButton } from "../icon-button";
import { config } from "../../theme/config.css";
import { Box } from "../box";
import { RectCords } from "../util";

export default {
title: "PopOut",
component: PopOut,
} as ComponentMeta<typeof PopOut>;

export const Interactive = () => {
const [open, setOpen] = useState(false);
const Template: ComponentStory<typeof PopOut> = (args) => {
const [anchor, setAnchor] = useState<RectCords>();

const handleOpen: MouseEventHandler<HTMLElement> = (evt) => {
const rect = evt.currentTarget?.getBoundingClientRect();
setAnchor(anchor ? undefined : rect);
};
const handleContextOpen: MouseEventHandler<HTMLElement> = (evt) => {
evt.preventDefault();
const rect = {
x: evt.clientX,
y: evt.clientY,
width: 0,
height: 0,
};
setAnchor(anchor ? undefined : rect);
};

return (
<div style={{ height: "100vh" }}>
<Box
onContextMenu={handleContextOpen}
justifyContent="Center"
alignItems="Center"
style={{ height: "100vh" }}
>
<PopOut
open={open}
align="Start"
{...args}
anchor={anchor}
offset={anchor?.width === 0 ? 0 : undefined}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
onDeactivate: () => setAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === "ArrowDown",
isKeyBackward: (evt: KeyboardEvent) => evt.key === "ArrowUp",
Expand All @@ -44,13 +67,12 @@ export const Interactive = () => {
</Menu>
</FocusTrap>
}
>
{(ref) => (
<IconButton variant="SurfaceVariant" onClick={() => setOpen((state) => !state)} ref={ref}>
<Icon src={Icons.VerticalDots} />
</IconButton>
)}
</PopOut>
</div>
/>
<IconButton variant="SurfaceVariant" onClick={handleOpen}>
<Icon src={Icons.VerticalDots} />
</IconButton>
</Box>
);
};

export const Interactive = Template.bind({});
87 changes: 26 additions & 61 deletions src/components/pop-out/PopOut.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,53 @@
import React, {
ReactNode,
RefCallback,
useCallback,
useEffect,
useLayoutEffect,
useRef,
} from "react";
import { config } from "../../theme";
import classNames from "classnames";
import React, { ReactNode, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { as } from "../as";
import { Portal } from "../portal";
import { Align, getRelativeFixedPosition, Position } from "../util";
import { Align, getRelativeFixedPosition, Position, RectCords } from "../util";
import * as css from "./PopOut.css";

export interface PopOutProps {
open: boolean;
anchor?: RectCords;
position?: Position;
align?: Align;
offset?: number;
alignOffset?: number;
content: ReactNode;
children: (anchorRef: RefCallback<HTMLElement | SVGElement>) => ReactNode;
}
export const PopOut = as<"div", PopOutProps>(
(
{
as: AsPopOut = "div",
open,
className,
anchor,
position = "Bottom",
align = "Center",
offset = 10,
alignOffset = 0,
content,
children,
style,
...props
},
ref
) => {
const anchorRef = useRef<HTMLElement | SVGElement | null>(null);
const baseRef = useRef<HTMLDivElement>(null);

const positionPopOut = useCallback(() => {
const anchor = anchorRef.current;
const baseEl = baseRef.current;
if (!anchor) return;
if (!baseEl) return;
if (!baseEl || !anchor) return;

const css = getRelativeFixedPosition(
anchor.getBoundingClientRect(),
const pCSS = getRelativeFixedPosition(
anchor,
baseEl.getBoundingClientRect(),
position,
align,
offset,
alignOffset,
baseEl.getBoundingClientRect()
alignOffset
);
baseEl.style.top = css.top;
baseEl.style.bottom = css.bottom;
baseEl.style.left = css.left;
baseEl.style.right = css.right;
baseEl.style.transform = css.transform;
}, [position, align, offset, alignOffset]);
baseEl.style.top = pCSS.top ?? "unset";
baseEl.style.bottom = pCSS.bottom ?? "unset";
baseEl.style.left = pCSS.left ?? "unset";
baseEl.style.right = pCSS.right ?? "unset";
}, [anchor, position, align, offset, alignOffset]);

useEffect(() => {
window.addEventListener("resize", positionPopOut);
Expand All @@ -68,45 +57,21 @@ export const PopOut = as<"div", PopOutProps>(
}, [positionPopOut]);

useLayoutEffect(() => {
if (open) positionPopOut();
}, [open, positionPopOut]);

const handleAnchorRef: RefCallback<HTMLElement | SVGElement> = useCallback((element) => {
anchorRef.current = element;
}, []);
positionPopOut();
}, [positionPopOut]);

return (
<>
{children(handleAnchorRef)}
<Portal>
{open && (
<AsPopOut
style={{
position: "fixed",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: config.zIndex.Max,
...style,
}}
{...props}
ref={ref}
>
<div
ref={baseRef}
style={{
display: "inline-block",
position: "fixed",
maxWidth: "100vw",
maxHeight: "100vh",
}}
>
{children}
{anchor && (
<Portal>
<AsPopOut className={classNames(css.PopOut, className)} {...props} ref={ref}>
<div ref={baseRef} className={css.PopOutContainer}>
{content}
</div>
</AsPopOut>
)}
</Portal>
</Portal>
)}
</>
);
}
Expand Down
11 changes: 10 additions & 1 deletion src/components/tooltip/Tooltip.css.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ComplexStyleRule, keyframes } from "@vanilla-extract/css";
import { ComplexStyleRule, keyframes, style } from "@vanilla-extract/css";
import { recipe, RecipeVariants } from "@vanilla-extract/recipes";
import { color } from "../../theme/color.css";
import { config } from "../../theme/config.css";
Expand Down Expand Up @@ -53,3 +53,12 @@ export const Tooltip = recipe({
});

export type TooltipVariants = RecipeVariants<typeof Tooltip>;

export const TooltipProvider = style({
display: "inline-block",
position: "fixed",
maxWidth: "100vw",
maxHeight: "100vh",
zIndex: config.zIndex.Max,
pointerEvents: "none",
});
Loading

0 comments on commit 86b66d9

Please sign in to comment.