65
common/resources/client/ui/overlays/dialog/confirmation-dialog.tsx
Executable file
65
common/resources/client/ui/overlays/dialog/confirmation-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
42
common/resources/client/ui/overlays/dialog/dialog-body.tsx
Executable file
42
common/resources/client/ui/overlays/dialog/dialog-body.tsx
Executable 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';
|
||||
}
|
||||
}
|
||||
23
common/resources/client/ui/overlays/dialog/dialog-context.ts
Executable file
23
common/resources/client/ui/overlays/dialog/dialog-context.ts
Executable 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>;
|
||||
}
|
||||
43
common/resources/client/ui/overlays/dialog/dialog-footer.tsx
Executable file
43
common/resources/client/ui/overlays/dialog/dialog-footer.tsx
Executable 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';
|
||||
}
|
||||
}
|
||||
101
common/resources/client/ui/overlays/dialog/dialog-header.tsx
Executable file
101
common/resources/client/ui/overlays/dialog/dialog-header.tsx
Executable 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';
|
||||
}
|
||||
}
|
||||
323
common/resources/client/ui/overlays/dialog/dialog-trigger.tsx
Executable file
323
common/resources/client/ui/overlays/dialog/dialog-trigger.tsx
Executable 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};
|
||||
}
|
||||
112
common/resources/client/ui/overlays/dialog/dialog.tsx
Executable file
112
common/resources/client/ui/overlays/dialog/dialog.tsx
Executable 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;
|
||||
}
|
||||
}
|
||||
25
common/resources/client/ui/overlays/dialog/dismiss-button.tsx
Executable file
25
common/resources/client/ui/overlays/dialog/dismiss-button.tsx
Executable 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
79
common/resources/client/ui/overlays/dialog/image-zoom-dialog.tsx
Executable file
79
common/resources/client/ui/overlays/dialog/image-zoom-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
104
common/resources/client/ui/overlays/floating-position.ts
Executable file
104
common/resources/client/ui/overlays/floating-position.ts
Executable 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,
|
||||
};
|
||||
}
|
||||
66
common/resources/client/ui/overlays/modal.tsx
Executable file
66
common/resources/client/ui/overlays/modal.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
25
common/resources/client/ui/overlays/overlay-props.tsx
Executable file
25
common/resources/client/ui/overlays/overlay-props.tsx
Executable 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;
|
||||
}
|
||||
8
common/resources/client/ui/overlays/popover-animation.ts
Executable file
8
common/resources/client/ui/overlays/popover-animation.ts
Executable 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},
|
||||
};
|
||||
258
common/resources/client/ui/overlays/popover.tsx
Executable file
258
common/resources/client/ui/overlays/popover.tsx
Executable 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
21
common/resources/client/ui/overlays/store/dialog-store-outlet.tsx
Executable file
21
common/resources/client/ui/overlays/store/dialog-store-outlet.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
44
common/resources/client/ui/overlays/store/dialog-store.ts
Executable file
44
common/resources/client/ui/overlays/store/dialog-store.ts
Executable 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);
|
||||
};
|
||||
50
common/resources/client/ui/overlays/tray.tsx
Executable file
50
common/resources/client/ui/overlays/tray.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
41
common/resources/client/ui/overlays/underlay.tsx
Executable file
41
common/resources/client/ui/overlays/underlay.tsx
Executable 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}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
common/resources/client/ui/overlays/use-overlay-viewport.ts
Executable file
9
common/resources/client/ui/overlays/use-overlay-viewport.ts
Executable 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`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user