diff --git a/openflare_server/web/components/ui/drawer.tsx b/openflare_server/web/components/ui/drawer.tsx new file mode 100644 index 00000000..9d2d7379 --- /dev/null +++ b/openflare_server/web/components/ui/drawer.tsx @@ -0,0 +1,312 @@ +'use client'; + +import { + cloneElement, + createContext, + isValidElement, + useContext, + useEffect, + useId, + useMemo, + useState, + type HTMLAttributes, + type ReactElement, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; + +import { cn } from '@/lib/utils/cn'; + +type DrawerDirection = 'top' | 'right' | 'bottom' | 'left'; + +type DrawerContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + direction: DrawerDirection; +}; + +const DrawerContext = createContext(null); + +function useDrawerContext() { + const context = useContext(DrawerContext); + + if (!context) { + throw new Error('Drawer components must be used within Drawer.'); + } + + return context; +} + +function renderWithOptionalChild( + child: ReactNode, + props: Record, + fallback: ReactNode, +) { + if (isValidElement(child)) { + return cloneElement(child as ReactElement, props); + } + + return fallback; +} + +export function Drawer({ + children, + open, + defaultOpen = false, + onOpenChange, + direction = 'bottom', + title, + description, + footer, +}: { + children: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + direction?: DrawerDirection; + title?: string; + description?: string; + footer?: ReactNode; + size?: 'md' | 'lg' | 'xl'; +}) { + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const isControlled = open !== undefined; + const resolvedOpen = isControlled ? open : internalOpen; + + const value = useMemo( + () => ({ + open: resolvedOpen, + direction, + setOpen: (nextOpen) => { + if (!isControlled) { + setInternalOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }, + }), + [direction, isControlled, onOpenChange, resolvedOpen], + ); + + return ( + + {title || description || footer ? ( + + +
+ {title ? {title} : null} + {description ? ( + {description} + ) : null} +
+ +
+
+ {children} +
+ {footer ? {footer} : null} +
+ ) : ( + children + )} +
+ ); +} + +export function DrawerTrigger({ + children, + asChild = false, +}: { + children: ReactNode; + asChild?: boolean; +}) { + const { setOpen } = useDrawerContext(); + + if (asChild) { + return renderWithOptionalChild( + children, + { + onClick: () => setOpen(true), + }, + children, + ); + } + + return ( + + ); +} + +export function DrawerClose({ + children, + asChild = false, +}: { + children: ReactNode; + asChild?: boolean; +}) { + const { setOpen } = useDrawerContext(); + + if (asChild) { + return renderWithOptionalChild( + children, + { + onClick: () => setOpen(false), + }, + children, + ); + } + + return ( + + ); +} + +export function DrawerContent({ + children, + className, + 'aria-label': ariaLabel, +}: HTMLAttributes) { + const { open, setOpen, direction } = useDrawerContext(); + const titleId = useId(); + const descriptionId = useId(); + + useEffect(() => { + if (!open) { + return; + } + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener('keydown', handleKeyDown); + }; + }, [open, setOpen]); + + if (!open || typeof document === 'undefined') { + return null; + } + + const positionClassName = + direction === 'right' + ? 'inset-y-0 right-0 h-full border-l' + : direction === 'left' + ? 'inset-y-0 left-0 h-full border-r' + : direction === 'top' + ? 'inset-x-0 top-0 border-b' + : 'inset-x-0 bottom-0 border-t'; + + return createPortal( +
+
, + document.body, + ); +} + +type DrawerMetaContextValue = { + titleId: string; + descriptionId: string; +}; + +const DrawerMetaContext = createContext(null); + +function useDrawerMetaContext() { + const context = useContext(DrawerMetaContext); + + if (!context) { + throw new Error('Drawer title and description must be used within DrawerContent.'); + } + + return context; +} + +export function DrawerHeader({ + className, + ...props +}: HTMLAttributes) { + return
; +} + +export function DrawerFooter({ + className, + ...props +}: HTMLAttributes) { + return
; +} + +export function DrawerTitle({ + className, + ...props +}: HTMLAttributes) { + const { titleId } = useDrawerMetaContext(); + + return ( +

+ ); +} + +export function DrawerDescription({ + className, + ...props +}: HTMLAttributes) { + const { descriptionId } = useDrawerMetaContext(); + + return ( +

+ ); +} diff --git a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx index f79238e8..86bbc47c 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-routes-page.tsx @@ -14,9 +14,9 @@ import { InlineMessage } from '@/components/feedback/inline-message'; import { LoadingState } from '@/components/feedback/loading-state'; import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; +import { Drawer } from '@/components/ui/drawer'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { AppModal } from '@/components/ui/app-modal'; import { StatusBadge } from '@/components/ui/status-badge'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; @@ -293,7 +293,7 @@ const HERO_SELECT_LISTBOX_CLASS_NAME = 'p-1'; const defaultValues: ProxyRouteFormValues = { managed_domain_id: '', subdomain_label: '', - origin_rows: [{ scheme: 'https', address: '', port: '443' }], + origin_rows: [{ scheme: 'http', address: '', port: '80' }], origin_uri: '', origin_host: '', enabled: true, @@ -1077,18 +1077,21 @@ function HeroTextArea( function HeroSelectField({ ariaLabel, value, + displayValue, disabled, onChange, children, }: { ariaLabel: string; value: string; + displayValue?: string; disabled?: boolean; onChange: (value: string) => void; children: React.ReactNode; }) { return (