first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import React, {ReactNode} from 'react';
import {Button} from '../../buttons/button';
import {ErrorOutlineIcon} from '@common/icons/material/ErrorOutline';
import {DialogFooter} from './dialog-footer';
import {useDialogContext} from './dialog-context';
import {Dialog} from './dialog';
import {DialogHeader} from './dialog-header';
import {DialogBody} from './dialog-body';
import {Trans} from '@common/i18n/trans';
interface Props {
className?: string;
title: ReactNode;
body: ReactNode;
confirm: ReactNode;
isDanger?: boolean;
isLoading?: boolean;
onConfirm?: () => void;
}
export function ConfirmationDialog({
className,
title,
body,
confirm,
isDanger,
isLoading,
onConfirm,
}: Props) {
const {close} = useDialogContext();
return (
<Dialog className={className} size="sm" role="alertdialog">
<DialogHeader
color={isDanger ? 'text-danger' : null}
leftAdornment={<ErrorOutlineIcon className="icon-sm" />}
>
{title}
</DialogHeader>
<DialogBody>{body}</DialogBody>
<DialogFooter>
<Button
variant="text"
onClick={() => {
close(false);
}}
>
<Trans message="Cancel" />
</Button>
<Button
disabled={isLoading}
variant="flat"
color={isDanger ? 'danger' : 'primary'}
onClick={() => {
onConfirm?.();
// if callback is passed in, caller is responsible for closing the dialog
if (!onConfirm) {
close(true);
}
}}
>
{confirm}
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,42 @@
import React, {ComponentProps, forwardRef, ReactNode} from 'react';
import clsx from 'clsx';
import {DialogSize} from './dialog';
interface DialogBodyProps extends ComponentProps<'div'> {
children: ReactNode;
className?: string;
padding?: string | null;
size?: DialogSize;
}
export const DialogBody = forwardRef<HTMLDivElement, DialogBodyProps>(
(props, ref) => {
const {children, className, padding, size, ...domProps} = props;
return (
<div
{...domProps}
ref={ref}
className={clsx(
className,
getPadding(props),
'overflow-y-auto overflow-x-hidden overscroll-contain text-sm flex-auto'
)}
>
{children}
</div>
);
}
);
function getPadding({size, padding}: DialogBodyProps) {
if (padding) {
return padding;
}
switch (size) {
case 'xs':
return 'p-14';
case 'sm':
return 'p-18';
default:
return 'px-24 py-20';
}
}

View File

@@ -0,0 +1,23 @@
import React, {ComponentPropsWithRef, useContext} from 'react';
export type DialogType = 'modal' | 'popover' | 'tray';
export interface DialogContextValue<T = unknown> {
labelId: string;
descriptionId: string;
type: DialogType;
isDismissable?: boolean;
close: (value?: T) => void;
value: T;
setValue: (value: T) => void;
initialValue: T;
formId: string;
dialogProps: ComponentPropsWithRef<'div'>;
disableInitialTransition?: boolean;
}
export const DialogContext = React.createContext<DialogContextValue>(null!);
export function useDialogContext<T = unknown>() {
return useContext(DialogContext) as DialogContextValue<T>;
}

View File

@@ -0,0 +1,43 @@
import React, {ReactNode} from 'react';
import clsx from 'clsx';
import {DialogSize} from './dialog';
interface DialogFooterProps {
children: ReactNode;
startAction?: ReactNode;
className?: string;
dividerTop?: boolean;
size?: DialogSize;
padding?: string;
}
export function DialogFooter(props: DialogFooterProps) {
const {children, startAction, className, dividerTop, padding, size} = props;
return (
<div
className={clsx(
className,
dividerTop && 'border-t',
getPadding(props),
'flex items-center gap-10 flex-shrink-0'
)}
>
<div>{startAction}</div>
<div className="ml-auto flex items-center gap-10">{children}</div>
</div>
);
}
function getPadding({padding, size}: DialogFooterProps) {
if (padding) {
return padding;
}
switch (size) {
case 'xs':
return 'p-14';
case 'sm':
return 'p-18';
default:
return 'px-24 py-20';
}
}

View File

@@ -0,0 +1,101 @@
import React, {ReactNode, useContext} from 'react';
import clsx from 'clsx';
import {DialogContext} from './dialog-context';
import {IconButton} from '../../buttons/icon-button';
import {CloseIcon} from '../../../icons/material/Close';
import {DialogSize} from './dialog';
import {ButtonSize} from '@common/ui/buttons/button-size';
interface DialogHeaderProps {
children: ReactNode;
className?: string;
color?: string | null;
onDismiss?: () => void;
hideDismissButton?: boolean;
leftAdornment?: ReactNode;
// Will hide default close button visually, but still accessible by screen readers
rightAdornment?: ReactNode;
// Will show between title and close button
actions?: ReactNode;
size?: DialogSize;
padding?: string;
justify?: string;
showDivider?: boolean;
titleTextSize?: string;
titleFontWeight?: string;
closeButtonSize?: ButtonSize;
}
export function DialogHeader(props: DialogHeaderProps) {
const {
children,
className,
color,
onDismiss,
leftAdornment,
rightAdornment,
hideDismissButton = false,
size,
showDivider,
justify = 'justify-between',
titleFontWeight = 'font-semibold',
titleTextSize = size === 'xs' ? 'text-xs' : 'text-sm',
closeButtonSize = size === 'xs' ? 'xs' : 'sm',
actions,
} = props;
const {labelId, isDismissable, close} = useContext(DialogContext);
return (
<div
className={clsx(
className,
'flex flex-shrink-0 items-center gap-10',
titleFontWeight,
showDivider && 'border-b',
getPadding(props),
color || 'text-main',
justify
)}
>
{leftAdornment}
<h3
id={labelId}
className={clsx(titleTextSize, 'mr-auto leading-5 opacity-90')}
>
{children}
</h3>
{rightAdornment}
{actions}
{isDismissable && !hideDismissButton && (
<IconButton
aria-label="Dismiss"
onClick={() => {
if (onDismiss) {
onDismiss();
} else {
close();
}
}}
size={closeButtonSize}
className={clsx('-mr-8 text-muted', rightAdornment && 'sr-only')}
>
<CloseIcon />
</IconButton>
)}
</div>
);
}
function getPadding({size, padding}: DialogHeaderProps) {
if (padding) {
return padding;
}
switch (size) {
case '2xs':
case 'xs':
return 'px-14 py-4';
case 'sm':
return 'px-18 py-4';
default:
return 'px-24 py-6';
}
}

View File

@@ -0,0 +1,323 @@
import React, {
Children,
cloneElement,
Fragment,
HTMLProps,
ReactElement,
ReactNode,
RefObject,
useCallback,
useId,
useMemo,
useRef,
} from 'react';
import {AnimatePresence} from 'framer-motion';
import {useControlledState} from '@react-stately/utils';
import {mergeProps, useLayoutEffect} from '@react-aria/utils';
import {useFloatingPosition} from '../floating-position';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
import {DialogContext, DialogContextValue} from './dialog-context';
import {Popover} from '../popover';
import {Tray} from '../tray';
import {Modal} from '../modal';
import {createPortal} from 'react-dom';
import {createEventHandler} from '@common/utils/dom/create-event-handler';
import {OffsetOptions, Placement, VirtualElement} from '@floating-ui/react-dom';
import {rootEl} from '@common/core/root-el';
import {pointToVirtualElement} from '@common/ui/navigation/menu/context-menu';
import {useCallbackRef} from '@common/utils/hooks/use-callback-ref';
type PopoverProps = {
type: 'popover';
mobileType?: 'tray' | 'modal';
placement?: Placement;
offset?: OffsetOptions;
};
type ModalProps = {
type: 'modal' | 'tray';
mobileType?: 'tray' | 'modal';
placement?: Placement;
};
type Props<T = any> = (PopoverProps | ModalProps) & {
children: [ReactElement, (ctx: DialogContextValue) => void] | ReactNode;
disableInitialTransition?: boolean;
onClose?: (
value: T | undefined,
data: {initialValue: T; valueChanged: boolean},
) => void;
isDismissable?: boolean;
isOpen?: boolean;
onValueChange?: (value: T) => void;
alwaysReturnCurrentValueOnClose?: boolean;
onOpenChange?: (isOpen: boolean) => void;
defaultIsOpen?: boolean;
triggerRef?: RefObject<HTMLElement> | RefObject<VirtualElement>;
moveFocusToDialog?: boolean;
returnFocusToTrigger?: boolean;
triggerOnHover?: boolean;
triggerOnContextMenu?: boolean;
value?: T;
defaultValue?: T;
usePortal?: boolean;
};
export function DialogTrigger(props: Props) {
let {
children,
type,
disableInitialTransition,
isDismissable = true,
moveFocusToDialog = true,
returnFocusToTrigger = true,
triggerOnHover = false,
triggerOnContextMenu = false,
usePortal = true,
mobileType,
alwaysReturnCurrentValueOnClose,
} = props;
// for context menu we will set triggerRef to VirtualElement in "onContextMenu" event.
// If dialog is not triggered on context menu, leave triggerRef null (unless it's passed in via props)
// otherwise it will prevent dialog from opening in "popover" mode.
const contextMenuTriggerRef = useRef<VirtualElement | null>(null);
const triggerRef =
triggerOnContextMenu && !props.triggerRef
? contextMenuTriggerRef
: props.triggerRef;
// initial value can be used to restore state to what it
// was before opening the dialog, for example in color picker
const initialValueRef = useRef(props.value);
const [isOpen, setIsOpen] = useControlledState(
props.isOpen,
props.defaultIsOpen,
props.onOpenChange,
);
const [value, setValue] = useControlledState(
props.value,
props.defaultValue,
props.onValueChange,
);
// On small devices, show a modal or tray instead of a popover.
const isMobile = useIsMobileMediaQuery();
if (isMobile && type === 'popover') {
type = mobileType || 'modal';
}
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const {x, y, reference, strategy, refs} = useFloatingPosition({
...props,
disablePositioning: type === 'modal',
});
const floatingStyle =
type === 'popover'
? {
position: strategy,
top: y ?? '',
left: x ?? '',
}
: {};
const id = useId();
const labelId = `${id}-label`;
const descriptionId = `${id}-description`;
const formId = `${id}-form`;
const onClose = useCallbackRef(props.onClose);
const close = useCallback(
(closeValue?: any) => {
if (
typeof closeValue === 'undefined' &&
alwaysReturnCurrentValueOnClose
) {
closeValue = value;
}
// if value is not provided (dialog cancel button or clicking outside of dialog), use initial value
const finalValue =
typeof closeValue !== 'undefined'
? closeValue
: initialValueRef.current;
onClose?.(finalValue, {
initialValue: initialValueRef.current,
valueChanged: finalValue !== initialValueRef.current,
});
setIsOpen(false);
},
[onClose, setIsOpen, value, alwaysReturnCurrentValueOnClose],
);
const open = useCallback(() => {
setIsOpen(true);
// set current value that is active at the time of opening dialog
initialValueRef.current = props.value;
}, [props.value, setIsOpen]);
// position dropdown relative to provided ref, not the trigger
useLayoutEffect(() => {
if (triggerRef?.current && refs.reference.current !== triggerRef.current) {
reference(triggerRef.current);
}
}, [reference, triggerRef?.current, refs]);
const dialogProps = useMemo(() => {
return {
'aria-labelledby': labelId,
'aria-describedby': descriptionId,
};
}, [labelId, descriptionId]);
let Overlay: typeof Modal | typeof Tray | typeof Popover;
if (type === 'modal') {
Overlay = Modal;
} else if (type === 'tray') {
Overlay = Tray;
} else {
Overlay = Popover;
}
const contextValue: DialogContextValue = useMemo(() => {
return {
dialogProps,
type,
labelId,
descriptionId,
isDismissable,
close,
value,
initialValue: initialValueRef.current,
setValue,
formId,
};
}, [
close,
descriptionId,
dialogProps,
formId,
labelId,
type,
isDismissable,
value,
setValue,
]);
triggerOnHover = triggerOnHover && type === 'popover';
const handleTriggerHover: HTMLProps<HTMLElement> = {
onPointerEnter: createEventHandler((e: React.PointerEvent) => {
open();
}),
onPointerLeave: createEventHandler((e: React.PointerEvent) => {
hoverTimeoutRef.current = setTimeout(() => {
close();
}, 150);
}),
};
const handleFloatingHover: HTMLProps<HTMLElement> = {
onPointerEnter: createEventHandler((e: React.PointerEvent) => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
}),
onPointerLeave: createEventHandler((e: React.PointerEvent) => {
close();
}),
};
const handleTriggerContextMenu: HTMLProps<HTMLElement> = {
onContextMenu: createEventHandler((e: React.MouseEvent) => {
e.preventDefault();
contextMenuTriggerRef.current = pointToVirtualElement(
{x: e.clientX, y: e.clientY},
e.currentTarget,
);
open();
}),
};
const handleTriggerClick: HTMLProps<HTMLElement> = {
onClick: createEventHandler((e: React.MouseEvent) => {
// prevent propagating to parent, in case floating element
// is attached to input field and button is inside the field
e.stopPropagation();
if (isOpen) {
close();
} else {
open();
}
}),
};
const {dialogTrigger, dialog} = extractChildren(children, contextValue);
const dialogContent = (
<AnimatePresence initial={!disableInitialTransition}>
{isOpen && (
<DialogContext.Provider value={contextValue}>
<Overlay
{...(triggerOnHover ? handleFloatingHover : {})}
ref={refs.setFloating}
triggerRef={refs.reference}
style={floatingStyle}
restoreFocus={returnFocusToTrigger}
autoFocus={moveFocusToDialog}
isOpen={isOpen}
onClose={close}
isDismissable={isDismissable}
isContextMenu={triggerOnContextMenu}
placement={props.placement}
>
{dialog}
</Overlay>
</DialogContext.Provider>
)}
</AnimatePresence>
);
return (
<Fragment>
{dialogTrigger &&
cloneElement(
dialogTrigger,
mergeProps(
{
// make sure ref specified on trigger element is not overwritten
...(!triggerRef && !triggerOnContextMenu ? {ref: reference} : {}),
...(!triggerOnContextMenu ? handleTriggerClick : {}),
...(triggerOnHover ? handleTriggerHover : {}),
...(triggerOnContextMenu ? handleTriggerContextMenu : {}),
},
{
...dialogTrigger.props,
},
),
)}
{usePortal
? rootEl && createPortal(dialogContent, rootEl)
: dialogContent}
</Fragment>
);
}
function extractChildren(
rawChildren: Props['children'],
ctx: DialogContextValue,
) {
const children = Array.isArray(rawChildren)
? rawChildren
: Children.toArray(rawChildren);
let dialog: any = children.length === 2 ? children[1] : children[0];
dialog = typeof dialog === 'function' ? dialog(ctx) : dialog;
// trigger and dialog passed as children
if (children.length === 2) {
return {
dialogTrigger: children[0] as ReactElement,
dialog: dialog as ReactElement,
};
}
// only dialog passed as child
return {dialog: dialog as ReactElement};
}

View File

@@ -0,0 +1,112 @@
import React, {
Children,
cloneElement,
ComponentPropsWithoutRef,
CSSProperties,
isValidElement,
ReactElement,
ReactNode,
useContext,
} from 'react';
import clsx from 'clsx';
import {mergeProps} from '@react-aria/utils';
import {DialogContext} from './dialog-context';
import {InputSize} from '../../forms/input-field/input-size';
import {DismissButton} from './dismiss-button';
export type DialogSize =
| InputSize
| '2xl'
| 'auto'
| 'fullscreen'
| 'fullscreenTakeover'
| string;
export interface DialogProps
extends Omit<ComponentPropsWithoutRef<'div'>, 'size'> {
children: ReactNode;
size?: DialogSize;
background?: string;
className?: string;
radius?: string;
maxWidth?: string;
}
export function Dialog(props: DialogProps) {
const {
type = 'modal',
dialogProps,
...contextProps
} = useContext(DialogContext);
const {
children,
className,
size = 'md',
background,
radius = 'rounded',
maxWidth = 'max-w-dialog',
...domProps
} = props;
// If rendered in a popover or tray there won't be a visible dismiss button,
// so we render a hidden one for screen readers.
let dismissButton: ReactElement | null = null;
if (type === 'popover' || type === 'tray') {
dismissButton = <DismissButton onDismiss={contextProps.close} />;
}
const isTrayOrFullScreen = size === 'fullscreenTakeover' || type === 'tray';
const mergedClassName = clsx(
'mx-auto pointer-events-auto outline-none flex flex-col overflow-hidden',
background || 'bg',
type !== 'tray' && sizeStyle(size),
type === 'tray' && 'rounded-t border-b-bg',
size !== 'fullscreenTakeover' && `shadow-2xl border max-h-dialog`,
!isTrayOrFullScreen && `${radius} ${maxWidth}`,
className,
);
return (
<div
{...mergeProps({role: 'dialog', tabIndex: -1}, dialogProps, domProps)}
style={{...props.style, '--be-dialog-padding': '24px'} as CSSProperties}
aria-modal
className={mergedClassName}
>
{Children.toArray(children).map(child => {
if (isValidElement<DialogProps>(child)) {
return cloneElement<DialogProps>(child, {
size: child.props.size ?? size,
});
}
return child;
})}
{dismissButton}
</div>
);
}
function sizeStyle(dialogSize?: DialogSize) {
switch (dialogSize) {
case '2xs':
return 'w-256';
case 'xs':
return 'w-320';
case 'sm':
return 'w-384';
case 'md':
return 'w-440';
case 'lg':
return 'w-620';
case 'xl':
return 'w-780';
case '2xl':
return 'w-850';
case 'fullscreen':
return 'w-1280';
case 'fullscreenTakeover':
return 'w-full h-full';
default:
return dialogSize;
}
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import {useTrans} from '../../../i18n/use-trans';
import {message} from '../../../i18n/message';
interface DismissButtonProps {
onDismiss?: () => void;
}
export function DismissButton({onDismiss}: DismissButtonProps) {
const {trans} = useTrans();
const onClick = () => {
if (onDismiss) {
onDismiss();
}
};
return (
<button
className="sr-only"
aria-label={trans(message('Dismiss'))}
tabIndex={-1}
onClick={onClick}
/>
);
}

View File

@@ -0,0 +1,79 @@
import React from 'react';
import {useDialogContext} from './dialog-context';
import {Dialog} from './dialog';
import {DialogBody} from './dialog-body';
import {IconButton} from '@common/ui/buttons/icon-button';
import {CloseIcon} from '@common/icons/material/Close';
import {KeyboardArrowLeftIcon} from '@common/icons/material/KeyboardArrowLeft';
import {KeyboardArrowRightIcon} from '@common/icons/material/KeyboardArrowRight';
import {useControlledState} from '@react-stately/utils';
interface Props {
image?: string;
images?: string[];
activeIndex?: number;
onActiveIndexChange?: (index: number) => void;
defaultActiveIndex?: number;
}
export function ImageZoomDialog(props: Props) {
const {close} = useDialogContext();
const {image, images} = props;
const [activeIndex, setActiveIndex] = useControlledState(
props.activeIndex,
props.defaultActiveIndex,
props.onActiveIndexChange,
);
const src = image || images?.[activeIndex];
return (
<Dialog size="fullscreenTakeover" background="bg-black/80">
<DialogBody padding="p-0" className="h-full w-full">
<IconButton
size="lg"
color="paper"
className="absolute right-0 top-0 z-20 text-white"
onClick={() => {
close();
}}
>
<CloseIcon />
</IconButton>
<div className="relative flex h-full w-full items-center justify-center p-40">
{images?.length ? (
<IconButton
size="lg"
color="white"
variant="flat"
className="absolute bottom-0 left-20 top-0 my-auto"
disabled={activeIndex < 1}
onClick={() => {
setActiveIndex(activeIndex - 1);
}}
>
<KeyboardArrowLeftIcon />
</IconButton>
) : null}
<img
src={src}
alt=""
className="max-h-full w-auto object-contain shadow"
/>
{images?.length ? (
<IconButton
size="lg"
color="white"
variant="flat"
className="absolute bottom-0 right-20 top-0 my-auto"
disabled={activeIndex + 1 === images?.length}
onClick={() => {
setActiveIndex(activeIndex + 1);
}}
>
<KeyboardArrowRightIcon />
</IconButton>
) : null}
</div>
</DialogBody>
</Dialog>
);
}

View File

@@ -0,0 +1,6 @@
import {createSvgIcon} from '@common/icons/create-svg-icon';
export const InfoDialogTriggerIcon = createSvgIcon(
<path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" />,
'InfoDialogTrigger'
);

View File

@@ -0,0 +1,41 @@
import {IconButton} from '@common/ui/buttons/icon-button';
import {InfoDialogTriggerIcon} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger-icon';
import {Dialog, DialogSize} from '@common/ui/overlays/dialog/dialog';
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import React, {ReactNode} from 'react';
import clsx from 'clsx';
interface Props {
title?: ReactNode;
body: ReactNode;
dialogSize?: DialogSize;
className?: string;
}
export function InfoDialogTrigger({
title,
body,
dialogSize = 'sm',
className,
}: Props) {
return (
<DialogTrigger type="popover" triggerOnHover>
<IconButton
className={clsx('ml-4 text-muted opacity-70', className)}
iconSize="xs"
size="2xs"
>
<InfoDialogTriggerIcon viewBox="0 0 16 16" />
</IconButton>
<Dialog size={dialogSize}>
{title && (
<DialogHeader padding="px-18 pt-12" size="md" hideDismissButton>
{title}
</DialogHeader>
)}
<DialogBody>{body}</DialogBody>
</Dialog>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,104 @@
import {
arrow,
autoUpdate,
flip,
offset as offsetMiddleware,
OffsetOptions,
Placement,
ReferenceType,
shift,
size,
useFloating,
} from '@floating-ui/react-dom';
import {CSSProperties, Ref, useMemo, useRef} from 'react';
import {mergeRefs} from 'react-merge-refs';
import {UseFloatingOptions} from '@floating-ui/react-dom/src/types';
interface Props {
floatingWidth?: 'auto' | 'matchTrigger';
ref?: Ref<HTMLElement>;
disablePositioning?: boolean;
placement?: Placement;
offset?: OffsetOptions;
showArrow?: boolean;
maxHeight?: number;
shiftCrossAxis?: boolean;
fallbackPlacements?: Placement[];
}
export function useFloatingPosition({
floatingWidth,
ref,
disablePositioning = false,
placement = 'bottom',
offset = 2,
showArrow = false,
maxHeight,
shiftCrossAxis = true,
fallbackPlacements,
}: Props) {
const arrowRef = useRef<HTMLElement>(null);
const floatingConfig: UseFloatingOptions = {placement, strategy: 'fixed'};
if (!disablePositioning) {
floatingConfig.whileElementsMounted = autoUpdate;
floatingConfig.middleware = [
offsetMiddleware(offset),
shift({padding: 16, crossAxis: shiftCrossAxis, mainAxis: true}),
flip({
padding: 16,
fallbackPlacements,
}),
size({
apply({rects, availableHeight, availableWidth, elements}) {
if (floatingWidth === 'matchTrigger' && maxHeight != null) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
maxWidth: `${availableWidth}`,
maxHeight: `${Math.min(availableHeight, maxHeight)}px`,
});
} else if (maxHeight != null) {
Object.assign(elements.floating.style, {
maxHeight: `${Math.min(availableHeight, maxHeight)}px`,
});
}
},
padding: 16,
}),
];
if (showArrow) {
floatingConfig.middleware.push(arrow({element: arrowRef}));
}
}
const floatingProps = useFloating(floatingConfig);
const mergedReferenceRef = useMemo(
() => mergeRefs<ReferenceType>([ref!, floatingProps.refs.setReference]),
[floatingProps.refs.setReference, ref]
);
const {x: arrowX, y: arrowY} = floatingProps.middlewareData.arrow || {};
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right',
}[floatingProps.placement.split('-')[0]]!;
const arrowStyle: CSSProperties = {
left: arrowX,
top: arrowY,
right: '',
bottom: '',
[staticSide]: '-4px',
};
return {
...floatingProps,
reference: mergedReferenceRef,
arrowRef,
arrowStyle,
};
}

