Skip to content

Commit

Permalink
feat: Popovers now register as dialogs and can be flagged as non-modal
Browse files Browse the repository at this point in the history
  • Loading branch information
eric-crowell committed Jun 30, 2024
1 parent 07e283c commit ba1f167
Show file tree
Hide file tree
Showing 16 changed files with 222 additions and 74 deletions.
4 changes: 3 additions & 1 deletion packages/ui/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ export type { DividerProps } from './components/Divider/Divider.types';

export { Image, type ImageProps } from './components/Image/Image';
export { Link, type LinkProps } from './components/Link/Link';
export { Popover, type PopoverProps } from './components/Popover/Popover';

export { Popover } from './components/Popover/Popover';
export type { PopoverProps } from './components/Popover/Popover.types';

export { Drawer } from './components/Drawer/Drawer';
export type { DrawerProps } from './components/Drawer/Drawer.types';
8 changes: 5 additions & 3 deletions packages/ui/src/components/Divider/Divider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@
import React from 'react';
import { DividerProps } from './Divider.types';
import { cn } from '@do-ob/ui/utility';
import { Separator } from 'react-aria-components';

export function Divider({
orientation = 'horizontal',
className,
...props
}: DividerProps & React.HTMLAttributes<HTMLDivElement>) {
return (
<div
<Separator
orientation={orientation}
className={cn(
'border-background-fg/50',
orientation === 'horizontal' ? 'w-full border-t' : 'h-full border-l',
orientation === 'horizontal' ? 'h-px w-full border-t' : 'h-full w-px border-l',
className
)}
{...props}
></div>
></Separator>
);
};

Expand Down
17 changes: 4 additions & 13 deletions packages/ui/src/components/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
'use client';

import { ModalOverlay, Modal, Dialog, Heading } from 'react-aria-components';
// import { cn } from '@do-ob/ui/utility';
import type { DrawerProps } from './Drawer.types';
import { nop } from '@do-ob/core';
import { dialogActions } from '@do-ob/ui/reducer';
import { useDebounce, useDialogControl } from '@do-ob/ui/hooks';
import { useDebounce, useDialogControl, useDialogRegister, useDialogState } from '@do-ob/ui/hooks';
import { Button, Divider } from '@do-ob/ui/components';
import { use, useCallback, useEffect } from 'react';
import { DialogContext, DialogDispatchContext } from '@do-ob/ui/context';
import { useCallback } from 'react';
import { cn } from '@do-ob/ui/utility';
import { XMarkIcon } from '@do-ob/ui/icons';

Expand Down Expand Up @@ -37,8 +34,7 @@ export function Drawer({
// ...props
}: DrawerProps & React.HTMLAttributes<HTMLElement>) {

const drawer = use(DialogContext).items[id] ?? { id, open: false };
const dispatch = use(DialogDispatchContext);
const drawer = useDialogState(id);
const isOpen = useDebounce(!!drawer.open, 300);

const controls = useDialogControl(id);
Expand All @@ -55,12 +51,7 @@ export function Drawer({
}
}, [ onOpenChange, onClose, onOpen, dismissable, drawer.open, controls ]);

useEffect(() => {
dispatch(dialogActions.register(id));
return () => {
dispatch(dialogActions.unregister(id));
};
}, [ dispatch, id ]);
useDialogRegister(id);

return (
<ModalOverlay
Expand Down
40 changes: 38 additions & 2 deletions packages/ui/src/components/Popover/Popover.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,44 @@ export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: (args) => (<>
<Button dialog="example">Click Me</Button>
<Popover {...args}>
<div>Content</div>
</Popover>
</>),
args: {
trigger: <Button>Click Me</Button>,
content: <div>Content</div>,
id: 'example',
}
};

export const NonModal: Story = {
render: (args) => (<>
<Button dialog="example">Click Me</Button>
<Popover {...args}>
<div>Content</div>
</Popover>
</>),
args: {
id: 'example',
nonmodal: true,
}
};


