-
Notifications
You must be signed in to change notification settings - Fork 13
feat(frontend): app shell with resizable chat drawer (M6.3) #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
5d7d216
a793666
da52913
793a1be
0d534b7
2871e6d
a30105e
8773bc2
e94d366
18e733f
eba6bdc
bbc0993
4222c21
e2cdba7
e01d352
877f424
2cdecc3
b79ea85
86720cb
a26b516
0db505b
adad8f6
10e2a1b
090d471
6a9744c
2045892
54bf6b6
19ab4b3
aa67504
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,23 +1,5 @@ | ||
| import { Navigate, Route, Routes } from "react-router-dom"; | ||
| import RequireAuth from "./components/RequireAuth"; | ||
| import DataPage from "./pages/DataPage"; | ||
| import DesignPage from "./pages/DesignPage"; | ||
| import LoginPage from "./pages/LoginPage"; | ||
| import { AppRoutes } from "./app/routes"; | ||
|
|
||
| export default function App() { | ||
| return ( | ||
| <Routes> | ||
| <Route path="/login" element={<LoginPage />} /> | ||
| <Route path="/design" element={<DesignPage />} /> | ||
| <Route | ||
| path="/data" | ||
| element={ | ||
| <RequireAuth> | ||
| <DataPage /> | ||
| </RequireAuth> | ||
| } | ||
| /> | ||
| <Route path="*" element={<Navigate to="/data" replace />} /> | ||
| </Routes> | ||
| ); | ||
| return <AppRoutes />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { useCallback, useEffect, useId } from "react"; | ||
| import { Outlet } from "react-router-dom"; | ||
| import { useLocalStorage } from "../lib/useLocalStorage"; | ||
| import { DRAWER_DEFAULT_WIDTH, DRAWER_MAX_WIDTH, DRAWER_MIN_WIDTH, Drawer } from "./Drawer"; | ||
| import { EQUIPMENT_OPTIONS, TopBar } from "./TopBar"; | ||
|
|
||
| interface ChatDrawerState { | ||
| open: boolean; | ||
| width: number; | ||
| } | ||
|
|
||
| const CHAT_DRAWER_KEY = "aria.chatDrawer"; | ||
| const EQUIPMENT_KEY = "aria.selectedEquipment"; | ||
|
|
||
| const DEFAULT_DRAWER_STATE: ChatDrawerState = { | ||
| open: true, | ||
| width: DRAWER_DEFAULT_WIDTH, | ||
| }; | ||
|
|
||
| function sanitizeDrawer(state: ChatDrawerState): ChatDrawerState { | ||
| return { | ||
| open: Boolean(state.open), | ||
| width: Math.max( | ||
| DRAWER_MIN_WIDTH, | ||
| Math.min(DRAWER_MAX_WIDTH, Math.round(state.width ?? DRAWER_DEFAULT_WIDTH)), | ||
| ), | ||
| }; | ||
| } | ||
|
|
||
| function isTypingTarget(target: EventTarget | null) { | ||
| if (!(target instanceof HTMLElement)) return false; | ||
| const tag = target.tagName; | ||
| if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; | ||
| if (target.isContentEditable) return true; | ||
| return false; | ||
| } | ||
|
|
||
| export function AppShell() { | ||
| const [drawer, setDrawer] = useLocalStorage<ChatDrawerState>( | ||
| CHAT_DRAWER_KEY, | ||
| DEFAULT_DRAWER_STATE, | ||
| ); | ||
| const [equipmentId, setEquipmentId] = useLocalStorage<string>( | ||
| EQUIPMENT_KEY, | ||
| EQUIPMENT_OPTIONS[0].id, | ||
| ); | ||
|
|
||
| const safeDrawer = sanitizeDrawer(drawer); | ||
| const drawerId = useId(); | ||
|
|
||
| const toggleDrawer = useCallback(() => { | ||
| setDrawer((prev) => ({ ...sanitizeDrawer(prev), open: !prev.open })); | ||
| }, [setDrawer]); | ||
|
|
||
| const setDrawerWidth = useCallback( | ||
| (width: number) => { | ||
| setDrawer((prev) => ({ ...sanitizeDrawer(prev), width })); | ||
| }, | ||
| [setDrawer], | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| function onKey(e: KeyboardEvent) { | ||
| const isMac = | ||
| typeof navigator !== "undefined" && | ||
| navigator.platform.toLowerCase().includes("mac"); | ||
| const comboPressed = (isMac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === "k"; | ||
| if (!comboPressed) return; | ||
| if (isTypingTarget(e.target)) return; | ||
| e.preventDefault(); | ||
| toggleDrawer(); | ||
| } | ||
| window.addEventListener("keydown", onKey); | ||
| return () => window.removeEventListener("keydown", onKey); | ||
| }, [toggleDrawer]); | ||
|
|
||
| return ( | ||
| <div className="flex h-screen w-screen flex-col overflow-hidden bg-[var(--ds-bg-base)] text-[var(--ds-fg-primary)]"> | ||
| <TopBar | ||
| equipmentId={equipmentId} | ||
| onEquipmentChange={setEquipmentId} | ||
| drawerOpen={safeDrawer.open} | ||
| drawerControlsId={drawerId} | ||
| onDrawerToggle={toggleDrawer} | ||
| /> | ||
| <div | ||
| className="grid min-h-0 flex-1" | ||
| style={{ | ||
| gridTemplateColumns: safeDrawer.open | ||
| ? `minmax(0, 1fr) ${safeDrawer.width}px` | ||
| : "minmax(0, 1fr) 0", | ||
| transition: `grid-template-columns var(--ds-motion-base) var(--ds-ease-out)`, | ||
| }} | ||
| > | ||
| <main className="relative min-h-0 overflow-auto"> | ||
| <Outlet /> | ||
| </main> | ||
| <Drawer | ||
| id={drawerId} | ||
| open={safeDrawer.open} | ||
| width={safeDrawer.width} | ||
| onToggle={toggleDrawer} | ||
| onWidthChange={setDrawerWidth} | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import { | ||
| type KeyboardEvent as ReactKeyboardEvent, | ||
| type ReactNode, | ||
| useCallback, | ||
| useEffect, | ||
| useId, | ||
| useRef, | ||
| } from "react"; | ||
| import { Hairline, Icons, SectionHeader } from "../design-system"; | ||
|
|
||
| export const DRAWER_MIN_WIDTH = 360; | ||
| export const DRAWER_MAX_WIDTH = 640; | ||
| export const DRAWER_DEFAULT_WIDTH = 420; | ||
| const KEYBOARD_STEP = 10; | ||
|
|
||
| export interface DrawerProps { | ||
| open: boolean; | ||
| width: number; | ||
| onToggle: () => void; | ||
| onWidthChange: (width: number) => void; | ||
| children?: ReactNode; | ||
| /** DOM id used by the topbar toggle `aria-controls`. */ | ||
| id?: string; | ||
| } | ||
|
|
||
| function clampWidth(width: number) { | ||
| return Math.max(DRAWER_MIN_WIDTH, Math.min(DRAWER_MAX_WIDTH, Math.round(width))); | ||
| } | ||
|
|
||
| /** | ||
| * Docked, resizable chat drawer. Sits inside the app-shell grid — not overlay. | ||
| * The primitive `design-system/Drawer` remains for modal contexts. | ||
| */ | ||
| export function Drawer({ open, width, onToggle, onWidthChange, children, id }: DrawerProps) { | ||
| const generatedId = useId(); | ||
| const drawerId = id ?? `chat-drawer-${generatedId}`; | ||
| const asideRef = useRef<HTMLElement>(null); | ||
| const draggingRef = useRef(false); | ||
| const startXRef = useRef(0); | ||
| const startWidthRef = useRef(0); | ||
|
|
||
| const onPointerMove = useCallback( | ||
| (e: PointerEvent) => { | ||
| if (!draggingRef.current) return; | ||
| const delta = startXRef.current - e.clientX; | ||
| onWidthChange(clampWidth(startWidthRef.current + delta)); | ||
| }, | ||
| [onWidthChange], | ||
| ); | ||
|
|
||
| const onPointerUp = useCallback(() => { | ||
| if (!draggingRef.current) return; | ||
| draggingRef.current = false; | ||
| document.body.style.cursor = ""; | ||
| document.body.style.userSelect = ""; | ||
| window.removeEventListener("pointermove", onPointerMove); | ||
| window.removeEventListener("pointerup", onPointerUp); | ||
| }, [onPointerMove]); | ||
|
|
||
| useEffect( | ||
| () => () => { | ||
| window.removeEventListener("pointermove", onPointerMove); | ||
| window.removeEventListener("pointerup", onPointerUp); | ||
| }, | ||
| [onPointerMove, onPointerUp], | ||
| ); | ||
|
Comment on lines
+60
to
+66
|
||
|
|
||
| const onHandlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => { | ||
| e.preventDefault(); | ||
| draggingRef.current = true; | ||
| startXRef.current = e.clientX; | ||
| startWidthRef.current = width; | ||
| document.body.style.cursor = "col-resize"; | ||
| document.body.style.userSelect = "none"; | ||
| window.addEventListener("pointermove", onPointerMove); | ||
| window.addEventListener("pointerup", onPointerUp); | ||
| }; | ||
|
|
||
| const onHandleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => { | ||
| let next: number | null = null; | ||
| switch (e.key) { | ||
| case "ArrowLeft": | ||
| next = clampWidth(width + KEYBOARD_STEP); | ||
| break; | ||
| case "ArrowRight": | ||
| next = clampWidth(width - KEYBOARD_STEP); | ||
| break; | ||
| case "Home": | ||
| next = DRAWER_MIN_WIDTH; | ||
| break; | ||
| case "End": | ||
| next = DRAWER_MAX_WIDTH; | ||
| break; | ||
| default: | ||
| return; | ||
| } | ||
| e.preventDefault(); | ||
| onWidthChange(next); | ||
| }; | ||
|
|
||
| return ( | ||
| <aside | ||
| ref={asideRef} | ||
| id={drawerId} | ||
| aria-label="Chat drawer" | ||
| className="relative h-full overflow-hidden border-l border-[var(--ds-border)] bg-[var(--ds-bg-surface)]" | ||
| style={{ | ||
| width: open ? `${width}px` : 0, | ||
| transition: `width var(--ds-motion-base) var(--ds-ease-out)`, | ||
| }} | ||
| > | ||
| {open && ( | ||
| // biome-ignore lint/a11y/useSemanticElements: separator must be interactive (draggable + keyboard resize) — <hr> cannot carry pointer/keyboard handlers | ||
| <div | ||
| role="separator" | ||
| aria-orientation="vertical" | ||
| aria-valuenow={width} | ||
| aria-valuemin={DRAWER_MIN_WIDTH} | ||
| aria-valuemax={DRAWER_MAX_WIDTH} | ||
| aria-label="Resize chat drawer" | ||
| tabIndex={0} | ||
| onPointerDown={onHandlePointerDown} | ||
| onKeyDown={onHandleKeyDown} | ||
| className="absolute left-0 top-0 z-10 h-full w-1.5 -translate-x-1/2 cursor-col-resize outline-none focus-visible:bg-[var(--ds-accent)]/60 hover:bg-[var(--ds-accent)]/40" | ||
| style={{ touchAction: "none" }} | ||
| /> | ||
| )} | ||
| {open && ( | ||
| <div className="flex h-full w-full flex-col" style={{ width: `${width}px` }}> | ||
| <header className="flex items-center justify-between gap-4 border-b border-[var(--ds-border)] px-4 py-3"> | ||
| <SectionHeader bracketed label="Chat" /> | ||
| <button | ||
| type="button" | ||
| onClick={onToggle} | ||
| aria-label="Collapse chat drawer" | ||
| className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-fg-muted)] transition-colors duration-[var(--ds-motion-fast)] hover:bg-[var(--ds-bg-elevated)] hover:text-[var(--ds-fg-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ds-accent)]" | ||
| > | ||
| <Icons.PanelRightClose className="size-4" /> | ||
| </button> | ||
| </header> | ||
| {children ? ( | ||
| <div className="flex-1 overflow-auto">{children}</div> | ||
| ) : ( | ||
| <div className="flex flex-1 flex-col gap-4 overflow-auto p-4"> | ||
| <Hairline label="Awaiting wire" /> | ||
| <p className="text-[var(--ds-text-sm)] text-[var(--ds-fg-muted)]"> | ||
| Chat shell mounts here in M6.5. | ||
| </p> | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
| </aside> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
toggleDrawersanitizesprevbut then computes the nextopenvalue from the unsanitizedprev.open(open: !prev.open). If the persisted value is non-boolean (corrupt localStorage), toggling can behave inconsistently withsafeDrawer. Compute the toggle from the sanitized value instead (and similarly clampwidthwhen persisting insetDrawerWidthso localStorage doesn’t store out-of-range widths ifonWidthChangeis ever called with an invalid value).