diff --git a/packages/json-document-calendar/src/calendar-time-grid.tsx b/packages/json-document-calendar/src/calendar-time-grid.tsx index aea1d644..01dc8434 100644 --- a/packages/json-document-calendar/src/calendar-time-grid.tsx +++ b/packages/json-document-calendar/src/calendar-time-grid.tsx @@ -112,7 +112,7 @@ export function CalendarTimeGrid(props: CalendarTimeGridProps): ReactNode { if (start !== null && end !== null) hand.createInterval(start, end); } - const gridColumns = `3.25rem repeat(${days.length}, minmax(4.5rem, 1fr))`; + const gridColumns = `4rem repeat(${days.length}, minmax(4.5rem, 1fr))`; const gridHeight = (props.hourEnd - props.hourStart) * props.pixelsPerHour; return (
- {Array.from({ length: props.hourEnd - props.hourStart }, (_, index) => props.hourStart + index).map((hour) => ( -
+ {Array.from({ length: props.hourEnd - props.hourStart }, (_, index) => index === 0 ? null : props.hourStart + index).map((hour) => hour === null ? null : ( +
{props.labels.hour(hour)}
))} diff --git a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts index 1fc18bec..940b8882 100644 --- a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts +++ b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts @@ -130,7 +130,10 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return; } setHoveredTime(null); - const targetInstant = instantAt(day, event.clientY, grid); + const targetGridMinutes = calendarMinutesFromWebGrid(event.clientY, grid.getBoundingClientRect(), { + hourStart: policy.hourStart, hourEnd: policy.hourEnd, stepMinutes: policy.stepMinutes, + }); + const targetInstant = calendarInstantAt(day, targetGridMinutes); if (targetInstant === null) return; const next = timePointer.preview(event.pointerId, (state) => { const dragSource = state.dragSource ?? (state.dragCandidate !== null && targetInstant !== state.originInstant @@ -139,8 +142,11 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return { ...state, targetInstant, dragSource }; }); if (next?.dragSource !== null && next?.dragSource !== undefined) { - if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "instant", instant: next.originInstant } }); - const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "instant", instant: targetInstant } })); + const originAnchor = next.dragSource.anchor.occurrenceStart; + const move = interpretCalendarTimeGridPointer(next); + if (move?.type !== "event.move") return; + if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "instant", instant: originAnchor } }); + const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "instant", instant: move.start } })); hand.previewSelectionDrag(gesture); } else if (next !== null) hand.setTimePreview(next); } diff --git a/packages/json-document-calendar/tests/calendar-time-grid.test.tsx b/packages/json-document-calendar/tests/calendar-time-grid.test.tsx index 5709b4c0..b39ad427 100644 --- a/packages/json-document-calendar/tests/calendar-time-grid.test.tsx +++ b/packages/json-document-calendar/tests/calendar-time-grid.test.tsx @@ -66,7 +66,11 @@ describe("CalendarTimeGrid", () => { } render(); - expect(screen.getByRole("grid", { name: "Week" })).toBeTruthy(); + const grid = screen.getByRole("grid", { name: "Week" }); + expect(grid).toBeTruthy(); + expect((grid.firstElementChild as HTMLElement).style.gridTemplateColumns).toBe("4rem repeat(7, minmax(4.5rem, 1fr))"); + expect(grid.querySelector('[data-calendar-hour="00"]')).toBeNull(); + expect((grid.querySelector('[data-calendar-hour="01"]') as HTMLElement).style.top).toBe("72px"); expect(screen.getAllByRole("columnheader")).toHaveLength(7); expect(screen.getByText("all-day")).toBeTruthy(); expect(screen.getByRole("presentation", { name: "Now" })).toBeTruthy(); diff --git a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx index 2851e9a9..999ec4bf 100644 --- a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx +++ b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx @@ -210,17 +210,17 @@ describe("useCalendarHand", () => { releasePointerCapture: () => undefined, }; act(() => result.current.pointer.timePointerDown({ - button: 0, clientY: 540, currentTarget: target, pointerId: 7, + button: 0, clientY: 555, currentTarget: target, pointerId: 7, } as never, "2026-08-03", "standup", "2026-08-03T09:00", "2026-08-03T09:30", "body")); act(() => result.current.pointer.timePointerMove({ pointerId: 7, clientX: 50, clientY: 600, target } as never)); expect(result.current.hand.selectionDragPreview).not.toBeNull(); expect(result.current.hand.paintedEvents.map((item) => item.start)).toEqual([ - "2026-08-03T10:00", "2026-08-04T12:00", + "2026-08-03T09:45", "2026-08-04T11:45", ]); act(() => result.current.pointer.timePointerUp({ pointerId: 7 } as never)); expect(result.current.hand.selectionDragPreview).toBeNull(); expect(result.current.hand.document.events.map((item) => item.start)).toEqual([ - "2026-08-03T10:00", "2026-08-04T12:00", + "2026-08-03T09:45", "2026-08-04T11:45", ]); expect(result.current.hand.selectedOccurrences).toHaveLength(2); act(() => result.current.hand.undo()); diff --git a/site/src/routes/calendar-demo/CalendarDemoRoute.tsx b/site/src/routes/calendar-demo/CalendarDemoRoute.tsx index a8408b46..41424d63 100644 --- a/site/src/routes/calendar-demo/CalendarDemoRoute.tsx +++ b/site/src/routes/calendar-demo/CalendarDemoRoute.tsx @@ -45,7 +45,6 @@ import { ToolbarGroup, ToolbarLayout, ToolbarRegion, - ToolbarSeparator, Toggle, } from "@interactive-os/json-document-ui-primitives-react"; import { useDemoEmbed } from "../../shared/demo-workbench/DemoPage"; @@ -160,6 +159,7 @@ export function CalendarDemoRoute(props: { active: !embedded && (view === "day" || view === "week"), resetKey: `${view}:${visibleDate}`, targetHour: workHourStart, + viewportOffset: 16, }); function setLocation(nextView: CalendarView, nextDate: string): void { @@ -179,8 +179,13 @@ export function CalendarDemoRoute(props: { const document = hand.document; const calendars = calendarDocumentCalendars(document); const selectedEvent = hand.selectedEvent; + const pointerGestureActive = hand.timePreview !== null + || hand.allDayPreview !== null + || hand.monthPreview !== null + || hand.selectionDragPreview !== null; + const eventInspectorVisible = selectedEvent !== null && !pointerGestureActive; const eventDetailsPosition = useAnchoredFloatingPosition({ - active: selectedEvent !== null, + active: eventInspectorVisible, policy: { type: "preferred", placement: "right-start", @@ -267,10 +272,10 @@ export function CalendarDemoRoute(props: { stickyHeader: classes("grid", styles.weekSticky()), columnHeader: styles.weekHead(), weekday: ui.text.meta, - dayNumber: styles.dayNumber(), - today: styles.todayMark(), - allDayLabel: classes("px-1 py-2 text-right", ui.text.meta), - allDayCell: classes("min-h-10", styles.weekCell()), + dayNumber: classes(styles.dayNumber(), ui.text.body), + today: styles.weekToday(), + allDayLabel: classes("whitespace-nowrap px-1 py-1.5 text-right text-foreground-muted/70", ui.text.meta), + allDayCell: classes("min-h-8", styles.weekCell()), allDayEventContainer: "group/event relative z-10 mx-0.5 my-1", allDayEvent: styles.allDayEvent(), resizeAllDayEnd: styles.resizeEdgeVertical(), @@ -278,16 +283,16 @@ export function CalendarDemoRoute(props: { timeViewportFill: styles.weekHours(), hourGutter: "relative overflow-hidden", viewportAnchor: "pointer-events-none absolute left-0 top-0", - hourLabel: styles.hourLabel(), + hourLabel: classes(styles.hourLabel(), ui.text.meta), timeCell: classes("overflow-hidden", styles.weekCell()), selectedSlot: styles.selectedSlot(), hourRule: styles.hourRule(), nowLine: styles.nowLine(), - creationTimeHint: styles.creationTimeHint(), - timedEventContainer: "group/event absolute z-10", + creationTimeHint: classes(styles.creationTimeHint(), ui.text.meta), + timedEventContainer: "group/event absolute z-10 py-0.5", timedEvent: styles.timedEvent(), - eventTitle: "min-w-0 truncate", - eventTime: styles.eventTime(), + eventTitle: classes("min-w-0 truncate", ui.text.meta), + eventTime: classes(styles.eventTime(), ui.text.meta), resizeTimedEnd: styles.resizeEdge(), }} labels={{ @@ -295,7 +300,7 @@ export function CalendarDemoRoute(props: { allDay: "all-day", now: "Now", resizeEnd: (event) => `Resize ${event.title} end`, - hour: (hour) => String(hour).padStart(2, "0"), + hour: calendarHourLabel, }} getEventColor={(event) => calendarColor(document, event.calendarId)} /> @@ -312,27 +317,20 @@ export function CalendarDemoRoute(props: { + - {visiblePeriodLabel(view, visibleDate, { - monthNames: months, - weekSeparator: " – ", - })} setVisibleDate(shiftVisibleDate(visibleDate, view, -1))}> + setLocation(view === "year" ? "month" : view, today)}>Today setVisibleDate(shiftVisibleDate(visibleDate, view, 1))}> - - - setLocation(view === "year" ? "month" : view, today)}>Today - - + + + {(context) => ( + <> + + {context.visible.includes("sources") ? ( + + ) : null} + + )} + + )} >
-
- + {eventInspectorVisible ?
-
-
@@ -581,3 +589,9 @@ export function CalendarDemoRoute(props: { function calendarColor(document: CalendarDocument, calendarId: string): "accent" | "subtle" { return calendarDocumentCalendar(document, calendarId)?.color === "accent" ? "accent" : "subtle"; } + +function calendarHourLabel(hour: number): string { + const period = hour < 12 ? "AM" : "PM"; + const displayHour = hour % 12 || 12; + return `${displayHour} ${period}`; +} diff --git a/site/src/routes/calendar-demo/calendar-demo-styles.ts b/site/src/routes/calendar-demo/calendar-demo-styles.ts index 09934eee..f270dae7 100644 --- a/site/src/routes/calendar-demo/calendar-demo-styles.ts +++ b/site/src/routes/calendar-demo/calendar-demo-styles.ts @@ -5,9 +5,11 @@ const chipFill = "bg-background-subtle text-foreground-strong data-[calendar-col export const calendarDemoRecipe = tv({ slots: { - shell: "relative isolate overflow-hidden bg-background-canvas", + shell: "relative isolate overflow-hidden bg-background-canvas [&>[data-ui-toolbar=product]]:absolute [&>[data-ui-toolbar=product]]:right-4 [&>[data-ui-toolbar=product]]:top-3 [&>[data-ui-toolbar=product]]:z-50 [&>[data-ui-toolbar=product]]:w-auto [&>[data-ui-toolbar=product]]:max-w-[calc(100%-1.5rem)] sm:[&>[data-ui-toolbar=product]]:max-w-[calc(100%-8rem)] [&>[data-ui-toolbar=product]]:rounded-full [&>[data-ui-toolbar=product]]:border [&>[data-ui-toolbar=product]]:border-line-subtle/35 [&>[data-ui-toolbar=product]]:bg-background-canvas [&>[data-ui-toolbar=product]]:px-1 [&>[data-ui-toolbar=product]]:py-1 [&>[data-ui-toolbar=product]]:shadow-none [&>[data-ui-toolbar=product]]:transition-[border-color,box-shadow] [&>[data-ui-toolbar=product]]:hover:border-line-subtle/60 [&>[data-ui-toolbar=product]]:focus-within:border-line-subtle/60 [&>[data-ui-toolbar=product]_[data-placement=end]]:overflow-visible [&>[data-ui-toolbar=product]_[data-ui-segment=true]]:rounded-full [&>[data-ui-toolbar=product]_[data-ui-segment=true]]:border-transparent [&>[data-ui-toolbar=product]_[data-ui-segment=true]]:bg-transparent [&>[data-ui-toolbar=product]_[data-ui-segment=true]]:shadow-none [&>[data-ui-toolbar=product]_[data-ui-segment=true]]:hover:bg-background-subtle/60 [&>[data-ui-toolbar=product]_[data-ui-segment=true][aria-checked=true]]:bg-background-subtle [&>[data-ui-toolbar=product]_[data-ui-segment=true][aria-checked=true]]:text-foreground-strong", + controlLayer: "pointer-events-none absolute inset-0 z-40 [&>*]:pointer-events-auto", + contentLayer: "relative z-0 h-full min-h-0 min-w-0 pb-24", allDayEvent: `h-full w-full rounded-control border-0 px-2 py-0.5 text-left text-xs leading-4 ${chipFill} ${chipRail}`, - hourRule: "absolute inset-x-0 border-t border-line-subtle/70", + hourRule: "absolute inset-x-0 border-t border-line-subtle/45", timedEvent: `group flex h-full w-full flex-col items-stretch gap-0 overflow-hidden rounded-control border-0 px-2 py-0.5 pl-2.5 text-left text-xs leading-4 ${chipFill} ${chipRail}`, monthWeek: "relative grid min-w-[36rem] grid-cols-7 border-t border-line-subtle/60", monthDay: "relative z-0 flex min-h-32 w-full flex-col items-stretch px-1.5 pb-1 text-left text-xs", @@ -18,17 +20,17 @@ export const calendarDemoRecipe = tv({ quietAction: "border-0 bg-transparent p-0 shadow-none hover:border-0 hover:bg-transparent", monthOverflow: "absolute left-0 top-0 z-30 flex w-max min-w-full max-w-56 flex-col gap-0.5 p-1.5", monthHead: "px-2 py-1.5 text-center text-xs text-foreground-muted", - weekSticky: "z-30 shrink-0 border-b border-line-subtle/60 bg-background-canvas", + weekSticky: "z-30 shrink-0 border-b border-line-subtle/40 bg-background-canvas pt-14", weekHours: "min-h-0 flex-1 overflow-auto", - weekHead: "flex flex-col items-center gap-0.5 px-1 py-2 text-center", - weekCell: "relative border-l border-line-subtle/60", - selectedSlot: "pointer-events-none absolute inset-x-0 z-10 rounded-control bg-background-subtle/60 ring-1 ring-inset ring-line-subtle/40", + weekHead: "flex min-h-12 items-center justify-center gap-1 px-1 py-2 text-center", + weekCell: "relative border-l border-line-subtle/30 outline-none focus-visible:ring-0 focus-visible:ring-offset-0", + selectedSlot: "pointer-events-none absolute inset-x-1 z-10 rounded-control border-y-2 border-transparent bg-background-accent-subtle/70 bg-clip-padding before:absolute before:inset-y-1 before:left-1 before:w-0.5 before:rounded-full before:bg-background-accent", resizeEdge: "absolute inset-x-0 bottom-0 h-2", resizeEdgeVertical: "absolute right-0 top-0 h-full w-2", - period: "px-1 text-sm font-medium text-foreground-strong", + periodHeading: "pointer-events-none absolute left-14 top-4 z-40 m-0 hidden whitespace-nowrap text-sm font-medium text-foreground-strong sm:block", todayAction: "border-0 bg-transparent px-2 shadow-none hover:border-0 hover:bg-background-subtle", - contextualSidebar: "relative z-30 w-20 shrink-0 rounded-control px-1 py-2 outline-none transition-[width] focus-within:w-44 hover:w-44 focus-visible:ring-2 focus-visible:ring-line-accent/25", - sidebarHint: "block text-center text-overline text-foreground-muted [writing-mode:vertical-rl]", + toolbarSources: "relative grid size-8 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-line-accent/25", + sidebarHint: "grid h-5 place-items-center text-foreground-muted", yearMonth: "flex min-h-0 w-full flex-col gap-1 px-1 py-1 text-left", yearDay: "flex aspect-square w-full items-center justify-center border-0 bg-transparent p-0 text-overline shadow-none hover:border-0 hover:bg-transparent", @@ -36,21 +38,22 @@ export const calendarDemoRecipe = tv({ nowLine: "pointer-events-none absolute inset-x-0 z-20 border-t border-line-accent", today: "font-semibold text-foreground-accent", todayMark: "font-semibold text-foreground-strong", - dayNumber: "inline-flex size-7 items-center justify-center text-sm font-medium", - sidebar: "flex w-44 shrink-0 flex-col gap-3 overflow-auto pr-1", - inspector: "group/details z-40 flex w-80 min-w-0 flex-col gap-3 overflow-auto border border-line-subtle/60 p-3.5 shadow-overlay", + weekToday: "rounded-full bg-background-accent text-foreground-inverse", + dayNumber: "inline-flex size-7 items-center justify-center font-medium text-foreground-strong", + sidebar: "absolute right-0 top-[calc(100%+0.75rem)] flex max-h-[calc(100vh-8rem)] w-44 shrink-0 flex-col gap-3 overflow-auto rounded-surface border border-line-subtle/45 bg-background-canvas p-3 shadow-overlay", + inspector: "group/details z-50 flex w-80 min-w-0 flex-col gap-3 overflow-auto rounded-surface border border-line-subtle/45 bg-background-canvas p-3.5 shadow-surface", field: "grid gap-1", inspectorHeader: "flex min-w-0 items-center gap-2", inspectorTitle: "m-0 min-w-0 flex-1 truncate px-0 text-base font-semibold tracking-tight text-foreground-strong", inspectorActions: "flex shrink-0 items-center gap-0.5 [&_[data-ui-control=command]]:size-7 [&_[data-ui-control=command]]:border-0 [&_[data-ui-control=command]]:bg-transparent [&_[data-ui-control=command]]:p-0 [&_[data-ui-control=command]]:shadow-none", eventSummary: "grid gap-2 border-t border-line-subtle/40 pt-3 text-xs text-foreground-muted [&_p]:m-0 [&_p]:flex [&_p]:items-center [&_p]:gap-2 [&_svg]:shrink-0 [&_svg]:text-foreground-muted", detailsEditor: "grid gap-3 border-t border-line-subtle/40 pt-3", - calendarToggle: "group flex w-full items-center justify-start gap-2 border-0 bg-transparent px-1 text-left shadow-none hover:border-0 hover:bg-transparent aria-pressed:border-0 aria-pressed:bg-transparent aria-pressed:text-foreground-strong aria-[pressed=false]:text-foreground-muted", + calendarToggle: "group flex w-full items-center justify-start gap-2 rounded-control border-0 bg-transparent px-2 py-1.5 text-left shadow-none outline-none hover:border-0 hover:bg-background-subtle focus-visible:ring-2 focus-visible:ring-foreground-muted/25 aria-pressed:border-0 aria-pressed:bg-transparent aria-pressed:text-foreground-strong aria-[pressed=false]:text-foreground-muted", calendarSwatch: "size-2.5 shrink-0 rounded-full bg-foreground-muted opacity-40 data-[calendar-color=accent]:bg-background-accent group-aria-pressed:opacity-100", - eventTime: "truncate text-overline font-normal text-foreground-muted", - creationTimeHint: "pointer-events-none absolute left-1 z-20 -translate-y-1/2 whitespace-nowrap text-overline tabular-nums leading-none text-foreground-muted/55", - hourLabel: "absolute right-1 whitespace-nowrap text-right text-overline tabular-nums leading-none text-foreground-muted", - composerDock: "bottom-4 left-1/2 z-30 flex w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 items-center gap-2 rounded-surface border border-line-subtle/70 bg-background-canvas/95 p-2 pl-3 shadow-overlay backdrop-blur", + eventTime: "truncate font-normal text-foreground-muted", + creationTimeHint: "pointer-events-none absolute left-2 z-20 translate-y-1 whitespace-nowrap tabular-nums text-foreground-muted/55", + hourLabel: "absolute right-2 -translate-y-1/2 whitespace-nowrap text-right tabular-nums text-foreground-muted/70", + composerDock: "bottom-4 left-1/2 z-30 flex w-[min(36rem,calc(100%-2rem))] -translate-x-1/2 items-center gap-2 rounded-full border border-line-subtle/55 bg-background-canvas p-2 pl-3 shadow-none", composerDockFixed: "fixed", composerDockEmbedded: "absolute", composerSpark: "shrink-0 text-foreground-accent", diff --git a/site/src/routes/date-controls-demo/date-controls-demo-styles.ts b/site/src/routes/date-controls-demo/date-controls-demo-styles.ts index 6fc7fd2c..e04785c1 100644 --- a/site/src/routes/date-controls-demo/date-controls-demo-styles.ts +++ b/site/src/routes/date-controls-demo/date-controls-demo-styles.ts @@ -31,7 +31,7 @@ export const dateControlsDemoRecipe = tv({ timeViewport: "grid", timeHourGutter: "relative overflow-hidden", timeViewportAnchor: "pointer-events-none absolute left-0 top-0", - timeHourLabel: "absolute right-1 text-xs text-foreground-muted", + timeHourLabel: "absolute right-1 -translate-y-1/2 text-xs text-foreground-muted", timeCell: "relative overflow-hidden border-l border-line-subtle", timeSelectedSlot: "pointer-events-none absolute inset-x-0 bg-background-subtle", timeHourRule: "absolute inset-x-0 border-t border-line-subtle", diff --git a/site/tests/unit/calendar-product-design.test.ts b/site/tests/unit/calendar-product-design.test.ts index d9b4651d..ef67db55 100644 --- a/site/tests/unit/calendar-product-design.test.ts +++ b/site/tests/unit/calendar-product-design.test.ts @@ -17,6 +17,7 @@ function assertNoAccentChrome(source: string, label: string): void { describe("calendar product design", () => { const styles = calendarDemoRecipe(); + const route = readFileSync(path.join(siteRoot, "src/routes/calendar-demo/CalendarDemoRoute.tsx"), "utf8"); test("event chips and calendar-visibility toggles use fill and type, not accent chrome", () => { assertNoAccentChrome(styles.timedEvent(), "timedEvent"); @@ -52,12 +53,18 @@ describe("calendar product design", () => { test("time grid keeps calendar structure visible without a full-column hover wash", () => { const css = readFileSync(path.join(siteRoot, "src/app/index.css"), "utf8"); - expect(styles.hourRule()).toContain("border-line-subtle/70"); - expect(styles.weekCell()).toContain("border-line-subtle/60"); - expect(styles.weekSticky()).toContain("border-line-subtle/60"); + expect(styles.hourRule()).toContain("border-line-subtle/45"); + expect(styles.weekCell()).toContain("border-line-subtle/30"); + expect(styles.weekSticky()).toContain("border-line-subtle/40"); expect(styles.monthWeek()).toContain("border-line-subtle/60"); expect(styles.creationTimeHint()).toContain("text-foreground-muted/55"); + expect(styles.creationTimeHint()).toContain("translate-y-1"); + expect(styles.creationTimeHint()).not.toContain("-translate-y-1/2"); + expect(route).toContain("const eventInspectorVisible = selectedEvent !== null && !pointerGestureActive"); + expect(route).toContain("eventInspectorVisible ?