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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 14:18:35 +02:00
1 parent b5f1077b94
commit 32a9624c82
4 files changed
+131 -78

No files matched your search

+11
View File
@@ -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();
+78 -28
View File
@@ -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<HTMLButtonElement>(null);
const [month, setMonth] = useState(() => keyToLocalDate(date));
const range = monthWindow(month, today);
const index = useDayIndex(range.from, range.to, open);
return (
<Popover
open={open}
onOpenChange={(next) => {
// 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 = (
<DayCalendar
date={date}
today={today}
month={month}
onMonthChange={setMonth}
withEntries={Object.keys(index.data?.days ?? {}) as DateKey[]}
onPick={(next) => {
setOpen(false);
const day = pickedDay(next, date, today);
if (day !== null) onPick(day);
}}
>
/>
);
if (variant === "sheet") {
return (
<>
<button
ref={buttonRef}
type="button"
data-slot="day-date-button"
data-state={open ? "open" : "closed"}
aria-haspopup="dialog"
aria-expanded={open}
title={t("day.pickDate")}
onClick={() => onOpenChange(true)}
className={buttonClass}
>
{label}
</button>
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("day.pickerTitle")}
hideHeader
showCloseButton={false}
bodyClassName="items-center p-2"
// Back to the date, inside the day's own sheet.
onCloseAutoFocus={(event) => {
event.preventDefault();
buttonRef.current?.focus({ preventScroll: true });
}}
>
<div data-slot="day-date-picker" className="w-fit">
{calendar}
</div>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="day-date-button"
aria-haspopup="dialog"
title={t("day.pickDate")}
className={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,
)}
className={buttonClass}
>
{label}
</button>
@@ -119,18 +180,7 @@ export function DayDatePicker({
aria-label={t("day.pickerTitle")}
className="w-auto p-0"
>
<DayCalendar
date={date}
today={today}
month={month}
onMonthChange={setMonth}
withEntries={Object.keys(index.data?.days ?? {}) as DateKey[]}
onPick={(next) => {
setOpen(false);
const day = pickedDay(next, date, today);
if (day !== null) onPick(day);
}}
/>
{calendar}
</PopoverContent>
</Popover>
);
+1
View File
@@ -238,6 +238,7 @@ export function DayView({
date={date}
today={today}
onPick={onPick}
variant={compact ? "sheet" : "popover"}
label={
compact ? (
<>
@@ -318,32 +318,29 @@ export function ConversationsPanel({
)}
>
{inSheet ? null : (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={collapseRef}
type="button"
variant="ghost"
size="icon"
onClick={collapseDocked}
data-slot="coach-panel-collapse"
aria-controls={COACH_PANEL_ID}
aria-expanded={true}
aria-label={t("insights.coach.frame.hidePanel")}
className={PANEL_HEADER_BUTTON}
>
{/* The day's own fold control and glyph, mirrored so it
points the way the panel goes. */}
<PanelRightClose
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("insights.coach.frame.hidePanel")}
</TooltipContent>
</Tooltip>
// 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.
<Button
ref={collapseRef}
type="button"
variant="ghost"
size="icon"
onClick={collapseDocked}
data-slot="coach-panel-collapse"
aria-controls={COACH_PANEL_ID}
aria-expanded={true}
aria-label={t("insights.coach.frame.hidePanel")}
title={t("insights.coach.frame.hidePanel")}
className={PANEL_HEADER_BUTTON}
>
{/* The day's own fold control and glyph, mirrored so it points
the way the panel goes. */}
<PanelRightClose
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
)}
{inSheet ? (
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
@@ -475,30 +472,24 @@ export function ConversationsPanel({
"border-sidebar-border flex shrink-0 items-center justify-center",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={expandRef}
type="button"
variant="ghost"
size="icon"
onClick={expandDocked}
data-slot="coach-panel-expand"
aria-controls={COACH_PANEL_ID}
aria-expanded={false}
aria-label={t("insights.coach.frame.showPanel")}
className={PANEL_HEADER_BUTTON}
>
<PanelRightOpen
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</TooltipTrigger>
<TooltipContent side="left">
{t("insights.coach.frame.showPanel")}
</TooltipContent>
</Tooltip>
<Button
ref={expandRef}
type="button"
variant="ghost"
size="icon"
onClick={expandDocked}
data-slot="coach-panel-expand"
aria-controls={COACH_PANEL_ID}
aria-expanded={false}
aria-label={t("insights.coach.frame.showPanel")}
title={t("insights.coach.frame.showPanel")}
className={PANEL_HEADER_BUTTON}
>
<PanelRightOpen
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</div>
{/* The rest of the edge takes a click too and names the
list; the keyboard has the button above. */}