diff --git a/packages/ui/src/components.ts b/packages/ui/src/components.ts index 131074d..d428cd9 100644 --- a/packages/ui/src/components.ts +++ b/packages/ui/src/components.ts @@ -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'; diff --git a/packages/ui/src/components/Divider/Divider.tsx b/packages/ui/src/components/Divider/Divider.tsx index f43fd97..4da97a4 100644 --- a/packages/ui/src/components/Divider/Divider.tsx +++ b/packages/ui/src/components/Divider/Divider.tsx @@ -2,6 +2,7 @@ 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', @@ -9,14 +10,15 @@ export function Divider({ ...props }: DividerProps & React.HTMLAttributes) { return ( -
+ > ); }; diff --git a/packages/ui/src/components/Drawer/Drawer.tsx b/packages/ui/src/components/Drawer/Drawer.tsx index f5e6fdd..3fc1359 100644 --- a/packages/ui/src/components/Drawer/Drawer.tsx +++ b/packages/ui/src/components/Drawer/Drawer.tsx @@ -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'; @@ -37,8 +34,7 @@ export function Drawer({ // ...props }: DrawerProps & React.HTMLAttributes) { - 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); @@ -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 ( ; export const Default: Story = { + render: (args) => (<> + + +
Content
+
+ ), args: { - trigger: , - content:
Content
, + id: 'example', + } +}; + +export const NonModal: Story = { + render: (args) => (<> + + +
Content
+
+ ), + args: { + id: 'example', + nonmodal: true, + } +}; + + +export const NonModalMultiple: Story = { + render: (args) => (
+ + + +
Content
+
+ +
Content
+
+
), + args: { + id: 'example', + nonmodal: true, } }; diff --git a/packages/ui/src/components/Popover/Popover.tsx b/packages/ui/src/components/Popover/Popover.tsx index c33dce9..bc124f0 100644 --- a/packages/ui/src/components/Popover/Popover.tsx +++ b/packages/ui/src/components/Popover/Popover.tsx @@ -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) { + + const popoverRef = useRef(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 ( - - {trigger} - { + 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" + > + 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', + )} + > + + + + + - {/* - - */} - - {content} - - - + {children} + + ); } diff --git a/packages/ui/src/components/Popover/Popover.types.tsx b/packages/ui/src/components/Popover/Popover.types.tsx new file mode 100644 index 0000000..6bbdfcb --- /dev/null +++ b/packages/ui/src/components/Popover/Popover.types.tsx @@ -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; +} diff --git a/packages/ui/src/hooks.ts b/packages/ui/src/hooks.ts index c61ac6f..7f97d6e 100644 --- a/packages/ui/src/hooks.ts +++ b/packages/ui/src/hooks.ts @@ -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'; diff --git a/packages/ui/src/hooks/useDialogControlButtonProps.ts b/packages/ui/src/hooks/useDialogControlButtonProps.ts index 021e216..900a04d 100644 --- a/packages/ui/src/hooks/useDialogControlButtonProps.ts +++ b/packages/ui/src/hooks/useDialogControlButtonProps.ts @@ -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(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, }; }; diff --git a/packages/ui/src/hooks/useDialogRegister.ts b/packages/ui/src/hooks/useDialogRegister.ts new file mode 100644 index 0000000..5e0c452 --- /dev/null +++ b/packages/ui/src/hooks/useDialogRegister.ts @@ -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 ]); +}; diff --git a/packages/ui/src/hooks/useDialogState.ts b/packages/ui/src/hooks/useDialogState.ts new file mode 100644 index 0000000..d3272d0 --- /dev/null +++ b/packages/ui/src/hooks/useDialogState.ts @@ -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 }; +}; diff --git a/packages/ui/src/hooks/useOutsidePress.ts b/packages/ui/src/hooks/useOutsidePress.ts new file mode 100644 index 0000000..335e190 --- /dev/null +++ b/packages/ui/src/hooks/useOutsidePress.ts @@ -0,0 +1,24 @@ +import { useEffect, RefObject } from 'react'; + +export function useOutsidePress( + ref: RefObject, + 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 ]); +} diff --git a/packages/ui/src/reducers/dialog.actions.ts b/packages/ui/src/reducers/dialog.actions.ts index c21555f..000e0f7 100644 --- a/packages/ui/src/reducers/dialog.actions.ts +++ b/packages/ui/src/reducers/dialog.actions.ts @@ -12,16 +12,19 @@ export type DialogAction = { type: 'dialog/toggle', payload: { id: string; + triggerRef?: React.RefObject; } } | { type: 'dialog/open', payload: { id: string; + triggerRef?: React.RefObject; } } | { type: 'dialog/close', payload: { id: string; + triggerRef?: React.RefObject; } }; @@ -52,11 +55,12 @@ export function unregister(id: string): DialogAction { /** * Toggles a dialog between open and closed. */ -export function toggle(id: string): DialogAction { +export function toggle(id: string, triggerRef?: React.RefObject): DialogAction { return { type: 'dialog/toggle', payload: { id, + triggerRef, } }; } @@ -64,11 +68,12 @@ export function toggle(id: string): DialogAction { /** * Opens a dialog. */ -export function open(id: string): DialogAction { +export function open(id: string, triggerRef?: React.RefObject): DialogAction { return { type: 'dialog/open', payload: { id, + triggerRef, } }; } @@ -76,11 +81,12 @@ export function open(id: string): DialogAction { /** * Closes a dialog. */ -export function close(id: string): DialogAction { +export function close(id: string, triggerRef?: React.RefObject): DialogAction { return { type: 'dialog/close', payload: { id, + triggerRef, } }; } diff --git a/packages/ui/src/reducers/dialog.reducer.ts b/packages/ui/src/reducers/dialog.reducer.ts index 581acba..9b899e6 100644 --- a/packages/ui/src/reducers/dialog.reducer.ts +++ b/packages/ui/src/reducers/dialog.reducer.ts @@ -5,6 +5,7 @@ export interface DialogState { items: Record; }> } @@ -58,6 +59,7 @@ export function reducer( [payload.id]: { ...state.items[payload.id], open: !state.items[payload.id].open, + triggerRef: payload.triggerRef ?? state.items[payload.id].triggerRef, } } }; @@ -72,6 +74,7 @@ export function reducer( [payload.id]: { ...state.items[payload.id], open: true, + triggerRef: payload.triggerRef ?? state.items[payload.id].triggerRef, } } }; @@ -86,6 +89,7 @@ export function reducer( [payload.id]: { ...state.items[payload.id], open: false, + triggerRef: payload.triggerRef ?? state.items[payload.id].triggerRef, } } }; diff --git a/packages/ui/src/widgets/Brand/Brand.tsx b/packages/ui/src/widgets/Brand/Brand.tsx index 8319b98..bcbfd57 100644 --- a/packages/ui/src/widgets/Brand/Brand.tsx +++ b/packages/ui/src/widgets/Brand/Brand.tsx @@ -76,10 +76,8 @@ export function Brand({ classNames = {}, ...props }: BrandProps & React.HTMLAttributes) { - console.log('test'); const Tag = href?.length ? Button : 'div' as React.ElementType; - console.log('Tag', Tag); return (
- - + + + +
diff --git a/packages/ui/src/widgets/Navigation/NavigationTabs.tsx b/packages/ui/src/widgets/Navigation/NavigationTabs.tsx index a27d1c6..a6b2a3c 100644 --- a/packages/ui/src/widgets/Navigation/NavigationTabs.tsx +++ b/packages/ui/src/widgets/Navigation/NavigationTabs.tsx @@ -43,6 +43,7 @@ export function NavigationTabs({ )} keyboardActivation="manual" onSelectionChange={(key) => { + console.log('key', key); if(key === selected) { onSelectionChange(key); }