| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react"; |
| 3 | import { useT } from "../lib/i18n"; |
| 4 | import { loadLayoutSize, saveLayoutSize, type LayoutSizeKey } from "../lib/layoutPreferences"; |
| 5 | import { createRafResizeUpdater } from "../lib/resizeDrag"; |
| 6 | import { useDeferredClose } from "../lib/useMountTransition"; |
| 7 | |
| 8 | const DRAWER_DEFAULT_WIDTH = 440; |
| 9 | const DRAWER_MIN_WIDTH = 360; |
| 10 | const DRAWER_MAX_WIDTH = 760; |
| 11 | const DRAWER_MAX_RATIO = 0.62; |
| 12 | const SETTINGS_DRAWER_DEFAULT_WIDTH = 720; |
| 13 | const SETTINGS_DRAWER_MIN_WIDTH = 620; |
| 14 | const SETTINGS_DRAWER_MAX_WIDTH = 1120; |
| 15 | const SETTINGS_DRAWER_MAX_RATIO = 0.82; |
| 16 | |
| 17 | function drawerConfig(wide: boolean) { |
| 18 | return wide |
| 19 | ? { |
| 20 | key: "settingsDrawerWidth" as LayoutSizeKey, |
| 21 | defaultWidth: SETTINGS_DRAWER_DEFAULT_WIDTH, |
| 22 | minWidth: SETTINGS_DRAWER_MIN_WIDTH, |
| 23 | maxWidth: SETTINGS_DRAWER_MAX_WIDTH, |
| 24 | maxRatio: SETTINGS_DRAWER_MAX_RATIO, |
| 25 | } |
| 26 | : { |
| 27 | key: "drawerWidth" as LayoutSizeKey, |
| 28 | defaultWidth: DRAWER_DEFAULT_WIDTH, |
| 29 | minWidth: DRAWER_MIN_WIDTH, |
| 30 | maxWidth: DRAWER_MAX_WIDTH, |
| 31 | maxRatio: DRAWER_MAX_RATIO, |
| 32 | }; |
| 33 | } |
| 34 | |
| 35 | function clampDrawerWidth(width: number, wide: boolean, viewportWidth = 1440): number { |
| 36 | const config = drawerConfig(wide); |
| 37 | const maxByViewport = Math.floor(viewportWidth * config.maxRatio); |
| 38 | const max = Math.max(config.minWidth, Math.min(config.maxWidth, maxByViewport)); |
| 39 | return Math.min(max, Math.max(config.minWidth, Math.round(width))); |
| 40 | } |
| 41 | |
| 42 | export function ResizableDrawer({ |
| 43 | children, |
| 44 | onClose, |
| 45 | subtle = false, |
| 46 | wide = false, |
| 47 | }: { |
| 48 | children: ReactNode; |
| 49 | onClose: () => void; |
| 50 | subtle?: boolean; |
| 51 | wide?: boolean; |
| 52 | }) { |
| 53 | const t = useT(); |
| 54 | const config = drawerConfig(wide); |
| 55 | const drawerRef = useRef<HTMLElement>(null); |
| 56 | const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1440 : window.innerWidth)); |
| 57 | const [width, setWidth] = useState(() => |
| 58 | loadLayoutSize(config.key, config.defaultWidth, (value) => clampDrawerWidth(value, wide)), |
| 59 | ); |
| 60 | const [resizing, setResizing] = useState(false); |
| 61 | // Slide the drawer back out before the parent unmounts it. |
| 62 | const { status, requestClose } = useDeferredClose(onClose, 240); |
| 63 | const effectiveWidth = useMemo(() => clampDrawerWidth(width, wide, viewportWidth), [viewportWidth, wide, width]); |
| 64 | const style = useMemo(() => ({ "--drawer-width": `${effectiveWidth}px` }) as CSSProperties, [effectiveWidth]); |
| 65 | |
| 66 | useEffect(() => { |
| 67 | const onResize = () => setViewportWidth(window.innerWidth); |
| 68 | window.addEventListener("resize", onResize); |
| 69 | return () => window.removeEventListener("resize", onResize); |
| 70 | }, []); |
| 71 | |
| 72 | const saveWidth = useCallback( |
| 73 | (nextWidth: number) => { |
| 74 | const next = clampDrawerWidth(nextWidth, wide, viewportWidth); |
| 75 | setWidth(next); |
| 76 | saveLayoutSize(config.key, next); |
| 77 | }, |
| 78 | [config.key, viewportWidth, wide], |
| 79 | ); |
| 80 | |
| 81 | const startResize = useCallback( |
| 82 | (event: ReactPointerEvent<HTMLButtonElement>) => { |
| 83 | if (event.button !== 0) return; |
| 84 | const drawer = drawerRef.current; |
| 85 | if (!drawer) return; |
| 86 | event.preventDefault(); |
| 87 | setResizing(true); |
| 88 | let nextWidth = effectiveWidth; |
| 89 | const liveResize = createRafResizeUpdater({ |
| 90 | target: drawer, |
| 91 | separator: event.currentTarget, |
| 92 | cssVar: "--drawer-width", |
| 93 | }); |
| 94 | const onMove = (moveEvent: PointerEvent) => { |
| 95 | nextWidth = clampDrawerWidth(window.innerWidth - moveEvent.clientX, wide, window.innerWidth); |
| 96 | liveResize.schedule(nextWidth); |
| 97 | }; |
| 98 | const onDone = () => { |
| 99 | liveResize.flush(); |
| 100 | setWidth(nextWidth); |
| 101 | saveLayoutSize(config.key, nextWidth); |
| 102 | setResizing(false); |
| 103 | window.removeEventListener("pointermove", onMove); |
| 104 | window.removeEventListener("pointerup", onDone); |
| 105 | window.removeEventListener("pointercancel", onDone); |
| 106 | document.body.style.cursor = ""; |
| 107 | document.body.style.userSelect = ""; |
| 108 | }; |
| 109 | document.body.style.cursor = "col-resize"; |
| 110 | document.body.style.userSelect = "none"; |
| 111 | window.addEventListener("pointermove", onMove); |
| 112 | window.addEventListener("pointerup", onDone); |
| 113 | window.addEventListener("pointercancel", onDone); |
| 114 | }, |
| 115 | [config.key, effectiveWidth, wide], |
| 116 | ); |
| 117 | |
| 118 | const onKeyDown = useCallback( |
| 119 | (event: KeyboardEvent<HTMLButtonElement>) => { |
| 120 | if (event.key === "ArrowLeft" || event.key === "ArrowRight") { |
| 121 | event.preventDefault(); |
| 122 | saveWidth(effectiveWidth + (event.key === "ArrowLeft" ? 16 : -16)); |
| 123 | } else if (event.key === "Home") { |
| 124 | event.preventDefault(); |
| 125 | saveWidth(config.minWidth); |
| 126 | } else if (event.key === "End") { |
| 127 | event.preventDefault(); |
| 128 | saveWidth(config.maxWidth); |
| 129 | } |
| 130 | }, |
| 131 | [config.maxWidth, config.minWidth, effectiveWidth, saveWidth], |
| 132 | ); |
| 133 | |
| 134 | return ( |
| 135 | <div className={`drawer-backdrop${subtle ? " drawer-backdrop--subtle" : ""}`} data-state={status} onClick={requestClose}> |
| 136 | <aside |
| 137 | ref={drawerRef} |
| 138 | className={`drawer${wide ? " drawer--wide" : ""}${resizing ? " drawer--resizing" : ""}`} |
| 139 | data-state={status} |
| 140 | onClick={(e) => e.stopPropagation()} |
| 141 | style={style} |
| 142 | > |
| 143 | <button |
| 144 | className="drawer-resizer" |
| 145 | type="button" |
| 146 | role="separator" |
| 147 | aria-orientation="vertical" |
| 148 | aria-label={t("drawer.resize")} |
| 149 | aria-valuemin={config.minWidth} |
| 150 | aria-valuemax={config.maxWidth} |
| 151 | aria-valuenow={effectiveWidth} |
| 152 | onPointerDown={startResize} |
| 153 | onKeyDown={onKeyDown} |
| 154 | onDoubleClick={() => saveWidth(config.defaultWidth)} |
| 155 | /> |
| 156 | {children} |
| 157 | </aside> |
| 158 | </div> |
| 159 | ); |
| 160 | } |
| 161 |