Skip to content

Commit 24e629a

Browse files
refactor(web): avoid duplicate sidebar toggle names on narrow close
Hide the header toggle while the narrow in-sidebar close is shown, and unmount that in-sidebar control as soon as the sidebar starts closing so collapse transitions do not leave two "Open sidebar" buttons. Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com>
1 parent 6fa1c3e commit 24e629a

5 files changed

Lines changed: 37 additions & 21 deletions

File tree

packages/web/src/components/CalendarHeader/CalendarHeader.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { type FC } from "react";
33
import { reloadLocation } from "@web/common/utils/browser/browser-navigation.util";
44
import { ArrowButton } from "@web/components/Button/ArrowButton";
55
import { SelectView } from "@web/components/SelectView/SelectView";
6+
import { useIsNarrowSidebarLayout } from "@web/components/Sidebar/hooks/useIsNarrowSidebarLayout";
67
import { useVersionCheck } from "@web/components/Sidebar/SidebarActions/useVersionCheck";
78
import { SidebarToggleButton } from "@web/components/Sidebar/SidebarToggleButton";
89
import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper";
10+
import {
11+
selectIsSidebarOpen,
12+
useViewStore,
13+
} from "@web/events/stores/view.store";
914

1015
interface Props {
1116
/** Left-aligned heading text (e.g. "June 2026" or "Wednesday, July 1"). */
@@ -38,6 +43,11 @@ export const CalendarHeader: FC<Props> = ({
3843
showNavigation = true,
3944
}) => {
4045
const { isUpdateAvailable } = useVersionCheck();
46+
const isSidebarOpen = useViewStore(selectIsSidebarOpen);
47+
const isNarrowLayout = useIsNarrowSidebarLayout();
48+
// On narrow layouts the open sidebar hosts its own close control; keep a
49+
// single "Close sidebar" name in the accessibility tree.
50+
const showHeaderSidebarToggle = !isNarrowLayout || !isSidebarOpen;
4151

4252
return (
4353
<div className="flex h-12 w-full shrink-0 items-center gap-3 text-text-muted">
@@ -80,9 +90,11 @@ export const CalendarHeader: FC<Props> = ({
8090
) : null}
8191
</div>
8292

83-
<div className="z-2 ml-auto flex shrink-0 items-center pr-5">
84-
<SidebarToggleButton />
85-
</div>
93+
{showHeaderSidebarToggle ? (
94+
<div className="z-2 flex shrink-0 items-center pr-5">
95+
<SidebarToggleButton />
96+
</div>
97+
) : null}
8698
</div>
8799
);
88100
};

packages/web/src/components/Sidebar/SidebarShell.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe("SidebarShell", () => {
7676
await user.click(closeButton);
7777

7878
expect(
79-
screen.getByRole("button", { name: "Open sidebar" }),
80-
).toBeInTheDocument();
79+
screen.queryByRole("button", { name: "Close sidebar" }),
80+
).not.toBeInTheDocument();
8181
});
8282
});

packages/web/src/components/Sidebar/SidebarShell.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { type HTMLAttributes, type ReactNode } from "react";
22
import { ID_SIDEBAR } from "@web/common/constants/web.constants";
33
import { type ShortcutOverlaySection } from "@web/components/Shortcuts/ShortcutOverlay/ShortcutsOverlay";
4+
import {
5+
selectIsSidebarOpen,
6+
useViewStore,
7+
} from "@web/events/stores/view.store";
48
import { useIsNarrowSidebarLayout } from "./hooks/useIsNarrowSidebarLayout";
59
import { ShortcutsOverlay } from "./ShortcutsOverlay/ShortcutsOverlay";
610
import { SidebarActions } from "./SidebarActions/SidebarActions";
@@ -29,6 +33,10 @@ export function SidebarShell({
2933
...props
3034
}: SidebarShellProps) {
3135
const isNarrowLayout = useIsNarrowSidebarLayout();
36+
const isSidebarOpen = useViewStore(selectIsSidebarOpen);
37+
// Only while open: during the collapse transition the shell stays mounted,
38+
// and a still-rendered toggle would duplicate the header's "Open sidebar".
39+
const showSidebarClose = isNarrowLayout && isSidebarOpen;
3240

3341
return (
3442
<aside
@@ -37,7 +45,7 @@ export function SidebarShell({
3745
className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-surface-panel pt-5 text-text"
3846
id={ID_SIDEBAR}
3947
>
40-
{isNarrowLayout ? (
48+
{showSidebarClose ? (
4149
<div className="flex shrink-0 items-center justify-end px-5 pb-2">
4250
<SidebarToggleButton />
4351
</div>

packages/web/src/components/Sidebar/hooks/useIsNarrowSidebarLayout.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@ import { SIDEBAR_AUTO_COLLAPSE_BREAKPOINT } from "@web/components/AuthenticatedL
88
* control.
99
*/
1010
export function useIsNarrowSidebarLayout() {
11-
const [isNarrow, setIsNarrow] = useState(() => {
12-
if (typeof window === "undefined") return false;
13-
return !window.matchMedia(
14-
`(min-width: ${SIDEBAR_AUTO_COLLAPSE_BREAKPOINT}px)`,
15-
).matches;
16-
});
11+
const [isNarrow, setIsNarrow] = useState(
12+
() =>
13+
!window.matchMedia(`(min-width: ${SIDEBAR_AUTO_COLLAPSE_BREAKPOINT}px)`)
14+
.matches,
15+
);
1716

1817
useLayoutEffect(() => {
1918
const query = window.matchMedia(

packages/web/src/views/Life/LifeView.test.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -389,24 +389,21 @@ describe("LifeView", () => {
389389
name: "Sidebar",
390390
});
391391

392-
const closeButtons = screen.getAllByRole("button", {
392+
const inSidebarClose = screen.getByRole("button", {
393393
name: "Close sidebar",
394394
});
395-
const inSidebarClose = closeButtons.find((button) =>
396-
sidebar.contains(button),
397-
);
398-
expect(inSidebarClose).toBeTruthy();
395+
expect(sidebar.contains(inSidebarClose)).toBe(true);
399396

400-
await user.click(inSidebarClose!);
397+
await user.click(inSidebarClose);
401398

402399
// Collapse keeps the panel mounted until the width transition ends; the
403-
// toggle label flipping off "Close" is the observable close signal in jsdom.
400+
// header "Open sidebar" control returning is the observable close signal.
404401
expect(
405402
screen.queryByRole("button", { name: "Close sidebar" }),
406403
).not.toBeInTheDocument();
407404
expect(
408-
screen.getAllByRole("button", { name: "Open sidebar" }).length,
409-
).toBeGreaterThan(0);
405+
screen.getByRole("button", { name: "Open sidebar" }),
406+
).toBeInTheDocument();
410407
});
411408

412409
it("shows the privacy tooltip on the date of birth label", async () => {

0 commit comments

Comments
 (0)