export const NonModalMultiple: Story = {
render: (args) => (<div className="flex flex-row gap-4">
<Button dialog="example1">Click Me 1</Button>
<Button dialog="example2">Click Me 2</Button>
<Popover {...args} id="example1">
<div>Content</div>
</Popover>
<Popover {...args} id="example2">
<div>Content</div>
</Popover>
</div>),
args: {
id: 'example',
nonmodal: true,
}
};
116 changes: 71 additions & 45 deletions packages/ui/src/components/Popover/Popover.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,86 @@
'use client';

import React from 'react';
import React, { useCallback, useRef } from 'react';
import {
Popover as AriaPopover,
Dialog as AriaDialog,
DialogTrigger as AriaDialogTrigger,
// OverlayArrow as AriaOverlayArrow
OverlayArrow as AriaOverlayArrow
} from 'react-aria-components';
import { fillStyles, cn } from '@do-ob/ui/utility';
// import { ArrowUpIcon } from '@do-ob/ui/icons-hero-solid';

export interface PopoverProps {
/**
* The content of the popover.
*/
content: React.ReactNode;
/**
* The trigger element of the popover.
*/
trigger: React.ReactNode;
/**
* The placement of the popover.
*/
placement?: 'top' | 'right' | 'bottom' | 'left';
/**
* The offset of the popover.
*/
offset?: number;
}
import { PopoverProps } from './Popover.types';
import { useDialogState, useDialogControl, useDialogRegister, useOutsidePress } from '@do-ob/ui/hooks';

export function Popover({
content,
trigger,
id,
placement = 'bottom',
offset = 8,
}: PopoverProps) {
nonmodal = false,
hideArrow = false,
children,
}: React.PropsWithChildren<PopoverProps>) {

const popoverRef = useRef<HTMLDivElement>(null);

// Get the popover dialog state.
const popover = useDialogState(id);

// Get the popover dialog control.
const controls = useDialogControl(id);

const handleOutsidePress = useCallback(() => {
if(nonmodal) {
controls.close();
}
}, [ controls, nonmodal ]);

useOutsidePress(popoverRef, handleOutsidePress);

// Register the popover dialog.
useDialogRegister(id);

const handleOpenChange = useCallback((next: boolean) => {
if (!next) {
controls.close();
}
}, [ controls ]);

return (
<AriaDialogTrigger>
{trigger}
<AriaPopover
placement={placement}
offset={offset}
className="min-w-56 origin-top-left overflow-auto rounded bg-background p-1 shadow-lg ring-1 ring-background-fg/30 fill-mode-forwards entering:animate-in entering:fade-in entering:zoom-in-95 exiting:animate-out exiting:fade-out exiting:zoom-out-95 dark:bg-background-dark dark:ring-background-dark-fg/30"
<AriaPopover
ref={popoverRef}
isOpen={popover.open && !!(popover.triggerRef?.current)}
triggerRef={popover.triggerRef ?? { current: null }}
onOpenChange={handleOpenChange}
placement={placement}
offset={offset}
isNonModal={nonmodal}
shouldCloseOnInteractOutside={(element) => {
return !!popover.triggerRef && !element.contains(popover.triggerRef?.current);
}}
className="min-w-56 origin-top-left rounded bg-background p-1 shadow-lg ring-1 ring-background-fg/30 fill-mode-forwards entering:animate-in entering:fade-in entering:zoom-in-95 exiting:animate-out exiting:fade-out exiting:zoom-out-95 dark:bg-background-dark dark:ring-background-dark-fg/30"
>
<AriaOverlayArrow
className={({ placement }) => cn(
'absolute left-1/2 z-10 size-[12px] -translate-x-1/2 [&_svg]:fill-background-fg/50 dark:[&_svg]:fill-background-dark-fg/30',
hideArrow && 'hidden',
placement === 'top' && '[&_svg]:rotate-0',
placement === 'bottom' && '[&_svg]:rotate-180',
placement === 'left' && '[&_svg]:rotate-90',
placement === 'right' && '[&_svg]:-rotate-90',
)}
>
<svg width={12} height={12} viewBox="0 0 12 12" fill="gray">
<path d="M0 0 L6 6 L12 0" />
</svg>
</AriaOverlayArrow>
<AriaDialog
aria-label={id}
className={cn(
'p-2 focus-visible:outline-none',
fillStyles.background,
)}
>
{/* <AriaOverlayArrow>
<ArrowUpIcon className="block size-4 bg-red-500 fill-black" />
</AriaOverlayArrow> */}
<AriaDialog
className={cn(
'p-2 focus-visible:outline-none',
fillStyles.background,
)}
>
{content}
</AriaDialog>
</AriaPopover>
</AriaDialogTrigger>
{children}
</AriaDialog>
</AriaPopover>
);
}
26 changes: 26 additions & 0 deletions packages/ui/src/components/Popover/Popover.types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export interface PopoverProps {
/**
* Required identifier of the popover.
*/
id: string;

/**
* The placement of the popover.
*/
placement?: 'top' | 'right' | 'bottom' | 'left';

/**
* The offset of the popover.
*/
offset?: number;

/**
* Allows elements outside the popover to be interactive.
*/
nonmodal?: boolean;

/**
* Hide the overlay arrow with the popup.
*/
hideArrow?: boolean;
}
3 changes: 3 additions & 0 deletions packages/ui/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ export * from './hooks/useTypewriter';
export * from './hooks/useOverflow';
export * from './hooks/useDebounce';
export * from './hooks/usePathname';
export * from './hooks/useDialogState';
export * from './hooks/useDialogControlButtonProps';
export * from './hooks/useDialogControl';
export * from './hooks/useDialogRegister';
export * from './hooks/useOutsidePress';
8 changes: 5 additions & 3 deletions packages/ui/src/hooks/useDialogControlButtonProps.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { dialogActions } from '@do-ob/ui/reducer';
import { use, useCallback } from 'react';
import { use, useCallback, useRef } from 'react';
import { DialogDispatchContext } from '@do-ob/ui/context';

