diff --git a/.changeset/quiet-sidebar-command-layer.md b/.changeset/quiet-sidebar-command-layer.md new file mode 100644 index 00000000000..bfa2845cff3 --- /dev/null +++ b/.changeset/quiet-sidebar-command-layer.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Keep the command picker above navigation drawers by using the shared dialog stacking order. diff --git a/packages/core/src/client/CommandMenu.spec.tsx b/packages/core/src/client/CommandMenu.spec.tsx index 8cf901e3e9a..420e0b8f8d3 100644 --- a/packages/core/src/client/CommandMenu.spec.tsx +++ b/packages/core/src/client/CommandMenu.spec.tsx @@ -274,7 +274,7 @@ describe("CommandMenu docs group", () => { expect(input).toBeTruthy(); expect(list).toBeTruthy(); expect(dialog?.className).toContain("top-[15vh]"); - expect(dialog?.className).toContain("!z-50"); + expect(dialog?.className).toContain("z-[280]"); expect(dialog?.className).toContain("!max-h-none"); expect(dialog?.className).toContain("!translate-y-0"); expect(dialog?.className).toContain("bg-popover"); @@ -282,8 +282,9 @@ describe("CommandMenu docs group", () => { expect(dialog?.style.transition).toBe("none"); expect(dialog?.style.maxWidth).toBe(""); expect(dialog?.style.backgroundColor).toBe(""); - expect(overlay?.className).toContain("z-50"); - expect(overlay?.className).toContain("bg-black/50"); + expect(overlay?.className).toContain("z-[270]"); + expect(overlay?.style.zIndex).toBe(""); + expect(overlay?.style.backgroundColor).toBe("rgb(0 0 0 / 0.5)"); expect(overlay?.style.backdropFilter).toBe("none"); expect(overlay?.style.transition).toBe("none"); expect(document.activeElement).toBe(input); diff --git a/packages/core/src/client/CommandMenu.tsx b/packages/core/src/client/CommandMenu.tsx index 8771d6e3f66..2baab6998d5 100644 --- a/packages/core/src/client/CommandMenu.tsx +++ b/packages/core/src/client/CommandMenu.tsx @@ -487,16 +487,15 @@ export function CommandMenu({ aria-describedby={undefined} hideClose motion="instant" - overlayClassName="fixed inset-0 z-50 bg-black/50 backdrop-blur-none transition-none" + overlayClassName="fixed inset-0 backdrop-blur-none transition-none" overlayStyle={{ - zIndex: 50, backgroundColor: "rgb(0 0 0 / 0.5)", backdropFilter: "none", animation: "none", transition: "none", }} className={cn( - "fixed left-1/2 top-[15vh] !z-50 !max-h-none -translate-x-1/2 !translate-y-0 !gap-0 w-full max-w-lg", + "fixed left-1/2 top-[15vh] !max-h-none -translate-x-1/2 !translate-y-0 !gap-0 w-full max-w-lg", "rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-lg", className, )} diff --git a/templates/content/app/components/layout/Layout.tsx b/templates/content/app/components/layout/Layout.tsx index 0535fb0abee..7761e086ad3 100644 --- a/templates/content/app/components/layout/Layout.tsx +++ b/templates/content/app/components/layout/Layout.tsx @@ -276,6 +276,8 @@ export function Layout({ children }: LayoutProps) { collapsed={sidebarCollapsed} onToggleCollapsed={() => setSidebarCollapsed((c) => !c)} width={sidebarWidth} + minWidth={MIN_SIDEBAR_WIDTH} + maxWidth={MAX_SIDEBAR_WIDTH} onResize={handleSidebarResize} /> diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 8a0c8c1190c..bce54456387 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -114,7 +114,6 @@ describe("document sidebar layout", () => { it("defaults database pages to the database icon before the page icon", () => { const treeItem = readSidebarSource("./DocumentTreeItem.tsx"); - const sidebar = readSidebarSource("./DocumentSidebar.tsx"); const iconSource = treeItem.slice( treeItem.indexOf("export function getDocumentSidebarIconKind"), treeItem.indexOf("export function DocumentTreeItem"), @@ -125,7 +124,6 @@ describe("document sidebar layout", () => { expect(iconSource.indexOf("if (document.database)")).toBeLessThan( iconSource.indexOf('return "page"'), ); - expect(sidebar).toContain(""); }); it("uses the database icon as the default for database pages", () => { @@ -166,13 +164,11 @@ describe("document sidebar layout", () => { ); }); - it("settles search dismissal by clearing the hidden query", () => { + it("opens the shared search picker without keeping a hidden sidebar query", () => { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); - expect(sidebar).toContain("const closeSearch = useCallback"); - expect(sidebar).toContain('setSearchQuery("")'); - expect(sidebar).toContain("if (isSearching)"); - expect(sidebar).toContain("closeSearch();"); + expect(sidebar).toContain("onClick={openCommandMenu}"); + expect(sidebar).not.toContain("setSearchQuery"); }); it("reveals child destinations without concurrent rollback conflicts", () => { @@ -214,9 +210,6 @@ describe("document sidebar layout", () => { expect(sidebar).toContain("selectedSpace?.id"); expect(sidebar).toContain("spaceId: parentId ? undefined : rootSpaceId"); expect(sidebar).toContain("const handleCreatePageInSpace = useCallback"); - expect(sidebar).toContain( - "const renderNewButton = (space = selectedSpace) =>", - ); expect(sidebar).toContain("const renderCollapsedNewButton = () =>"); expect(sidebar).toContain('t("sidebar.newPage")'); expect(sidebar).not.toContain( diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index 3023078f160..ddb3e9b99df 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -7,6 +7,7 @@ import { useActionQuery, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; +import { openCommandMenu } from "@agent-native/core/client/navigation"; import { OrgSwitcher } from "@agent-native/core/client/org"; import { AgentNativeIcon, FeedbackButton } from "@agent-native/core/client/ui"; import { SidebarFooterActions } from "@agent-native/toolkit/app-shell"; @@ -123,7 +124,6 @@ import { import { cn } from "@/lib/utils"; import { getDocumentSidebarSections } from "./document-sidebar-sections"; -import { DocumentSidebarIcon } from "./DocumentTreeItem"; import { firstLocalSourceDocumentId, localSourceItemIdentity, @@ -159,6 +159,8 @@ interface DocumentSidebarProps { onNavigate?: () => void; width?: number; onResize?: (width: number) => void; + minWidth?: number; + maxWidth?: number; } const LIST_DOCUMENTS_QUERY_KEY = [ @@ -855,6 +857,8 @@ export function DocumentSidebar({ onNavigate, width, onResize, + minWidth, + maxWidth, }: DocumentSidebarProps) { const navigate = useNavigate(); const location = useLocation(); @@ -1222,15 +1226,6 @@ export function DocumentSidebar({ RemoveLocalFileSourceResult, { sourceRootPath?: string | null } >("remove-local-file-source"); - const [searchQuery, setSearchQuery] = useState(""); - const [isSearching, setIsSearching] = useState(false); - const closeSearch = useCallback(() => { - setIsSearching(false); - setSearchQuery(""); - }, []); - useEffect(() => { - closeSearch(); - }, [closeSearch, location.key]); // Track user-expanded nodes only; active ancestors are derived below so they // do not stay open after navigation unless the user explicitly expanded them. const expandedIdsRef = useRef(new Set()); @@ -1854,25 +1849,6 @@ export function DocumentSidebar({ } }, [queryClient, removeLocalFileSource, t]); - const filteredDocuments = searchQuery - ? documents.filter((d) => - d.title.toLowerCase().includes(searchQuery.toLowerCase()), - ) - : null; - - const renderNewButton = (space = selectedSpace) => - space ? ( - - ) : null; - const renderCollapsedNewButton = () => selectedSpace ? ( @@ -1880,6 +1856,7 @@ export function DocumentSidebar({ @@ -2378,6 +2349,7 @@ export function DocumentSidebar({ - {/* Search */} - {isSearching && ( -
- setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - closeSearch(); - } - }} - className="w-full px-2 py-1.5 text-sm bg-background border border-border rounded-md outline-none focus:ring-1 focus:ring-ring" - /> -
- )} -
- {/* Search results */} - {filteredDocuments ? ( - <> -
-
- {t("sidebar.results")} -
- {filteredDocuments.length === 0 ? ( -
- {t("sidebar.noPagesFound")} -
- ) : ( - filteredDocuments.map((doc) => ( - - )) - )} + /> + + + + {t("sidebar.pinned")} +
- {renderNewButton()} - - ) : ( - <> - {/* Pinned */} - {showFavorites && ( -
-
- - - {t("sidebar.pinned")} - -
- {!collapsedSections.favorites ? ( - favoritesDatabase.isError ? ( - void favoritesDatabase.refetch()} - retrying={favoritesDatabase.isFetching} - /> - ) : ( - { - const document = documents.find( - (candidate) => candidate.id === item.document.id, - ); - const space = document - ? contentSpaces.find( - (candidate) => - candidate.filesDocumentId === - document.databaseMembership - ?.databaseDocumentId, - ) - : undefined; - if (!space || selectedSpace?.id === space.id) { - onNavigate?.(); - return false; + {!collapsedSections.favorites ? ( + favoritesDatabase.isError ? ( + void favoritesDatabase.refetch()} + retrying={favoritesDatabase.isFetching} + /> + ) : ( + - void handleCreatePage(item.document.id) - } - onCreateChildDatabase={(item) => - void handleCreateDatabase(item.document.id) - } - onDeleteItem={(item) => - requestDelete( - item.document.id, - item.document.title || t("sidebar.untitled"), + } + onOpenItem={(item) => { + const document = documents.find( + (candidate) => candidate.id === item.document.id, + ); + const space = document + ? contentSpaces.find( + (candidate) => + candidate.filesDocumentId === + document.databaseMembership?.databaseDocumentId, ) - } - onToggleFavorite={(item) => - handleToggleFavorite(item.document.id, false) - } - scroll={false} - labels={{ - noMatchesLabel: t("database.noRowsMatchThisView"), - clearLabel: t("database.clearSearchAndFilters"), - navigationLabel: t("sidebar.pinned"), - untitledLabel: t("sidebar.untitled"), - }} - /> - ) - ) : null} -
- )} - - {renderWorkspaceNavigation()} - {renderTrashSection()} - + : undefined; + if (!space || selectedSpace?.id === space.id) { + onNavigate?.(); + return false; + } + void handleSelectContentSpace(space, item.document.id); + onNavigate?.(); + return true; + }} + onCreateChildPage={(item) => + void handleCreatePage(item.document.id) + } + onCreateChildDatabase={(item) => + void handleCreateDatabase(item.document.id) + } + onDeleteItem={(item) => + requestDelete( + item.document.id, + item.document.title || t("sidebar.untitled"), + ) + } + onToggleFavorite={(item) => + handleToggleFavorite(item.document.id, false) + } + scroll={false} + labels={{ + noMatchesLabel: t("database.noRowsMatchThisView"), + clearLabel: t("database.clearSearchAndFilters"), + navigationLabel: t("sidebar.pinned"), + untitledLabel: t("sidebar.untitled"), + }} + /> + ) + ) : null} +
)} + + {renderWorkspaceNavigation()} + {renderTrashSection()}
@@ -2624,12 +2530,42 @@ export function DocumentSidebar({ {/* Resize handle */} - {onResize && ( + {onResize && width !== undefined && (
{ + let nextWidth: number; + switch (event.key) { + case "ArrowLeft": + nextWidth = width - 10; + break; + case "ArrowRight": + nextWidth = width + 10; + break; + case "Home": + if (minWidth === undefined) return; + nextWidth = minWidth; + break; + case "End": + if (maxWidth === undefined) return; + nextWidth = maxWidth; + break; + default: + return; + } + event.preventDefault(); + onResize(nextWidth); + }} onMouseDown={handleMouseDown} /> )} diff --git a/templates/content/app/i18n-data.ts b/templates/content/app/i18n-data.ts index 1e67e25336d..11c771852d6 100644 --- a/templates/content/app/i18n-data.ts +++ b/templates/content/app/i18n-data.ts @@ -3481,6 +3481,7 @@ const enUS = { addChildTo: "Add child to {{title}}", addSubPage: "Add sub-page", collapse: "Collapse sidebar", + resize: "Resize sidebar", completeStepsAboveFirst: "Complete steps above first", connectWorkspace: "Connect workspace", connected: "Connected", @@ -10215,6 +10216,7 @@ export const messagesByLocale = { newDatabase: "新建数据库", noWorkspaces: "还没有工作区", collapse: "折叠侧边栏", + resize: "调整侧边栏宽度", expand: "展开侧边栏", failedCreatePage: "创建页面失败", failedDeletePage: "删除页面失败", @@ -10416,6 +10418,7 @@ export const messagesByLocale = { newDatabase: "Nueva base de datos", noWorkspaces: "Aún no hay espacios de trabajo", collapse: "Contraer barra lateral", + resize: "Cambiar ancho de la barra lateral", expand: "Expandir barra lateral", failedCreatePage: "No se pudo crear la página", failedDeletePage: "No se pudo eliminar la página", @@ -10619,6 +10622,7 @@ export const messagesByLocale = { newDatabase: "Nouvelle base de données", noWorkspaces: "Aucun espace de travail pour le moment", collapse: "Réduire la barre latérale", + resize: "Redimensionner la barre latérale", expand: "Développer la barre latérale", failedCreatePage: "Échec de la création de la page", failedDeletePage: "Échec de la suppression de la page", @@ -10820,6 +10824,7 @@ export const messagesByLocale = { newDatabase: "Neue Datenbank", noWorkspaces: "Noch keine Arbeitsbereiche", collapse: "Seitenleiste einklappen", + resize: "Seitenleiste skalieren", expand: "Seitenleiste ausklappen", failedCreatePage: "Seite konnte nicht erstellt werden", failedDeletePage: "Seite konnte nicht gelöscht werden", @@ -11020,6 +11025,7 @@ export const messagesByLocale = { newDatabase: "新しいデータベース", noWorkspaces: "ワークスペースはまだありません", collapse: "サイドバーを折りたたむ", + resize: "サイドバーの幅を変更", expand: "サイドバーを展開", failedCreatePage: "ページを作成できませんでした", failedDeletePage: "ページを削除できませんでした", @@ -11209,6 +11215,7 @@ export const messagesByLocale = { newDatabase: "새 데이터베이스", noWorkspaces: "아직 워크스페이스가 없습니다", collapse: "사이드바 접기", + resize: "사이드바 너비 조절", expand: "사이드바 펼치기", failedCreatePage: "페이지를 만들지 못했습니다", failedDeletePage: "페이지를 삭제하지 못했습니다", @@ -11410,6 +11417,7 @@ export const messagesByLocale = { newDatabase: "Novo banco de dados", noWorkspaces: "Ainda não há espaços de trabalho", collapse: "Recolher barra lateral", + resize: "Redimensionar barra lateral", expand: "Expandir barra lateral", failedCreatePage: "Falha ao criar página", failedDeletePage: "Falha ao excluir página", @@ -11597,6 +11605,7 @@ export const messagesByLocale = { newDatabase: "नया डेटाबेस", noWorkspaces: "अभी कोई कार्यस्थान नहीं है", collapse: "साइडबार संकुचित करें", + resize: "साइडबार का आकार बदलें", expand: "साइडबार फैलाएं", failedCreatePage: "पेज नहीं बन सका", failedDeletePage: "पेज हटाया नहीं जा सका", @@ -11787,6 +11796,7 @@ export const messagesByLocale = { newDatabase: "قاعدة بيانات جديدة", noWorkspaces: "لا توجد مساحات عمل بعد", collapse: "طي الشريط الجانبي", + resize: "تغيير حجم الشريط الجانبي", expand: "توسيع الشريط الجانبي", failedCreatePage: "فشل إنشاء الصفحة", failedDeletePage: "فشل حذف الصفحة", diff --git a/templates/content/app/i18n/zh-TW.ts b/templates/content/app/i18n/zh-TW.ts index d5699828d51..7014e21d9fa 100644 --- a/templates/content/app/i18n/zh-TW.ts +++ b/templates/content/app/i18n/zh-TW.ts @@ -1267,6 +1267,7 @@ const messages = { addChildTo: "新增子項目至 {{title}}", addSubPage: "新增子頁面", collapse: "折疊側邊欄", + resize: "調整側邊欄寬度", completeStepsAboveFirst: "請先完成上面的步驟", connectWorkspace: "連線工作區", connected: "已連線", diff --git a/templates/content/app/root.tsx b/templates/content/app/root.tsx index b59d965fa6a..176dd0819cc 100644 --- a/templates/content/app/root.tsx +++ b/templates/content/app/root.tsx @@ -40,6 +40,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, } from "react"; import { @@ -564,9 +565,27 @@ function ContentCommandMenu({ export default function Root() { const [queryClient] = useState(() => createAgentNativeQueryClient()); const [cmdkOpen, setCmdkOpen] = useState(false); + const commandTrigger = useRef(null); const location = useLocation(); const loaderData = useLoaderData(); - useCommandMenuShortcut(useCallback(() => setCmdkOpen(true), [])); + useCommandMenuShortcut( + useCallback(() => { + commandTrigger.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + setCmdkOpen(true); + }, []), + ); + useEffect(() => { + if (cmdkOpen || !commandTrigger.current) return; + const trigger = commandTrigger.current; + commandTrigger.current = null; + const frame = window.requestAnimationFrame(() => { + if (trigger.isConnected) trigger.focus(); + }); + return () => window.cancelAnimationFrame(frame); + }, [cmdkOpen]); // Public document paths (/p/*) SSR real content without the ClientOnly gate // so crawlers and unauthenticated visitors receive full markup on first visit. diff --git a/templates/content/changelog/2026-09-09-accessible-sidebar-controls.md b/templates/content/changelog/2026-09-09-accessible-sidebar-controls.md new file mode 100644 index 00000000000..e2fa7a97929 --- /dev/null +++ b/templates/content/changelog/2026-09-09-accessible-sidebar-controls.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-09-09 +--- + +Sidebar Search opens the command picker, icon controls have accessible names, and the sidebar can be resized with the keyboard. diff --git a/templates/content/docs/product/capabilities/content.navigation.sidebar.md b/templates/content/docs/product/capabilities/content.navigation.sidebar.md index 7c2a63ecb9f..f134d7d9968 100644 --- a/templates/content/docs/product/capabilities/content.navigation.sidebar.md +++ b/templates/content/docs/product/capabilities/content.navigation.sidebar.md @@ -26,6 +26,7 @@ proof_requirements: evidence: [ "../../../app/components/sidebar/document-sidebar-sections.test.ts", + "../../../app/components/sidebar/DocumentSidebar.layout.test.ts", "../../../app/components/editor/database/sidebar.tsx", ] superseded_by: null @@ -75,6 +76,14 @@ Existing sidebar section tests and sidebar rendering show useful donor behavior. not prove the full Reference/query, access, recovery, and personal-state contract; this Capability remains `approved_shape`. +The September 9, 2026 SB-01–03 controls pass exercised collapsed and expanded +Search, Escape focus return, accessible control names, keyboard and pointer +resizing within 240–480px, width persistence after reload, and a 390px mobile +drawer through the local interface. The command picker uses the shared dialog +stack so it remains visible above the drawer. This bounded controls evidence +does not establish the remaining personal-reference, dynamic-section, access, +or recovery contracts. + ## Proof plan 1. Test pin, reorder, expand, query sections, reload, and stale-reference recovery.