View File

@@ -0,0 +1,66 @@
import {forwardRef} from 'react';
import {m} from 'framer-motion';
import {OverlayProps} from './overlay-props';
import {useOverlayViewport} from './use-overlay-viewport';
import {Underlay} from './underlay';
import {FocusScope} from '@react-aria/focus';
import {useObjectRef} from '@react-aria/utils';
import clsx from 'clsx';
export const Modal = forwardRef<HTMLDivElement, OverlayProps>(
(
{
children,
autoFocus = false,
restoreFocus = true,
isDismissable = true,
isOpen = false,
placement = 'center',
onClose,
},
ref
) => {
const viewPortStyle = useOverlayViewport();
const objRef = useObjectRef(ref);
return (
<div
className="fixed inset-0 isolate z-modal"
style={viewPortStyle}
onKeyDown={e => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
onClose();
}
}}
>
<Underlay
key="modal-underlay"
onClick={() => {
if (isDismissable) {
onClose();
}
}}
/>
<m.div
ref={objRef}
className={clsx(
'pointer-events-none absolute inset-0 z-20 flex h-full w-full',
placement === 'center' && 'items-center justify-center',
placement === 'top' && 'items-start justify-center pt-40'
)}
role="presentation"
initial={{opacity: 0, scale: placement === 'top' ? 1 : 0.7}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0, scale: 1}}
transition={{duration: 0.1}}
>
<FocusScope restoreFocus={restoreFocus} autoFocus={autoFocus} contain>
{children}
</FocusScope>
</m.div>
</div>
);
}
);

