From 32a9624c827dd9539eb30fef61d66cf53fd199d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 14:18:35 +0200 Subject: [PATCH] fix(day): raise the calendar as its own sheet on a phone In the phone's bottom sheet the date sits halfway down the screen, and a month of finger-sized cells below it ran off the bottom, Today button and all. There the calendar now opens as a bottom sheet of its own and hands focus back to the date when it closes. The docked column and the side sheet keep the popover. The conversations panel's fold and unfold buttons carry a title instead of a tooltip: focus lands on them when the panel opens or folds, and a tooltip opened by that focus took the next Escape for itself. --- e2e/day-view.spec.ts | 11 ++ src/components/day/day-date-picker.tsx | 106 +++++++++++++----- src/components/day/day-view.tsx | 1 + .../coach-panel/conversations-panel.tsx | 91 +++++++-------- 4 files changed, 131 insertions(+), 78 deletions(-) diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index 31427c9fe..74d624c60 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -224,6 +224,17 @@ test.describe("the day view", () => { // The keyboard: Enter opens, Escape closes the calendar only. await page.keyboard.press("Enter"); await expect(picker).toBeVisible(); + // The calendar has the keyboard before Escape is pressed. + await expect + .poll(() => + page.evaluate( + () => + document.activeElement?.closest( + '[role="dialog"]:not([data-slot="day-panel"])', + ) != null, + ), + ) + .toBe(true); await page.keyboard.press("Escape"); await expect(picker).toHaveCount(0); await expect(panel(page)).toBeVisible(); diff --git a/src/components/day/day-date-picker.tsx b/src/components/day/day-date-picker.tsx index a85fef543..eda77a736 100644 --- a/src/components/day/day-date-picker.tsx +++ b/src/components/day/day-date-picker.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Calendar, CalendarDayButton } from "@/components/ui/calendar"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { resolveDateFnsLocale } from "@/components/ui/date-field"; import { Popover, @@ -25,9 +26,10 @@ import { useDayIndex } from "./use-day"; * * No icon and no field: the header keeps its one line, and the arrows * beside it stay the way to the neighbouring days. Radix owns the keyboard - * (Enter or Space opens, Escape closes, focus returns to the date), and - * the same popover serves the docked column and both sheets, with touch- - * sized cells wherever the pointer is coarse. + * (Enter or Space opens, Escape closes, focus returns to the date). A + * popover beside the date serves the docked column and the side sheet; in + * the phone's bottom sheet the calendar rises as a sheet of its own, with + * touch-sized cells wherever the pointer is coarse. */ /** `YYYY-MM-DD` → a local `Date` at midnight, the calendar's own unit. */ @@ -75,6 +77,7 @@ export function DayDatePicker({ today, label, onPick, + variant = "popover", className, }: { date: DateKey; @@ -82,33 +85,91 @@ export function DayDatePicker({ /** The date as the header writes it: the button's text. */ label: React.ReactNode; onPick: (date: DateKey) => void; + /** + * `sheet` in the phone's bottom sheet: a month of finger-sized cells + * does not fit beside a header that sits halfway down the screen, so the + * calendar rises as its own bottom sheet there. + */ + variant?: "popover" | "sheet"; className?: string; }) { const { t } = useTranslations(); const [open, setOpen] = useState(false); + const buttonRef = useRef(null); const [month, setMonth] = useState(() => keyToLocalDate(date)); const range = monthWindow(month, today); const index = useDayIndex(range.from, range.to, open); - return ( - { - // Each opening starts on the month of the day on screen. - if (next) setMonth(keyToLocalDate(date)); - setOpen(next); + const onOpenChange = (next: boolean) => { + // Each opening starts on the month of the day on screen. + if (next) setMonth(keyToLocalDate(date)); + setOpen(next); + }; + const buttonClass = cn( + "hover:bg-muted/60 focus-visible:ring-ring/50 data-[state=open]:bg-muted/60 -mx-1 block max-w-full truncate rounded-md px-1 text-left transition-colors focus-visible:ring-[3px] focus-visible:outline-none", + className, + ); + const calendar = ( + { + setOpen(false); + const day = pickedDay(next, date, today); + if (day !== null) onPick(day); }} - > + /> + ); + + if (variant === "sheet") { + return ( + <> + + { + event.preventDefault(); + buttonRef.current?.focus({ preventScroll: true }); + }} + > +
+ {calendar} +
+
+ + ); + } + + return ( + @@ -119,18 +180,7 @@ export function DayDatePicker({ aria-label={t("day.pickerTitle")} className="w-auto p-0" > - { - setOpen(false); - const day = pickedDay(next, date, today); - if (day !== null) onPick(day); - }} - /> + {calendar} ); diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index e8d4b145c..831653891 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -238,6 +238,7 @@ export function DayView({ date={date} today={today} onPick={onPick} + variant={compact ? "sheet" : "popover"} label={ compact ? ( <> diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index 92fab16cd..41bd0d228 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -318,32 +318,29 @@ export function ConversationsPanel({ )} > {inSheet ? null : ( - - - - - - {t("insights.coach.frame.hidePanel")} - - + // A title, not a tooltip: focus lands here when the panel opens + // from its edge, and a tooltip opened by that focus would take the + // next Escape for itself. + )} {inSheet ? ( @@ -475,30 +472,24 @@ export function ConversationsPanel({ "border-sidebar-border flex shrink-0 items-center justify-center", )} > - - - - - - {t("insights.coach.frame.showPanel")} - - + {/* The rest of the edge takes a click too and names the list; the keyboard has the button above. */}