diff --git a/app/globals.css b/app/globals.css index 4e41210eb..f9eb2c995 100644 --- a/app/globals.css +++ b/app/globals.css @@ -617,8 +617,9 @@ optgroup { --titlebar-right-inset: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw)); --titlebar-left-inset: env(titlebar-area-x, 0px); --titlebar-row-height: env(titlebar-area-height, 0px); - /* Where a drawer from the window's edge starts (the folder drawer): under - the top bar, and under the window's controls when they are over the page. */ + /* Where a drawer from the window's edge starts (Mail's folders, the + Calendar, Contacts and Files sidebars): under the top bar, and under the + window's controls when they are over the page. */ --drawer-top: max(var(--app-top-inset), var(--titlebar-row-height)); } diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index d9323df6f..b6dc61f6d 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -945,3 +945,56 @@ describe('CalendarApp company calendars on a phone', () => { } }); }); + +// Below the full desktop the sidebar is the same drawer as Mail's folders +// (usability test 2026-11-04): from the window's edge, the page dimmed, +// Escape closes it, and the focus goes in and back to the menu button. +describe('CalendarApp sidebar drawer', () => { + beforeEach(() => { + media.desktop = false; + }); + + const drawer = () => screen.getByTestId('calendar-sidebar-drawer'); + const menu = () => screen.getByRole('button', { name: 'nav_open_menu' }); + + it("opens from the window's edge, over the app rail and under the top bar", () => { + renderApp(); + const classes = drawer().className.split(/\s+/); + expect(classes).toEqual(expect.arrayContaining(['fixed', 'start-0', 'top-[var(--drawer-top)]', 'bottom-0'])); + expect(classes).not.toContain('absolute'); + }); + + it("is out of reach while closed, so Tab doesn't walk into the hidden calendars", () => { + renderApp(); + expect(drawer()).toHaveAttribute('inert'); + fireEvent.click(menu()); + expect(drawer()).not.toHaveAttribute('inert'); + }); + + it('takes the focus in, keeps Tab there and gives it back to the menu button on Escape', () => { + renderApp(); + menu().focus(); + fireEvent.click(menu()); + expect(drawer().contains(document.activeElement)).toBe(true); + const stops = Array.from(drawer().querySelectorAll('button:not(:disabled)')); + stops.at(-1)!.focus(); + fireEvent.keyDown(stops.at(-1)!, { key: 'Tab' }); + expect(document.activeElement).toBe(stops[0]); + + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(drawer()).toHaveAttribute('inert'); + expect(document.activeElement).toBe(menu()); + }); + + it('closes on a click on the dimmed window and gives the focus back to the menu button', () => { + renderApp(); + menu().focus(); + fireEvent.click(menu()); + const scrim = screen.getByTestId('drawer-scrim'); + expect(scrim.className.split(/\s+/)).toEqual(expect.arrayContaining(['fixed', 'inset-0'])); + (document.activeElement as HTMLElement).blur(); + fireEvent.click(scrim); + expect(drawer()).toHaveAttribute('inert'); + expect(document.activeElement).toBe(menu()); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 2b3232029..1b7ebe31c 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -60,6 +60,7 @@ import { useIsFocusedProTab, useIsPaneScoped } from "@/hooks/use-pane-context"; import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars"; import { useEventUserEmails } from "@/hooks/use-event-user-emails"; import { ResizeHandle } from "@/components/layout/resize-handle"; +import { EdgeDrawer } from "@/components/layout/edge-drawer"; import { descriptionWithNote } from "@/lib/calendar-description"; import { filterTasksByCalendars } from "@/lib/calendar-tasks"; import { baseEventStoreId, isServerRecurrenceInstance } from "@/lib/recurrence-instances"; @@ -183,6 +184,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = // collapses into a burger-toggled overlay instead of taking inline space. const isNarrow = !isFullDesktop; const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false); + const closeNarrowSidebar = useCallback(() => setNarrowSidebarOpen(false), []); useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore(); @@ -2236,6 +2238,97 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = /> ); + // The sidebar's content: beside the views on the full desktop, a drawer below it. + const calendarSidebar = ( + <> + { setNarrowSidebarOpen(false); openCreateForView(); }} + createLabel={createLabel} + tourTarget={!isNarrow} + /> +
+
+ +
+ { + if (calendarId === BIRTHDAY_CALENDAR_ID) { + updateSetting('birthdayCalendarColor', color); + return; + } + // Shared calendars: recolor locally only (the viewer usually + // can't write the owner's calendar, and it'd recolor it for + // everyone). Personal calendars write through to the server. + const cal = allCalendars.find((c) => c.id === calendarId); + if (cal?.isShared) { + setSharedCalendarColor(sharedCalendarColorKey(cal), color); + return; + } + updateCalendar(client, calendarId, { color }); + } : undefined} + onResetColor={(cal) => { + // Drop the local override. A calendar with a server color goes + // back to it; one without gets a fresh unused color from the + // auto-assign effect (so it never reverts to a collision). + removeSharedCalendarColor(sharedCalendarColorKey(cal)); + }} + onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined} + onCreateEvent={(cal: Calendar) => { + setDefaultCalendarIdForCreate(cal.id); + openCreateModal(); + }} + onClearCalendar={client ? async (cal: Calendar) => { + const ok = await confirmAction({ + title: tMgmt("clear_events"), + message: tMgmt("confirm_clear", { name: shownName.calendar(cal) }), + variant: "destructive", + confirmText: tMgmt("clear_events"), + }); + if (!ok) return; + try { + const count = await clearCalendarEvents(client, cal.id); + toast.success(tMgmt("events_cleared", { count })); + } catch (err) { + // The store rethrows the server's reason (#434). + toast.error(err instanceof Error && err.message ? err.message : tMgmt("error_clear")); + } + } : undefined} + onDeleteCalendar={client ? async (cal: Calendar) => { + const ok = await confirmAction({ + title: tMgmt("delete"), + message: tMgmt("confirm_delete", { name: shownName.calendar(cal) }), + variant: "destructive", + confirmText: tMgmt("delete"), + }); + if (!ok) return; + try { + await removeCalendar(client, cal.id); + toast.success(tMgmt("calendar_deleted")); + } catch { + toast.error(tMgmt("error_delete")); + } + } : undefined} + onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined} + onSubscribe={canSubscribe ? () => setShowSubscriptionModal(true) : undefined} + onEditSubscription={(subId) => setEditingSubscription(subId)} + client={client} + multiAccountMode={multiAccountEnabled && accountClients.length > 1} + /> +
+ + ); + return (
{!isEmbedded && {tShell("skip_to_content")}} @@ -2271,138 +2364,49 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = )} - {/* Narrow-pane backdrop: dim and close overlay sidebar */} - {isNarrow && narrowSidebarOpen && !inlineApp && ( -
setNarrowSidebarOpen(false)} - /> - )} - {!inlineApp && (
- {/* Sidebar - in-flow when desktop pane, overlay when narrow */} - {(isNarrow || !sidebarCollapsed) && ( + {/* The sidebar: in flow on the full desktop; below it a drawer from + the window's edge, over the app rail, as Mail's folders. */} + {isNarrow && ( + + {calendarSidebar} + + )} + {!isNarrow && !sidebarCollapsed && ( <>
- { setNarrowSidebarOpen(false); openCreateForView(); }} - createLabel={createLabel} - tourTarget={!isNarrow} - /> -
-
- -
- { - if (calendarId === BIRTHDAY_CALENDAR_ID) { - updateSetting('birthdayCalendarColor', color); - return; - } - // Shared calendars: recolor locally only (the viewer usually - // can't write the owner's calendar, and it'd recolor it for - // everyone). Personal calendars write through to the server. - const cal = allCalendars.find((c) => c.id === calendarId); - if (cal?.isShared) { - setSharedCalendarColor(sharedCalendarColorKey(cal), color); - return; - } - updateCalendar(client, calendarId, { color }); - } : undefined} - onResetColor={(cal) => { - // Drop the local override. A calendar with a server color goes - // back to it; one without gets a fresh unused color from the - // auto-assign effect (so it never reverts to a collision). - removeSharedCalendarColor(sharedCalendarColorKey(cal)); - }} - onShareCalendar={client ? (cal) => setSharingCalendarId(cal.id) : undefined} - onCreateEvent={(cal: Calendar) => { - setDefaultCalendarIdForCreate(cal.id); - openCreateModal(); - }} - onClearCalendar={client ? async (cal: Calendar) => { - const ok = await confirmAction({ - title: tMgmt("clear_events"), - message: tMgmt("confirm_clear", { name: shownName.calendar(cal) }), - variant: "destructive", - confirmText: tMgmt("clear_events"), - }); - if (!ok) return; - try { - const count = await clearCalendarEvents(client, cal.id); - toast.success(tMgmt("events_cleared", { count })); - } catch (err) { - // The store rethrows the server's reason (#434). - toast.error(err instanceof Error && err.message ? err.message : tMgmt("error_clear")); - } - } : undefined} - onDeleteCalendar={client ? async (cal: Calendar) => { - const ok = await confirmAction({ - title: tMgmt("delete"), - message: tMgmt("confirm_delete", { name: shownName.calendar(cal) }), - variant: "destructive", - confirmText: tMgmt("delete"), - }); - if (!ok) return; - try { - await removeCalendar(client, cal.id); - toast.success(tMgmt("calendar_deleted")); - } catch { - toast.error(tMgmt("error_delete")); - } - } : undefined} - onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined} - onSubscribe={canSubscribe ? () => setShowSubscriptionModal(true) : undefined} - onEditSubscription={(subId) => setEditingSubscription(subId)} - client={client} - multiAccountMode={multiAccountEnabled && accountClients.length > 1} - /> -
+ {calendarSidebar}
- {!isNarrow && ( - { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }} - onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} - onResizeEnd={() => { - setIsResizing(false); - localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth)); - }} - onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }} - /> - )} + { dragStartWidth.current = calSidebarWidth; setIsResizing(true); }} + onResize={(delta) => setCalSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsResizing(false); + localStorage.setItem("calendar-sidebar-width", String(calSidebarWidth)); + }} + onDoubleClick={() => { setCalSidebarWidth(256); localStorage.setItem("calendar-sidebar-width", "256"); }} + /> )} diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index ab4c422ed..393b29bac 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -102,6 +102,48 @@ describe('ContactsApp', () => { expect(within(drawer()).getByText('tabs.all')).toBeInTheDocument(); }); + // The same drawer as Mail's folders (usability test 2026-11-04): it opened + // beside the app rail, ignored Escape and left the focus on the page. + it("opens the drawer from the window's edge, over the app rail and under the top bar", () => { + renderContacts(); + const classes = drawer().className.split(/\s+/); + expect(classes).toEqual(expect.arrayContaining(['fixed', 'start-0', 'top-[var(--drawer-top)]', 'bottom-0'])); + expect(classes).not.toContain('absolute'); + }); + + it('takes the focus into the drawer, keeps Tab there and gives it back to the menu button on Escape', () => { + renderContacts(); + const menu = screen.getAllByRole('button', { name: 'open_categories' })[0]; + menu.focus(); + fireEvent.click(menu); + // The category on screen. + const current = within(drawer()).getByRole('button', { name: /tabs\.all/ }); + expect(document.activeElement).toBe(current); + const stops = Array.from(drawer().querySelectorAll('button:not(:disabled)')); + stops.at(-1)!.focus(); + fireEvent.keyDown(stops.at(-1)!, { key: 'Tab' }); + expect(document.activeElement).toBe(stops[0]); + + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(drawer()).toHaveAttribute('inert'); + expect(document.activeElement).toBe(menu); + }); + + it('dims the whole window behind the drawer and closes it on a click there, back to the menu button', () => { + renderContacts(); + const menu = screen.getAllByRole('button', { name: 'open_categories' })[0]; + menu.focus(); + fireEvent.click(menu); + const scrim = screen.getByTestId('drawer-scrim'); + expect(scrim.className.split(/\s+/)).toEqual(expect.arrayContaining(['fixed', 'inset-0'])); + // A press on the scrim drops the focus to the page. + (document.activeElement as HTMLElement).blur(); + fireEvent.click(scrim); + expect(drawer()).toHaveAttribute('inert'); + expect(screen.queryByTestId('drawer-scrim')).toBeNull(); + expect(document.activeElement).toBe(menu); + }); + it("closes the drawer when something in it is picked, so it doesn't stay over the form", () => { renderContacts(); fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 70d00e408..dbbebf3d3 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -49,6 +49,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useIsFocusedProTab } from "@/hooks/use-pane-context"; import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts"; import { ResizeHandle } from "@/components/layout/resize-handle"; +import { EdgeDrawer } from "@/components/layout/edge-drawer"; import { useIsFullDesktop, useIsMobile } from "@/hooks/use-media-query"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types"; @@ -210,6 +211,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = // into a burger-toggled overlay. const isNarrow = !isFullDesktop; const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false); + const closeNarrowSidebar = useCallback(() => setNarrowSidebarOpen(false), []); useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]); // A new view (a form, a group, a contact) replaces whatever the drawer was for. useEffect(() => { setNarrowSidebarOpen(false); }, [view]); @@ -1285,6 +1287,60 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = } })(); + // The categories: beside the list on the full desktop, a drawer below it. + const contactsSidebar = ( + setCreatingAddressBook(true)) : undefined} + onImport={fromDrawer(() => setShowImportDialog(true))} + onEditGroup={fromDrawer(guard(handleEditGroupFromSidebar))} + onDeleteGroup={fromDrawer(handleDeleteGroupFromSidebar)} + onComposeGroup={fromDrawer(handleComposeGroupFromSidebar)} + onDropContacts={handleDropContacts} + onDropContactsToCategory={handleDropContactsToCategory} + onRenameAddressBook={client ? fromDrawer((book: AddressBook) => setRenamingAddressBook(book)) : undefined} + onShareAddressBook={client ? fromDrawer((book: AddressBook) => setSharingAddressBookId(book.id)) : undefined} + onSetDefaultAddressBook={client ? fromDrawer(async (book: AddressBook) => { + try { + await setDefaultAddressBook(client, book); + toast.success(t("address_books.default_updated")); + } catch { + toast.error(t("address_books.set_default_failed")); + } + }) : undefined} + onCreateContactInBook={fromDrawer(guard((book: AddressBook) => { + setDefaultBookIdForCreate(book.id); + setSelectedContact(null); + setView("create"); + }))} + onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => { + const ok = await confirmDialog({ + title: t("address_books.delete_title"), + message: t("address_books.confirm_delete", { name: shownName.addressBook(book) }), + variant: "destructive", + confirmText: t("address_books.delete"), + }); + if (!ok) return; + try { + await removeAddressBook(client, book); + toast.success(t("address_books.deleted")); + } catch { + toast.error(t("address_books.delete_failed")); + } + }) : undefined} + onRenameKeyword={fromDrawer((kw: string) => setRenamingKeyword(kw))} + onDeleteKeyword={fromDrawer((kw: string) => void handleDeleteKeyword(kw))} + directoryCount={showDirectory ? directoryPrincipals.length : undefined} + multiAccountMode={multiAccountEnabled && accountClients.length > 1} + /> + ); + const mobileBackToList = () => { if (returnToEmail) { setReturnToEmail(false); @@ -1331,105 +1387,49 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = {inlineApp && ( )} - {/* Clips the closed drawer, so it never sits over the nav rail. */} + {/* Holds the drawer in a Pro pane and clips it there when closed. */}
- {/* Narrow-pane backdrop for the overlay categories sidebar */} - {isNarrow && narrowSidebarOpen && ( -
setNarrowSidebarOpen(false)} - /> + {/* Panel 1: the categories. Below the full desktop a drawer from the + window's edge, over the app rail, as Mail's folders. */} + {showListPanel && isNarrow && ( + + {contactsSidebar} + + )} + {showListPanel && !isNarrow && !isMobile && ( + <> +
+ {contactsSidebar} +
+ { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} + onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsSidebarResizing(false); + localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); + }} + onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} + /> + )} - {/* Panel 1: Categories sidebar (in-flow on desktop, overlay on narrow) */} - {showListPanel && (!isMobile || isNarrow) && ( - <> -
- setCreatingAddressBook(true)) : undefined} - onImport={fromDrawer(() => setShowImportDialog(true))} - onEditGroup={fromDrawer(guard(handleEditGroupFromSidebar))} - onDeleteGroup={fromDrawer(handleDeleteGroupFromSidebar)} - onComposeGroup={fromDrawer(handleComposeGroupFromSidebar)} - onDropContacts={handleDropContacts} - onDropContactsToCategory={handleDropContactsToCategory} - onRenameAddressBook={client ? fromDrawer((book: AddressBook) => setRenamingAddressBook(book)) : undefined} - onShareAddressBook={client ? fromDrawer((book: AddressBook) => setSharingAddressBookId(book.id)) : undefined} - onSetDefaultAddressBook={client ? fromDrawer(async (book: AddressBook) => { - try { - await setDefaultAddressBook(client, book); - toast.success(t("address_books.default_updated")); - } catch { - toast.error(t("address_books.set_default_failed")); - } - }) : undefined} - onCreateContactInBook={fromDrawer(guard((book: AddressBook) => { - setDefaultBookIdForCreate(book.id); - setSelectedContact(null); - setView("create"); - }))} - onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => { - const ok = await confirmDialog({ - title: t("address_books.delete_title"), - message: t("address_books.confirm_delete", { name: shownName.addressBook(book) }), - variant: "destructive", - confirmText: t("address_books.delete"), - }); - if (!ok) return; - try { - await removeAddressBook(client, book); - toast.success(t("address_books.deleted")); - } catch { - toast.error(t("address_books.delete_failed")); - } - }) : undefined} - onRenameKeyword={fromDrawer((kw: string) => setRenamingKeyword(kw))} - onDeleteKeyword={fromDrawer((kw: string) => void handleDeleteKeyword(kw))} - directoryCount={showDirectory ? directoryPrincipals.length : undefined} - multiAccountMode={multiAccountEnabled && accountClients.length > 1} - /> -
- {!isNarrow && ( - { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} - onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))} - onResizeEnd={() => { - setIsSidebarResizing(false); - localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); - }} - onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} - /> - )} - - )} {/* The list and the contact: the main content, after the sidebar. */} onSelectCategory("all")} + // Marked as the other categories are: the drawer opens on it. + aria-current={isAllActive ? "true" : undefined} className={cn( "w-full flex items-center gap-2 px-3 text-sm transition-colors min-h-8 pointer-coarse:min-h-11", isAllActive @@ -342,6 +344,7 @@ export function ContactsSidebar({ {directoryCount !== undefined && ( + + setOpen(false)} contained={contained} data-testid="drawer"> + + + + + + ); +} + +describe('EdgeDrawer', () => { + it('opens on the current item and gives the focus back to the opener after a pick', () => { + render(); + const menu = screen.getByText('menu'); + menu.focus(); + fireEvent.click(menu); + expect(document.activeElement).toBe(screen.getByText('current')); + fireEvent.click(screen.getByText('pick')); + expect(screen.getByTestId('drawer')).toHaveAttribute('inert'); + expect(document.activeElement).toBe(menu); + }); + + // Seen in the desktop app: with visibility in the opening transition the + // panel is still hidden in its first frame and refuses the focus. + it('is visible from its first frame when it opens, and hidden only after sliding out', () => { + render(); + const classes = () => screen.getByTestId('drawer').className.split(/\s+/); + expect(classes()).toContain('transition-[transform,visibility]'); + fireEvent.click(screen.getByText('menu')); + expect(classes()).toContain('transition-transform'); + expect(classes()).not.toContain('transition-[transform,visibility]'); + expect(classes()).not.toContain('invisible'); + }); + + it('leaves the focus where a pick moved it, outside the drawer', () => { + render( screen.getByLabelText('elsewhere').focus()} />); + const menu = screen.getByText('menu'); + menu.focus(); + fireEvent.click(menu); + act(() => fireEvent.click(screen.getByText('pick'))); + expect(document.activeElement).toBe(screen.getByLabelText('elsewhere')); + }); + + it('stays inside a Pro pane: at the pane\'s edge, with only the pane dimmed', () => { + render(); + fireEvent.click(screen.getByText('menu')); + const classes = screen.getByTestId('drawer').className.split(/\s+/); + expect(classes).toEqual(expect.arrayContaining(['absolute', 'inset-y-0', 'start-0'])); + expect(classes).not.toContain('fixed'); + expect(screen.getByTestId('drawer-scrim').className.split(/\s+/)).toContain('absolute'); + }); +}); diff --git a/components/layout/__tests__/sidebar-folder-drawer.test.tsx b/components/layout/__tests__/sidebar-folder-drawer.test.tsx index 413d7316b..d6f603121 100644 --- a/components/layout/__tests__/sidebar-folder-drawer.test.tsx +++ b/components/layout/__tests__/sidebar-folder-drawer.test.tsx @@ -102,11 +102,15 @@ describe('the folded column\'s drawer (default layout)', () => { expect(inDrawer).toBe(docked); }); - it('mirrors the collapse arrows in RTL, where the drawer comes from the right', () => { + // RTL: globals.css mirrors the chevrons icons; a flip of their own as well would turn them back. + it('leaves the collapse arrows to the global RTL mirror, where the drawer comes from the right', () => { + const arrow = () => screen.getByTestId('sidebar-collapse').querySelector('svg')!.getAttribute('class') ?? ''; render(); - expect(screen.getByTestId('sidebar-collapse').querySelector('svg')!.getAttribute('class')).toContain('rtl:-scale-x-100'); + expect(arrow()).toMatch(/tabler-icon-chevrons-(left|right)/); + expect(arrow()).not.toMatch(/rtl:/); openFromAllFolders(); - expect(screen.getByTestId('sidebar-collapse').querySelector('svg')!.getAttribute('class')).toContain('rtl:-scale-x-100'); + expect(arrow()).toMatch(/tabler-icon-chevrons-(left|right)/); + expect(arrow()).not.toMatch(/rtl:/); }); it('keeps Tab and Shift+Tab inside while open', () => { diff --git a/components/layout/edge-drawer.tsx b/components/layout/edge-drawer.tsx new file mode 100644 index 000000000..04a42fac0 --- /dev/null +++ b/components/layout/edge-drawer.tsx @@ -0,0 +1,76 @@ +"use client"; + +import type { HTMLAttributes, ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { useEdgeDrawer } from "@/hooks/use-edge-drawer"; + +interface DrawerScrimProps { + onClose: () => void; + /** In a Pro pane: dims the pane only, not the other one beside it. */ + contained?: boolean; + className?: string; + "data-testid"?: string; +} + +/** + * The dimmed page behind a drawer. A click on it closes the drawer; the + * click, not the press, so it can't go through to what is under it. + */ +export function DrawerScrim({ onClose, contained, className, "data-testid": testId = "drawer-scrim" }: DrawerScrimProps) { + return ( +