View File

@@ -0,0 +1,25 @@
import {
CSSProperties,
PointerEventHandler,
ReactElement,
Ref,
RefObject,
} from 'react';
import {FocusScopeProps} from '@react-aria/focus';
import {Placement, VirtualElement} from '@floating-ui/react-dom';
export interface OverlayProps
extends Omit<FocusScopeProps, 'children' | 'contain'> {
children: ReactElement;
style?: CSSProperties;
isDismissable: boolean;
isContextMenu?: boolean;
isOpen: boolean;
onClose: (value?: any) => void;
triggerRef: RefObject<HTMLElement> | RefObject<VirtualElement>;
arrowRef?: Ref<HTMLElement>;
arrowStyle?: CSSProperties;
onPointerLeave?: PointerEventHandler<HTMLElement>;
onPointerEnter?: PointerEventHandler<HTMLElement>;
placement?: Placement;
}

View File

@@ -0,0 +1,8 @@
import {HTMLMotionProps} from 'framer-motion';
export const PopoverAnimation: HTMLMotionProps<'div'> = {
initial: {opacity: 0, y: 5},
animate: {opacity: 1, y: 0},
exit: {opacity: 0, y: 5},
transition: {type: 'tween', duration: 0.125},
};

View File

@@ -0,0 +1,258 @@
import React, {
forwardRef,
RefObject,
useCallback,
useEffect,
useRef,
} from 'react';
import {m} from 'framer-motion';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import {PopoverAnimation} from './popover-animation';
import {OverlayProps} from './overlay-props';
import {useOverlayViewport} from './use-overlay-viewport';
import {FocusScope} from '@react-aria/focus';
import {VirtualElement} from '@floating-ui/react-dom';
export const Popover = forwardRef<HTMLDivElement, OverlayProps>(
(
{
children,
style,
autoFocus = false,
restoreFocus = true,
isDismissable,
isContextMenu,
isOpen,
onClose,
triggerRef,
arrowRef,
arrowStyle,
onPointerLeave,
onPointerEnter,
},
ref,
) => {
const viewPortStyle = useOverlayViewport();
const objRef = useObjectRef(ref);
const {domProps} = useCloseOnInteractOutside(
{
isDismissable,
isOpen,
onClose,
triggerRef,
isContextMenu,
},
objRef,
);
return (
<m.div
className="isolate z-popover"
role="presentation"
ref={objRef}
style={{...viewPortStyle, ...style, position: 'fixed'}}
{...PopoverAnimation}
{...mergeProps(domProps as any, {onPointerLeave, onPointerEnter})}
>
<FocusScope
restoreFocus={restoreFocus}
autoFocus={autoFocus}
contain={false}
>
{children}
</FocusScope>
</m.div>
);
},
);
// this should only be rendered when overlay is open
const visibleOverlays: RefObject<Element>[] = [];
interface useCloseOnInteractOutsideProps {
isOpen: boolean;
onClose: () => void;
isDismissable: boolean;
isContextMenu?: boolean;
triggerRef: OverlayProps['triggerRef'];
}
function useCloseOnInteractOutside(
{
onClose,
isDismissable = true,
triggerRef,
isContextMenu = false,
}: useCloseOnInteractOutsideProps,
ref: RefObject<Element>,
) {
const stateRef = useRef({
isPointerDown: false,
isContextMenu,
onClose,
});
const state = stateRef.current;
state.isContextMenu = isContextMenu;
state.onClose = onClose;
const isValidEvent = useCallback(
(e: PointerEvent | MouseEvent) => {
// if (e.button > 0 && (!state.isContextMenu || e.button !== 2)) {
// return false;
// }
const target = e.target as Element;
// if the event target is no longer in the document
if (target) {
const ownerDocument = target.ownerDocument;
if (!ownerDocument || !ownerDocument.documentElement.contains(target)) {
return false;
}
}
return ref.current && !ref.current.contains(target);
},
[ref],
);
// Only hide the overlay when it is the topmost visible overlay in the stack.
// For context menu, hide it regardless
const isTopMostPopover = useCallback(() => {
return visibleOverlays[visibleOverlays.length - 1] === ref;
}, [ref]);
const hideOverlay = useCallback(() => {
if (isTopMostPopover()) {
state.onClose();
}
}, [isTopMostPopover, state]);
const clickedOnTriggerElement = useCallback(
(el: Element) => {
if (triggerRef.current && 'contains' in triggerRef.current) {
return triggerRef.current.contains?.(el);
}
return false;
},
[triggerRef],
);
const onInteractOutsideStart = useCallback(
(e: PointerEvent) => {
if (!clickedOnTriggerElement(e.target as Element)) {
if (isTopMostPopover()) {
e.stopPropagation();
e.preventDefault();
}
}
},
[clickedOnTriggerElement, isTopMostPopover],
);
const onInteractOutside = useCallback(
(e: PointerEvent) => {
if (!clickedOnTriggerElement(e.target as Element)) {
if (isTopMostPopover()) {
e.stopPropagation();
e.preventDefault();
}
// don't close context menu on right click, it will be done in "onInteractOutsideStart" already.
// And it would prevent repositioning of context menu when right-clicking on the same element
if (!state.isContextMenu || e.button !== 2) {
hideOverlay();
}
}
},
[clickedOnTriggerElement, hideOverlay, state, isTopMostPopover],
);
// Add popover ref to the stack of visible popovers on mount, and remove on unmount.
useEffect(() => {
visibleOverlays.push(ref);
// handle pointer up and down events
const onPointerDown = (e: PointerEvent) => {
if (isValidEvent(e)) {
onInteractOutsideStart(e);
stateRef.current.isPointerDown = true;
}
};
const onPointerUp = (e: PointerEvent) => {
if (stateRef.current.isPointerDown && isValidEvent(e)) {
stateRef.current.isPointerDown = false;
onInteractOutside(e);
}
};
// handle context menu event
const onContextMenu = (e: MouseEvent) => {
e.preventDefault();
if (isValidEvent(e)) {
hideOverlay();
}
};
// handle closing on scroll
const onScroll = (e: Event) => {
if (!triggerRef.current) {
return;
}
const scrollableRegion = e.target;
let triggerEl: Element | undefined;
if (triggerRef.current instanceof Node) {
triggerEl = triggerRef.current;
} else if ('contextElement' in triggerRef.current) {
triggerEl = (triggerRef.current as VirtualElement).contextElement;
}
// window is not a Node and doesn't have "contain", but window contains everything
if (
!(scrollableRegion instanceof Node) ||
!triggerEl ||
scrollableRegion.contains(triggerEl)
) {
state.onClose();
}
};
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('pointerup', onPointerUp, true);
document.addEventListener('contextmenu', onContextMenu, true);
document.addEventListener('scroll', onScroll, true);
return () => {
const index = visibleOverlays.indexOf(ref);
if (index >= 0) {
visibleOverlays.splice(index, 1);
}
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('pointerup', onPointerUp, true);
document.removeEventListener('contextmenu', onContextMenu, true);
document.removeEventListener('scroll', onScroll, true);
};
}, [
ref,
isValidEvent,
state,
onInteractOutside,
onInteractOutsideStart,
triggerRef,
clickedOnTriggerElement,
hideOverlay,
]);
// Handle the escape key
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
e.preventDefault();
hideOverlay();
}
};
return {
domProps: {
onKeyDown,
},
};
}

