@@ -804,8 +808,8 @@ export function CalendarWeekView({
data-pending-preview
className="absolute left-0.5 right-0.5 z-30 rounded-xs pointer-events-none overflow-hidden px-1.5 py-0.5 text-xs shadow-md"
style={{
- top: ((clip.startMinutes - hours.startMinutes) / 60) * HOUR_HEIGHT,
- height: Math.max(20, ((clip.endMinutes - clip.startMinutes) / 60) * HOUR_HEIGHT),
+ top: ((clip.startMinutes - hours.startMinutes) / 60) * hourHeight,
+ height: Math.max(20, ((clip.endMinutes - clip.startMinutes) / 60) * hourHeight),
backgroundColor: color,
color: readableTextOn(color),
}}
diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx
index cd851d5ff..0aefaaef1 100644
--- a/components/calendar/event-card.tsx
+++ b/components/calendar/event-card.tsx
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format } from "date-fns";
-import { Users } from "@/components/icons";
+import { ChevronDown, ChevronUp, Users } from "@/components/icons";
import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants";
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store";
@@ -22,7 +22,12 @@ interface EventCardProps {
onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void;
isSelected?: boolean;
draggable?: boolean;
+ /**
+ * The event goes on before what is drawn: before the first day of a span,
+ * above the first visible hour of a block.
+ */
continuesBefore?: boolean;
+ /** The event goes on after what is drawn: past the last day or hour. */
continuesAfter?: boolean;
className?: string;
style?: CSSProperties;
@@ -234,14 +239,25 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
data-calendar-event
+ data-continues-before={continuesBefore || undefined}
+ data-continues-after={continuesAfter || undefined}
className={cn(
- "w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
+ // A block box: an inline one hangs its line's descent below the
+ // grid's last hour and makes the filled pane scroll.
+ "relative block w-full h-full text-start rounded-xs px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
+ // Cut at the edge of the visible hours: a square edge and an arrow
+ // say the event runs on past it.
+ continuesBefore && "rounded-t-none",
+ continuesAfter && "rounded-b-none",
+ continuesBefore && "pe-4",
isBeingDragged && "opacity-50",
className
)}
style={{ ...eventFillStyle(color, { inactive: isInactive, separated: true, selected: isSelected }), ...style }}
>
+ {continuesBefore &&
}
+ {continuesAfter &&
}
{/* Shorter blocks have no room for a second line at the 60px hour, so
their start time follows the title on the same line, when the
block is wide enough to keep the title readable. */}
diff --git a/hooks/use-display-hours.ts b/hooks/use-display-hours.ts
index e9112127a..9617ee2fb 100644
--- a/hooks/use-display-hours.ts
+++ b/hooks/use-display-hours.ts
@@ -11,17 +11,33 @@ export interface RevealTime {
interface UseDisplayHoursOptions {
scrollRef: RefObject
;
- hourHeight: number;
+ /** The smallest height of an hour row; rows grow to fill a taller pane. */
+ minHourHeight: number;
+ /** The sticky header above the hour rows, whose height the rows don't get. */
+ headerRef?: RefObject;
revealAt?: RevealTime | null;
}
+/**
+ * The height of an hour row: `min`, or more when the hours would leave the
+ * pane's bottom empty (a short range on a tall tablet). A pane only a little
+ * short of the hours gets slightly lower rows rather than a few pixels of
+ * scrolling. Whole pixels, so the hour lines stay sharp.
+ */
+export function fillHourHeight(min: number, available: number, hourCount: number): number {
+ if (hourCount <= 0 || available <= 0) return min;
+ const fit = Math.floor(available / hourCount);
+ return fit >= Math.ceil(min * 0.9) ? fit : min;
+}
+
/**
* The hours a time grid draws (#1164): the configured range, or the whole
* day while the user has asked to see it. Owns the grid's vertical scroll
* position too - it opens an hour before now, and keeps the same hours in
- * place when the range changes underneath it.
+ * place when the range or the row height changes underneath it. Rows fill
+ * the pane when the hours are fewer than it has room for.
*/
-export function useDisplayHours({ scrollRef, hourHeight, revealAt }: UseDisplayHoursOptions) {
+export function useDisplayHours({ scrollRef, minHourHeight, headerRef, revealAt }: UseDisplayHoursOptions) {
const limitHours = useSettingsStore((s) => s.calendarLimitHours);
const startHour = useSettingsStore((s) => s.calendarDayStartHour);
const endHour = useSettingsStore((s) => s.calendarDayEndHour);
@@ -31,7 +47,27 @@ export function useDisplayHours({ scrollRef, hourHeight, revealAt }: UseDisplayH
);
const [showAllHours, setShowAllHours] = useState(false);
const hours: DisplayHours = showAllHours ? FULL_DAY_HOURS : configured;
+ const hourCount = (hours.endMinutes - hours.startMinutes) / 60;
+ // The room below the header, measured whenever the pane or the header
+ // (the all-day row grows with its events) changes size.
+ const [available, setAvailable] = useState(0);
+ useLayoutEffect(() => {
+ const root = scrollRef.current;
+ if (!root) return;
+ const measure = () => setAvailable(Math.max(0, root.clientHeight - (headerRef?.current?.offsetHeight ?? 0)));
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(measure);
+ observer.observe(root);
+ if (headerRef?.current) observer.observe(headerRef.current);
+ return () => observer.disconnect();
+ }, [scrollRef, headerRef]);
+ const hourHeight = fillHourHeight(minHourHeight, available, hourCount);
+
+ // Opens an hour before now, once. Rows of any other height than the
+ // minimum fit the pane without scrolling, so the minimum is the height
+ // that matters here.
const initialHoursRef = useRef(hours);
useEffect(() => {
const root = scrollRef.current;
@@ -45,22 +81,24 @@ export function useDisplayHours({ scrollRef, hourHeight, revealAt }: UseDisplayH
root.scrollTop = 0;
return;
}
- root.scrollTop = Math.max(0, ((now.getHours() - 1) * 60 - startMinutes) / 60 * hourHeight);
- }, [scrollRef, hourHeight]);
+ root.scrollTop = Math.max(0, ((now.getHours() - 1) * 60 - startMinutes) / 60 * minHourHeight);
+ }, [scrollRef, minHourHeight]);
const revealRef = useRef(null);
- const renderedStartRef = useRef(hours.startMinutes);
+ const renderedRef = useRef({ startMinutes: hours.startMinutes, hourHeight });
useLayoutEffect(() => {
const root = scrollRef.current;
- const prev = renderedStartRef.current;
- renderedStartRef.current = hours.startMinutes;
+ const prev = renderedRef.current;
+ renderedRef.current = { startMinutes: hours.startMinutes, hourHeight };
const reveal = revealRef.current;
revealRef.current = null;
if (!root) return;
if (reveal !== null) {
root.scrollTop = Math.max(0, ((reveal - hours.startMinutes) / 60 - 0.5) * hourHeight);
- } else if (prev !== hours.startMinutes) {
- root.scrollTop = Math.max(0, root.scrollTop + ((prev - hours.startMinutes) / 60) * hourHeight);
+ } else if (prev.startMinutes !== hours.startMinutes || prev.hourHeight !== hourHeight) {
+ // The time at the top of the pane stays there.
+ const topMinutes = prev.startMinutes + (root.scrollTop / prev.hourHeight) * 60;
+ root.scrollTop = Math.max(0, ((topMinutes - hours.startMinutes) / 60) * hourHeight);
}
}, [scrollRef, hourHeight, hours.startMinutes]);
@@ -93,6 +131,8 @@ export function useDisplayHours({ scrollRef, hourHeight, revealAt }: UseDisplayH
return {
hours,
+ /** The drawn height of an hour row, in pixels. */
+ hourHeight,
/** Whether a range is configured at all, i.e. the toggle has anything to do. */
canToggle: configured.restricted,
configured,
From 2da1aa48db3623d458f31e541f58adc00525908f Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 06:10:45 +0100
Subject: [PATCH 02/10] fix: reach empty week and day slots from the keyboard
with one tab stop, arrow keys and Enter or n to create
---
.../calendar-time-grid-keyboard.test.tsx | 166 ++++++++++++++++++
components/calendar/calendar-day-view.tsx | 18 +-
components/calendar/calendar-week-view.tsx | 20 ++-
components/calendar/quick-create-popover.tsx | 12 ++
hooks/use-time-grid-focus.ts | 114 ++++++++++++
lib/keyboard-shortcuts/calendar.ts | 1 +
locales/ar/common.json | 3 +-
locales/ca/common.json | 3 +-
locales/cs/common.json | 3 +-
locales/da/common.json | 3 +-
locales/de/common.json | 3 +-
locales/en/common.json | 3 +-
locales/es/common.json | 3 +-
locales/et/common.json | 3 +-
locales/fa/common.json | 3 +-
locales/fr/common.json | 3 +-
locales/he/common.json | 3 +-
locales/hu/common.json | 3 +-
locales/it/common.json | 3 +-
locales/ja/common.json | 3 +-
locales/ko/common.json | 3 +-
locales/lv/common.json | 3 +-
locales/mn/common.json | 3 +-
locales/nb/common.json | 3 +-
locales/nl/common.json | 3 +-
locales/pl/common.json | 3 +-
locales/pt/common.json | 3 +-
locales/ro/common.json | 3 +-
locales/ru/common.json | 3 +-
locales/sk/common.json | 3 +-
locales/th/common.json | 3 +-
locales/tr/common.json | 3 +-
locales/uk/common.json | 3 +-
locales/zh-TW/common.json | 3 +-
locales/zh/common.json | 3 +-
35 files changed, 385 insertions(+), 33 deletions(-)
create mode 100644 components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx
create mode 100644 hooks/use-time-grid-focus.ts
diff --git a/components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx b/components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx
new file mode 100644
index 000000000..5af65d961
--- /dev/null
+++ b/components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx
@@ -0,0 +1,166 @@
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { CalendarWeekView } from '../calendar-week-view';
+import { CalendarDayView } from '../calendar-day-view';
+import { useSettingsStore } from '@/stores/settings-store';
+import type { Calendar } from '@/lib/jmap/types';
+
+const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
+const initialSettings = useSettingsStore.getState();
+
+type WeekProps = React.ComponentProps;
+
+function renderWeek(overrides: Partial = {}) {
+ const props: WeekProps = {
+ focus: { date: new Date(2026, 8, 9), nonce: 0 },
+ windowKey: 'week:2026-09-07',
+ // Monday 7 Sep to Sunday 13 Sep 2026
+ rangeStart: new Date(2026, 8, 7),
+ rangeEnd: new Date(2026, 8, 13),
+ selectedDate: new Date(2026, 8, 9),
+ events: [],
+ calendars,
+ onSelectDate: vi.fn(),
+ onSelectEvent: vi.fn(),
+ onCreateAtTime: vi.fn(),
+ ...overrides,
+ };
+ render();
+ return props;
+}
+
+function tabStops(): HTMLElement[] {
+ return screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0);
+}
+
+function focusedSlot(): string | null {
+ return (document.activeElement as HTMLElement | null)?.getAttribute('data-slot') ?? null;
+}
+
+describe('keyboard access to the time grid (CAL-13)', () => {
+ beforeEach(() => {
+ useSettingsStore.setState(initialSettings, true);
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('gives the week grid one tab stop on the selected day', () => {
+ renderWeek();
+ const stops = tabStops();
+ expect(stops).toHaveLength(1);
+ expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-09T\d+$/);
+ // The slot is read out with its date and time (the test formatter
+ // prints the whole Date), not just "Wed 08:00"
+ expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/);
+ // ...and one date in the header, not each loaded day's
+ const dates = screen.getAllByRole('columnheader').map((h) => h.querySelector('button')!);
+ expect(dates.filter((b) => b.tabIndex === 0)).toHaveLength(1);
+ });
+
+ it('moves by hour and day with the arrows and Home/End', () => {
+ renderWeek();
+ const start = tabStops()[0];
+ start.focus();
+ fireEvent.keyDown(start, { key: 'Home' });
+ expect(focusedSlot()).toBe('2026-09-09T8');
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
+ expect(focusedSlot()).toBe('2026-09-09T9');
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
+ expect(focusedSlot()).toBe('2026-09-10T9');
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
+ expect(focusedSlot()).toBe('2026-09-08T9');
+ fireEvent.keyDown(document.activeElement!, { key: 'End' });
+ expect(focusedSlot()).toBe('2026-09-08T19');
+ // The edge of the visible hours stops the arrow
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
+ expect(focusedSlot()).toBe('2026-09-08T19');
+ // The tab stop follows the focus
+ expect(tabStops().map((cell) => cell.getAttribute('data-slot'))).toEqual(['2026-09-08T19']);
+ });
+
+ it('keeps the arrows from paging the calendar', () => {
+ renderWeek();
+ const start = tabStops()[0];
+ start.focus();
+ const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true });
+ start.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it('creates an event at the focused slot with Enter or n', () => {
+ const props = renderWeek();
+ const start = tabStops()[0];
+ start.focus();
+ fireEvent.keyDown(start, { key: 'Home' });
+ fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
+ expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 8, 0, 0, 0));
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
+ fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN' });
+ expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 9, 0, 0, 0));
+ // Not with a modifier: Ctrl+N belongs to the browser
+ fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN', ctrlKey: true });
+ expect(props.onCreateAtTime).toHaveBeenCalledTimes(2);
+ });
+
+ it('leaves the slots out of the tab order on phones', () => {
+ renderWeek({ isMobile: true });
+ expect(tabStops()).toHaveLength(0);
+ });
+
+ it('works the same in the day view', () => {
+ const onCreateAtTime = vi.fn();
+ render(
+ ,
+ );
+ const stops = tabStops();
+ expect(stops).toHaveLength(1);
+ expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/);
+ stops[0].focus();
+ fireEvent.keyDown(stops[0], { key: 'End' });
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
+ expect(focusedSlot()).toBe('2026-09-10T19');
+ fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
+ expect(onCreateAtTime).toHaveBeenCalledWith(new Date(2026, 8, 10, 19, 0, 0, 0));
+ });
+});
+
+describe('quick editor focus (CAL-13)', () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('returns focus to the slot it was opened from', async () => {
+ const { QuickCreatePopover } = await import('../quick-create-popover');
+ const slot = document.createElement('div');
+ slot.tabIndex = -1;
+ document.body.appendChild(slot);
+ slot.focus();
+ const { unmount } = render(
+ ,
+ );
+ (screen.getAllByRole('textbox')[0] as HTMLElement).focus();
+ unmount();
+ expect(document.activeElement).toBe(slot);
+ slot.remove();
+ });
+});
diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx
index d4db6f55f..d927ee602 100644
--- a/components/calendar/calendar-day-view.tsx
+++ b/components/calendar/calendar-day-view.tsx
@@ -15,6 +15,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours";
+import { useTimeGridFocus } from "@/hooks/use-time-grid-focus";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
@@ -297,6 +298,16 @@ export function CalendarDayView({
onMoveEvent,
});
+ const { slotProps } = useTimeGridFocus({
+ rootRef,
+ headerRef,
+ gutterWidth,
+ days,
+ hours,
+ selectedDate,
+ onCreate: onCreateAtTime,
+ });
+
const formatHour = (h: number): string => {
if (timeFormat === "12h") {
const d = new Date(2000, 0, 1, h);
@@ -304,6 +315,8 @@ export function CalendarDayView({
}
return format(new Date(2000, 0, 1, h), "HH:mm");
};
+ // A slot is read out with its whole date: "Wednesday, October 28, 10:00".
+ const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" });
// Above every in-column overlay (events z-10, handles z-20, drag z-30) so
// columns scrolled past the start do not show through the gutter.
@@ -469,11 +482,12 @@ export function CalendarDayView({
handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined}
- className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
+ className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
style={{ height: hourHeight }}
/>
))}
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx
index ce8f4f230..506e394d4 100644
--- a/components/calendar/calendar-week-view.tsx
+++ b/components/calendar/calendar-week-view.tsx
@@ -18,6 +18,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours";
+import { useTimeGridFocus } from "@/hooks/use-time-grid-focus";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
@@ -393,6 +394,16 @@ export function CalendarWeekView({
onMoveEvent,
});
+ const { slotProps, tabStopDayKey } = useTimeGridFocus({
+ rootRef,
+ headerRef,
+ gutterWidth,
+ days,
+ hours,
+ selectedDate,
+ onCreate: onCreateAtTime,
+ });
+
const formatHour = (h: number): string => {
if (timeFormat === "12h") {
const d = new Date(2000, 0, 1, h);
@@ -400,6 +411,8 @@ export function CalendarWeekView({
}
return format(new Date(2000, 0, 1, h), "HH:mm");
};
+ // A slot is read out with its whole date: "Wednesday, October 28, 10:00".
+ const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" });
// Above every in-column overlay (events z-10, handles z-20, drag z-30) so
// columns scrolled past the start do not show through the gutter.
@@ -451,6 +464,8 @@ export function CalendarWeekView({