Merge branch 'feat/desktop-offline' into feat/do-month-drag

This commit is contained in:
Linus Rath
2026-11-04 17:49:35 +01:00
14 changed files with 627 additions and 275 deletions
+3 -2
View File
@@ -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));
}
@@ -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<HTMLElement>('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());
});
});
+125 -121
View File
@@ -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 = (
<>
<CalendarSidebarHeader
onCreateEvent={() => { setNarrowSidebarOpen(false); openCreateForView(); }}
createLabel={createLabel}
tourTarget={!isNarrow}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3">
<div className="px-3">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleMiniCalendarSelect}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
showWeekNumbers={showWeekNumbers}
/>
</div>
<CalendarSidebarPanel
calendars={allCalendars}
selectedCalendarIds={shownCalendarIds}
onToggleVisibility={toggleShownCalendar}
onColorChange={client ? (calendarId, color) => {
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}
/>
</div>
</>
);
return (
<div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
@@ -2271,138 +2364,49 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Narrow-pane backdrop: dim and close overlay sidebar */}
{isNarrow && narrowSidebarOpen && !inlineApp && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{!inlineApp && (
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div className="flex flex-1 min-w-0 min-h-0">
{/* 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 && (
<EdgeDrawer
open={narrowSidebarOpen}
onClose={closeNarrowSidebar}
contained={isEmbedded || mvShell}
// A closed drawer is off-screen: not a pane for F6.
data-pane={narrowSidebarOpen ? "sidebar" : undefined}
data-testid="calendar-sidebar-drawer"
className="border-e border-border bg-secondary overflow-hidden"
>
{calendarSidebar}
</EdgeDrawer>
)}
{!isNarrow && !sidebarCollapsed && (
<>
<div
// A closed drawer is off-screen: not a pane for F6.
data-pane={!isNarrow || narrowSidebarOpen ? "sidebar" : undefined}
data-pane="sidebar"
className={cn(
"border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0",
!isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full rtl:translate-x-full"
)
)}
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
style={{ width: `${calSidebarWidth}px` }}
>
<CalendarSidebarHeader
onCreateEvent={() => { setNarrowSidebarOpen(false); openCreateForView(); }}
createLabel={createLabel}
tourTarget={!isNarrow}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3">
<div className="px-3">
<MiniCalendar
selectedDate={selectedDate}
displayMonth={miniMonth}
onSelectDate={handleMiniCalendarSelect}
onChangeMonth={handleMiniMonthChange}
events={events}
firstDayOfWeek={firstDayOfWeek}
showWeekNumbers={showWeekNumbers}
/>
</div>
<CalendarSidebarPanel
calendars={allCalendars}
selectedCalendarIds={shownCalendarIds}
onToggleVisibility={toggleShownCalendar}
onColorChange={client ? (calendarId, color) => {
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}
/>
</div>
{calendarSidebar}
</div>
{!isNarrow && (
<ResizeHandle
pane="sidebar"
value={calSidebarWidth}
min={180}
max={400}
onResizeStart={() => { 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"); }}
/>
)}
<ResizeHandle
pane="sidebar"
value={calSidebarWidth}
min={180}
max={400}
onResizeStart={() => { 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"); }}
/>
</>
)}
@@ -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<HTMLElement>('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]);
+97 -97
View File
@@ -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 = (
<ContactsSidebar
groups={groups}
individuals={individuals}
addressBooks={addressBooks}
activeCategory={activeCategory}
onSelectCategory={guard(handleSelectCategory)}
onCreateGroup={fromDrawer(guard(handleCreateGroup))}
onCreateContact={fromDrawer(guard(handleCreateNew))}
onCreateAddressBook={client ? fromDrawer(() => 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 && (
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
)}
{/* 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. */}
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", inlineApp && "hidden")}>
{/* Narrow-pane backdrop for the overlay categories sidebar */}
{isNarrow && narrowSidebarOpen && (
<div
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed"
)}
onClick={() => 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 && (
<EdgeDrawer
open={narrowSidebarOpen}
onClose={closeNarrowSidebar}
contained={isEmbedded || mvShell}
data-testid="contacts-sidebar-drawer"
data-pane="sidebar"
className="border-e border-border bg-background"
>
{contactsSidebar}
</EdgeDrawer>
)}
{showListPanel && !isNarrow && !isMobile && (
<>
<div
data-pane="sidebar"
className={cn(
"border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
)}
style={{ width: `${sidebarWidth}px` }}
>
{contactsSidebar}
</div>
<ResizeHandle
pane="sidebar"
value={sidebarWidth}
min={180}
max={400}
onResizeStart={() => { 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) && (
<>
<div
data-testid={isNarrow ? "contacts-sidebar-drawer" : undefined}
data-pane="sidebar"
className={cn(
"border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 start-0 z-50 w-72 pt-[var(--app-top-inset)]",
"transform transition-[transform,visibility] duration-300 ease-in-out",
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full"
)
)}
// Closed, the drawer is out of reach: no clicks, no Tab stops.
inert={isNarrow && !narrowSidebarOpen}
style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
>
<ContactsSidebar
groups={groups}
individuals={individuals}
addressBooks={addressBooks}
activeCategory={activeCategory}
onSelectCategory={guard(handleSelectCategory)}
onCreateGroup={fromDrawer(guard(handleCreateGroup))}
onCreateContact={fromDrawer(guard(handleCreateNew))}
onCreateAddressBook={client ? fromDrawer(() => 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}
/>
</div>
{!isNarrow && (
<ResizeHandle
pane="sidebar"
value={sidebarWidth}
min={180}
max={400}
onResizeStart={() => { 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. */}
<AppMain
+3
View File
@@ -323,6 +323,8 @@ export function ContactsSidebar({
{/* All contacts */}
<button
onClick={() => 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 && (
<button
onClick={() => onSelectCategory("directory")}
aria-current={activeCategory === "directory" ? "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",
activeCategory === "directory"
@@ -366,6 +366,53 @@ describe('FileBrowser', () => {
expect(drawer).not.toHaveAttribute('inert');
});
// The same drawer as Mail's folders (usability test 2026-11-04): it opened
// beside the app rail with only the file list dimmed, ignored Escape and
// left the focus on the page.
it("opens the folder drawer from the window's edge, with the whole window dimmed", () => {
renderBrowser();
const drawer = screen.getByTestId('files-sidebar-drawer');
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');
tree();
expect(screen.getByTestId('drawer-scrim').className.split(/\s+/)).toEqual(expect.arrayContaining(['fixed', 'inset-0']));
});
it('takes the focus into the folder drawer, keeps Tab there and gives it back on Escape without clearing the search', () => {
renderBrowser();
const drawer = screen.getByTestId('files-sidebar-drawer');
const menu = screen.getByRole('button', { name: 'open_folder_tree' });
fireEvent.change(screen.getAllByRole('searchbox')[0], { target: { value: 'notes' } });
menu.focus();
fireEvent.click(menu);
// The folder on screen.
expect(drawer.contains(document.activeElement)).toBe(true);
expect(document.activeElement).toHaveAttribute('aria-current', 'page');
const stops = Array.from(drawer.querySelectorAll<HTMLElement>('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);
expect(screen.getAllByRole('searchbox')[0]).toHaveValue('notes');
});
it('closes the folder drawer on a click on the dimmed window and gives the focus back to the menu button', () => {
renderBrowser();
const drawer = screen.getByTestId('files-sidebar-drawer');
const menu = screen.getByRole('button', { name: 'open_folder_tree' });
menu.focus();
fireEvent.click(menu);
// A press on the scrim drops the focus to the page.
(document.activeElement as HTMLElement).blur();
fireEvent.click(screen.getByTestId('drawer-scrim'));
expect(drawer).toHaveAttribute('inert');
expect(document.activeElement).toBe(menu);
});
it("renames in a dialog that selects the name without its extension and closes on Escape", async () => {
renderBrowser({ props: { selectedResources: new Set(['notes.txt']) } });
fireEvent.keyDown(window, { key: 'F2' });
+17 -15
View File
@@ -32,6 +32,8 @@ import { INTERNAL_DRAG_TYPE, isInternalDrag, readDraggedNames } from "@/componen
import { ResizeHandle } from "@/components/layout/resize-handle";
import { AppMain } from "@/components/layout/app-landmarks";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
import { EdgeDrawer } from "@/components/layout/edge-drawer";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import { getFilePreviewKind } from "@/lib/file-preview";
import type { FileAppAction } from "@/lib/files-app-actions";
@@ -298,6 +300,7 @@ export function FileBrowser({
const isMobile = useIsMobile();
const touch = useMediaQuery("(pointer: coarse)") && isMobile;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
const closeNarrowSidebar = useCallback(() => setNarrowSidebarOpen(false), []);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
useEffect(() => { setNarrowSidebarOpen(false); }, [currentPath]);
// Anything picked in the drawer closes it (R7).
@@ -844,6 +847,8 @@ export function FileBrowser({
return;
}
if (e.key === "Escape") {
// The folder drawer is the innermost layer; it closes itself (R8).
if (narrowSidebarOpen) return;
// The details panel over the list closes first (innermost layer, R8).
if (showDetails && isNarrow) toggleDetails(false);
else if (searchQuery) setSearchQuery("");
@@ -906,7 +911,7 @@ export function FileBrowser({
return () => window.removeEventListener("keydown", handler);
}, [
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash,
showDetails, isNarrow, toggleDetails,
showDetails, isNarrow, toggleDetails, narrowSidebarOpen,
selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource,
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus, displayResources,
]);
@@ -1107,6 +1112,8 @@ export function FileBrowser({
const tSidebar = useTranslations("sidebar");
const tShell = useTranslations("shell");
const isEmbedded = useIsEmbedded();
// Mountain View's card holds the drawer, under its top bar.
const mvShell = useMountainViewShell(isEmbedded);
useRegisterCommands(tSidebar("files"), [
{ id: "upload-files", label: tMenu("files_upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: tMenu("files_upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
@@ -1312,26 +1319,21 @@ export function FileBrowser({
} : null;
return (
// 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.
<div className="relative flex flex-1 min-h-0 overflow-hidden">
{/* Folder sidebar: in flow on desktop, a drawer on narrow panes */}
{sidebar && isNarrow && narrowSidebarOpen && (
<div className="absolute inset-0 bg-black/50 z-40" onClick={() => setNarrowSidebarOpen(false)} />
)}
{/* Folder sidebar: in flow on the full desktop; below it a drawer from
the window's edge, over the app rail, as Mail's folders. */}
{sidebar && (isNarrow ? (
<div
<EdgeDrawer
open={narrowSidebarOpen}
onClose={closeNarrowSidebar}
contained={isEmbedded || mvShell}
data-testid="files-sidebar-drawer"
data-pane="sidebar"
className={cn(
"absolute inset-y-0 start-0 z-50 w-72 border-e border-border",
"transform transition-[transform,visibility] duration-300 ease-in-out",
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full",
)}
// Closed, the drawer is out of reach: no clicks, no Tab stops.
inert={!narrowSidebarOpen}
className="border-e border-border"
>
{sidebar}
</div>
</EdgeDrawer>
) : (
<>
<div
@@ -0,0 +1,65 @@
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 (
<>
<button type="button" onClick={() => setOpen(true)}>menu</button>
<input aria-label="elsewhere" />
<EdgeDrawer open={open} onClose={() => setOpen(false)} contained={contained} data-testid="drawer">
<button type="button">first</button>
<button type="button" aria-current="true">current</button>
<button type="button" onClick={() => { setOpen(false); onPickFocus?.(); }}>pick</button>
</EdgeDrawer>
</>
);
}
describe('EdgeDrawer', () => {
it('opens on the current item and gives the focus back to the opener after a pick', () => {
render(<Harness />);
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(<Harness />);
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(<Harness onPickFocus={() => 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(<Harness contained />);
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');
});
});
@@ -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(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
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', () => {
+76
View File
@@ -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 (
<div
aria-hidden="true"
data-testid={testId}
data-drawer-scrim=""
className={cn("inset-0 bg-black/50 z-40", contained ? "absolute" : "fixed", className)}
onClick={() => onClose()}
/>
);
}
interface EdgeDrawerProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
open: boolean;
onClose: () => void;
/**
* In a Pro pane or Mountain View's card: at the start edge of the nearest
* positioned box instead of the window's.
*/
contained?: boolean;
/** Where the focus goes back to when the opener is gone by then. */
returnFocus?: () => HTMLElement | null | undefined;
children: ReactNode;
}
/**
* A sidebar opened over the page below the full desktop, as Mail's folders
* do: from the window's start edge (the right in RTL), over the app rail,
* from under the top bar and the window's controls (--drawer-top) to the
* bottom, with the page dimmed behind it. Keyboard and focus: useEdgeDrawer.
* Closed, it is off-screen and out of reach: no clicks, no Tab stops.
*/
export function EdgeDrawer({ open, onClose, contained, returnFocus, className, children, ...rest }: EdgeDrawerProps) {
const ref = useEdgeDrawer({ open, onClose, returnFocus });
return (
<>
{open && <DrawerScrim onClose={onClose} contained={contained} />}
<div
{...rest}
ref={ref}
data-edge-drawer=""
inert={!open}
className={cn(
"z-50 w-72 max-w-[85%] flex flex-col",
contained ? "absolute inset-y-0 start-0" : "fixed start-0 top-[var(--drawer-top)] bottom-0",
"transform duration-300 ease-in-out",
// Visible at once on opening, so the focus can go in straight away
// (a visibility transition keeps it hidden for that first frame);
// hidden only once it has slid out on closing.
open ? "shadow-xl transition-transform" : "invisible -translate-x-full rtl:translate-x-full transition-[transform,visibility]",
className
)}
>
{children}
</div>
</>
);
}
+2 -2
View File
@@ -2143,8 +2143,8 @@ function SidebarImpl({
data-testid="sidebar-collapse"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
{/* The arrows point the way the column goes: mirrored in RTL, where it sits at the right. */}
{isCollapsed ? <ChevronsRight className="w-4 h-4 rtl:-scale-x-100" /> : <ChevronsLeft className="w-4 h-4 rtl:-scale-x-100" />}
{/* The arrows point the way the column goes; the app's global RTL mirror turns them in RTL. */}
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button>
{/* The account switcher heads the folders by default, with the
+18 -35
View File
@@ -79,7 +79,8 @@ import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore, sidebarFolded, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store";
import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-query";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useEdgeDrawer } from "@/hooks/use-edge-drawer";
import { DrawerScrim } from "@/components/layout/edge-drawer";
import { usePaneSize } from "@/hooks/use-pane-size";
import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
@@ -2621,29 +2622,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Pro can mount two mail panes at once, so the sidebar id must be per-instance.
const sidebarId = useId();
const listHeadingId = useId();
// The phone drawer keeps Tab inside until it closes, and Escape closes it.
// The phone drawer keeps Tab inside until it closes, Escape closes it, and
// the focus goes back to the toggle however it closes (Escape, X, a pick,
// the scrim), unless it has moved on to something outside the drawer. The
// toggle lives in whichever MobileHeader the current view renders, so it is
// looked up inside this app instance when the one that opened it is gone.
const phoneDrawerOpen = isMobile && sidebarOpen;
const sidebarContainerRef = useFocusTrap({
isActive: phoneDrawerOpen,
onEscape: () => setSidebarOpen(false),
restoreFocus: false,
const closeSidebarDrawer = useCallback(() => setSidebarOpen(false), [setSidebarOpen]);
const sidebarContainerRef = useEdgeDrawer({
open: phoneDrawerOpen,
onClose: closeSidebarDrawer,
returnFocus: () => appRootRef.current?.querySelector<HTMLElement>('[data-sidebar-toggle]'),
});
const sidebarWasOpen = useRef(false);
useEffect(() => {
if (phoneDrawerOpen) {
sidebarWasOpen.current = true;
return;
}
if (!sidebarWasOpen.current) return;
sidebarWasOpen.current = false;
// Back to the toggle however it closed (Escape, X, a pick, the scrim),
// unless the focus has moved on to something outside the drawer. The
// toggle lives in whichever MobileHeader the current view renders, so
// look it up inside this app instance rather than threading a ref through.
const active = document.activeElement;
if (active && active !== document.body && !sidebarContainerRef.current?.contains(active)) return;
appRootRef.current?.querySelector<HTMLElement>('[data-sidebar-toggle]')?.focus();
}, [phoneDrawerOpen, sidebarContainerRef]);
// The folded column's tree open over the page as a drawer (Sidebar's
// onDrawerChange): the function that closes it, for the scrim.
const [closeFolderDrawer, setCloseFolderDrawer] = useState<(() => void) | null>(null);
@@ -5890,20 +5880,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
scope to the pane via `absolute` so the backdrop stays inside
the pane instead of covering the whole window. */}
{((isMobile && sidebarOpen) || closeFolderDrawer) && !inlineApp && (
<div
aria-hidden="true"
<DrawerScrim
data-testid="folder-drawer-scrim"
data-drawer-scrim=""
className={cn(
"inset-0 bg-black/50 z-40",
// The folded column's drawer opens on a desktop, so only the
// phone's scrim goes with the phone layout.
isEmbedded ? "absolute" : cn("fixed", !closeFolderDrawer && "desk:hidden")
)}
onClick={() => {
if (closeFolderDrawer) closeFolderDrawer();
else setSidebarOpen(false);
}}
contained={isEmbedded}
// The folded column's drawer opens on a desktop, so only the
// phone's scrim goes with the phone layout.
className={!isEmbedded && !closeFolderDrawer ? "desk:hidden" : undefined}
onClose={closeFolderDrawer ?? closeSidebarDrawer}
/>
)}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { useFocusTrap } from '@/hooks/use-focus-trap';
interface UseEdgeDrawerOptions {
/** Whether the drawer is open over the page. */
open: boolean;
/** Closes it: Escape, and whatever else the caller wires (scrim, X, a pick). */
onClose: () => void;
/**
* Where the focus goes back to when the button that opened the drawer is
* gone by then (a header that rendered again).
*/
returnFocus?: () => HTMLElement | null | undefined;
}
/**
* The keyboard side of a sidebar opened over the page as a drawer (Mail's
* folders on a phone, Calendar, Contacts and Files below the full desktop):
* the focus goes to the current item in it, else the first, Tab stays inside
* until it closes, Escape closes it, and however it closes (Escape, the scrim,
* a close button, a pick) the focus goes back to the button that opened it,
* unless the pick moved it somewhere else on purpose.
*
* Returns the ref for the drawer's element.
*/
export function useEdgeDrawer({ open, onClose, returnFocus }: UseEdgeDrawerOptions) {
const ref = useFocusTrap({
isActive: open,
onEscape: onClose,
restoreFocus: false,
initialFocus: () => ref.current?.querySelector<HTMLElement>(
'[aria-current]:not(:disabled):not([tabindex="-1"])'
),
});
// The opener, read before the trap moves the focus into the drawer.
const openerRef = useRef<HTMLElement | null>(null);
const returnFocusRef = useRef(returnFocus);
useLayoutEffect(() => {
returnFocusRef.current = returnFocus;
if (!open) return;
const active = document.activeElement;
if (active instanceof HTMLElement && active !== document.body && !ref.current?.contains(active)) {
openerRef.current = active;
}
});
const wasOpen = useRef(false);
useEffect(() => {
if (open) {
wasOpen.current = true;
// The current item can sit in a folded group: then the first one.
const root = ref.current;
if (root && !root.contains(document.activeElement)) {
root.querySelector<HTMLElement>(
'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])'
)?.focus();
}
return;
}
if (!wasOpen.current) return;
wasOpen.current = false;
const active = document.activeElement;
// A pick that moved the focus on (a dialog, a form) keeps it there.
if (active && active !== document.body && !ref.current?.contains(active)) return;
const opener = openerRef.current;
openerRef.current = null;
(opener?.isConnected ? opener : returnFocusRef.current?.())?.focus();
}, [open, ref]);
return ref;
}