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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user