View File

@@ -0,0 +1,21 @@
import {
closeDialog,
useDialogStore,
} from '@common/ui/overlays/store/dialog-store';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import React from 'react';
export function DialogStoreOutlet() {
const {dialog: DialogElement, data} = useDialogStore();
return (
<DialogTrigger
type="modal"
isOpen={DialogElement != null}
onClose={value => {
closeDialog(value);
}}
>
{DialogElement ? <DialogElement {...data} /> : null}
</DialogTrigger>
);
}

View File

@@ -0,0 +1,44 @@
import {create} from 'zustand';
import {immer} from 'zustand/middleware/immer';
import React, {JSXElementConstructor} from 'react';
interface DialogStore<
C extends JSXElementConstructor<unknown> = JSXElementConstructor<any>,
D = React.ComponentProps<C>
> {
dialog: C | null;
data: D;
openDialog: (dialog: C, data?: D) => Promise<any>;
closeActiveDialog: (value: any) => void;
resolveClosePromise: null | ((value: any) => void);
}
export const useDialogStore = create<DialogStore>()(
immer((set, get) => ({
dialog: null,
data: undefined,
resolveClosePromise: null,
openDialog: (dialog, data) => {
return new Promise(resolve => {
set(state => {
state.dialog = dialog;
state.data = data;
state.resolveClosePromise = resolve;
});
});
},
closeActiveDialog: value => {
get().resolveClosePromise?.(value);
set(state => {
state.dialog = null;
state.data = undefined;
state.resolveClosePromise = null;
});
},
}))
);
export const openDialog = useDialogStore.getState().openDialog;
export const closeDialog = (value?: any) => {
useDialogStore.getState().closeActiveDialog(value);
};

