Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 40 additions & 46 deletions packages/web/src/components/CalendarHeader/CalendarHeader.tsx
Original file line number Diff line number Diff line change
@@ -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"). */
Expand Down Expand Up @@ -43,51 +37,51 @@ export const CalendarHeader: FC<Props> = ({
nextLabel = "Next",
showNavigation = true,
}) => {
const isSidebarOpen = useViewStore(selectIsSidebarOpen);
const { isUpdateAvailable } = useVersionCheck();

return (
<div className="flex h-12 w-full shrink-0 items-center gap-3 text-text-muted">
{showNavigation && onPrev && onNext && (
<>
<TooltipWrapper shortcut="J">
<ArrowButton direction="left" label={prevLabel} onClick={onPrev} />
</TooltipWrapper>
<TooltipWrapper shortcut="K">
<ArrowButton direction="right" label={nextLabel} onClick={onNext} />
</TooltipWrapper>
</>
)}
<SelectView label={label} onToday={onToday} />
{isUpdateAvailable ? (
<TooltipWrapper
description="Get latest version"
onClick={reloadLocation}
>
<button
aria-label="Get latest version"
className="flex size-7 items-center justify-center rounded-default text-accent transition hover:bg-surface-panel hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
type="button"
{/* min-w-0 lets the title cluster shrink so the sidebar toggle stays in
layout. Avoid overflow-hidden here — SelectView's menu is absolutely
positioned inside this cluster and must paint below the header. */}
<div className="flex min-w-0 flex-1 items-center gap-3">
{showNavigation && onPrev && onNext && (
<>
<TooltipWrapper shortcut="J">
<ArrowButton
direction="left"
label={prevLabel}
onClick={onPrev}
/>
</TooltipWrapper>
<TooltipWrapper shortcut="K">
<ArrowButton
direction="right"
label={nextLabel}
onClick={onNext}
/>
</TooltipWrapper>
</>
)}
<SelectView label={label} onToday={onToday} />
{isUpdateAvailable ? (
<TooltipWrapper
description="Get latest version"
onClick={reloadLocation}
>
<ArrowClockwiseIcon aria-hidden="true" size={16} />
</button>
</TooltipWrapper>
) : null}
<button
aria-label="Get latest version"
className="flex size-7 shrink-0 items-center justify-center rounded-default text-accent transition hover:bg-surface-panel hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
type="button"
>
<ArrowClockwiseIcon aria-hidden="true" size={16} />
</button>
</TooltipWrapper>
) : null}
</div>

<div className="z-2 ml-auto flex items-center pr-5">
<TooltipWrapper
description={isSidebarOpen ? "Close sidebar" : "Open sidebar"}
onClick={() => viewActions.toggleSidebar()}
shortcut="]"
>
<button
type="button"
aria-label={isSidebarOpen ? "Close sidebar" : "Open sidebar"}
className="c-focus-ring flex h-6 w-6 cursor-pointer items-center justify-center"
>
<SidebarIcon color={colors.textMuted} size={21} />
</button>
</TooltipWrapper>
<div className="z-2 flex shrink-0 items-center pr-5">
<SidebarToggleButton />
</div>
</div>
);
Expand Down
81 changes: 81 additions & 0 deletions packages/web/src/components/Sidebar/SidebarCloseButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<SidebarToggleButton />
{isOpen || isEventFormOpen ? <SidebarCloseButton /> : null}
</div>
);
}

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(<Harness />, { 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(<Harness />, { 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();
});
});
49 changes: 49 additions & 0 deletions packages/web/src/components/Sidebar/SidebarCloseButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TooltipWrapper
description="Close sidebar"
onClick={() => {
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<HTMLButtonElement>('[aria-label="Open sidebar"]')
?.focus();
}, 0);
}}
shortcut="]"
>
<button
type="button"
aria-label="Dismiss sidebar"
className="c-focus-ring flex h-6 w-6 cursor-pointer items-center justify-center text-text-muted"
>
<XIcon aria-hidden="true" size={16} />
</button>
</TooltipWrapper>
);
};
112 changes: 112 additions & 0 deletions packages/web/src/components/Sidebar/SidebarShell.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<SidebarShell
isShortcutsOpen={false}
onCloseShortcuts={mock()}
onToggleShortcuts={mock()}
shortcutSections={[]}
SidebarActionsComponent={() => <></>}
ShortcutsOverlayComponent={() => <></>}
>
<div>Sidebar body</div>
</SidebarShell>,
{ 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();
});
});
22 changes: 22 additions & 0 deletions packages/web/src/components/Sidebar/SidebarShell.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement> {
children: ReactNode;
Expand All @@ -26,13 +36,25 @@ 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 (
<aside
{...props}
aria-label="Sidebar"
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-surface-panel pt-5 text-text"
id={ID_SIDEBAR}
>
{showSidebarClose ? (
<div className="flex shrink-0 items-center justify-end px-5 pb-2">
<SidebarCloseButton />
</div>
) : null}
{children}
<SidebarActionsComponent
isShortcutsOpen={isShortcutsOpen}
Expand Down
Loading