diff --git a/apps/app/bundle-budget.json b/apps/app/bundle-budget.json index f33bfa71bf..bb0a97f5d4 100644 --- a/apps/app/bundle-budget.json +++ b/apps/app/bundle-budget.json @@ -24,6 +24,8 @@ "composer, render math). Each one reached the boot path through a barrel", "re-export rather than a direct import, which type checking cannot catch,", "so the check names them explicitly.", + "forbiddenBootModules applies the same exact-source gate to app modules", + "whose normally closed UI must never return to the boot closure.", "", "onDemandPackages goes one step further than the boot path: each package", "maps to its gate, the source module a dynamic import() resolves to. The", @@ -36,17 +38,19 @@ "", "routeClosures ratchets, per lazy route, the static-import closure of the", "route chunk minus the boot chunks: the JavaScript between 'app shell", - "painted' and 'route content painted'. SplitWorkspaceRoute is every thread,", - "compose and plugin-panel page, so its closure is the second number that", - "decides how slow bb feels on a phone. Same 10% ratchet; its forbiddenPackages", - "are the diff engine, math and terminal code that only a user action needs.", + "painted' and 'route content painted'. ProjectSettingsView forbids the path", + "dialog and remote browser needed only after opening a dialog.", + "SplitWorkspaceRoute is every thread, compose and plugin-panel page, so its", + "closure is the second number that decides how slow bb feels on a phone.", + "Same 10% ratchet; its forbiddenPackages are the diff engine, math and", + "terminal code that only a user action needs.", "The composer (tiptap/prosemirror) is visible on every thread page and is", "allowed until it moves behind a first-focus handoff. Run", "`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx `", "to print the static chain that pulled a package into the closure." ], - "maxBootBytes": 1723617, - "maxBootBrotliBytes": 479067, + "maxBootBytes": 1718043, + "maxBootBrotliBytes": 482677, "forbiddenBootPackages": [ "@pierre/diffs", "@pierre/theming", @@ -66,20 +70,41 @@ "prosemirror-model", "prosemirror-state", "prosemirror-view", + "preact", + "preact-render-to-string", "rehype-katex", "shiki" ], + "forbiddenBootModules": [ + "src/components/dialogs/ProjectDeleteDialog.tsx", + "src/components/dialogs/ProjectPathDialog.tsx", + "src/components/dialogs/ProjectRenameDialog.tsx", + "src/components/dialogs/RemotePathBrowser.tsx" + ], "onDemandPackages": { + "@pierre/trees": "src/components/secondary-panel/ThreadSecondaryPanelWithStorage.tsx", "katex": "src/components/ui/markdown-katex.ts", + "preact": "src/components/secondary-panel/ThreadSecondaryPanelWithStorage.tsx", + "preact-render-to-string": "src/components/secondary-panel/ThreadSecondaryPanelWithStorage.tsx", "rehype-katex": "src/components/ui/markdown-katex.ts" }, "routeClosures": { + "ProjectSettingsView": { + "maxBytes": 121009, + "maxBrotliBytes": 32864, + "forbiddenPackages": [], + "forbiddenModules": [ + "src/components/dialogs/ProjectPathDialog.tsx", + "src/components/dialogs/RemotePathBrowser.tsx" + ] + }, "SplitWorkspaceRoute": { - "maxBytes": 2533484, - "maxBrotliBytes": 671561, + "maxBytes": 2269199, + "maxBrotliBytes": 607310, "forbiddenPackages": [ "@pierre/diffs", "@pierre/theming", + "@pierre/trees", "@shikijs/core", "@shikijs/engine-javascript", "@shikijs/engine-oniguruma", @@ -89,6 +114,8 @@ "katex", "mermaid", "oniguruma-to-es", + "preact", + "preact-render-to-string", "rehype-katex", "shiki" ] diff --git a/apps/app/scripts/check-bundle-budget.mjs b/apps/app/scripts/check-bundle-budget.mjs index 5c1d4fa94a..ae1cdffa5f 100644 --- a/apps/app/scripts/check-bundle-budget.mjs +++ b/apps/app/scripts/check-bundle-budget.mjs @@ -54,9 +54,16 @@ const MIN_PRECOMPRESS_BYTES = 1024; * budget, so an unrun precompression step could hide real growth. Treat it as * an error rather than guessing a size. */ -function measureClosure(chunks, forbiddenPackages, brotliSizeOf) { +function measureClosure( + chunks, + forbiddenPackages, + forbiddenModules, + brotliSizeOf, +) { const forbidden = new Set(forbiddenPackages); + const forbiddenSourceModules = new Set(forbiddenModules); const offenders = new Map(); + const moduleOffenders = new Map(); const missingBrotli = []; let bytes = 0; let brotliBytes = 0; @@ -77,8 +84,21 @@ function measureClosure(chunks, forbiddenPackages, brotliSizeOf) { if (!offenders.has(pkg)) offenders.set(pkg, []); offenders.get(pkg).push(chunk.fileName); } + for (const sourceModule of chunk.modules ?? []) { + if (!forbiddenSourceModules.has(sourceModule)) continue; + if (!moduleOffenders.has(sourceModule)) { + moduleOffenders.set(sourceModule, []); + } + moduleOffenders.get(sourceModule).push(chunk.fileName); + } } - return { bytes, brotliBytes, missingBrotli, offenders }; + return { + bytes, + brotliBytes, + missingBrotli, + offenders, + moduleOffenders, + }; } const brotliSizeOf = (fileName) => { @@ -89,6 +109,7 @@ const brotliSizeOf = (fileName) => { const boot = measureClosure( stats.bootChunks, budget.forbiddenBootPackages, + budget.forbiddenBootModules ?? [], brotliSizeOf, ); @@ -173,6 +194,11 @@ for (const [pkg, chunks] of boot.offenders) { `${pkg} is in the boot payload (${chunks.join(", ")}). It must load on demand.`, ); } +for (const [sourceModule, chunks] of boot.moduleOffenders) { + failures.push( + `${sourceModule} is in the boot payload (${chunks.join(", ")}). It must load on demand.`, + ); +} failures.push(...onDemandFailures); const failingRouteChunkLists = []; @@ -189,6 +215,7 @@ for (const [routeName, routeBudget] of Object.entries( const route = measureClosure( closure.chunks, routeBudget.forbiddenPackages, + routeBudget.forbiddenModules ?? [], brotliSizeOf, ); console.log( @@ -222,6 +249,11 @@ for (const [routeName, routeBudget] of Object.entries( `${pkg} is in the ${routeName} closure (${chunks.join(", ")}). It must load on demand (behind React.lazy or import()).`, ); } + for (const [sourceModule, chunks] of route.moduleOffenders) { + routeFailures.push( + `${sourceModule} is in the ${routeName} closure (${chunks.join(", ")}). It must load on demand (behind React.lazy or import()).`, + ); + } if (routeFailures.length > 0) { failures.push(...routeFailures); failingRouteChunkLists.push([routeName, closure.chunks]); diff --git a/apps/app/src/bundle-budget.test.ts b/apps/app/src/bundle-budget.test.ts index c27eaeea21..afae59f93b 100644 --- a/apps/app/src/bundle-budget.test.ts +++ b/apps/app/src/bundle-budget.test.ts @@ -39,7 +39,10 @@ const chunks: BundleStatsChunkInput[] = [ imports: ["assets/boot-shared.js"], }), chunk("assets/boot-shared.js", { - moduleIds: ["/repo/node_modules/react/index.js"], + moduleIds: [ + "/repo/node_modules/react/index.js", + "/repo/apps/app/src/components/dialogs/ProjectPathDialog.tsx", + ], }), chunk("assets/SplitWorkspaceRoute.js", { facadeModuleId: "/repo/apps/app/src/views/SplitWorkspaceRoute.tsx", @@ -70,6 +73,9 @@ describe("computeBundleStats", () => { "assets/boot-shared.js", "assets/index.js", ]); + expect(stats.bootChunks[0]?.modules).toEqual([ + "src/components/dialogs/ProjectPathDialog.tsx", + ]); const route = stats.routeClosures.SplitWorkspaceRoute; if (route === undefined) throw new Error("expected the route closure"); expect(route.entry).toBe("assets/SplitWorkspaceRoute.js"); @@ -174,6 +180,37 @@ describe("check-bundle-budget", () => { ); }); + it("fails when a forbidden source module reaches the route closure", async () => { + const result = await runCheck( + await writeFixture({ + ...passingBudget, + routeClosures: { + SplitWorkspaceRoute: { + ...passingBudget.routeClosures.SplitWorkspaceRoute, + forbiddenModules: ["src/lib/x.ts"], + }, + }, + }), + ); + expect(result.code).toBe(1); + expect(result.output).toContain( + "src/lib/x.ts is in the SplitWorkspaceRoute closure (assets/route-only.js)", + ); + }); + + it("fails when an on-demand source module reaches the boot closure", async () => { + const result = await runCheck( + await writeFixture({ + ...passingBudget, + forbiddenBootModules: ["src/components/dialogs/ProjectPathDialog.tsx"], + }), + ); + expect(result.code).toBe(1); + expect(result.output).toContain( + "src/components/dialogs/ProjectPathDialog.tsx is in the boot payload (assets/boot-shared.js)", + ); + }); + it("fails when the route closure grows past its ratchet", async () => { // Two 2 KiB chunks in the closure; a 3 KiB raw budget is exceeded while // the brotli budget (2 x 100 B) is not. diff --git a/apps/app/src/check-bundle-budget.test.ts b/apps/app/src/check-bundle-budget.test.ts index 33f69bc786..9bcd6ae842 100644 --- a/apps/app/src/check-bundle-budget.test.ts +++ b/apps/app/src/check-bundle-budget.test.ts @@ -14,6 +14,7 @@ const scriptPath = resolve( const KATEX_GATE = "src/components/ui/markdown-katex.ts"; interface ChunkSpec { + modules?: string[]; packages?: string[]; imports?: string[]; facade?: string | null; @@ -24,6 +25,7 @@ function chunk(fileName: string, spec: ChunkSpec = {}): BundleChunk { return { fileName, bytes: 512, + modules: spec.modules ?? [], packages: spec.packages ?? [], imports: spec.imports ?? [], facade: spec.facade ?? null, diff --git a/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx b/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx index 1318d7a675..c01ee202fe 100644 --- a/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx +++ b/apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx @@ -65,6 +65,8 @@ export function ConfirmDeleteDialogContent({ interface ConfirmDeleteDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + accessibleLabel?: string; + accessibleDescription?: string; children: ReactNode; } @@ -75,11 +77,18 @@ interface ConfirmDeleteDialogProps { export function ConfirmDeleteDialog({ open, onOpenChange, + accessibleLabel, + accessibleDescription, children, }: ConfirmDeleteDialogProps) { return ( - {open ? children : null} + + {open ? children : null} + ); } diff --git a/apps/app/src/components/dialogs/ProjectMachineSetupDialog.tsx b/apps/app/src/components/dialogs/ProjectMachineSetupDialog.tsx index 3f0eac161b..dbaba81e81 100644 --- a/apps/app/src/components/dialogs/ProjectMachineSetupDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectMachineSetupDialog.tsx @@ -1,4 +1,11 @@ -import { useEffect, useState, type FormEvent, type ReactNode } from "react"; +import { + lazy, + Suspense, + useEffect, + useState, + type FormEvent, + type ReactNode, +} from "react"; import { getProjectPathValidationMessage, normalizeProjectPathInput, @@ -16,7 +23,6 @@ import { import { Icon } from "@bb/shared-ui/icon"; import { Input } from "@bb/shared-ui/input"; import { cn } from "@bb/shared-ui/lib/utils"; -import { RemotePathBrowser } from "@/components/dialogs/RemotePathBrowser"; import { useAddProjectSource } from "@/hooks/mutations/project-mutations"; import { isHostPathMissing, @@ -26,6 +32,12 @@ import { useHostCloneDefaultPath } from "@/hooks/queries/host-queries"; import { BbHttpError } from "@bb/sdk/browser"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; +const RemotePathBrowserChunk = lazy(() => + import("@/components/dialogs/RemotePathBrowser").then( + ({ RemotePathBrowser }) => ({ default: RemotePathBrowser }), + ), +); + export interface ProjectMachineSetupDialogTarget { projectId: string; projectName: string; @@ -306,15 +318,25 @@ export function ProjectMachineSetupDialogContent({ ) : null} {option === "folder" ? ( - { - setFolderPath(directory); - setValidationMessage(null); - }} - disabled={pending} - /> + ) : null} {pending && option === "clone" ? (

diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx index 5f0592b5b7..658ac0f252 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.test.tsx @@ -2,8 +2,12 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { Host } from "@bb/domain"; +import { Dialog, DialogContent } from "@bb/shared-ui/dialog"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ProjectPathDialog } from "./ProjectPathDialog"; +import { + ProjectPathDialogContent, + type ProjectPathDialogProps, +} from "./ProjectPathDialog"; vi.mock("@/components/dialogs/RemotePathBrowser", () => ({ RemotePathBrowser: ({ @@ -55,6 +59,36 @@ const offline = host({ }); const offlineKunst = host({ ...kunst, status: "disconnected" }); +function ProjectPathDialog({ + target, + pending = false, + platform, + hostId, + hostName, + hosts, + onOpenChange, + onSubmit, +}: ProjectPathDialogProps) { + return ( +

+ + {target ? ( + + ) : null} + + + ); +} + afterEach(() => { cleanup(); vi.clearAllMocks(); diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.tsx index 20114ed9e7..c3bb7b096e 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.tsx @@ -15,8 +15,6 @@ import { import type { HostPlatform } from "@bb/host-daemon-contract"; import { Button } from "@bb/shared-ui/button"; import { - Dialog, - DialogContent, DialogDescription, DialogFooter, DialogHeader, @@ -50,7 +48,7 @@ export type ProjectPathDialogSubmitHandler = ( hostId: string | null, ) => Promise | void; -interface ProjectPathDialogProps { +export interface ProjectPathDialogProps { target: ProjectPathDialogTarget | null; pending?: boolean; platform: HostPlatform | null; @@ -61,36 +59,6 @@ interface ProjectPathDialogProps { onSubmit: ProjectPathDialogSubmitHandler; } -export function ProjectPathDialog({ - target, - pending = false, - platform, - hostId, - hostName, - hosts, - onOpenChange, - onSubmit, -}: ProjectPathDialogProps) { - return ( - - - {target ? ( - - ) : null} - - - ); -} - export interface ProjectPathDialogContentProps { target: ProjectPathDialogTarget; pending: boolean; diff --git a/apps/app/src/components/dialogs/ProjectRenameDialog.tsx b/apps/app/src/components/dialogs/ProjectRenameDialog.tsx index c390dce0f6..fb67d4ae2a 100644 --- a/apps/app/src/components/dialogs/ProjectRenameDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectRenameDialog.tsx @@ -1,4 +1,5 @@ -import type { RefObject } from "react"; +import { useLayoutEffect, type RefObject } from "react"; +import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { RenameDialog, RenameDialogContent } from "./RenameDialog"; export interface ProjectRenameDialogTarget { @@ -18,6 +19,8 @@ export interface ProjectRenameDialogContentProps { pending: boolean; onRename: (projectId: string, name: string) => void; inputRef: RefObject; + /** Restores autofocus when a lazy body mounts after the shell opened. */ + focusOnMount?: boolean; } export function ProjectRenameDialog({ @@ -48,7 +51,15 @@ export function ProjectRenameDialogContent({ pending, onRename, inputRef, + focusOnMount = false, }: ProjectRenameDialogContentProps) { + const isPointerCoarse = usePointerCoarse(); + useLayoutEffect(() => { + if (!focusOnMount || isPointerCoarse) return; + inputRef.current?.focus(); + inputRef.current?.select(); + }, [focusOnMount, inputRef, isPointerCoarse]); + return ( void; + accessibleLabel?: string; + accessibleDescription?: string; /** Extra classes for the modal shell, e.g. the compact thread variant. */ shellClassName?: string; /** Render the entity's rename content, wiring in the autofocus ref. */ @@ -35,6 +37,8 @@ interface RenameDialogProps { export function RenameDialog({ open, onOpenChange, + accessibleLabel, + accessibleDescription, shellClassName, children, }: RenameDialogProps) { @@ -42,6 +46,8 @@ export function RenameDialog({ return ( diff --git a/apps/app/src/components/dialogs/lazyProjectDialogs.accessibility.test.tsx b/apps/app/src/components/dialogs/lazyProjectDialogs.accessibility.test.tsx new file mode 100644 index 0000000000..1704865a2e --- /dev/null +++ b/apps/app/src/components/dialogs/lazyProjectDialogs.accessibility.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { + LazyProjectDeleteDialog, + LazyProjectPathDialog, + LazyProjectRenameDialog, +} from "./lazyProjectDialogs"; + +// Keep every body import pending so each assertion observes the eager shell +// during a real Suspense cold load rather than after the mocked chunk resolves. +vi.mock("./ProjectPathDialog", () => new Promise(() => {})); +vi.mock("./ProjectRenameDialog", () => new Promise(() => {})); +vi.mock("./ProjectDeleteDialog", () => new Promise(() => {})); + +afterEach(() => cleanup()); + +const noop = () => {}; + +function getAccessibleDescription(element: HTMLElement): string | null { + const describedBy = element.getAttribute("aria-describedby"); + if (describedBy) { + return describedBy + .split(/\s+/u) + .map((id) => document.getElementById(id)?.textContent ?? "") + .join(" ") + .trim(); + } + return element.getAttribute("aria-description"); +} + +const dialogCases = [ + { + name: "path", + label: "Add project", + description: "Choose the folder to add as a project.", + render: () => ( + + ), + }, + { + name: "rename", + label: "Rename project", + description: "Choose a new name for this project.", + render: () => ( + + ), + }, + { + name: "delete", + label: "Remove project?", + description: + 'Remove "Test project" and all of its threads? This cannot be undone.', + render: () => ( + + ), + }, +] as const; + +describe("lazy project dialog accessibility", () => { + for (const compact of [false, true]) { + for (const dialogCase of dialogCases) { + it(`names and describes the ${dialogCase.name} dialog while its ${ + compact ? "compact" : "desktop" + } body chunk is pending`, () => { + render( + + {dialogCase.render()} + , + ); + + const dialog = screen.getByRole("dialog", { + name: dialogCase.label, + }); + expect(getAccessibleDescription(dialog)).toBe(dialogCase.description); + }); + } + } +}); diff --git a/apps/app/src/components/dialogs/lazyProjectDialogs.autofocus.test.tsx b/apps/app/src/components/dialogs/lazyProjectDialogs.autofocus.test.tsx new file mode 100644 index 0000000000..9c5d0d913f --- /dev/null +++ b/apps/app/src/components/dialogs/lazyProjectDialogs.autofocus.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { LazyProjectRenameDialog } from "./lazyProjectDialogs"; + +afterEach(() => cleanup()); + +const noop = () => {}; + +describe("LazyProjectRenameDialog", () => { + it("focuses and selects the rename input when the lazy body arrives", async () => { + const { rerender } = render( + , + ); + + rerender( + , + ); + + const input = await screen.findByLabelText( + "Project name", + ); + await waitFor(() => expect(document.activeElement).toBe(input)); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe("Test project".length); + }); +}); diff --git a/apps/app/src/components/dialogs/lazyProjectDialogs.test.tsx b/apps/app/src/components/dialogs/lazyProjectDialogs.test.tsx new file mode 100644 index 0000000000..7fe9801ec8 --- /dev/null +++ b/apps/app/src/components/dialogs/lazyProjectDialogs.test.tsx @@ -0,0 +1,186 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { + LazyProjectDeleteDialog, + LazyProjectPathDialog, + LazyProjectRenameDialog, +} from "./lazyProjectDialogs"; + +const moduleLoads = vi.hoisted(() => ({ + deleteDialog: 0, + pathDialog: 0, + renameDialog: 0, +})); + +vi.mock("./ProjectPathDialog", async () => { + const React = await import("react"); + moduleLoads.pathDialog += 1; + return { + ProjectPathDialogContent: ({ target }: { target: { kind: string } }) => + React.createElement("div", { + "data-kind": target.kind, + "data-testid": "project-path-dialog", + }), + }; +}); + +vi.mock("./ProjectRenameDialog", async () => { + const React = await import("react"); + moduleLoads.renameDialog += 1; + return { + ProjectRenameDialogContent: ({ + focusOnMount, + target, + }: { + focusOnMount?: boolean; + target: { id: string; currentName: string }; + }) => + React.createElement("div", { + "data-focus-on-mount": focusOnMount ? "true" : "false", + "data-project-id": target.id, + "data-testid": "project-rename-dialog", + }), + }; +}); + +vi.mock("./ProjectDeleteDialog", async () => { + const React = await import("react"); + moduleLoads.deleteDialog += 1; + return { + ProjectDeleteDialogContent: ({ + target, + }: { + target: { id: string; name: string }; + }) => + React.createElement("div", { + "data-project-id": target.id, + "data-testid": "project-delete-dialog", + }), + }; +}); + +afterEach(() => cleanup()); + +const noop = () => {}; + +describe("lazy project dialogs", () => { + it("loads each body on first open and reuses the chunk after close", async () => { + const renderDialogs = ({ + deleteOpen, + pathOpen, + renameOpen, + }: { + deleteOpen: boolean; + pathOpen: boolean; + renameOpen: boolean; + }) => ( + <> + + + + + ); + + const closed = { deleteOpen: false, pathOpen: false, renameOpen: false }; + const { rerender } = render(renderDialogs(closed)); + + expect(moduleLoads).toEqual({ + deleteDialog: 0, + pathDialog: 0, + renameDialog: 0, + }); + + rerender( + renderDialogs({ deleteOpen: true, pathOpen: true, renameOpen: true }), + ); + expect(await screen.findByTestId("project-path-dialog")).not.toBeNull(); + expect( + (await screen.findByTestId("project-rename-dialog")).dataset.focusOnMount, + ).toBe("true"); + expect(await screen.findByTestId("project-delete-dialog")).not.toBeNull(); + expect(moduleLoads).toEqual({ + deleteDialog: 1, + pathDialog: 1, + renameDialog: 1, + }); + + rerender(renderDialogs(closed)); + expect(screen.queryByTestId("project-path-dialog")).toBeNull(); + expect(screen.queryByTestId("project-rename-dialog")).toBeNull(); + expect(screen.queryByTestId("project-delete-dialog")).toBeNull(); + + rerender( + renderDialogs({ deleteOpen: true, pathOpen: true, renameOpen: true }), + ); + expect(await screen.findByTestId("project-path-dialog")).not.toBeNull(); + expect(await screen.findByTestId("project-rename-dialog")).not.toBeNull(); + expect(await screen.findByTestId("project-delete-dialog")).not.toBeNull(); + expect(moduleLoads).toEqual({ + deleteDialog: 1, + pathDialog: 1, + renameDialog: 1, + }); + }); + + it("opens through the persistent compact drawer without inerting the app", async () => { + const appTree = document.createElement("main"); + document.body.appendChild(appTree); + const renderPathDialog = (open: boolean) => ( + + + + ); + + try { + const { rerender } = render(renderPathDialog(true)); + const drawer = document.querySelector( + "[data-persistent-drawer-content]", + ); + expect(drawer?.dataset.state).toBe("open"); + expect(appTree.hasAttribute("inert")).toBe(false); + expect(appTree.getAttribute("aria-hidden")).toBeNull(); + + await waitFor(() => + expect( + document.querySelector("[data-responsive-drawer-placeholder]"), + ).toBeNull(), + ); + + rerender(renderPathDialog(false)); + expect(drawer?.dataset.state).toBe("closed"); + } finally { + appTree.remove(); + } + }); +}); diff --git a/apps/app/src/components/dialogs/lazyProjectDialogs.tsx b/apps/app/src/components/dialogs/lazyProjectDialogs.tsx new file mode 100644 index 0000000000..85e16ead8f --- /dev/null +++ b/apps/app/src/components/dialogs/lazyProjectDialogs.tsx @@ -0,0 +1,200 @@ +import { lazy, Suspense, type ComponentProps } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@bb/shared-ui/dialog"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { RenameDialog } from "./RenameDialog"; +import type { + ProjectPathDialogProps, + ProjectPathDialogTarget, +} from "./ProjectPathDialog"; + +type ProjectRenameDialogModule = typeof import("./ProjectRenameDialog"); +type ProjectDeleteDialogModule = typeof import("./ProjectDeleteDialog"); + +const ProjectPathDialogContentChunk = lazy(() => + import("./ProjectPathDialog").then(({ ProjectPathDialogContent }) => ({ + default: ProjectPathDialogContent, + })), +); +const ProjectRenameDialogContentChunk = lazy(() => + import("./ProjectRenameDialog").then(({ ProjectRenameDialogContent }) => ({ + default: ProjectRenameDialogContent, + })), +); +const ProjectDeleteDialogContentChunk = lazy(() => + import("./ProjectDeleteDialog").then(({ ProjectDeleteDialogContent }) => ({ + default: ProjectDeleteDialogContent, + })), +); + +function DialogContentFallback({ + label, + description, +}: { + label: string; + description: string; +}) { + return ( + <> + + {label} + {description} + + - ({ useThreads: useThreadsMock, })); +vi.mock("@/components/secondary-panel/useThreadStorageBrowser", () => ({ + useThreadStorageBrowser: () => ({ + closeSearch: () => {}, + filteredFiles: [], + isSearchOpen: false, + loadedFiles: [], + model: {}, + openSearch: () => {}, + searchQuery: "", + setSearchQuery: () => {}, + }), +})); + vi.mock("jotai", async (importOriginal) => ({ ...(await importOriginal()), useAtomValue: () => 50, @@ -237,6 +250,13 @@ function createProps( onToggleConversationCollapse: noop, onToggleSecondaryPanel: noop, renderHostedPanel: (panel) => panel, + storageBrowser: { + files: undefined, + filesError: null, + isFilesLoading: false, + onSelectPath: noop, + selectedPath: null, + }, secondaryPanel: { activeTab: null, canUseGitUi: false, diff --git a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx index 6c7551cd73..42aba7fa05 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx @@ -1,4 +1,4 @@ -import { useMemo, type ComponentProps, type ReactNode } from "react"; +import { useCallback, type ComponentProps, type ReactNode } from "react"; import { Skeleton } from "@bb/shared-ui/skeleton"; import { cn } from "@bb/shared-ui/lib/utils"; import { @@ -6,7 +6,7 @@ import { usePluginComposerHost, } from "@/components/plugin/plugin-composer-host"; import { SecondaryPanelLayout } from "@/components/secondary-panel/SecondaryPanelLayout"; -import { LazyThreadSecondaryPanel } from "@/components/secondary-panel/lazySecondaryPanelComponents"; +import { LazyThreadSecondaryPanelWithStorage } from "@/components/secondary-panel/lazySecondaryPanelComponents"; import { ThreadMetadataCard, ThreadMetadataContent, @@ -22,8 +22,10 @@ type ThreadTimelinePaneProps = Omit< "footer" >; type ThreadSecondaryPanelProps = Omit< - ComponentProps, + ComponentProps, | "metadataContent" + | "renderMetadataContent" + | "storageBrowser" | "renderAsDrawer" | "isConversationCollapsed" | "onToggleConversationCollapse" @@ -53,7 +55,10 @@ interface ThreadDetailSecondaryContentProps { onToggleSecondaryPanel: () => void; onToggleConversationCollapse: () => void; renderHostedPanel: (panel: ReactNode) => ReactNode; - metadata: ThreadMetadataContentProps; + metadata: Omit; + storageBrowser: ComponentProps< + typeof LazyThreadSecondaryPanelWithStorage + >["storageBrowser"]; secondaryPanel: ThreadSecondaryPanelProps; timeline: ThreadTimelinePaneProps; } @@ -79,6 +84,7 @@ function ThreadDetailSecondaryContentBody({ onToggleConversationCollapse, renderHostedPanel, metadata, + storageBrowser, secondaryPanel, timeline, }: ThreadDetailSecondaryContentProps) { @@ -98,11 +104,11 @@ function ThreadDetailSecondaryContentBody({ { enabled: isSecondaryPanelOpen }, ); const hasForks = (forksQuery.data?.length ?? 0) > 0; - const metadataContent = useMemo( - () => + const renderMetadataContent = useCallback( + (storage: ReactNode): ReactNode => hasAnyThreadMetadata(metadata, hasForks) ? (
- +
) : isMetadataLoading ? ( @@ -145,9 +151,12 @@ function ThreadDetailSecondaryContentBody({ onToggleMainCollapse, resizablePanelId, }) => ( - } + renderMetadataContent={renderMetadataContent} + storageBrowser={storageBrowser} renderBrowserDeck={(activeBrowserTabId, pane) => renderBrowserDeck?.({ activeBrowserTabId, @@ -166,7 +175,6 @@ function ThreadDetailSecondaryContentBody({ ? { inlinePanelToggle: "button" as const } : {})} resizablePanelId={resizablePanelId} - metadataContent={metadataContent} /> )} /> diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 3c50449ed9..6b3f516510 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -217,10 +217,6 @@ import { } from "@/lib/live-file-navigation"; import { getFilePreviewLineRangeStart } from "@/lib/file-preview"; import { getBrowserUrlHost } from "@/lib/browser-url"; -import { - useThreadStorageBrowser, - type ThreadStoragePathSelectHandler, -} from "@/components/secondary-panel/useThreadStorageBrowser"; import { useThreadFileTabs, type FileSearchSelection, @@ -1512,21 +1508,15 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { handleOpenUrlByPreference(url); }); }, [browserTabIds, handleOpenUrlByPreference]); - const handleSelectStorageBrowserPath = - useCallback( - (path) => { - openStorageFile({ - lineRange: null, - path, - }); - }, - [openStorageFile], - ); - const storageBrowserController = useThreadStorageBrowser({ - files: threadStorageFiles?.files, - onSelectPath: handleSelectStorageBrowserPath, - selectedPath: activeStorageFilePath, - }); + const handleSelectStorageBrowserPath = useCallback( + (path: string) => { + openStorageFile({ + lineRange: null, + path, + }); + }, + [openStorageFile], + ); const [storedConversationCollapsed, setStoredConversationCollapsed] = useAtom( getThreadConversationCollapsedAtom(threadId), ); @@ -2239,15 +2229,19 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { }, [openSecondaryPanelDiffFile, handleOpenTimelinePluginPanel, threadId], ); - const metadataStorage = useMemo( + const metadataStorageBrowser = useMemo( () => ({ - controller: storageBrowserController, + files: threadStorageFiles?.files, filesError: threadStorageFilesError, isFilesLoading: isThreadStorageFilesLoading, + onSelectPath: handleSelectStorageBrowserPath, + selectedPath: activeStorageFilePath, }), [ + activeStorageFilePath, + handleSelectStorageBrowserPath, isThreadStorageFilesLoading, - storageBrowserController, + threadStorageFiles?.files, threadStorageFilesError, ], ); @@ -2962,6 +2956,7 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { )} + storageBrowser={metadataStorageBrowser} metadata={{ thread, projectId, @@ -2985,7 +2980,6 @@ function ThreadDetailViewInternal(props: ThreadDetailViewInternalProps) { isLoadingMergeBaseBranchOptions, updateThreadPending: updateThread.isPending || updateEnvironment.isPending, - storage: metadataStorage, onAssignParent: handleAssignParent, onParentSelectorOpenChange: handleParentSelectorOpenChange, onRetryParentThreads: handleRetryParentThreads, diff --git a/apps/app/vite-bundle-stats.ts b/apps/app/vite-bundle-stats.ts index 9ce4be5299..c86f36cc79 100644 --- a/apps/app/vite-bundle-stats.ts +++ b/apps/app/vite-bundle-stats.ts @@ -10,6 +10,8 @@ export interface BundleBootChunk { bytes: number; /** npm package names whose code landed in this chunk. */ packages: string[]; + /** App-relative source modules whose code landed in this chunk. */ + modules: string[]; } export interface BundleChunk extends BundleBootChunk { @@ -47,6 +49,7 @@ export interface BundleStats { * path suffix, matched against the output chunk's `facadeModuleId`. */ export const MEASURED_ROUTE_CLOSURES: Record = { + ProjectSettingsView: "/src/views/ProjectSettingsView.tsx", SplitWorkspaceRoute: "/src/views/SplitWorkspaceRoute.tsx", }; @@ -98,14 +101,18 @@ export function computeBundleStats( const describeChunk = (chunk: BundleStatsChunkInput): BundleBootChunk => { const packages = new Set(); + const modules = new Set(); for (const moduleId of chunk.moduleIds) { const name = packageNameOf(moduleId); if (name !== null) packages.add(name); + const sourceModule = appModulePathOf(moduleId); + if (sourceModule !== null) modules.add(sourceModule); } return { fileName: chunk.fileName, bytes: Buffer.byteLength(chunk.code), packages: [...packages].sort(), + modules: [...modules].sort(), }; }; @@ -219,3 +226,24 @@ function packageNameOf(moduleId: string): string | null { return second === undefined ? null : `${first}/${second}`; return first; } + +/** Absolute app source id -> app-relative path; dependencies/virtual ids -> null. */ +function appModulePathOf(moduleId: string): string | null { + const cleanId = moduleId.split("?", 1)[0]; + if (cleanId === undefined || cleanId.startsWith("\0")) return null; + const normalizedId = cleanId.split(sep).join("/"); + const appMarker = "/apps/app/"; + const appMarkerIndex = normalizedId.lastIndexOf(appMarker); + if (appMarkerIndex >= 0) { + return normalizedId.slice(appMarkerIndex + appMarker.length); + } + const sourcePath = relative(appDir, cleanId); + if ( + sourcePath === "" || + sourcePath === ".." || + sourcePath.startsWith(`..${sep}`) + ) { + return null; + } + return sourcePath.split(sep).join("/"); +} diff --git a/packages/plugin-registry/r/dialog.json b/packages/plugin-registry/r/dialog.json index da4d59af28..a117b752b6 100644 --- a/packages/plugin-registry/r/dialog.json +++ b/packages/plugin-registry/r/dialog.json @@ -19,7 +19,7 @@ "files": [ { "path": "registry/components/ui/dialog.tsx", - "content": "/* shadcn/ui-derived */\nimport * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { Slot } from \"@radix-ui/react-slot\";\n\nimport { cn } from \"../../lib/utils\";\nimport { usePortalScopeProps } from \"../../lib/portal-scope\";\nimport { useBrowserDimmingModal } from \"../../hooks/useBrowserDimmingModal\";\nimport {\n type ResponsiveOverlayContextValue,\n useResponsiveRoot,\n MobileTrigger,\n ResponsiveDrawerShell,\n stripRadixContentProps,\n} from \"./responsive-overlay.js\";\nimport {\n blurActiveKeyboardInputBeforeOverlayOpen,\n getOverlayTriggerClassName,\n preventOverlayTriggerSelection,\n} from \"./overlay-trigger.js\";\nimport { Icon } from \"../../components/ui/icon.js\";\n\n// ---------------------------------------------------------------------------\n// Context — separate instance from DropdownMenu / Popover.\n// ---------------------------------------------------------------------------\n\ninterface ResponsiveDialogContextValue extends ResponsiveOverlayContextValue {\n titleId: string;\n descriptionId: string;\n registerTitleId: (id: string) => () => void;\n registerDescriptionId: (id: string) => () => void;\n}\n\nconst ResponsiveDialogContext =\n React.createContext({\n isCompactViewport: false,\n open: false,\n onOpenChange: () => {},\n titleId: \"\",\n descriptionId: \"\",\n registerTitleId: () => () => {},\n registerDescriptionId: () => () => {},\n });\n\nfunction useResponsiveDialog() {\n return React.useContext(ResponsiveDialogContext);\n}\n\n// ---------------------------------------------------------------------------\n// Root\n// ---------------------------------------------------------------------------\n\nfunction Dialog({\n children,\n open: controlledOpen,\n onOpenChange: controlledOnChange,\n ...props\n}: React.ComponentProps) {\n const responsiveRoot = useResponsiveRoot(controlledOpen, controlledOnChange);\n const generatedTitleId = React.useId();\n const generatedDescriptionId = React.useId();\n const [titleId, setTitleId] = React.useState(generatedTitleId);\n const [descriptionId, setDescriptionId] = React.useState(\n generatedDescriptionId,\n );\n const registerTitleId = React.useCallback(\n (id: string) => {\n setTitleId(id);\n return () => setTitleId(generatedTitleId);\n },\n [generatedTitleId],\n );\n const registerDescriptionId = React.useCallback(\n (id: string) => {\n setDescriptionId(id);\n return () => setDescriptionId(generatedDescriptionId);\n },\n [generatedDescriptionId],\n );\n const ctx = React.useMemo(\n () => ({\n ...responsiveRoot,\n titleId,\n descriptionId,\n registerTitleId,\n registerDescriptionId,\n }),\n [\n descriptionId,\n registerDescriptionId,\n registerTitleId,\n responsiveRoot,\n titleId,\n ],\n );\n\n const body = ctx.isCompactViewport ? (\n children\n ) : (\n \n {children}\n \n );\n\n return (\n \n {body}\n \n );\n}\n\n// ---------------------------------------------------------------------------\n// Trigger\n// ---------------------------------------------------------------------------\n\nconst DialogTrigger = React.forwardRef<\n HTMLButtonElement,\n React.ComponentPropsWithoutRef\n>(({ asChild, children, className, ...props }, ref) => {\n const { isCompactViewport, open, onOpenChange } = useResponsiveDialog();\n\n if (isCompactViewport) {\n return (\n \n {children}\n \n );\n }\n\n return (\n {\n if (!open) {\n blurActiveKeyboardInputBeforeOverlayOpen();\n }\n preventOverlayTriggerSelection(event);\n }}\n {...props}\n >\n {children}\n \n );\n});\nDialogTrigger.displayName = \"DialogTrigger\";\n\n// ---------------------------------------------------------------------------\n// Close — closes the dialog/drawer. Works in both modes.\n// ---------------------------------------------------------------------------\n\ninterface DialogCloseProps extends React.ButtonHTMLAttributes {\n asChild?: boolean;\n}\n\nconst DialogClose = React.forwardRef(\n ({ asChild, onClick, children, ...props }, ref) => {\n const { isCompactViewport, onOpenChange } = useResponsiveDialog();\n\n if (isCompactViewport) {\n const Comp = asChild ? Slot : \"button\";\n const handleClick: React.MouseEventHandler = (\n event,\n ) => {\n onClick?.(event);\n if (!event.defaultPrevented) {\n onOpenChange(false);\n }\n };\n return (\n \n {children}\n \n );\n }\n\n return (\n \n {children}\n \n );\n },\n);\nDialogClose.displayName = \"DialogClose\";\n\n// ---------------------------------------------------------------------------\n// Overlay — desktop only. Kept for backwards compatibility; the drawer\n// provides its own overlay on mobile.\n// ---------------------------------------------------------------------------\n\nconst DialogOverlay = React.forwardRef<\n React.ComponentRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\n// ---------------------------------------------------------------------------\n// Content\n// ---------------------------------------------------------------------------\n\ntype DialogContentProps = React.ComponentPropsWithoutRef<\n typeof DialogPrimitive.Content\n>;\n\nconst DialogContent = React.forwardRef(\n ({ className, children, ...props }, ref) => {\n const { isCompactViewport, open, onOpenChange, titleId, descriptionId } =\n useResponsiveDialog();\n useBrowserDimmingModal(open);\n // Unconditional (rules of hooks — the compact branch returns early); the\n // compact drawer path is covered by the persistent drawer shell.\n const scopeProps = usePortalScopeProps();\n\n if (isCompactViewport) {\n const domProps = stripRadixContentProps(props);\n return (\n \n \n {children}\n \n \n );\n }\n\n return (\n \n \n \n {children}\n \n \n Close\n \n \n \n );\n },\n);\nDialogContent.displayName = \"DialogContent\";\n\n// ---------------------------------------------------------------------------\n// Header / Footer — layout primitives, unchanged.\n// ---------------------------------------------------------------------------\n\nconst DialogHeader = ({\n className,\n ...props\n}: React.HTMLAttributes) => (\n \n);\nDialogHeader.displayName = \"DialogHeader\";\n\nconst DialogFooter = ({\n className,\n ...props\n}: React.HTMLAttributes) => (\n \n);\nDialogFooter.displayName = \"DialogFooter\";\n\n// ---------------------------------------------------------------------------\n// Title / Description — use plain elements on mobile. The persistent drawer\n// links its dialog semantics to these stable IDs.\n// ---------------------------------------------------------------------------\n\nconst DialogTitle = React.forwardRef<\n HTMLHeadingElement,\n React.ComponentPropsWithoutRef\n>(({ asChild, className, id, children, ...props }, ref) => {\n const { isCompactViewport, titleId, registerTitleId } = useResponsiveDialog();\n const resolvedId = id ?? titleId;\n React.useLayoutEffect(() => {\n if (!isCompactViewport) {\n return;\n }\n return registerTitleId(resolvedId);\n }, [isCompactViewport, registerTitleId, resolvedId]);\n\n if (isCompactViewport) {\n const titleProps = {\n id: resolvedId,\n className: cn(\n \"text-base font-semibold leading-none tracking-tight\",\n className,\n ),\n ...props,\n };\n if (asChild) {\n return (\n \n {children}\n \n );\n }\n return (\n

\n {children}\n

\n );\n }\n return (\n \n {children}\n \n );\n});\nDialogTitle.displayName = \"DialogTitle\";\n\nconst DialogDescription = React.forwardRef<\n React.ComponentRef,\n React.ComponentPropsWithoutRef\n>(({ asChild, className, id, children, ...props }, ref) => {\n const { isCompactViewport, descriptionId, registerDescriptionId } =\n useResponsiveDialog();\n const resolvedId = id ?? descriptionId;\n React.useLayoutEffect(() => {\n if (!isCompactViewport) {\n return;\n }\n return registerDescriptionId(resolvedId);\n }, [isCompactViewport, registerDescriptionId, resolvedId]);\n\n if (isCompactViewport) {\n const descriptionProps = {\n id: resolvedId,\n className: cn(\"text-sm text-muted-foreground\", className),\n ...props,\n };\n if (asChild) {\n return (\n \n {children}\n \n );\n }\n return (\n

\n {children}\n

\n );\n }\n return (\n \n {children}\n \n );\n});\nDialogDescription.displayName = DialogPrimitive.Description.displayName;\n\nexport {\n Dialog,\n DialogOverlay,\n DialogTrigger,\n DialogClose,\n DialogContent,\n DialogHeader,\n DialogFooter,\n DialogTitle,\n DialogDescription,\n};\n", + "content": "/* shadcn/ui-derived */\nimport * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { Slot } from \"@radix-ui/react-slot\";\n\nimport { cn } from \"../../lib/utils\";\nimport { usePortalScopeProps } from \"../../lib/portal-scope\";\nimport { useBrowserDimmingModal } from \"../../hooks/useBrowserDimmingModal\";\nimport {\n type ResponsiveOverlayContextValue,\n useResponsiveRoot,\n MobileTrigger,\n ResponsiveDrawerShell,\n stripRadixContentProps,\n} from \"./responsive-overlay.js\";\nimport {\n blurActiveKeyboardInputBeforeOverlayOpen,\n getOverlayTriggerClassName,\n preventOverlayTriggerSelection,\n} from \"./overlay-trigger.js\";\nimport { Icon } from \"../../components/ui/icon.js\";\n\n// ---------------------------------------------------------------------------\n// Context — separate instance from DropdownMenu / Popover.\n// ---------------------------------------------------------------------------\n\ninterface ResponsiveDialogContextValue extends ResponsiveOverlayContextValue {\n titleId: string;\n descriptionId: string;\n registerTitleId: (id: string) => () => void;\n registerDescriptionId: (id: string) => () => void;\n}\n\nconst ResponsiveDialogContext =\n React.createContext({\n isCompactViewport: false,\n open: false,\n onOpenChange: () => {},\n titleId: \"\",\n descriptionId: \"\",\n registerTitleId: () => () => {},\n registerDescriptionId: () => () => {},\n });\n\nfunction useResponsiveDialog() {\n return React.useContext(ResponsiveDialogContext);\n}\n\n// ---------------------------------------------------------------------------\n// Root\n// ---------------------------------------------------------------------------\n\nfunction Dialog({\n children,\n open: controlledOpen,\n onOpenChange: controlledOnChange,\n ...props\n}: React.ComponentProps) {\n const responsiveRoot = useResponsiveRoot(controlledOpen, controlledOnChange);\n const generatedTitleId = React.useId();\n const generatedDescriptionId = React.useId();\n const [titleId, setTitleId] = React.useState(generatedTitleId);\n const [descriptionId, setDescriptionId] = React.useState(\n generatedDescriptionId,\n );\n const registerTitleId = React.useCallback(\n (id: string) => {\n setTitleId(id);\n return () => setTitleId(generatedTitleId);\n },\n [generatedTitleId],\n );\n const registerDescriptionId = React.useCallback(\n (id: string) => {\n setDescriptionId(id);\n return () => setDescriptionId(generatedDescriptionId);\n },\n [generatedDescriptionId],\n );\n const ctx = React.useMemo(\n () => ({\n ...responsiveRoot,\n titleId,\n descriptionId,\n registerTitleId,\n registerDescriptionId,\n }),\n [\n descriptionId,\n registerDescriptionId,\n registerTitleId,\n responsiveRoot,\n titleId,\n ],\n );\n\n const body = ctx.isCompactViewport ? (\n children\n ) : (\n \n {children}\n \n );\n\n return (\n \n {body}\n \n );\n}\n\n// ---------------------------------------------------------------------------\n// Trigger\n// ---------------------------------------------------------------------------\n\nconst DialogTrigger = React.forwardRef<\n HTMLButtonElement,\n React.ComponentPropsWithoutRef\n>(({ asChild, children, className, ...props }, ref) => {\n const { isCompactViewport, open, onOpenChange } = useResponsiveDialog();\n\n if (isCompactViewport) {\n return (\n \n {children}\n \n );\n }\n\n return (\n {\n if (!open) {\n blurActiveKeyboardInputBeforeOverlayOpen();\n }\n preventOverlayTriggerSelection(event);\n }}\n {...props}\n >\n {children}\n \n );\n});\nDialogTrigger.displayName = \"DialogTrigger\";\n\n// ---------------------------------------------------------------------------\n// Close — closes the dialog/drawer. Works in both modes.\n// ---------------------------------------------------------------------------\n\ninterface DialogCloseProps extends React.ButtonHTMLAttributes {\n asChild?: boolean;\n}\n\nconst DialogClose = React.forwardRef(\n ({ asChild, onClick, children, ...props }, ref) => {\n const { isCompactViewport, onOpenChange } = useResponsiveDialog();\n\n if (isCompactViewport) {\n const Comp = asChild ? Slot : \"button\";\n const handleClick: React.MouseEventHandler = (\n event,\n ) => {\n onClick?.(event);\n if (!event.defaultPrevented) {\n onOpenChange(false);\n }\n };\n return (\n \n {children}\n \n );\n }\n\n return (\n \n {children}\n \n );\n },\n);\nDialogClose.displayName = \"DialogClose\";\n\n// ---------------------------------------------------------------------------\n// Overlay — desktop only. Kept for backwards compatibility; the drawer\n// provides its own overlay on mobile.\n// ---------------------------------------------------------------------------\n\nconst DialogOverlay = React.forwardRef<\n React.ComponentRef,\n React.ComponentPropsWithoutRef\n>(({ className, ...props }, ref) => (\n \n));\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\n// ---------------------------------------------------------------------------\n// Content\n// ---------------------------------------------------------------------------\n\ntype DialogContentProps = React.ComponentPropsWithoutRef<\n typeof DialogPrimitive.Content\n>;\n\nconst DialogContent = React.forwardRef(\n (\n {\n className,\n children,\n \"aria-label\": accessibleLabel,\n \"aria-description\": accessibleDescription,\n ...props\n },\n ref,\n ) => {\n const { isCompactViewport, open, onOpenChange, titleId, descriptionId } =\n useResponsiveDialog();\n useBrowserDimmingModal(open);\n // Unconditional (rules of hooks — the compact branch returns early); the\n // compact drawer path is covered by the persistent drawer shell.\n const scopeProps = usePortalScopeProps();\n\n if (isCompactViewport) {\n const domProps = stripRadixContentProps(props);\n return (\n \n \n {children}\n \n \n );\n }\n\n return (\n \n \n \n {children}\n \n \n Close\n \n \n \n );\n },\n);\nDialogContent.displayName = \"DialogContent\";\n\n// ---------------------------------------------------------------------------\n// Header / Footer — layout primitives, unchanged.\n// ---------------------------------------------------------------------------\n\nconst DialogHeader = ({\n className,\n ...props\n}: React.HTMLAttributes) => (\n \n);\nDialogHeader.displayName = \"DialogHeader\";\n\nconst DialogFooter = ({\n className,\n ...props\n}: React.HTMLAttributes) => (\n \n);\nDialogFooter.displayName = \"DialogFooter\";\n\n// ---------------------------------------------------------------------------\n// Title / Description — use plain elements on mobile. The persistent drawer\n// links its dialog semantics to these stable IDs.\n// ---------------------------------------------------------------------------\n\nconst DialogTitle = React.forwardRef<\n HTMLHeadingElement,\n React.ComponentPropsWithoutRef\n>(({ asChild, className, id, children, ...props }, ref) => {\n const { isCompactViewport, titleId, registerTitleId } = useResponsiveDialog();\n const resolvedId = id ?? titleId;\n React.useLayoutEffect(() => {\n if (!isCompactViewport) {\n return;\n }\n return registerTitleId(resolvedId);\n }, [isCompactViewport, registerTitleId, resolvedId]);\n\n if (isCompactViewport) {\n const titleProps = {\n id: resolvedId,\n className: cn(\n \"text-base font-semibold leading-none tracking-tight\",\n className,\n ),\n ...props,\n };\n if (asChild) {\n return (\n \n {children}\n \n );\n }\n return (\n

\n {children}\n

\n );\n }\n return (\n \n {children}\n \n );\n});\nDialogTitle.displayName = \"DialogTitle\";\n\nconst DialogDescription = React.forwardRef<\n React.ComponentRef,\n React.ComponentPropsWithoutRef\n>(({ asChild, className, id, children, ...props }, ref) => {\n const { isCompactViewport, descriptionId, registerDescriptionId } =\n useResponsiveDialog();\n const resolvedId = id ?? descriptionId;\n React.useLayoutEffect(() => {\n if (!isCompactViewport) {\n return;\n }\n return registerDescriptionId(resolvedId);\n }, [isCompactViewport, registerDescriptionId, resolvedId]);\n\n if (isCompactViewport) {\n const descriptionProps = {\n id: resolvedId,\n className: cn(\"text-sm text-muted-foreground\", className),\n ...props,\n };\n if (asChild) {\n return (\n \n {children}\n \n );\n }\n return (\n

\n {children}\n

\n );\n }\n return (\n \n {children}\n \n );\n});\nDialogDescription.displayName = DialogPrimitive.Description.displayName;\n\nexport {\n Dialog,\n DialogOverlay,\n DialogTrigger,\n DialogClose,\n DialogContent,\n DialogHeader,\n DialogFooter,\n DialogTitle,\n DialogDescription,\n};\n", "type": "registry:ui", "target": "components/ui/dialog.tsx" } diff --git a/packages/plugin-registry/r/responsive-overlay.json b/packages/plugin-registry/r/responsive-overlay.json index dff9a8b863..89a125053a 100644 --- a/packages/plugin-registry/r/responsive-overlay.json +++ b/packages/plugin-registry/r/responsive-overlay.json @@ -16,7 +16,7 @@ "files": [ { "path": "registry/components/ui/responsive-overlay.tsx", - "content": "import * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Slot } from \"@radix-ui/react-slot\";\n\nimport {\n blurActiveKeyboardInputBeforeOverlayOpen,\n blurActiveKeyboardInputBeforeOverlayClose,\n blurActiveKeyboardInputWithin,\n getOverlayTriggerClassName,\n preventOverlayTriggerSelection,\n} from \"./overlay-trigger.js\";\nimport { useIsCompactViewport } from \"./hooks/use-compact-viewport.js\";\nimport { usePortalScopeProps } from \"../../lib/portal-scope.js\";\nimport { cn } from \"../../lib/utils.js\";\n\n// ---------------------------------------------------------------------------\n// Shared context value for responsive overlays (dropdown menus, popovers)\n// ---------------------------------------------------------------------------\n\nexport interface ResponsiveOverlayContextValue {\n isCompactViewport: boolean;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n}\n\nconst RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS = 120;\n\nfunction resetDrawerKeyboardStyles(drawerElement: HTMLElement | null): void {\n if (drawerElement === null) return;\n\n drawerElement.style.height = \"\";\n drawerElement.style.bottom = \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Hook: manages open state, mobile detection, and breakpoint-cross close.\n// One useMediaQuery subscription per Root (not two).\n// ---------------------------------------------------------------------------\n\nexport function useResponsiveRoot(\n controlledOpen: boolean | undefined,\n controlledOnChange: ((open: boolean) => void) | undefined,\n defaultOpen: boolean = false,\n): ResponsiveOverlayContextValue {\n const isCompactViewport = useIsCompactViewport();\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const onOpenChange = React.useCallback(\n (next: boolean) => {\n if (open && !next && isCompactViewport) {\n blurActiveKeyboardInputBeforeOverlayClose();\n }\n if (!isControlled) {\n setInternalOpen(next);\n }\n controlledOnChange?.(next);\n },\n [isCompactViewport, isControlled, controlledOnChange, open],\n );\n\n return React.useMemo(\n () => ({ isCompactViewport, open, onOpenChange }),\n [isCompactViewport, open, onOpenChange],\n );\n}\n\n// ---------------------------------------------------------------------------\n// MobileTrigger: shared trigger for mobile overlays.\n// Adds aria-expanded, aria-haspopup, and data-state that Radix normally\n// provides on desktop but which are missing from a bare \n );\n },\n);\nMobileTrigger.displayName = \"MobileTrigger\";\n\n// ---------------------------------------------------------------------------\n// stripRadixContentProps: removes Radix positioning/behavior props from a\n// props object so that only DOM-compatible props remain for mobile rendering.\n// Derived from a single const to prevent interface/set drift.\n// ---------------------------------------------------------------------------\n\nconst RADIX_CONTENT_PROP_NAMES = [\n \"side\",\n \"sideOffset\",\n \"align\",\n \"alignOffset\",\n \"collisionPadding\",\n \"collisionBoundary\",\n \"arrowPadding\",\n \"sticky\",\n \"hideWhenDetached\",\n \"avoidCollisions\",\n \"onOpenAutoFocus\",\n \"onCloseAutoFocus\",\n \"onEscapeKeyDown\",\n \"onPointerDownOutside\",\n \"onFocusOutside\",\n \"onInteractOutside\",\n] as const;\n\ntype RadixContentPropName = (typeof RADIX_CONTENT_PROP_NAMES)[number];\n\nconst RADIX_CONTENT_KEYS: ReadonlySet = new Set(\n RADIX_CONTENT_PROP_NAMES,\n);\n\nexport function stripRadixContentProps>(\n props: T,\n): Omit {\n const result = {} as Record;\n for (const key of Object.keys(props)) {\n if (!RADIX_CONTENT_KEYS.has(key)) {\n result[key] = props[key];\n }\n }\n return result as Omit;\n}\n\n// ---------------------------------------------------------------------------\n// ResponsiveDrawerShell: shared scaffold for compact menus, popovers, and\n// dialogs. It uses the persistent shell so opening an overlay never applies\n// modal attributes to the app tree. It also lets the transform start before\n// it mounts the overlay body, then retains that body for later opens.\n// ---------------------------------------------------------------------------\n\ninterface ResponsiveDrawerShellProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n /**\n * Sr-only label announced when the drawer opens. Omit if the caller\n * renders its own labeled heading inside children (e.g. DialogTitle).\n */\n srLabel?: string;\n /** Existing visible title used to label a dialog body. */\n labelledBy?: string;\n /** Existing visible description for a dialog body. */\n describedBy?: string;\n /** Class name on the drawer panel. */\n contentClassName?: string;\n /** Called when the drawer transform completes. */\n onContentAnimationEnd?: (open: boolean) => void;\n children: React.ReactNode;\n}\n\nexport function useResponsiveDrawerRealization({\n open,\n enabled = true,\n}: {\n open: boolean;\n enabled?: boolean;\n}): { isContentRealized: boolean; realizeContent: () => void } {\n const [isContentRealized, setIsContentRealized] = React.useState(false);\n const realizeContent = React.useCallback(\n () => setIsContentRealized(true),\n [],\n );\n\n React.useEffect(() => {\n if (!enabled || !open || isContentRealized) {\n return;\n }\n\n let firstFrame: number | null = null;\n let secondFrame: number | null = null;\n firstFrame = window.requestAnimationFrame(() => {\n firstFrame = null;\n secondFrame = window.requestAnimationFrame(() => {\n secondFrame = null;\n realizeContent();\n });\n });\n const fallback = window.setTimeout(\n realizeContent,\n RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS,\n );\n\n return () => {\n if (firstFrame !== null) {\n window.cancelAnimationFrame(firstFrame);\n }\n if (secondFrame !== null) {\n window.cancelAnimationFrame(secondFrame);\n }\n window.clearTimeout(fallback);\n };\n }, [enabled, isContentRealized, open, realizeContent]);\n\n return {\n isContentRealized: enabled && isContentRealized,\n realizeContent,\n };\n}\n\nexport function ResponsiveDrawerShell({\n open,\n onOpenChange,\n srLabel,\n labelledBy,\n describedBy,\n contentClassName,\n onContentAnimationEnd,\n children,\n}: ResponsiveDrawerShellProps) {\n const { isContentRealized } = useResponsiveDrawerRealization({ open });\n\n if (!open && !isContentRealized) {\n return null;\n }\n\n return (\n \n {isContentRealized ? (\n children\n ) : (\n \n )}\n \n );\n}\n\n// ---------------------------------------------------------------------------\n// PersistentResponsiveDrawerShell: a bottom drawer for a large, persistent\n// panel. Unlike Radix/Vaul, this shell does not apply modal attributes to the\n// app root. Those attributes make WebKit resolve styles for the full chat tree\n// on each open. The backdrop blocks pointer input, while the key handler keeps\n// keyboard focus inside the drawer.\n// ---------------------------------------------------------------------------\n\ninterface PersistentResponsiveDrawerShellProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n srLabel?: string;\n labelledBy?: string;\n describedBy?: string;\n contentClassName?: string;\n motionDurationMs?: number;\n onContentAnimationEnd?: (open: boolean) => void;\n children: React.ReactNode;\n}\n\nconst PERSISTENT_DRAWER_EASING = \"cubic-bezier(0.32, 0.72, 0, 1)\";\nconst PERSISTENT_DRAWER_CLOSE_RATIO = 0.25;\nconst PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC = 450;\nconst PERSISTENT_DRAWER_FOCUSABLE_SELECTOR = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"textarea:not([disabled])\",\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\ntype PersistentDrawerStackEntry = {\n panel: () => HTMLElement | null;\n requestClose: () => void;\n};\n\ntype PersistentDrawerStack = {\n entries: PersistentDrawerStackEntry[];\n handleKeyDown: (event: KeyboardEvent) => void;\n};\n\nconst persistentDrawerStacks = new WeakMap();\n\nfunction getDrawerFocusableElements(panel: HTMLElement): HTMLElement[] {\n return Array.from(\n panel.querySelectorAll(PERSISTENT_DRAWER_FOCUSABLE_SELECTOR),\n ).filter(\n (element) => element.closest('[aria-hidden=\"true\"], [inert]') === null,\n );\n}\n\nfunction activeElementIsInAnotherOverlay(\n activeElement: Element | null,\n panel: HTMLElement,\n): boolean {\n const overlay = activeElement?.closest(\n \"[data-bb-portaled-overlay]\",\n );\n return overlay !== null && overlay !== undefined && overlay !== panel;\n}\n\nfunction handleDrawerTab(event: KeyboardEvent, panel: HTMLElement): void {\n const activeElement = panel.ownerDocument.activeElement;\n if (\n !panel.contains(activeElement) &&\n activeElementIsInAnotherOverlay(activeElement, panel)\n ) {\n return;\n }\n\n const focusable = getDrawerFocusableElements(panel);\n event.preventDefault();\n if (focusable.length === 0) {\n panel.focus({ preventScroll: true });\n return;\n }\n\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n if (event.shiftKey) {\n if (\n !panel.contains(activeElement) ||\n activeElement === panel ||\n activeElement === first\n ) {\n last?.focus({ preventScroll: true });\n return;\n }\n const index = focusable.indexOf(activeElement as HTMLElement);\n focusable[Math.max(0, index - 1)]?.focus({ preventScroll: true });\n return;\n }\n\n if (\n !panel.contains(activeElement) ||\n activeElement === panel ||\n activeElement === last\n ) {\n first?.focus({ preventScroll: true });\n return;\n }\n const index = focusable.indexOf(activeElement as HTMLElement);\n focusable[Math.min(focusable.length - 1, index + 1)]?.focus({\n preventScroll: true,\n });\n}\n\nfunction registerOpenDrawer(\n ownerDocument: Document,\n entry: PersistentDrawerStackEntry,\n): () => void {\n let stack = persistentDrawerStacks.get(ownerDocument);\n if (stack === undefined) {\n const entries: PersistentDrawerStackEntry[] = [];\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented) {\n return;\n }\n const topEntry = entries[entries.length - 1];\n const panel = topEntry?.panel() ?? null;\n if (topEntry === undefined || panel === null) {\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n topEntry.requestClose();\n } else if (event.key === \"Tab\") {\n handleDrawerTab(event, panel);\n }\n };\n stack = { entries, handleKeyDown };\n persistentDrawerStacks.set(ownerDocument, stack);\n ownerDocument.addEventListener(\"keydown\", handleKeyDown);\n }\n stack.entries.push(entry);\n\n return () => {\n const currentStack = persistentDrawerStacks.get(ownerDocument);\n if (currentStack === undefined) {\n return;\n }\n const index = currentStack.entries.indexOf(entry);\n if (index >= 0) {\n currentStack.entries.splice(index, 1);\n }\n if (currentStack.entries.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", currentStack.handleKeyDown);\n persistentDrawerStacks.delete(ownerDocument);\n }\n };\n}\n\ntype PersistentDrawerDrag = {\n pointerId: number;\n startY: number;\n lastY: number;\n lastTimeMs: number;\n velocityY: number;\n height: number;\n};\n\nexport function PersistentResponsiveDrawerShell({\n open,\n onOpenChange,\n srLabel,\n labelledBy,\n describedBy,\n contentClassName,\n motionDurationMs = 220,\n onContentAnimationEnd,\n children,\n}: PersistentResponsiveDrawerShellProps) {\n const panelRef = React.useRef(null);\n const backdropRef = React.useRef(null);\n const dragRef = React.useRef(null);\n const returnFocusRef = React.useRef(null);\n const settledStateRef = React.useRef(null);\n const labelId = React.useId();\n const portalScopeProps = usePortalScopeProps();\n const transition = `transform ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;\n const backdropTransition = `opacity ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;\n const onOpenChangeRef = React.useRef(onOpenChange);\n React.useLayoutEffect(() => {\n onOpenChangeRef.current = onOpenChange;\n }, [onOpenChange]);\n const requestClose = React.useCallback(() => {\n blurActiveKeyboardInputWithin(panelRef.current);\n resetDrawerKeyboardStyles(panelRef.current);\n onOpenChangeRef.current(false);\n }, []);\n\n const reportSettled = React.useCallback(\n (settledOpen: boolean) => {\n if (settledStateRef.current === settledOpen) {\n return;\n }\n settledStateRef.current = settledOpen;\n onContentAnimationEnd?.(settledOpen);\n },\n [onContentAnimationEnd],\n );\n\n React.useEffect(() => {\n settledStateRef.current = null;\n const timeout = window.setTimeout(\n () => reportSettled(open),\n motionDurationMs + 50,\n );\n return () => window.clearTimeout(timeout);\n }, [motionDurationMs, open, reportSettled]);\n\n React.useLayoutEffect(() => {\n if (!open) {\n return;\n }\n const panel = panelRef.current;\n if (panel === null) {\n return;\n }\n const ownerDocument = panel.ownerDocument;\n const previousFocus = ownerDocument.activeElement;\n returnFocusRef.current =\n previousFocus instanceof HTMLElement ? previousFocus : null;\n const unregister = registerOpenDrawer(ownerDocument, {\n panel: () => panelRef.current,\n requestClose,\n });\n panel.focus({ preventScroll: true });\n\n return () => {\n unregister();\n };\n }, [open, requestClose]);\n\n const previousOpenRef = React.useRef(open);\n React.useLayoutEffect(() => {\n if (previousOpenRef.current && !open) {\n blurActiveKeyboardInputWithin(panelRef.current);\n resetDrawerKeyboardStyles(panelRef.current);\n const returnFocus = returnFocusRef.current;\n if (\n returnFocus?.isConnected &&\n returnFocus.closest('[aria-hidden=\"true\"], [inert]') === null\n ) {\n returnFocus.focus({ preventScroll: true });\n }\n returnFocusRef.current = null;\n }\n previousOpenRef.current = open;\n }, [open]);\n\n const setDragPosition = React.useCallback(\n (offsetY: number, height: number, animate: boolean) => {\n const panel = panelRef.current;\n const backdrop = backdropRef.current;\n if (panel === null || backdrop === null) {\n return;\n }\n panel.style.transition = animate ? transition : \"none\";\n panel.style.transform = `translate3d(0, ${offsetY}px, 0)`;\n backdrop.style.transition = animate ? backdropTransition : \"none\";\n backdrop.style.opacity = String(\n Math.max(0, Math.min(1, 1 - offsetY / height)),\n );\n },\n [backdropTransition, transition],\n );\n\n const handleDragStart = React.useCallback(\n (event: React.PointerEvent) => {\n if (!open || event.button !== 0) {\n return;\n }\n event.currentTarget.setPointerCapture(event.pointerId);\n const nowMs = Date.now();\n const height = Math.max(panelRef.current?.clientHeight ?? 0, 1);\n dragRef.current = {\n pointerId: event.pointerId,\n startY: event.clientY,\n lastY: event.clientY,\n lastTimeMs: nowMs,\n velocityY: 0,\n height,\n };\n setDragPosition(0, height, false);\n event.preventDefault();\n },\n [open, setDragPosition],\n );\n\n const handleDragMove = React.useCallback(\n (event: React.PointerEvent) => {\n const drag = dragRef.current;\n if (drag === null || drag.pointerId !== event.pointerId) {\n return;\n }\n const nowMs = Date.now();\n const elapsedMs = nowMs - drag.lastTimeMs;\n if (elapsedMs > 0) {\n drag.velocityY = ((event.clientY - drag.lastY) / elapsedMs) * 1000;\n drag.lastY = event.clientY;\n drag.lastTimeMs = nowMs;\n }\n setDragPosition(\n Math.max(0, event.clientY - drag.startY),\n drag.height,\n false,\n );\n event.preventDefault();\n },\n [setDragPosition],\n );\n\n const finishDrag = React.useCallback(\n (event: React.PointerEvent, cancelled: boolean) => {\n const drag = dragRef.current;\n if (drag === null || drag.pointerId !== event.pointerId) {\n return;\n }\n dragRef.current = null;\n const offsetY = Math.max(0, event.clientY - drag.startY);\n const shouldClose =\n !cancelled &&\n (offsetY >= drag.height * PERSISTENT_DRAWER_CLOSE_RATIO ||\n drag.velocityY >= PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC);\n if (shouldClose) {\n setDragPosition(drag.height, drag.height, true);\n requestClose();\n } else {\n setDragPosition(0, drag.height, true);\n }\n event.preventDefault();\n },\n [requestClose, setDragPosition],\n );\n\n const portalTarget = typeof document === \"undefined\" ? null : document.body;\n if (portalTarget === null) {\n return null;\n }\n\n return createPortal(\n <>\n event.preventDefault()}\n />\n {\n if (\n event.currentTarget === event.target &&\n event.propertyName === \"transform\"\n ) {\n reportSettled(open);\n }\n }}\n >\n finishDrag(event, false)}\n onPointerCancel={(event) => finishDrag(event, true)}\n >\n
\n
\n {srLabel === undefined ? null : (\n

\n {srLabel}\n

\n )}\n {children}\n \n ,\n portalTarget,\n );\n}\n", + "content": "import * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Slot } from \"@radix-ui/react-slot\";\n\nimport {\n blurActiveKeyboardInputBeforeOverlayOpen,\n blurActiveKeyboardInputBeforeOverlayClose,\n blurActiveKeyboardInputWithin,\n getOverlayTriggerClassName,\n preventOverlayTriggerSelection,\n} from \"./overlay-trigger.js\";\nimport { useIsCompactViewport } from \"./hooks/use-compact-viewport.js\";\nimport { usePortalScopeProps } from \"../../lib/portal-scope.js\";\nimport { cn } from \"../../lib/utils.js\";\n\n// ---------------------------------------------------------------------------\n// Shared context value for responsive overlays (dropdown menus, popovers)\n// ---------------------------------------------------------------------------\n\nexport interface ResponsiveOverlayContextValue {\n isCompactViewport: boolean;\n open: boolean;\n onOpenChange: (open: boolean) => void;\n}\n\nconst RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS = 120;\n\nfunction resetDrawerKeyboardStyles(drawerElement: HTMLElement | null): void {\n if (drawerElement === null) return;\n\n drawerElement.style.height = \"\";\n drawerElement.style.bottom = \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Hook: manages open state, mobile detection, and breakpoint-cross close.\n// One useMediaQuery subscription per Root (not two).\n// ---------------------------------------------------------------------------\n\nexport function useResponsiveRoot(\n controlledOpen: boolean | undefined,\n controlledOnChange: ((open: boolean) => void) | undefined,\n defaultOpen: boolean = false,\n): ResponsiveOverlayContextValue {\n const isCompactViewport = useIsCompactViewport();\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n const isControlled = controlledOpen !== undefined;\n const open = isControlled ? controlledOpen : internalOpen;\n\n const onOpenChange = React.useCallback(\n (next: boolean) => {\n if (open && !next && isCompactViewport) {\n blurActiveKeyboardInputBeforeOverlayClose();\n }\n if (!isControlled) {\n setInternalOpen(next);\n }\n controlledOnChange?.(next);\n },\n [isCompactViewport, isControlled, controlledOnChange, open],\n );\n\n return React.useMemo(\n () => ({ isCompactViewport, open, onOpenChange }),\n [isCompactViewport, open, onOpenChange],\n );\n}\n\n// ---------------------------------------------------------------------------\n// MobileTrigger: shared trigger for mobile overlays.\n// Adds aria-expanded, aria-haspopup, and data-state that Radix normally\n// provides on desktop but which are missing from a bare \n );\n },\n);\nMobileTrigger.displayName = \"MobileTrigger\";\n\n// ---------------------------------------------------------------------------\n// stripRadixContentProps: removes Radix positioning/behavior props from a\n// props object so that only DOM-compatible props remain for mobile rendering.\n// Derived from a single const to prevent interface/set drift.\n// ---------------------------------------------------------------------------\n\nconst RADIX_CONTENT_PROP_NAMES = [\n \"side\",\n \"sideOffset\",\n \"align\",\n \"alignOffset\",\n \"collisionPadding\",\n \"collisionBoundary\",\n \"arrowPadding\",\n \"sticky\",\n \"hideWhenDetached\",\n \"avoidCollisions\",\n \"onOpenAutoFocus\",\n \"onCloseAutoFocus\",\n \"onEscapeKeyDown\",\n \"onPointerDownOutside\",\n \"onFocusOutside\",\n \"onInteractOutside\",\n] as const;\n\ntype RadixContentPropName = (typeof RADIX_CONTENT_PROP_NAMES)[number];\n\nconst RADIX_CONTENT_KEYS: ReadonlySet = new Set(\n RADIX_CONTENT_PROP_NAMES,\n);\n\nexport function stripRadixContentProps>(\n props: T,\n): Omit {\n const result = {} as Record;\n for (const key of Object.keys(props)) {\n if (!RADIX_CONTENT_KEYS.has(key)) {\n result[key] = props[key];\n }\n }\n return result as Omit;\n}\n\n// ---------------------------------------------------------------------------\n// ResponsiveDrawerShell: shared scaffold for compact menus, popovers, and\n// dialogs. It uses the persistent shell so opening an overlay never applies\n// modal attributes to the app tree. It also lets the transform start before\n// it mounts the overlay body, then retains that body for later opens.\n// ---------------------------------------------------------------------------\n\ninterface ResponsiveDrawerShellProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n /**\n * Sr-only label announced when the drawer opens. Omit if the caller\n * renders its own labeled heading inside children (e.g. DialogTitle).\n */\n srLabel?: string;\n /** Sr-only description announced before deferred drawer content realizes. */\n srDescription?: string;\n /** Existing visible title used to label a dialog body. */\n labelledBy?: string;\n /** Existing visible description for a dialog body. */\n describedBy?: string;\n /** Class name on the drawer panel. */\n contentClassName?: string;\n /** Called when the drawer transform completes. */\n onContentAnimationEnd?: (open: boolean) => void;\n children: React.ReactNode;\n}\n\nexport function useResponsiveDrawerRealization({\n open,\n enabled = true,\n}: {\n open: boolean;\n enabled?: boolean;\n}): { isContentRealized: boolean; realizeContent: () => void } {\n const [isContentRealized, setIsContentRealized] = React.useState(false);\n const realizeContent = React.useCallback(\n () => setIsContentRealized(true),\n [],\n );\n\n React.useEffect(() => {\n if (!enabled || !open || isContentRealized) {\n return;\n }\n\n let firstFrame: number | null = null;\n let secondFrame: number | null = null;\n firstFrame = window.requestAnimationFrame(() => {\n firstFrame = null;\n secondFrame = window.requestAnimationFrame(() => {\n secondFrame = null;\n realizeContent();\n });\n });\n const fallback = window.setTimeout(\n realizeContent,\n RESPONSIVE_DRAWER_REALIZE_FALLBACK_MS,\n );\n\n return () => {\n if (firstFrame !== null) {\n window.cancelAnimationFrame(firstFrame);\n }\n if (secondFrame !== null) {\n window.cancelAnimationFrame(secondFrame);\n }\n window.clearTimeout(fallback);\n };\n }, [enabled, isContentRealized, open, realizeContent]);\n\n return {\n isContentRealized: enabled && isContentRealized,\n realizeContent,\n };\n}\n\nexport function ResponsiveDrawerShell({\n open,\n onOpenChange,\n srLabel,\n srDescription,\n labelledBy,\n describedBy,\n contentClassName,\n onContentAnimationEnd,\n children,\n}: ResponsiveDrawerShellProps) {\n const { isContentRealized } = useResponsiveDrawerRealization({ open });\n\n if (!open && !isContentRealized) {\n return null;\n }\n\n return (\n \n {isContentRealized ? (\n children\n ) : (\n \n )}\n \n );\n}\n\n// ---------------------------------------------------------------------------\n// PersistentResponsiveDrawerShell: a bottom drawer for a large, persistent\n// panel. Unlike Radix/Vaul, this shell does not apply modal attributes to the\n// app root. Those attributes make WebKit resolve styles for the full chat tree\n// on each open. The backdrop blocks pointer input, while the key handler keeps\n// keyboard focus inside the drawer.\n// ---------------------------------------------------------------------------\n\ninterface PersistentResponsiveDrawerShellProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n srLabel?: string;\n srDescription?: string;\n labelledBy?: string;\n describedBy?: string;\n contentClassName?: string;\n motionDurationMs?: number;\n onContentAnimationEnd?: (open: boolean) => void;\n children: React.ReactNode;\n}\n\nconst PERSISTENT_DRAWER_EASING = \"cubic-bezier(0.32, 0.72, 0, 1)\";\nconst PERSISTENT_DRAWER_CLOSE_RATIO = 0.25;\nconst PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC = 450;\nconst PERSISTENT_DRAWER_FOCUSABLE_SELECTOR = [\n \"a[href]\",\n \"button:not([disabled])\",\n \"input:not([disabled])\",\n \"select:not([disabled])\",\n \"textarea:not([disabled])\",\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\ntype PersistentDrawerStackEntry = {\n panel: () => HTMLElement | null;\n requestClose: () => void;\n};\n\ntype PersistentDrawerStack = {\n entries: PersistentDrawerStackEntry[];\n handleKeyDown: (event: KeyboardEvent) => void;\n};\n\nconst persistentDrawerStacks = new WeakMap();\n\nfunction getDrawerFocusableElements(panel: HTMLElement): HTMLElement[] {\n return Array.from(\n panel.querySelectorAll(PERSISTENT_DRAWER_FOCUSABLE_SELECTOR),\n ).filter(\n (element) => element.closest('[aria-hidden=\"true\"], [inert]') === null,\n );\n}\n\nfunction activeElementIsInAnotherOverlay(\n activeElement: Element | null,\n panel: HTMLElement,\n): boolean {\n const overlay = activeElement?.closest(\n \"[data-bb-portaled-overlay]\",\n );\n return overlay !== null && overlay !== undefined && overlay !== panel;\n}\n\nfunction handleDrawerTab(event: KeyboardEvent, panel: HTMLElement): void {\n const activeElement = panel.ownerDocument.activeElement;\n if (\n !panel.contains(activeElement) &&\n activeElementIsInAnotherOverlay(activeElement, panel)\n ) {\n return;\n }\n\n const focusable = getDrawerFocusableElements(panel);\n event.preventDefault();\n if (focusable.length === 0) {\n panel.focus({ preventScroll: true });\n return;\n }\n\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n if (event.shiftKey) {\n if (\n !panel.contains(activeElement) ||\n activeElement === panel ||\n activeElement === first\n ) {\n last?.focus({ preventScroll: true });\n return;\n }\n const index = focusable.indexOf(activeElement as HTMLElement);\n focusable[Math.max(0, index - 1)]?.focus({ preventScroll: true });\n return;\n }\n\n if (\n !panel.contains(activeElement) ||\n activeElement === panel ||\n activeElement === last\n ) {\n first?.focus({ preventScroll: true });\n return;\n }\n const index = focusable.indexOf(activeElement as HTMLElement);\n focusable[Math.min(focusable.length - 1, index + 1)]?.focus({\n preventScroll: true,\n });\n}\n\nfunction registerOpenDrawer(\n ownerDocument: Document,\n entry: PersistentDrawerStackEntry,\n): () => void {\n let stack = persistentDrawerStacks.get(ownerDocument);\n if (stack === undefined) {\n const entries: PersistentDrawerStackEntry[] = [];\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented) {\n return;\n }\n const topEntry = entries[entries.length - 1];\n const panel = topEntry?.panel() ?? null;\n if (topEntry === undefined || panel === null) {\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n topEntry.requestClose();\n } else if (event.key === \"Tab\") {\n handleDrawerTab(event, panel);\n }\n };\n stack = { entries, handleKeyDown };\n persistentDrawerStacks.set(ownerDocument, stack);\n ownerDocument.addEventListener(\"keydown\", handleKeyDown);\n }\n stack.entries.push(entry);\n\n return () => {\n const currentStack = persistentDrawerStacks.get(ownerDocument);\n if (currentStack === undefined) {\n return;\n }\n const index = currentStack.entries.indexOf(entry);\n if (index >= 0) {\n currentStack.entries.splice(index, 1);\n }\n if (currentStack.entries.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", currentStack.handleKeyDown);\n persistentDrawerStacks.delete(ownerDocument);\n }\n };\n}\n\ntype PersistentDrawerDrag = {\n pointerId: number;\n startY: number;\n lastY: number;\n lastTimeMs: number;\n velocityY: number;\n height: number;\n};\n\nexport function PersistentResponsiveDrawerShell({\n open,\n onOpenChange,\n srLabel,\n srDescription,\n labelledBy,\n describedBy,\n contentClassName,\n motionDurationMs = 220,\n onContentAnimationEnd,\n children,\n}: PersistentResponsiveDrawerShellProps) {\n const panelRef = React.useRef(null);\n const backdropRef = React.useRef(null);\n const dragRef = React.useRef(null);\n const returnFocusRef = React.useRef(null);\n const settledStateRef = React.useRef(null);\n const labelId = React.useId();\n const descriptionId = React.useId();\n const portalScopeProps = usePortalScopeProps();\n const transition = `transform ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;\n const backdropTransition = `opacity ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`;\n const onOpenChangeRef = React.useRef(onOpenChange);\n React.useLayoutEffect(() => {\n onOpenChangeRef.current = onOpenChange;\n }, [onOpenChange]);\n const requestClose = React.useCallback(() => {\n blurActiveKeyboardInputWithin(panelRef.current);\n resetDrawerKeyboardStyles(panelRef.current);\n onOpenChangeRef.current(false);\n }, []);\n\n const reportSettled = React.useCallback(\n (settledOpen: boolean) => {\n if (settledStateRef.current === settledOpen) {\n return;\n }\n settledStateRef.current = settledOpen;\n onContentAnimationEnd?.(settledOpen);\n },\n [onContentAnimationEnd],\n );\n\n React.useEffect(() => {\n settledStateRef.current = null;\n const timeout = window.setTimeout(\n () => reportSettled(open),\n motionDurationMs + 50,\n );\n return () => window.clearTimeout(timeout);\n }, [motionDurationMs, open, reportSettled]);\n\n React.useLayoutEffect(() => {\n if (!open) {\n return;\n }\n const panel = panelRef.current;\n if (panel === null) {\n return;\n }\n const ownerDocument = panel.ownerDocument;\n const previousFocus = ownerDocument.activeElement;\n returnFocusRef.current =\n previousFocus instanceof HTMLElement ? previousFocus : null;\n const unregister = registerOpenDrawer(ownerDocument, {\n panel: () => panelRef.current,\n requestClose,\n });\n panel.focus({ preventScroll: true });\n\n return () => {\n unregister();\n };\n }, [open, requestClose]);\n\n const previousOpenRef = React.useRef(open);\n React.useLayoutEffect(() => {\n if (previousOpenRef.current && !open) {\n blurActiveKeyboardInputWithin(panelRef.current);\n resetDrawerKeyboardStyles(panelRef.current);\n const returnFocus = returnFocusRef.current;\n if (\n returnFocus?.isConnected &&\n returnFocus.closest('[aria-hidden=\"true\"], [inert]') === null\n ) {\n returnFocus.focus({ preventScroll: true });\n }\n returnFocusRef.current = null;\n }\n previousOpenRef.current = open;\n }, [open]);\n\n const setDragPosition = React.useCallback(\n (offsetY: number, height: number, animate: boolean) => {\n const panel = panelRef.current;\n const backdrop = backdropRef.current;\n if (panel === null || backdrop === null) {\n return;\n }\n panel.style.transition = animate ? transition : \"none\";\n panel.style.transform = `translate3d(0, ${offsetY}px, 0)`;\n backdrop.style.transition = animate ? backdropTransition : \"none\";\n backdrop.style.opacity = String(\n Math.max(0, Math.min(1, 1 - offsetY / height)),\n );\n },\n [backdropTransition, transition],\n );\n\n const handleDragStart = React.useCallback(\n (event: React.PointerEvent) => {\n if (!open || event.button !== 0) {\n return;\n }\n event.currentTarget.setPointerCapture(event.pointerId);\n const nowMs = Date.now();\n const height = Math.max(panelRef.current?.clientHeight ?? 0, 1);\n dragRef.current = {\n pointerId: event.pointerId,\n startY: event.clientY,\n lastY: event.clientY,\n lastTimeMs: nowMs,\n velocityY: 0,\n height,\n };\n setDragPosition(0, height, false);\n event.preventDefault();\n },\n [open, setDragPosition],\n );\n\n const handleDragMove = React.useCallback(\n (event: React.PointerEvent) => {\n const drag = dragRef.current;\n if (drag === null || drag.pointerId !== event.pointerId) {\n return;\n }\n const nowMs = Date.now();\n const elapsedMs = nowMs - drag.lastTimeMs;\n if (elapsedMs > 0) {\n drag.velocityY = ((event.clientY - drag.lastY) / elapsedMs) * 1000;\n drag.lastY = event.clientY;\n drag.lastTimeMs = nowMs;\n }\n setDragPosition(\n Math.max(0, event.clientY - drag.startY),\n drag.height,\n false,\n );\n event.preventDefault();\n },\n [setDragPosition],\n );\n\n const finishDrag = React.useCallback(\n (event: React.PointerEvent, cancelled: boolean) => {\n const drag = dragRef.current;\n if (drag === null || drag.pointerId !== event.pointerId) {\n return;\n }\n dragRef.current = null;\n const offsetY = Math.max(0, event.clientY - drag.startY);\n const shouldClose =\n !cancelled &&\n (offsetY >= drag.height * PERSISTENT_DRAWER_CLOSE_RATIO ||\n drag.velocityY >= PERSISTENT_DRAWER_CLOSE_VELOCITY_PX_PER_SEC);\n if (shouldClose) {\n setDragPosition(drag.height, drag.height, true);\n requestClose();\n } else {\n setDragPosition(0, drag.height, true);\n }\n event.preventDefault();\n },\n [requestClose, setDragPosition],\n );\n\n const portalTarget = typeof document === \"undefined\" ? null : document.body;\n if (portalTarget === null) {\n return null;\n }\n\n return createPortal(\n <>\n event.preventDefault()}\n />\n {\n if (\n event.currentTarget === event.target &&\n event.propertyName === \"transform\"\n ) {\n reportSettled(open);\n }\n }}\n >\n finishDrag(event, false)}\n onPointerCancel={(event) => finishDrag(event, true)}\n >\n
\n
\n {srLabel === undefined ? null : (\n

\n {srLabel}\n

\n )}\n {srDescription === undefined ? null : (\n

\n {srDescription}\n

\n )}\n {children}\n \n ,\n portalTarget,\n );\n}\n", "type": "registry:ui", "target": "components/ui/responsive-overlay.tsx" } diff --git a/packages/shared-ui/src/components/ui/dialog.tsx b/packages/shared-ui/src/components/ui/dialog.tsx index 8250f7cda8..7d8a8d90f7 100644 --- a/packages/shared-ui/src/components/ui/dialog.tsx +++ b/packages/shared-ui/src/components/ui/dialog.tsx @@ -233,7 +233,16 @@ type DialogContentProps = React.ComponentPropsWithoutRef< >; const DialogContent = React.forwardRef( - ({ className, children, ...props }, ref) => { + ( + { + className, + children, + "aria-label": accessibleLabel, + "aria-description": accessibleDescription, + ...props + }, + ref, + ) => { const { isCompactViewport, open, onOpenChange, titleId, descriptionId } = useResponsiveDialog(); useBrowserDimmingModal(open); @@ -247,6 +256,8 @@ const DialogContent = React.forwardRef( @@ -273,6 +284,8 @@ const DialogContent = React.forwardRef( void; srLabel?: string; + srDescription?: string; labelledBy?: string; describedBy?: string; contentClassName?: string; @@ -474,6 +485,7 @@ export function PersistentResponsiveDrawerShell({ open, onOpenChange, srLabel, + srDescription, labelledBy, describedBy, contentClassName, @@ -487,6 +499,7 @@ export function PersistentResponsiveDrawerShell({ const returnFocusRef = React.useRef(null); const settledStateRef = React.useRef(null); const labelId = React.useId(); + const descriptionId = React.useId(); const portalScopeProps = usePortalScopeProps(); const transition = `transform ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`; const backdropTransition = `opacity ${motionDurationMs}ms ${PERSISTENT_DRAWER_EASING}`; @@ -671,10 +684,10 @@ export function PersistentResponsiveDrawerShell({ ref={panelRef} {...portalScopeProps} aria-hidden={!open} - aria-labelledby={ - labelledBy ?? (srLabel === undefined ? undefined : labelId) + aria-labelledby={srLabel === undefined ? labelledBy : labelId} + aria-describedby={ + srDescription === undefined ? describedBy : descriptionId } - aria-describedby={describedBy} aria-modal={open || undefined} data-bb-portaled-overlay="" data-persistent-drawer-content="" @@ -715,6 +728,11 @@ export function PersistentResponsiveDrawerShell({ {srLabel} )} + {srDescription === undefined ? null : ( +

+ {srDescription} +

+ )} {children} ,