View File

@@ -0,0 +1,50 @@
import {m} from 'framer-motion';
import {forwardRef} from 'react';
import {OverlayProps} from './overlay-props';
import {useOverlayViewport} from './use-overlay-viewport';
import {Underlay} from './underlay';
import {FocusScope} from '@react-aria/focus';
import {useObjectRef} from '@react-aria/utils';
export const Tray = forwardRef<HTMLDivElement, OverlayProps>(
(
{
children,
autoFocus = false,
restoreFocus = true,
isDismissable,
isOpen,
onClose,
},
ref
) => {
const viewPortStyle = useOverlayViewport();
const objRef = useObjectRef(ref);
return (
<div className="isolate z-tray fixed inset-0" style={viewPortStyle}>
<Underlay
key="tray-underlay"
onClick={() => {
if (isDismissable) {
onClose();
}
}}
/>
<m.div
ref={objRef}
className="absolute bottom-0 left-0 right-0 w-full z-20 rounded-t overflow-hidden max-w-375 max-h-tray mx-auto pb-safe-area"
role="presentation"
initial={{opacity: 0, y: '100%'}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: '100%'}}
transition={{type: 'tween', duration: 0.2}}
>
<FocusScope restoreFocus={restoreFocus} autoFocus={autoFocus} contain>
{children}
</FocusScope>
</m.div>
</div>
);
}
);

View File

@@ -0,0 +1,41 @@
import {m} from 'framer-motion';
import clsx from 'clsx';
import {ComponentPropsWithoutRef} from 'react';
import {opacityAnimation} from '../animation/opacity-animation';
interface UnderlayProps
extends Omit<
ComponentPropsWithoutRef<'div'>,
'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'
> {
position?: 'fixed' | 'absolute';
className?: string;
isTransparent?: boolean;
disableInitialTransition?: boolean;
}
export function Underlay({
position = 'absolute',
className,
isTransparent = false,
disableInitialTransition,
...domProps
}: UnderlayProps) {
return (
<m.div
{...domProps}
className={clsx(
className,
!isTransparent && 'bg-background/80',
'inset-0 z-10 h-full w-full',
position,
'backdrop-blur-sm'
)}
aria-hidden
initial={disableInitialTransition ? undefined : {opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
{...opacityAnimation}
transition={{duration: 0.15}}
/>
);
}

View File

@@ -0,0 +1,9 @@
import {useViewportSize} from '@react-aria/utils';
export function useOverlayViewport(): Record<string, string> {
const {width, height} = useViewportSize();
return {
'--be-viewport-height': `${height}px`,
'--be-viewport-width': `${width}px`,
};
}