ui(components): New Dialog and Modal components for ui-next (#4772)
Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
1 parent
62b4fa3e89
commit
a3b51aa8db
90 files changed
+3459
-1357
No files matched your search
@@ -190,7 +190,8 @@ const DataRow: React.FC<DataRowProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
className={`flex flex-col ${isVisible ? '' : 'opacity-60'}`}>
|
||||
className={`flex flex-col ${isVisible ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center ${
|
||||
isSelected ? 'bg-popover' : 'bg-muted'
|
||||
@@ -291,7 +292,11 @@ const DataRow: React.FC<DataRowProps> = ({
|
||||
<Icons.More className="h-6 w-6" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
// this was causing issue for auto focus on input dialog
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<>
|
||||
<DropdownMenuItem onClick={e => handleAction('Rename', e)}>
|
||||
<Icons.Rename className="text-foreground" />
|
||||
|
||||
@@ -3,8 +3,41 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Cross2Icon } from '@radix-ui/react-icons';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useDraggable } from './useDraggable';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
interface DialogContextValue {
|
||||
isDraggable?: boolean;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
shouldCloseOnOverlayClick?: boolean;
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
const DialogContext = React.createContext<DialogContextValue>({
|
||||
isDraggable: false,
|
||||
shouldCloseOnEsc: true,
|
||||
shouldCloseOnOverlayClick: true,
|
||||
});
|
||||
|
||||
interface DialogRootProps extends DialogPrimitive.DialogProps {
|
||||
isDraggable?: boolean;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
shouldCloseOnOverlayClick?: boolean;
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
const Dialog = ({
|
||||
isDraggable,
|
||||
shouldCloseOnEsc = true,
|
||||
shouldCloseOnOverlayClick = true,
|
||||
showOverlay = true,
|
||||
...props
|
||||
}: DialogRootProps) => (
|
||||
<DialogContext.Provider
|
||||
value={{ isDraggable, shouldCloseOnEsc, shouldCloseOnOverlayClick, showOverlay }}
|
||||
>
|
||||
<DialogPrimitive.Root {...props} />
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
@@ -14,12 +47,14 @@ const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-40 bg-black/60',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -27,36 +62,83 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
DialogContentProps & {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { isDraggable, shouldCloseOnEsc, shouldCloseOnOverlayClick, showOverlay } =
|
||||
React.useContext(DialogContext);
|
||||
|
||||
const { handlePointerDown, setRefs, initialTransform } = useDraggable(
|
||||
{
|
||||
enabled: isDraggable,
|
||||
},
|
||||
ref
|
||||
);
|
||||
|
||||
// When not isDraggable, Tailwind centers the dialog.
|
||||
// When isDraggable, we remove the built‑in centering so our inline transform takes over.
|
||||
const contentClassName = cn(
|
||||
'max-w-md w-full bg-muted data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid gap-4 p-4 shadow-lg duration-200 sm:rounded-lg',
|
||||
!isDraggable ? 'translate-x-[-50%] translate-y-[-50%]' : '',
|
||||
className
|
||||
);
|
||||
|
||||
const style = isDraggable ? { ...props.style, transform: initialTransform } : props.style;
|
||||
|
||||
const content = (
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
ref={setRefs}
|
||||
className={contentClassName}
|
||||
{...props}
|
||||
style={style}
|
||||
onPointerDown={isDraggable ? handlePointerDown : props.onPointerDown}
|
||||
onEscapeKeyDown={event => {
|
||||
if (!shouldCloseOnEsc) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onInteractOutside={event => {
|
||||
if (!shouldCloseOnOverlayClick) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<Cross2Icon className="text-primary h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
{showOverlay && !isDraggable && <DialogOverlay />}
|
||||
{content}
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
'drag-handle relative flex select-none flex-col space-y-1.5 text-center sm:text-left',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
@@ -69,11 +151,13 @@ DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
|
||||
className={cn('text-primary-light text-xl font-normal leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -81,11 +165,13 @@ DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
|
||||
className?: string;
|
||||
}
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-base', className)}
|
||||
className={cn('text-base', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface Offset {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
initialOffset: Offset;
|
||||
}
|
||||
|
||||
interface UseDraggableProps {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseDraggableReturn {
|
||||
offset: React.RefObject<Offset>;
|
||||
internalRef: React.RefObject<HTMLDivElement>;
|
||||
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
|
||||
setRefs: (node: HTMLDivElement) => void;
|
||||
initialTransform: string | undefined;
|
||||
}
|
||||
|
||||
export function useDraggable(
|
||||
props: UseDraggableProps,
|
||||
ref?: React.ForwardedRef<HTMLDivElement>
|
||||
): UseDraggableReturn {
|
||||
const { enabled = false } = props;
|
||||
const offsetRef = React.useRef<Offset>({ x: 0, y: 0 });
|
||||
const internalRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const dragState = React.useRef<DragState | null>(null);
|
||||
|
||||
const handlePointerMove = React.useCallback((e: PointerEvent) => {
|
||||
if (!dragState.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = e.clientX - dragState.current.startX;
|
||||
const deltaY = e.clientY - dragState.current.startY;
|
||||
const newOffset = {
|
||||
x: dragState.current.initialOffset.x + deltaX,
|
||||
y: dragState.current.initialOffset.y + deltaY,
|
||||
};
|
||||
|
||||
offsetRef.current = newOffset;
|
||||
if (internalRef.current) {
|
||||
internalRef.current.style.transform = `translate(-50%, -50%) translate(${newOffset.x}px, ${newOffset.y}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = React.useCallback(() => {
|
||||
if (internalRef.current) {
|
||||
internalRef.current.style.transition = '';
|
||||
}
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
dragState.current = null;
|
||||
}, [handlePointerMove]);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.drag-handle')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (internalRef.current) {
|
||||
internalRef.current.style.transition = 'none';
|
||||
}
|
||||
dragState.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
initialOffset: { ...offsetRef.current },
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
},
|
||||
[handlePointerMove, handlePointerUp]
|
||||
);
|
||||
|
||||
const setRefs = React.useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
internalRef.current = node;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
}
|
||||
},
|
||||
[ref]
|
||||
);
|
||||
|
||||
const initialTransform = enabled
|
||||
? `translate(-50%, -50%) translate(${offsetRef.current.x}px, ${offsetRef.current.y}px)`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
offset: offsetRef,
|
||||
internalRef,
|
||||
handlePointerDown,
|
||||
setRefs,
|
||||
initialTransform,
|
||||
};
|
||||
}
|
||||
@@ -62,6 +62,11 @@ const DropdownMenuContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
onKeyDown={e => {
|
||||
// Todo: or maybe we just want to prevent arrows?
|
||||
e.stopPropagation();
|
||||
props.onKeyDown?.(e);
|
||||
}}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground border-input z-50 min-w-[8rem] overflow-hidden rounded border p-1 shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../Button/Button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface FooterActionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ActionProps extends FooterActionProps {
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type FooterActionComponent = React.FC<FooterActionProps> & {
|
||||
Left: React.FC<FooterActionProps>;
|
||||
Right: React.FC<FooterActionProps>;
|
||||
Primary: React.FC<ActionProps>;
|
||||
Secondary: React.FC<ActionProps>;
|
||||
Auxiliary: React.FC<ActionProps>;
|
||||
};
|
||||
|
||||
export const FooterAction: FooterActionComponent = ({ children, className }: FooterActionProps) => {
|
||||
// Convert children to array for easier inspection
|
||||
const arrayChildren = React.Children.toArray(children);
|
||||
|
||||
// Check if we have a <FooterAction.Left> or <FooterAction.Right> among children
|
||||
const hasLeft = arrayChildren.some(
|
||||
(child: any) => child.type?.displayName === 'FooterAction.Left'
|
||||
);
|
||||
const hasRight = arrayChildren.some(
|
||||
(child: any) => child.type?.displayName === 'FooterAction.Right'
|
||||
);
|
||||
|
||||
// Decide on the justification class based on presence of Left/Right
|
||||
let justifyClass = 'justify-between'; // default
|
||||
if (hasLeft && !hasRight) {
|
||||
justifyClass = 'justify-start';
|
||||
} else if (!hasLeft && hasRight) {
|
||||
justifyClass = 'justify-end';
|
||||
}
|
||||
// If both or neither are present, keep justify-between (or adjust if you like)
|
||||
return (
|
||||
<div className={cn('flex w-full flex-shrink-0 items-center', justifyClass, className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FooterAction.displayName = 'FooterAction';
|
||||
|
||||
FooterAction.Left = ({ children }: FooterActionProps) => {
|
||||
return <div className="flex items-center">{children}</div>;
|
||||
};
|
||||
FooterAction.Left.displayName = 'FooterAction.Left';
|
||||
|
||||
FooterAction.Right = ({ children }: FooterActionProps) => {
|
||||
return <div className="flex items-center space-x-2">{children}</div>;
|
||||
};
|
||||
FooterAction.Right.displayName = 'FooterAction.Right';
|
||||
|
||||
// Primary action: Solid button (default)
|
||||
FooterAction.Primary = ({ children, onClick, className = 'min-w-[80px]' }: ActionProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
FooterAction.Primary.displayName = 'FooterAction.Primary';
|
||||
|
||||
// Secondary action: Ghost button
|
||||
FooterAction.Secondary = ({ children, onClick, className = 'min-w-[80px]' }: ActionProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
FooterAction.Secondary.displayName = 'FooterAction.Secondary';
|
||||
|
||||
// Tertiary action: Ghost button with different styling
|
||||
FooterAction.Auxiliary = ({ children, onClick, className }: ActionProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
FooterAction.Auxiliary.displayName = 'FooterAction.Auxiliary';
|
||||
@@ -0,0 +1,3 @@
|
||||
import { FooterAction } from './FooterAction';
|
||||
|
||||
export { FooterAction };
|
||||
@@ -46,6 +46,7 @@ import Show from './Sources/Show';
|
||||
import SidePanelCloseLeft from './Sources/SidePanelCloseLeft';
|
||||
import SidePanelCloseRight from './Sources/SidePanelCloseRight';
|
||||
import SortingAscending from './Sources/SortingAscending';
|
||||
import SocialGithub from './Sources/SocialGithub';
|
||||
import SortingDescending from './Sources/SortingDescending';
|
||||
import StatusError from './Sources/StatusError';
|
||||
import StatusSuccess from './Sources/StatusSuccess';
|
||||
@@ -471,6 +472,7 @@ export const Icons = {
|
||||
Show,
|
||||
SidePanelCloseLeft,
|
||||
SidePanelCloseRight,
|
||||
SocialGithub,
|
||||
SortingAscending,
|
||||
SortingDescending,
|
||||
Sorting,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import type { IconProps } from '../types';
|
||||
|
||||
export const SocialGithub = (props: IconProps) => (
|
||||
<svg
|
||||
width="24px"
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
version="1.1"
|
||||
>
|
||||
<g
|
||||
id="SocialGithub"
|
||||
stroke="none"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
>
|
||||
<rect
|
||||
id="Rectangle"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
></rect>
|
||||
<g
|
||||
id="github-mark"
|
||||
transform="translate(1, 1)"
|
||||
fill="#FFFFFF"
|
||||
>
|
||||
<path
|
||||
d="M11.009102,0 C4.92135296,0 0,5.04164559 0,11.2788487 C0,16.2645778 3.15328046,20.4848935 7.52771627,21.9785956 C8.07463342,22.0908868 8.2749669,21.7359091 8.2749669,21.4373062 C8.2749669,21.1758281 8.25693914,20.279561 8.25693914,19.3457108 C5.19447352,20.018083 4.55674153,18.0011956 4.55674153,18.0011956 C4.0645837,16.6940344 3.33536083,16.3580774 3.33536083,16.3580774 C2.33301741,15.6671428 3.40837326,15.6671428 3.40837326,15.6671428 C4.52023532,15.7418508 5.10365868,16.824888 5.10365868,16.824888 C6.08774899,18.5427141 7.67351578,18.0573412 8.31147311,17.7585091 C8.4025133,17.0302205 8.69433765,16.5260559 9.00418977,16.2460154 C6.56165372,15.9845373 3.99179662,15.0135622 3.99179662,10.6811845 C3.99179662,9.44873132 4.42896978,8.44040221 5.12168644,7.65619715 C5.01239315,7.37615666 4.62952861,6.21818233 5.23120508,4.66833465 C5.23120508,4.66833465 6.16076142,4.36950256 8.25671379,5.8260798 C9.15406554,5.57919102 10.0794899,5.45359714 11.009102,5.4525397 C11.9386583,5.4525397 12.8862424,5.58339332 13.7612648,5.8260798 C15.8574425,4.36950256 16.7869988,4.66833465 16.7869988,4.66833465 C17.3886753,6.21818233 17.0055854,7.37615666 16.8962921,7.65619715 C17.6072619,8.44040221 18.0264073,9.44873132 18.0264073,10.6811845 C18.0264073,15.0135622 15.4565502,15.9657457 12.995761,16.2460154 C13.3968787,16.6007639 13.7430117,17.2729069 13.7430117,18.3373817 C13.7430117,19.8498753 13.7249839,21.0637661 13.7249839,21.437077 C13.7249839,21.7359091 13.9255427,22.0908868 14.4722345,21.9788248 C18.8466703,20.4846643 22,16.2645778 22,11.2788487 C22.0179786,5.04164559 17.0785978,0 11.009102,0 Z"
|
||||
id="Path"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SocialGithub;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../Dialog';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
contentClassName?: string;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
shouldCloseOnOverlayClick?: boolean;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
shouldCloseOnEsc = true,
|
||||
shouldCloseOnOverlayClick = true,
|
||||
containerClassName,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={open => !open && onClose()}
|
||||
shouldCloseOnEsc={shouldCloseOnEsc}
|
||||
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
|
||||
>
|
||||
<DialogContent className={containerClassName}>
|
||||
{title && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
<div className={cn('mt-2')}>{children}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { createContext, useContext, useRef, useEffect } from 'react';
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
||||
import { Label, Input as InputComponent, FooterAction } from '../../components';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface InputDialogContextValue {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
submitOnEnter?: boolean;
|
||||
}
|
||||
|
||||
const InputDialogContext = createContext<InputDialogContextValue | null>(null);
|
||||
|
||||
export type InputDialogRootProps = {
|
||||
/** The controlled value of the input */
|
||||
value?: string;
|
||||
/** The default value for uncontrolled usage */
|
||||
defaultValue?: string;
|
||||
/** Callback when input value changes */
|
||||
onChange?: (value: string) => void;
|
||||
/** Optional className for the root container */
|
||||
className?: string;
|
||||
/** Enable save on Enter key press */
|
||||
submitOnEnter?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const InputDialogRoot = React.forwardRef<HTMLDivElement, InputDialogRootProps>(
|
||||
({ value, defaultValue = '', onChange, className, submitOnEnter, children }, ref) => {
|
||||
const [internalValue, setInternalValue] = useControllableState({
|
||||
prop: value,
|
||||
defaultProp: defaultValue,
|
||||
onChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<InputDialogContext.Provider
|
||||
value={{
|
||||
value: internalValue,
|
||||
setValue: setInternalValue,
|
||||
submitOnEnter,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</InputDialogContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputDialogRoot.displayName = 'InputDialog';
|
||||
|
||||
export interface InputDialogFieldProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Optional className for the field container */
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Field = React.forwardRef<HTMLDivElement, InputDialogFieldProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('mb-4 flex flex-col space-y-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Field.displayName = 'InputDialog.Field';
|
||||
|
||||
export interface InputDialogInputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||
/** ID for the input field */
|
||||
id?: string;
|
||||
/** Optional className for the input container */
|
||||
className?: string;
|
||||
/** Save handler */
|
||||
onSave?: (value: string) => void;
|
||||
/** Placeholder text for the input field */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const InputDialogInput = React.forwardRef<HTMLInputElement, InputDialogInputProps>(
|
||||
({ id = 'dialog-input', className, onSave, ...props }, ref) => {
|
||||
const context = useContext(InputDialogContext);
|
||||
if (!context) {
|
||||
throw new Error('InputDialog.Input must be used within an InputDialog');
|
||||
}
|
||||
|
||||
const { value, setValue } = context;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Combine the forwarded ref with our local ref
|
||||
React.useImperativeHandle(ref, () => inputRef.current);
|
||||
|
||||
// Focus the input when it mounts
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (context.submitOnEnter && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const saveButton = document.querySelector(
|
||||
'[data-cy="input-dialog-save-button"]'
|
||||
) as HTMLButtonElement;
|
||||
if (saveButton) {
|
||||
saveButton.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<InputComponent
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputDialogInput.displayName = 'InputDialog.Input';
|
||||
|
||||
export interface InputDialogLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
/** Optional className for the label */
|
||||
className?: string;
|
||||
/** For attribute to match input ID */
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const InputDialogLabel = React.forwardRef<HTMLLabelElement, InputDialogLabelProps>(
|
||||
({ className, htmlFor = 'dialog-input', children, ...props }, ref) => {
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
htmlFor={htmlFor}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputDialogLabel.displayName = 'InputDialog.Label';
|
||||
|
||||
export interface InputDialogActionsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Optional className for the actions container */
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Actions = React.forwardRef<HTMLDivElement, InputDialogActionsProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<FooterAction className={cn(className)}>
|
||||
<FooterAction.Right>{children}</FooterAction.Right>
|
||||
</FooterAction>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Actions.displayName = 'InputDialog.Actions';
|
||||
|
||||
export interface InputDialogActionButtonProps {
|
||||
/** Optional className for the button */
|
||||
className?: string;
|
||||
/** Click handler that receives the current input value */
|
||||
onClick: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ActionsSecondary = React.forwardRef<HTMLDivElement, InputDialogActionButtonProps>(
|
||||
({ className, onClick, children, ...props }, ref) => {
|
||||
const context = useContext(InputDialogContext);
|
||||
if (!context) {
|
||||
throw new Error('InputDialog.ActionsSecondary must be used within an InputDialog');
|
||||
}
|
||||
|
||||
const { value } = context;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<FooterAction.Secondary
|
||||
onClick={() => onClick(value)}
|
||||
className={cn(className)}
|
||||
>
|
||||
{children}
|
||||
</FooterAction.Secondary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ActionsSecondary.displayName = 'InputDialog.ActionsSecondary';
|
||||
|
||||
const ActionsPrimary = React.forwardRef<HTMLDivElement, InputDialogActionButtonProps>(
|
||||
({ className, onClick, children, ...props }, ref) => {
|
||||
const context = useContext(InputDialogContext);
|
||||
if (!context) {
|
||||
throw new Error('InputDialog.ActionsPrimary must be used within an InputDialog');
|
||||
}
|
||||
|
||||
const { value } = context;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
data-cy="input-dialog-save-button"
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
<FooterAction.Primary
|
||||
onClick={() => onClick(value)}
|
||||
className={cn(className)}
|
||||
>
|
||||
{children}
|
||||
</FooterAction.Primary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ActionsPrimary.displayName = 'InputDialog.ActionsPrimary';
|
||||
|
||||
export const InputDialog = Object.assign(InputDialogRoot, {
|
||||
Label: InputDialogLabel,
|
||||
Input: InputDialogInput,
|
||||
Field,
|
||||
Actions,
|
||||
ActionsSecondary,
|
||||
ActionsPrimary,
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as React from 'react';
|
||||
import { Input } from '../Input';
|
||||
import { ScrollArea } from '../ScrollArea';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface PresetDialogProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PresetDialog({ children, className }: PresetDialogProps) {
|
||||
return <div className={cn('flex h-[500px] max-w-lg flex-col', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetBody
|
||||
* A dark "box" container that wraps the filter and grid area.
|
||||
* Adjust bg color, padding, border, etc. to match your design.
|
||||
*/
|
||||
interface PresetBodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function PresetBody({ children, className }: PresetBodyProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Adjust these classes for your desired look
|
||||
'flex min-h-0 flex-1 flex-col rounded-md border border-white/10 bg-black p-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetFilter
|
||||
*/
|
||||
interface PresetFilterProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function PresetFilter({ children, className }: PresetFilterProps) {
|
||||
return (
|
||||
<div className={cn('mb-2 flex w-full flex-shrink-0 items-center space-x-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetSearch
|
||||
*/
|
||||
interface PresetSearchProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
className?: string;
|
||||
}
|
||||
function PresetSearch({ className, ...props }: PresetSearchProps) {
|
||||
return (
|
||||
<Input
|
||||
className={cn('w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetGrid
|
||||
*/
|
||||
interface PresetGridProps {
|
||||
children: React.ReactNode;
|
||||
maxHeight?: string;
|
||||
className?: string;
|
||||
}
|
||||
function PresetGrid({ children, maxHeight = 'flex-1', className }: PresetGridProps) {
|
||||
return (
|
||||
<ScrollArea className={cn('min-h-0 flex-1', className)}>
|
||||
<div className="grid grid-cols-4 gap-2 pr-2">{children}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subcomponent: PresetOption
|
||||
*/
|
||||
interface PresetOptionProps {
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
function PresetOption({ label = 'Label', className }: PresetOptionProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-start space-y-1', className)}>
|
||||
{/* Default dark placeholder box (swap in an <img> if you like) */}
|
||||
<div className="bg-popover h-16 w-24 rounded" />
|
||||
<div className="text-muted-foreground text-left text-base">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Attach subcomponents to PresetDialog as static properties */
|
||||
PresetDialog.PresetBody = PresetBody;
|
||||
PresetDialog.PresetFilter = PresetFilter;
|
||||
PresetDialog.PresetSearch = PresetSearch;
|
||||
PresetDialog.PresetGrid = PresetGrid;
|
||||
PresetDialog.PresetOption = PresetOption;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { InputDialog } from './InputDialog';
|
||||
export { PresetDialog } from './PresetDialog';
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../Button';
|
||||
import { Icons } from '../Icons';
|
||||
|
||||
interface AboutModalProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AboutModal({ children, className }: AboutModalProps) {
|
||||
return <div className={cn('space-y-1 text-center', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/** Sub component: Product Name */
|
||||
interface ProductNameProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ProductName({ children, className }: ProductNameProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground pt-3 text-2xl font-medium leading-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Product Version */
|
||||
interface ProductVersionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ProductVersion({ children, className }: ProductVersionProps) {
|
||||
return (
|
||||
<div className={cn('text-muted-foreground text-2xl font-light leading-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Product Beta */
|
||||
interface ProductBetaProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ProductBeta({ children, className }: ProductBetaProps) {
|
||||
return (
|
||||
<div className={cn('text-muted-foreground text-xl font-light', className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Body (wraps all detail items) */
|
||||
interface BodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function Body({ children, className }: BodyProps) {
|
||||
return (
|
||||
<div className={cn('my-3 flex flex-col items-center space-y-0', className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Detail Item */
|
||||
interface DetailItemProps {
|
||||
label: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
}
|
||||
function DetailItem({ label, value, className }: DetailItemProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
<div className="text-muted-foreground pt-2 text-sm font-semibold tracking-wide">{label}</div>
|
||||
<div className="text-muted-foreground text-sm">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sub-component: Social Item */
|
||||
interface SocialItemProps {
|
||||
icon: string;
|
||||
url: string;
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
function SocialItem({ icon, url, text, className }: SocialItemProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground flex items-center', className)}>
|
||||
<div className="inline-block">
|
||||
<Icons.ByName name={icon} />
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
variant="link"
|
||||
className="py-6 text-lg"
|
||||
>
|
||||
<a
|
||||
href={`https://github.com/${url}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Attach sub-components to AboutModal as static properties */
|
||||
AboutModal.ProductName = ProductName;
|
||||
AboutModal.ProductVersion = ProductVersion;
|
||||
AboutModal.ProductBeta = ProductBeta;
|
||||
AboutModal.Body = Body;
|
||||
AboutModal.DetailItem = DetailItem;
|
||||
AboutModal.SocialItem = SocialItem;
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as React from 'react';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../Select/Select';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface ImageModalProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main ImageModal container. By default, we do not force any
|
||||
* layout here. We'll use a "Body" subcomponent for the main area
|
||||
* that sets up a flex row with a 70/30 split.
|
||||
*/
|
||||
export function ImageModal({ children, className }: ImageModalProps) {
|
||||
return <div className={cn(className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Body subcomponent */
|
||||
|
||||
interface ImageBodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ImageBody({ children, className }: ImageBodyProps) {
|
||||
return <div className={cn('flex flex-col sm:flex-row', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ImageVisual subcomponent */
|
||||
|
||||
interface ImageVisualProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
function ImageVisual({ children, className }: ImageVisualProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 items-center justify-center rounded-2xl bg-black/80 p-4 sm:flex-[7]',
|
||||
'flex', // ensure the container is a flex box
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="h-[512px] w-[512px] overflow-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ImageOptions subcomponent */
|
||||
|
||||
interface ImageOptionsProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function ImageOptions({ children, className }: ImageOptionsProps) {
|
||||
return <div className={cn('flex-1 space-y-5 p-4 sm:flex-[3]', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Filename subcomponent */
|
||||
|
||||
interface FilenameProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'children'> {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** Handler is optional. If not provided, default to a no‐op. */
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
function Filename({ children, className, value, onChange, ...props }: FilenameProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground space-y-1', className)}>
|
||||
<label className="block text-base">{children}</label>
|
||||
<Input
|
||||
{...props}
|
||||
className={cn('w-full', className)}
|
||||
value={value}
|
||||
onChange={onChange ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Filetype subcomponent */
|
||||
|
||||
interface FiletypeProps {
|
||||
selected: string;
|
||||
/** Handler is optional. If not provided, we do nothing. */
|
||||
onSelect?: (val: string) => void;
|
||||
className?: string;
|
||||
/** Array of file type options */
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
function Filetype({ selected, onSelect, className, options = [] }: FiletypeProps) {
|
||||
const defaultOptions = [
|
||||
{ value: 'jpg', label: 'JPG' },
|
||||
{ value: 'png', label: 'PNG' },
|
||||
];
|
||||
|
||||
const fileTypeOptions = options.length ? options : defaultOptions;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selected}
|
||||
onValueChange={val => onSelect?.(val)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="File type"
|
||||
className={cn('w-[5.5rem] sm:w-24', className)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fileTypeOptions.map(option => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ImageSize subcomponent */
|
||||
|
||||
interface ImageSizeProps {
|
||||
children: React.ReactNode;
|
||||
width: string;
|
||||
height: string;
|
||||
/** Handlers optional. If not provided, default no‐op. */
|
||||
onWidthChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
onHeightChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
className?: string;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
function ImageSize({
|
||||
children,
|
||||
width,
|
||||
height,
|
||||
onWidthChange,
|
||||
onHeightChange,
|
||||
className,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
}: ImageSizeProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground space-y-1', className)}>
|
||||
<label className="block text-base">{children}</label>
|
||||
|
||||
{/* Flex container for width/height inputs */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Width group */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-foreground text-base">W</span>
|
||||
<Input
|
||||
value={width}
|
||||
onChange={onWidthChange ?? (() => {})}
|
||||
placeholder="Width"
|
||||
className="w-20"
|
||||
max={maxWidth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Height group */}
|
||||
<div className="text-foreground flex items-center space-x-2 text-base">
|
||||
<span className="text-foreground text-base">H</span>
|
||||
<Input
|
||||
value={height}
|
||||
onChange={onHeightChange ?? (() => {})}
|
||||
placeholder="Height"
|
||||
className="w-20"
|
||||
max={maxHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SwitchOption subcomponent */
|
||||
|
||||
interface SwitchOptionProps {
|
||||
children: React.ReactNode;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SwitchOption({
|
||||
children,
|
||||
checked,
|
||||
defaultChecked,
|
||||
onCheckedChange,
|
||||
className,
|
||||
}: SwitchOptionProps) {
|
||||
return (
|
||||
<div className={cn('text-foreground flex items-center space-x-2', className)}>
|
||||
<Switch
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={val => onCheckedChange?.(val)}
|
||||
/>
|
||||
<span className="text-base">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Attach subcomponents onto the main ImageModal function. */
|
||||
|
||||
ImageModal.Body = ImageBody;
|
||||
ImageModal.ImageVisual = ImageVisual;
|
||||
ImageModal.ImageOptions = ImageOptions;
|
||||
ImageModal.Filename = Filename;
|
||||
ImageModal.Filetype = Filetype;
|
||||
ImageModal.ImageSize = ImageSize;
|
||||
ImageModal.SwitchOption = SwitchOption;
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react';
|
||||
import { Label } from '../Label';
|
||||
import { Input } from '../Input';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface UserPreferencesModalProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserPreferencesModal({ children, className }: UserPreferencesModalProps) {
|
||||
return (
|
||||
<div className={cn('flex max-h-[80vh] w-full max-w-4xl flex-col overflow-hidden', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Body
|
||||
* Automatically wraps content in a scrollable area.
|
||||
*/
|
||||
interface BodyProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function Body({ children, className }: BodyProps) {
|
||||
return (
|
||||
<div className={cn('flex-1 overflow-y-auto', className)}>
|
||||
<div className={cn('mt-1 mb-4 flex flex-col space-y-4', className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Subheading
|
||||
* Section labels
|
||||
*/
|
||||
interface SubHeadingProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function SubHeading({ children, className }: SubHeadingProps) {
|
||||
return <span className={cn('text-muted-foreground text-lg', className)}>{children}</span>;
|
||||
}
|
||||
|
||||
/** Responsive 3-column grid for hotkeys, etc. */
|
||||
interface HotkeysGridProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
function HotkeysGrid({ children, className }: HotkeysGridProps) {
|
||||
return (
|
||||
<div className={cn('grid grid-cols-1 gap-3 gap-x-16 md:grid-cols-2 lg:grid-cols-3', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single hotkey row: label + input */
|
||||
interface HotkeyProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
hotkeys?: {
|
||||
record: (callback: (sequence: string[]) => void) => void;
|
||||
pause: () => void;
|
||||
unpause: () => void;
|
||||
startRecording: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
function Hotkey({ label, placeholder, className, value, onChange, hotkeys }: HotkeyProps) {
|
||||
const [isRecording, setIsRecording] = React.useState(false);
|
||||
|
||||
const onInputKeyDown = (event: React.KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
hotkeys?.record((sequence: string[]) => {
|
||||
const keys = sequence.join('+');
|
||||
hotkeys?.unpause();
|
||||
setIsRecording(false);
|
||||
onChange?.(keys);
|
||||
});
|
||||
};
|
||||
|
||||
const onFocus = () => {
|
||||
setIsRecording(true);
|
||||
hotkeys?.pause();
|
||||
hotkeys?.startRecording();
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
setIsRecording(false);
|
||||
hotkeys?.unpause();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between space-x-2', className)}>
|
||||
<Label className="whitespace-nowrap">{label}</Label>
|
||||
<Input
|
||||
className={cn(
|
||||
'w-16 text-center transition-colors',
|
||||
isRecording && 'bg-accent text-accent-foreground caret-accent-foreground'
|
||||
)}
|
||||
placeholder={isRecording ? 'Press keys...' : placeholder}
|
||||
value={value}
|
||||
onKeyDown={onInputKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
readOnly={!isRecording}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Attach subcomponents as static properties for a nicer API */
|
||||
UserPreferencesModal.Body = Body;
|
||||
UserPreferencesModal.HotkeysGrid = HotkeysGrid;
|
||||
UserPreferencesModal.Hotkey = Hotkey;
|
||||
UserPreferencesModal.SubHeading = SubHeading;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { UserPreferencesModal } from './UserPreferencesModal';
|
||||
export { ImageModal } from './ImageModal';
|
||||
export { AboutModal } from './AboutModal';
|
||||
@@ -54,6 +54,10 @@ import { DisplaySetMessageListTooltip } from './DisplaySetMessageListTooltip';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tooltip';
|
||||
import { ToolboxUI, Toolbox } from './OHIFToolbox';
|
||||
import Numeric from './Numeric';
|
||||
import { InputDialog, PresetDialog } from './OHIFDialogs';
|
||||
import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals';
|
||||
import Modal from './Modal/Modal';
|
||||
import { FooterAction } from './FooterAction';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -225,4 +229,11 @@ export {
|
||||
ToolButtonListDropDown,
|
||||
ToolButtonListItem,
|
||||
ToolButtonListDivider,
|
||||
InputDialog,
|
||||
PresetDialog,
|
||||
Modal,
|
||||
AboutModal,
|
||||
ImageModal,
|
||||
UserPreferencesModal,
|
||||
FooterAction,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState, createContext, useContext, useCallback, useEffect, useMemo } from 'react';
|
||||
import ManagedDialog, { ManagedDialogProps } from './ManagedDialog';
|
||||
|
||||
interface DialogContextType {
|
||||
show: (options: ManagedDialogProps) => string;
|
||||
hide: (id: string) => void;
|
||||
hideAll: () => void;
|
||||
isEmpty: () => boolean;
|
||||
}
|
||||
|
||||
interface DialogService {
|
||||
setServiceImplementation: (implementation: DialogContextType) => void;
|
||||
getCustomComponent?: () => React.ComponentType<ManagedDialogProps> | null;
|
||||
}
|
||||
|
||||
interface DialogProviderProps {
|
||||
children: React.ReactNode;
|
||||
dialog?: React.ComponentType<ManagedDialogProps>;
|
||||
service?: DialogService | null;
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextType | null>(null);
|
||||
|
||||
export const useDialog = () => {
|
||||
const context = useContext(DialogContext);
|
||||
if (!context) {
|
||||
throw new Error('useDialog must be used within a DialogProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const DialogProvider: React.FC<DialogProviderProps> = ({
|
||||
children,
|
||||
dialog: DialogComponent = ManagedDialog,
|
||||
service = null,
|
||||
}) => {
|
||||
const [dialogs, setDialogs] = useState<(ManagedDialogProps & { id: string })[]>([]);
|
||||
|
||||
const show = useCallback((options: ManagedDialogProps) => {
|
||||
const id = options.id;
|
||||
setDialogs(prev => [...prev, { ...options, id }]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const hide = useCallback((id: string) => {
|
||||
setDialogs(prev => prev.filter(dialog => dialog.id !== id));
|
||||
}, []);
|
||||
|
||||
const hideAll = useCallback(() => {
|
||||
setDialogs([]);
|
||||
}, []);
|
||||
|
||||
const isEmpty = useCallback(() => dialogs.length === 0, [dialogs]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
show,
|
||||
hide,
|
||||
hideAll,
|
||||
isEmpty,
|
||||
}),
|
||||
[show, hide, hideAll, isEmpty]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (service) {
|
||||
service.setServiceImplementation(contextValue);
|
||||
}
|
||||
}, [service, contextValue]);
|
||||
|
||||
const CustomDialog = service?.getCustomComponent?.();
|
||||
const RenderedDialog = CustomDialog || DialogComponent;
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={contextValue}>
|
||||
{dialogs.map(dialog => (
|
||||
<RenderedDialog
|
||||
key={dialog.id}
|
||||
onClose={hide}
|
||||
isOpen={true}
|
||||
{...dialog}
|
||||
/>
|
||||
))}
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { DialogProvider };
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../components/Dialog/Dialog';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export interface ManagedDialogProps {
|
||||
id: string;
|
||||
isOpen?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
content: React.ComponentType<{ hide?: () => void }>;
|
||||
contentProps?: Record<string, unknown>;
|
||||
isDraggable?: boolean;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
shouldCloseOnOverlayClick?: boolean;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
onClose?: (id: string) => void;
|
||||
unstyled?: boolean;
|
||||
showOverlay?: boolean;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
const ManagedDialog: React.FC<ManagedDialogProps> = ({
|
||||
id,
|
||||
isOpen,
|
||||
title,
|
||||
content: DialogContentComponent,
|
||||
contentProps,
|
||||
isDraggable,
|
||||
shouldCloseOnEsc = false,
|
||||
shouldCloseOnOverlayClick = false,
|
||||
showOverlay = true,
|
||||
defaultPosition,
|
||||
onClose,
|
||||
unstyled,
|
||||
containerClassName,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
modal={false} // keep modal behavior off for independent windows
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
onClose(id);
|
||||
}
|
||||
}}
|
||||
isDraggable={isDraggable}
|
||||
shouldCloseOnEsc={shouldCloseOnEsc}
|
||||
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
|
||||
showOverlay={showOverlay}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(unstyled ? 'p-0' : '', containerClassName)}
|
||||
style={{
|
||||
...(defaultPosition
|
||||
? {
|
||||
position: 'fixed',
|
||||
left: `${defaultPosition.x}px`,
|
||||
top: `${defaultPosition.y}px`,
|
||||
transform: 'translate(0, 0)',
|
||||
margin: 0,
|
||||
animation: 'none',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{!unstyled && <DialogHeader>{title && <DialogTitle>{title}</DialogTitle>}</DialogHeader>}
|
||||
<DialogContentComponent
|
||||
{...contentProps}
|
||||
hide={() => onClose(id)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagedDialog;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState, createContext, useContext, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ModalOptions {
|
||||
title?: string;
|
||||
shouldCloseOnEsc?: boolean;
|
||||
content?: React.ComponentType;
|
||||
contentProps?: Record<string, unknown>;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
interface ModalContextType {
|
||||
show: (options: Partial<ModalOptions>) => void;
|
||||
hide: () => void;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
interface ModalComponentProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalContext = createContext<ModalContextType | null>(null);
|
||||
|
||||
export const useModal = () => {
|
||||
const ctx = useContext(ModalContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useModal must be used within a ModalProvider');
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: ModalOptions = {
|
||||
title: '',
|
||||
shouldCloseOnEsc: true,
|
||||
};
|
||||
|
||||
interface ModalService {
|
||||
setServiceImplementation: (implementation: ModalContextType) => void;
|
||||
getCustomComponent: () => React.ComponentType<ModalComponentProps> | null;
|
||||
}
|
||||
|
||||
interface ModalProviderProps {
|
||||
children: React.ReactNode;
|
||||
modal: React.ComponentType<ModalComponentProps>;
|
||||
service?: ModalService | null;
|
||||
}
|
||||
|
||||
const ModalProvider: React.FC<ModalProviderProps> = ({
|
||||
children,
|
||||
modal: ModalComponent,
|
||||
service = null,
|
||||
}) => {
|
||||
const { t } = useTranslation('Modals');
|
||||
const [options, setOptions] = useState<ModalOptions>(DEFAULT_OPTIONS);
|
||||
|
||||
const ModalContent = options.content;
|
||||
|
||||
const show = useCallback((props: Partial<ModalOptions>) => {
|
||||
setOptions(prev => ({ ...prev, ...props }));
|
||||
}, []);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
setOptions(DEFAULT_OPTIONS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (service) {
|
||||
service.setServiceImplementation({ show, hide });
|
||||
}
|
||||
}, [hide, service, show]);
|
||||
|
||||
const { title } = options;
|
||||
|
||||
const CustomModal = service?.getCustomComponent();
|
||||
const RenderedModal = CustomModal || ModalComponent;
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ show, hide }}>
|
||||
{ModalContent && (
|
||||
<RenderedModal
|
||||
isOpen={true}
|
||||
onClose={hide}
|
||||
title={t(title)}
|
||||
{...options}
|
||||
>
|
||||
<ModalContent
|
||||
{...options.contentProps}
|
||||
show={show}
|
||||
hide={hide}
|
||||
/>
|
||||
</RenderedModal>
|
||||
)}
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export { ModalProvider };
|
||||
export const ModalConsumer = ModalContext.Consumer;
|
||||
@@ -1,7 +1,13 @@
|
||||
import NotificationProvider, { useNotification } from './NotificationProvider';
|
||||
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './ViewportGridProvider';
|
||||
import { ToolboxProvider, useToolbox } from './ToolboxContext';
|
||||
import { ModalProvider, useModal } from './ModalProvider';
|
||||
import { DialogProvider, useDialog } from './DialogProvider';
|
||||
import ManagedDialog from './ManagedDialog';
|
||||
|
||||
export { useNotification, NotificationProvider };
|
||||
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
|
||||
export { ToolboxProvider, useToolbox };
|
||||
export { ModalProvider, useModal };
|
||||
export { DialogProvider, useDialog };
|
||||
export { ManagedDialog };
|
||||
@@ -100,6 +100,13 @@ import {
|
||||
ToolButtonListDivider,
|
||||
Toolbox,
|
||||
Numeric,
|
||||
InputDialog,
|
||||
PresetDialog,
|
||||
Modal,
|
||||
AboutModal,
|
||||
ImageModal,
|
||||
UserPreferencesModal,
|
||||
FooterAction,
|
||||
} from './components';
|
||||
import { DataRow } from './components/DataRow';
|
||||
|
||||
@@ -108,6 +115,11 @@ import {
|
||||
NotificationProvider,
|
||||
useToolbox,
|
||||
ToolboxProvider,
|
||||
useModal,
|
||||
ModalProvider,
|
||||
DialogProvider,
|
||||
useDialog,
|
||||
ManagedDialog,
|
||||
} from './contextProviders';
|
||||
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './contextProviders';
|
||||
import * as utils from './utils';
|
||||
@@ -225,4 +237,16 @@ export {
|
||||
useToolbox,
|
||||
utils,
|
||||
Numeric,
|
||||
AboutModal,
|
||||
ImageModal,
|
||||
UserPreferencesModal,
|
||||
InputDialog,
|
||||
PresetDialog,
|
||||
Modal,
|
||||
useModal,
|
||||
ModalProvider,
|
||||
FooterAction,
|
||||
DialogProvider,
|
||||
useDialog,
|
||||
ManagedDialog,
|
||||
};
|
||||
Reference in new issue
Block a user