From cc587f6c5be57828df1c42749385d34da91e6e38 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:38:00 +0100 Subject: [PATCH 1/4] refactor: share the folder drawer's focus handling and dimmed backdrop with other sidebars --- app/globals.css | 5 +- .../layout/__tests__/edge-drawer.test.tsx | 53 ++++++++++++++ components/layout/edge-drawer.tsx | 73 +++++++++++++++++++ components/mail/mail-app.tsx | 53 +++++--------- hooks/use-edge-drawer.ts | 72 ++++++++++++++++++ 5 files changed, 219 insertions(+), 37 deletions(-) create mode 100644 components/layout/__tests__/edge-drawer.test.tsx create mode 100644 components/layout/edge-drawer.tsx create mode 100644 hooks/use-edge-drawer.ts 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/layout/__tests__/edge-drawer.test.tsx b/components/layout/__tests__/edge-drawer.test.tsx new file mode 100644 index 000000000..04ac3b893 --- /dev/null +++ b/components/layout/__tests__/edge-drawer.test.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { EdgeDrawer } from '../edge-drawer'; + +// The drawer Calendar, Contacts and Files open below the full desktop, with +// the keyboard side Mail's phone drawer uses too (useEdgeDrawer). + +function Harness({ contained = false, onPickFocus }: { contained?: boolean; onPickFocus?: () => void }) { + const [open, setOpen] = useState(false); + return ( + <> + + + 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); + }); + + 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/edge-drawer.tsx b/components/layout/edge-drawer.tsx new file mode 100644 index 000000000..616a6e30f --- /dev/null +++ b/components/layout/edge-drawer.tsx @@ -0,0 +1,73 @@ +"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 ( +