Skip to content

Commit

Permalink
feat(#954): resolves #954 popover with portal
Browse files Browse the repository at this point in the history
  • Loading branch information
Kenji Shiroma committed Jan 12, 2025
1 parent 345c765 commit be65c6b
Show file tree
Hide file tree
Showing 11 changed files with 248 additions and 54 deletions.
2 changes: 1 addition & 1 deletion apps/site/src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default async function Homepage() {
);

return (
<main className="font-gel-sans text-gel-text pb-8">
<main className="pb-8 font-gel-sans text-gel-text">
<Hero />
<ActionBar />
<HomePageContent articleRows={articleRows} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { tv } from 'tailwind-variants';

// scroll margin top = header height + 24px
export const styles = tv({
base: 'mt-5 scroll-mt-[9.125rem] font-bold first:mt-0 md:scroll-mt-[11.25rem]',
base: 'mt-5 scroll-mt-[9.125rem] font-bold first:mt-0 md:scroll-mt-30',
variants: {
textAlign: {
left: 'text-left',
Expand All @@ -11,7 +11,7 @@ export const styles = tv({
},
level: {
1: 'typography-body-5 mb-3',
2: 'typography-body-7 sm:typography-body-6 mb-4 sm:mb-7',
2: 'typography-body-7 mb-4 sm:typography-body-6 sm:mb-7',
3: 'typography-body-8 mb-2',
4: 'typography-body-10 mb-2 uppercase',
5: 'typography-body-9 mb-2',
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/header/header.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const styles = tv(
largeLogo: 'max-sm:hidden',
leftContent: 'flex items-center',
leftButton:
'my-1 border-r border-[#E8E8ED] p-0 rounded-none max-sm:-ml-2 max-sm:mr-2 max-sm:h-7 max-sm:min-w-[2.625rem] sm:-ml-4 sm:mr-3 sm:h-[3.3125rem] sm:min-w-[3.75rem]',
'my-1 rounded-none border-r border-[#E8E8ED] p-0 max-sm:-ml-2 max-sm:mr-2 max-sm:h-7 max-sm:min-w-[2.625rem] sm:-ml-4 sm:mr-3 sm:h-[3.3125rem] sm:min-w-[3.75rem]',
rightContent: 'ml-auto flex items-center',
},
variants: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,56 +1,32 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useRef } from 'react';
import { FocusScope } from 'react-aria';
import { createPortal } from 'react-dom';

import { Button } from '../../../button/index.js';
import { CloseIcon } from '../../../icon/index.js';
import { getPopoverPosition } from '../../popover.utils.js';

import { usePanel } from './panel.hook.js';
import { styles as panelStyles } from './panel.styles.js';
import { type PanelProps, Position } from './panel.types.js';

/**
* @private
*/
export function Panel({ state, heading, headingTag: Tag = 'h1', content, placement, id, triggerRef }: PanelProps) {
import { type PanelProps } from './panel.types.js';

export function BasePanel({
state,
heading,
headingTag: Tag = 'h1',
content,
placement = 'bottom',
id,
triggerRef,
portal,
}: PanelProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const arrowRef = useRef<HTMLDivElement>(null);
const remSize = useMemo(() => {
if (typeof window !== 'undefined') {
return parseInt(window.getComputedStyle(document.getElementsByTagName('html')[0]).fontSize);
}
return 1;
}, []);

const [position, setPosition] = useState<Position>({
placement: 'top',
offset: 'left',
panelPosition: triggerRef.current ? triggerRef.current.offsetWidth / 2 / remSize : 0,
arrowPosition: popoverRef.current ? popoverRef.current.getBoundingClientRect().width / 2 / remSize : 0,
});

useLayoutEffect(() => {
setPosition(getPopoverPosition(triggerRef, popoverRef, arrowRef, placement));
}, [placement, remSize, state.isOpen, triggerRef]);

const getPopoverClass = useCallback(() => {
return {
[position.offset as string]:
position.offset === 'left' ? `${position.panelPosition}rem` : `-${position.panelPosition}rem`,
transform: position.offset === 'left' ? 'translateX(-50%)' : 'none',
};
}, [position]);

const getArrowClass = useCallback(() => {
return {
[!position.offset || position.offset === 'left' ? 'left' : 'right']: `${position.arrowPosition}rem`,
};
}, [position]);

const styles = panelStyles({ placement: position.placement });
const { popoverPosition, arrowPosition } = usePanel({ state, placement, triggerRef, portal });

const styles = panelStyles({ placement });
return (
<FocusScope restoreFocus>
<div className={styles.popover()} style={getPopoverClass()} id={id} ref={popoverRef}>
<div style={popoverPosition} className={styles.popover()} test-id="popover" id={id} ref={popoverRef}>
<div className={styles.content()}>
<Tag className={styles.heading()}>{heading}</Tag>
<div className={styles.body()}>{content}</div>
Expand All @@ -62,9 +38,20 @@ export function Panel({ state, heading, headingTag: Tag = 'h1', content, placeme
aria-label="Close popover"
/>
</div>
<div aria-hidden className={styles.arrow()} style={getArrowClass()} ref={arrowRef} />
<div aria-hidden className={styles.arrow()} style={arrowPosition} test-id="arrow" ref={arrowRef} />
</div>
</FocusScope>
);
}

/**
* @private
*/
export function Panel({ portal = false, ...props }: PanelProps) {
if (portal) {
const portalValue = typeof portal === 'boolean' ? document.body : portal;
return createPortal(<BasePanel {...props} portal={portalValue} />, portalValue);
}
return <BasePanel portal={portal} {...props} />;
}
Panel.displayName = 'Popover.Panel';
113 changes: 113 additions & 0 deletions packages/ui/src/components/popover/components/panel/panel.hook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useMemo } from 'react';

import { PanelProps } from './panel.types.js';

const PANEL_WIDTH_SIZE = 282;

const getVerticalPositionPopover = (element: HTMLDivElement) => {
const triggerDOMRect = element.getBoundingClientRect();

const offsetLeftToCenter = (PANEL_WIDTH_SIZE - (triggerDOMRect?.width || 0) / 2) * -1;
if (triggerDOMRect.left + offsetLeftToCenter <= 0) {
return 'left';
}
if (
triggerDOMRect.left + offsetLeftToCenter >= 0 &&
triggerDOMRect.right + offsetLeftToCenter * -1 <= window.innerWidth
) {
return 'center';
}
if (PANEL_WIDTH_SIZE + (triggerDOMRect?.left || 0) >= window.innerWidth) {
return 'right';
}
};

const getLeftOffsetPerVerticalPosition = (element: HTMLDivElement) => {
const triggerDOMRect = element.getBoundingClientRect();
switch (getVerticalPositionPopover(element)) {
case 'center':
return ((PANEL_WIDTH_SIZE - (triggerDOMRect?.width || 0)) / 2) * -1;
case 'right':
return (
(PANEL_WIDTH_SIZE +
(triggerDOMRect?.left || 0) -
window.innerWidth +
(window.innerWidth - (triggerDOMRect?.right || 0))) *
-1
);
default:
return 0;
}
};

export type PanelHookProps = {
placement: PanelProps['placement'];
portal: PanelProps['portal'];
state: PanelProps['state'];
triggerRef: PanelProps['triggerRef'];
};

/**
* Custom hook to calculate the position of a popover panel relative to its trigger element.
* @returns {Object} An object containing the calculated positions for the popover and its arrow.
* @returns {Object} return.popoverPosition - The calculated position styles for the popover.
* @returns {Object} return.arrowPosition - The calculated position styles for the popover arrow.
*/
export function usePanel({ state, placement = 'bottom', triggerRef, portal }: PanelHookProps) {
const popoverPosition = useMemo(() => {
const triggerDOMRect = triggerRef.current?.getBoundingClientRect();
// The offset is calculated according if the popover will overflow the window
const leftOffset = triggerRef.current ? getLeftOffsetPerVerticalPosition(triggerRef.current) : 0;
// If it is not portal, we can simplify the logic
if (!portal) {
switch (placement) {
case 'top':
return {
bottom: '100%',
left: leftOffset,
};
case 'bottom':
default:
return {
top: '100%',
left: leftOffset,
};
}
}

// If it is portal, we need to considerate the scroll if there is a scroll in the portal
const portalElement = portal as Element;
switch (placement) {
case 'top':
return {
// The top is calculated according to the portal element
top: `${(triggerDOMRect?.top || 0) - portalElement.getBoundingClientRect().top}px`,
left: `${(triggerDOMRect?.left || 0) + leftOffset}px`,
transform: 'translateY(-100%)',
};
case 'bottom':
default:
return {
// The top is calculated according to the portal element
top: `${(triggerDOMRect?.bottom || 0) - portalElement.getBoundingClientRect().top}px`,
left: `${(triggerDOMRect?.left || 0) + leftOffset}px`,
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [placement, portal, triggerRef, state.isOpen]);

const arrowPosition = useMemo(() => {
const triggerDOMRect = triggerRef.current?.getBoundingClientRect();
const leftOffset = triggerRef.current ? getLeftOffsetPerVerticalPosition(triggerRef.current) * -1 : 0;

return {
left: `${(triggerDOMRect?.width || 0) / 2 + leftOffset}px`,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [triggerRef, state.isOpen]);

return {
popoverPosition,
arrowPosition,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,24 @@ export const styles = tv(
slots: {
base: '',
popover: 'absolute z-[999] rounded border border-muted bg-white shadow-[0_5px_10px_rgba(0,0,0,0.2)]',
arrow:
'absolute h-0 w-0 border-x-8 border-t-[12px] border-x-[transparent] border-t-muted after:absolute after:h-0 after:w-0 after:border-x-[7px] after:border-t-[11px] after:border-x-[transparent] after:border-t-white',
arrow: `absolute -z-10 h-0
w-0 before:absolute before:left-0 before:top-0 before:h-0 before:w-0 before:border-x-[7px] before:border-t-[12px] before:border-x-[transparent]
before:border-t-muted after:absolute after:left-0 after:top-0 after:h-0 after:w-0 after:border-x-[7px] after:border-t-[11px] after:border-x-[transparent] after:border-t-white
`,
closeBtn: 'absolute right-1 top-1 m-1 p-0 hover:opacity-80',
content: 'w-[17.625rem] py-4 pl-3 pr-5',
content: 'w-[17.625rem] bg-white py-4 pl-3 pr-5',
heading: 'typography-body-9 mb-2 font-bold text-text',
body: 'typography-body-10 text-text',
},
variants: {
placement: {
top: {
popover: 'bottom-full mb-2.5',
arrow: 'top-full after:top-[-12px] after:translate-x-[-7px]',
popover: '-mt-2.5 mb-2.5',
arrow: 'top-full translate-x-[-6px] translate-y-[-1px]',
},
bottom: {
popover: 'top-full mt-2.5',
arrow: 'bottom-full rotate-180 after:bottom-[1px] after:translate-x-[-7px]',
popover: 'mt-2.5',
arrow: 'bottom-full translate-x-[6px] rotate-180 after:bottom-[1px]',
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export type PanelProps = {
* Placement of popover. If no placement provided it will default to top unless there is no space then will appear on bottom.
*/
placement?: 'top' | 'bottom';
/**
* Uses portal to render popover
*/
portal?: boolean | Element;
/**
* Overlay trigger state
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/components/popover/popover.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function Popover({
linkStyling = false,
size = 'medium',
icon,
portal = false,
}: PopoverProps) {
const state = useOverlayTriggerState({});
const panelId = useId();
Expand Down Expand Up @@ -56,6 +57,7 @@ export function Popover({
useLayoutEffect(() => {
if (open) state.setOpen(true);
}, [open, state]);

return (
<div className={styles.base({ className })}>
<Button
Expand All @@ -73,6 +75,7 @@ export function Popover({
</Button>
{state.isOpen && (
<Panel
portal={portal}
placement={placement}
heading={heading ? heading : ''}
headingTag={headingTag}
Expand Down
76 changes: 76 additions & 0 deletions packages/ui/src/components/popover/popover.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,79 @@ export const PopoverPlacement = () => (
</div>
</>
);

/**
* > Popover trigger as inline link appearance
*/
export const WhenThereIsALongScrollWithPortal = () => (
<>
<h3 className="typography-body-7 mb-2 font-bold">Inside paragraph</h3>
{[...Array(20)].map((_, index) => (
<p key={index}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Blanditiis provident, porro dolor error nemo expedita
non mollitia est fugiat officiis deleniti harum dignissimos doloribus accusantium maxime optio libero. Ut,
laboriosam!
</p>
))}
<p className="mb-4">
{' '}
This is an example of using a popover that looks like an inline link.{' '}
<Popover portal linkStyling heading="Heading" placement="top" content={popoverContent} size="small">
Click here portal top.
</Popover>{' '}
To test popover.
</p>
<p className="mb-4">
{' '}
This is an example of using a popover that looks like an inline link.{' '}
<Popover portal linkStyling heading="Heading" placement="bottom" content={popoverContent} size="small">
Click here portal bottom.
</Popover>{' '}
To test popover.
</p>
{[...Array(10)].map((_, index) => (
<p key={index}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Blanditiis provident, porro dolor error nemo expedita
non mollitia est fugiat officiis deleniti harum dignissimos doloribus accusantium maxime optio libero. Ut,
laboriosam!
</p>
))}
<h3 className="typography-body-7 mb-2 font-bold">Inside hint</h3>
<Field
label="Example with field."
hintMessage={
<p>
{' '}
This is an example of using a popover that looks like an inline link.{' '}
<Popover linkStyling heading="Heading" content={popoverContent} size="small">
Click here. bottom placement
</Popover>
</p>
}
>
<Input />
</Field>
{[...Array(10)].map((_, index) => (
<p key={index}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Blanditiis provident, porro dolor error nemo expedita
non mollitia est fugiat officiis deleniti harum dignissimos doloribus accusantium maxime optio libero. Ut,
laboriosam!
</p>
))}
<h3 className="typography-body-7 mb-2 font-bold">Inside hint</h3>
<Field
label="Example with field."
hintMessage={
<p>
{' '}
This is an example of using a popover that looks like an inline link.{' '}
<Popover placement="top" linkStyling heading="Heading" content={popoverContent} size="small">
Click here. top
</Popover>
</p>
}
>
<Input />
</Field>
</>
);
Loading

0 comments on commit be65c6b

Please sign in to comment.