diff --git a/packages/web/src/components/CalendarHeader/CalendarHeader.tsx b/packages/web/src/components/CalendarHeader/CalendarHeader.tsx index cbcfbd26b1..bcc5ce29d8 100644 --- a/packages/web/src/components/CalendarHeader/CalendarHeader.tsx +++ b/packages/web/src/components/CalendarHeader/CalendarHeader.tsx @@ -1,17 +1,11 @@ import { ArrowClockwiseIcon } from "@phosphor-icons/react"; import { type FC } from "react"; -import { colors } from "@web/common/styles/colors"; import { reloadLocation } from "@web/common/utils/browser/browser-navigation.util"; import { ArrowButton } from "@web/components/Button/ArrowButton"; -import { SidebarIcon } from "@web/components/Icons/Sidebar"; import { SelectView } from "@web/components/SelectView/SelectView"; import { useVersionCheck } from "@web/components/Sidebar/SidebarActions/useVersionCheck"; +import { SidebarToggleButton } from "@web/components/Sidebar/SidebarToggleButton"; import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; -import { - selectIsSidebarOpen, - useViewStore, - viewActions, -} from "@web/events/stores/view.store"; interface Props { /** Left-aligned heading text (e.g. "June 2026" or "Wednesday, July 1"). */ @@ -43,51 +37,51 @@ export const CalendarHeader: FC = ({ nextLabel = "Next", showNavigation = true, }) => { - const isSidebarOpen = useViewStore(selectIsSidebarOpen); const { isUpdateAvailable } = useVersionCheck(); return (
- {showNavigation && onPrev && onNext && ( - <> - - - - - - - - )} - - {isUpdateAvailable ? ( - - - - ) : null} + + + ) : null} +
-
- viewActions.toggleSidebar()} - shortcut="]" - > - - +
+
); diff --git a/packages/web/src/components/Sidebar/SidebarCloseButton.test.tsx b/packages/web/src/components/Sidebar/SidebarCloseButton.test.tsx new file mode 100644 index 0000000000..a94f51936b --- /dev/null +++ b/packages/web/src/components/Sidebar/SidebarCloseButton.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createStoreWrapper } from "@web/__tests__/render-with-store"; +import { + createGridEventDraft, + timedGridSchedule, +} from "@web/events/grid-event-draft.adapter"; +import { + draftActions, + selectIsEventFormOpen, + useDraftStore, +} from "@web/events/stores/draft.store"; +import { + selectIsSidebarOpen, + useViewStore, + viewActions, +} from "@web/events/stores/view.store"; +import { SidebarCloseButton } from "./SidebarCloseButton"; +import { SidebarToggleButton } from "./SidebarToggleButton"; +import { beforeEach, describe, expect, it } from "bun:test"; + +function Harness() { + const isOpen = useViewStore(selectIsSidebarOpen); + const isEventFormOpen = useDraftStore(selectIsEventFormOpen); + return ( +
+ + {isOpen || isEventFormOpen ? : null} +
+ ); +} + +beforeEach(() => { + viewActions.setSidebarOpen(true); + draftActions.discard(); +}); + +describe("SidebarCloseButton", () => { + it("closes the sidebar and focuses the header open control", async () => { + const user = userEvent.setup(); + const { wrapper } = createStoreWrapper(); + + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: "Dismiss sidebar" })); + + expect( + screen.queryByRole("button", { name: "Dismiss sidebar" }), + ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Open sidebar" }), + ).toHaveFocus(); + }); + }); + + it("also discards an open event form so the panel can fully close", async () => { + const user = userEvent.setup(); + const { wrapper } = createStoreWrapper(); + viewActions.setSidebarOpen(false); + draftActions.startGridDraft({ + activity: "gridClick", + draft: createGridEventDraft( + timedGridSchedule( + new Date("2026-05-20T09:00:00.000Z"), + new Date("2026-05-20T10:00:00.000Z"), + ), + ), + }); + draftActions.setFormOpen(true); + + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: "Dismiss sidebar" })); + + expect(selectIsEventFormOpen(useDraftStore.getState())).toBe(false); + expect( + screen.queryByRole("button", { name: "Dismiss sidebar" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/Sidebar/SidebarCloseButton.tsx b/packages/web/src/components/Sidebar/SidebarCloseButton.tsx new file mode 100644 index 0000000000..f6b84b4a0c --- /dev/null +++ b/packages/web/src/components/Sidebar/SidebarCloseButton.tsx @@ -0,0 +1,49 @@ +import { XIcon } from "@phosphor-icons/react"; +import { type FC } from "react"; +import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper"; +import { + selectIsEventFormOpen, + useDraftStore, +} from "@web/events/stores/draft.store"; +import { viewActions } from "@web/events/stores/view.store"; +import { useCloseEventForm } from "@web/views/Forms/hooks/useCloseEventForm"; + +/** + * Narrow-layout dismiss control rendered inside the sidebar. Uses a distinct + * accessible name from the header toggle so the two controls do not collide + * while the panel is open. Closes both the sidebar preference and any open + * event form (Day/Week keep the panel mounted for event details), then + * restores focus to the header "Open sidebar" control. + */ +export const SidebarCloseButton: FC = () => { + const closeEventForm = useCloseEventForm(); + const isEventFormOpen = useDraftStore(selectIsEventFormOpen); + + return ( + { + viewActions.setSidebarOpen(false); + if (isEventFormOpen) { + closeEventForm(); + } + // The header toggle stays mounted and flips to "Open sidebar"; move + // focus there after this in-sidebar control unmounts. + window.setTimeout(() => { + document + .querySelector('[aria-label="Open sidebar"]') + ?.focus(); + }, 0); + }} + shortcut="]" + > + + + ); +}; diff --git a/packages/web/src/components/Sidebar/SidebarShell.test.tsx b/packages/web/src/components/Sidebar/SidebarShell.test.tsx new file mode 100644 index 0000000000..73b808ae53 --- /dev/null +++ b/packages/web/src/components/Sidebar/SidebarShell.test.tsx @@ -0,0 +1,112 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createStoreWrapper } from "@web/__tests__/render-with-store"; +import { SIDEBAR_AUTO_COLLAPSE_BREAKPOINT } from "@web/components/AuthenticatedLayout/responsive.constants"; +import { + createGridEventDraft, + timedGridSchedule, +} from "@web/events/grid-event-draft.adapter"; +import { draftActions } from "@web/events/stores/draft.store"; +import { viewActions } from "@web/events/stores/view.store"; +import { SidebarShell } from "./SidebarShell"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +const originalMatchMedia = window.matchMedia; + +function mockViewport(isNarrow: boolean) { + window.matchMedia = mock((query: string) => { + const matchesMinWidth = query.includes( + `min-width: ${SIDEBAR_AUTO_COLLAPSE_BREAKPOINT}px`, + ) + ? !isNarrow + : false; + return { + matches: matchesMinWidth, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => true, + } as MediaQueryList; + }) as typeof window.matchMedia; +} + +function renderShell() { + const { wrapper } = createStoreWrapper(); + return render( + <>} + ShortcutsOverlayComponent={() => <>} + > +
Sidebar body
+
, + { wrapper }, + ); +} + +beforeEach(() => { + viewActions.setSidebarOpen(true); + draftActions.discard(); + mockViewport(false); +}); + +afterEach(() => { + window.matchMedia = originalMatchMedia; + draftActions.discard(); +}); + +describe("SidebarShell", () => { + it("keeps the dismiss control out of the sidebar on wide layouts", () => { + mockViewport(false); + renderShell(); + + expect( + screen.queryByRole("button", { name: "Dismiss sidebar" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Sidebar body")).toBeInTheDocument(); + }); + + it("shows a dismiss control inside the sidebar on narrow layouts", async () => { + const user = userEvent.setup(); + mockViewport(true); + renderShell(); + + const closeButton = screen.getByRole("button", { name: "Dismiss sidebar" }); + expect(closeButton).toBeInTheDocument(); + + await user.click(closeButton); + + expect( + screen.queryByRole("button", { name: "Dismiss sidebar" }), + ).not.toBeInTheDocument(); + }); + + it("keeps dismiss available on narrow layouts while an event form is open", () => { + mockViewport(true); + act(() => { + viewActions.setSidebarOpen(false); + draftActions.startGridDraft({ + activity: "gridClick", + draft: createGridEventDraft( + timedGridSchedule( + new Date("2026-05-20T09:00:00.000Z"), + new Date("2026-05-20T10:00:00.000Z"), + ), + ), + }); + draftActions.setFormOpen(true); + }); + + renderShell(); + + expect( + screen.getByRole("button", { name: "Dismiss sidebar" }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/Sidebar/SidebarShell.tsx b/packages/web/src/components/Sidebar/SidebarShell.tsx index e4bac11c5c..b9eca97ad0 100644 --- a/packages/web/src/components/Sidebar/SidebarShell.tsx +++ b/packages/web/src/components/Sidebar/SidebarShell.tsx @@ -1,8 +1,18 @@ import { type HTMLAttributes, type ReactNode } from "react"; import { ID_SIDEBAR } from "@web/common/constants/web.constants"; import { type ShortcutOverlaySection } from "@web/components/Shortcuts/ShortcutOverlay/ShortcutsOverlay"; +import { + selectIsEventFormOpen, + useDraftStore, +} from "@web/events/stores/draft.store"; +import { + selectIsSidebarOpen, + useViewStore, +} from "@web/events/stores/view.store"; +import { useIsNarrowSidebarLayout } from "./hooks/useIsNarrowSidebarLayout"; import { ShortcutsOverlay } from "./ShortcutsOverlay/ShortcutsOverlay"; import { SidebarActions } from "./SidebarActions/SidebarActions"; +import { SidebarCloseButton } from "./SidebarCloseButton"; interface SidebarShellProps extends HTMLAttributes { children: ReactNode; @@ -26,6 +36,13 @@ export function SidebarShell({ ShortcutsOverlayComponent = ShortcutsOverlay, ...props }: SidebarShellProps) { + const isNarrowLayout = useIsNarrowSidebarLayout(); + const isSidebarOpen = useViewStore(selectIsSidebarOpen); + const isEventFormOpen = useDraftStore(selectIsEventFormOpen); + // Day/Week keep the panel mounted for event details even when the sidebar + // preference is closed; keep dismiss available for that case too. + const showSidebarClose = isNarrowLayout && (isSidebarOpen || isEventFormOpen); + return (