export function useDialogControlButtonProps(name?: string) {
const triggerRef = useRef<HTMLButtonElement>(null);
const dispatch = use(DialogDispatchContext);

const onPress = useCallback(() => {
if(!name) return;
dispatch(dialogActions.toggle(name));
}, [ dispatch, name ]);
dispatch(dialogActions.toggle(name, triggerRef));
}, [ dispatch, name, triggerRef ]);

return {
ref: triggerRef,
onPress,
};
};
15 changes: 15 additions & 0 deletions packages/ui/src/hooks/useDialogRegister.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@

import { dialogActions } from '@do-ob/ui/reducer';
import { use, useEffect } from 'react';
import { DialogDispatchContext } from '@do-ob/ui/context';

export function useDialogRegister(id: string) {
const dispatch = use(DialogDispatchContext);

useEffect(() => {
dispatch(dialogActions.register(id));
return () => {
dispatch(dialogActions.unregister(id));
};
}, [ dispatch, id ]);
};
9 changes: 9 additions & 0 deletions packages/ui/src/hooks/useDialogState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { use } from 'react';
import { DialogContext } from '@do-ob/ui/context';

/**
* Gets the dialog state for the given id
*/
export function useDialogState(id: string) {
return use(DialogContext).items[id] ?? { id, open: false };
};
24 changes: 24 additions & 0 deletions packages/ui/src/hooks/useOutsidePress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect, RefObject } from 'react';

export function useOutsidePress<T extends HTMLElement>(
ref: RefObject<T | null>,
handler: (event: MouseEvent | TouchEvent) => void
): void {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
// Do nothing if clicking ref's element or its descendants
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler(event);
};

document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);

return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ ref, handler ]);
}
Loading

0 comments on commit ba1f167

Please sign in to comment.