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>
);
}