@twoux/ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/LICENSE.md +61 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/NOTICE.md +64 -0
  4. package/README.md +211 -2
  5. package/SHADCN-DIFF.md +228 -0
  6. package/agent/AGENTS.md +358 -0
  7. package/agent/docs-md/accessibility.md +49 -0
  8. package/agent/docs-md/action-catalogue.md +81 -0
  9. package/agent/docs-md/actions.md +74 -0
  10. package/agent/docs-md/agent-activity-log.md +70 -0
  11. package/agent/docs-md/agent-permissions.md +82 -0
  12. package/agent/docs-md/agent-run.md +90 -0
  13. package/agent/docs-md/agents-on-behalf.md +74 -0
  14. package/agent/docs-md/ai-column.md +85 -0
  15. package/agent/docs-md/ai-origin-label.md +70 -0
  16. package/agent/docs-md/announcing-features.md +73 -0
  17. package/agent/docs-md/answer-actions.md +81 -0
  18. package/agent/docs-md/app-shell.md +114 -0
  19. package/agent/docs-md/artifact-card.md +89 -0
  20. package/agent/docs-md/ask-on-screen.md +72 -0
  21. package/agent/docs-md/assistant-page.md +100 -0
  22. package/agent/docs-md/assistant-panel.md +142 -0
  23. package/agent/docs-md/avatar.md +82 -0
  24. package/agent/docs-md/banner.md +106 -0
  25. package/agent/docs-md/board.md +163 -0
  26. package/agent/docs-md/breadcrumb.md +89 -0
  27. package/agent/docs-md/breakdown-table.md +97 -0
  28. package/agent/docs-md/bulk-action-bar.md +112 -0
  29. package/agent/docs-md/button.md +293 -0
  30. package/agent/docs-md/card.md +115 -0
  31. package/agent/docs-md/change-list.md +99 -0
  32. package/agent/docs-md/change-request.md +166 -0
  33. package/agent/docs-md/chart-area.md +87 -0
  34. package/agent/docs-md/chart-bar.md +100 -0
  35. package/agent/docs-md/chart-box-plot.md +88 -0
  36. package/agent/docs-md/chart-bullet.md +89 -0
  37. package/agent/docs-md/chart-candlestick.md +112 -0
  38. package/agent/docs-md/chart-card-line.md +120 -0
  39. package/agent/docs-md/chart-donut.md +90 -0
  40. package/agent/docs-md/chart-drill-down.md +72 -0
  41. package/agent/docs-md/chart-funnel.md +71 -0
  42. package/agent/docs-md/chart-heatmap.md +76 -0
  43. package/agent/docs-md/chart-histogram.md +81 -0
  44. package/agent/docs-md/chart-map.md +165 -0
  45. package/agent/docs-md/chart-mixed.md +88 -0
  46. package/agent/docs-md/chart-price.md +92 -0
  47. package/agent/docs-md/chart-radial-bars.md +109 -0
  48. package/agent/docs-md/chart-radial-lines.md +111 -0
  49. package/agent/docs-md/chart-sankey.md +104 -0
  50. package/agent/docs-md/chart-scatter.md +87 -0
  51. package/agent/docs-md/chart-share-bar.md +89 -0
  52. package/agent/docs-md/chart-treemap.md +98 -0
  53. package/agent/docs-md/chart-waterfall.md +93 -0
  54. package/agent/docs-md/checkbox.md +131 -0
  55. package/agent/docs-md/choosing-selection-control.md +71 -0
  56. package/agent/docs-md/citations-sources.md +79 -0
  57. package/agent/docs-md/citing-sources.md +63 -0
  58. package/agent/docs-md/clarify-before-acting.md +69 -0
  59. package/agent/docs-md/clarifying-question.md +92 -0
  60. package/agent/docs-md/code-block.md +174 -0
  61. package/agent/docs-md/colours.md +87 -0
  62. package/agent/docs-md/columns-menu.md +140 -0
  63. package/agent/docs-md/combobox.md +161 -0
  64. package/agent/docs-md/command-palette.md +126 -0
  65. package/agent/docs-md/confidence-note.md +87 -0
  66. package/agent/docs-md/configurable-dashboard.md +76 -0
  67. package/agent/docs-md/confirmation-dialog.md +93 -0
  68. package/agent/docs-md/connected-tools.md +80 -0
  69. package/agent/docs-md/connector-picker.md +127 -0
  70. package/agent/docs-md/consistency.md +53 -0
  71. package/agent/docs-md/content-density.md +61 -0
  72. package/agent/docs-md/conversation-history.md +78 -0
  73. package/agent/docs-md/create-record.md +80 -0
  74. package/agent/docs-md/csv-import.md +88 -0
  75. package/agent/docs-md/dashboard-items.md +78 -0
  76. package/agent/docs-md/data-export.md +61 -0
  77. package/agent/docs-md/data-grid.md +137 -0
  78. package/agent/docs-md/data-vis-colours.md +76 -0
  79. package/agent/docs-md/data-visualisation.md +76 -0
  80. package/agent/docs-md/date-picker.md +178 -0
  81. package/agent/docs-md/delete-record.md +75 -0
  82. package/agent/docs-md/dense-table.md +495 -0
  83. package/agent/docs-md/design-tokens.md +53 -0
  84. package/agent/docs-md/disabled-read-only.md +75 -0
  85. package/agent/docs-md/drag-and-drop.md +74 -0
  86. package/agent/docs-md/dropdown-menu.md +137 -0
  87. package/agent/docs-md/edit-record.md +73 -0
  88. package/agent/docs-md/empty-loading-error.md +78 -0
  89. package/agent/docs-md/empty-state.md +128 -0
  90. package/agent/docs-md/error-messages.md +75 -0
  91. package/agent/docs-md/error-state.md +123 -0
  92. package/agent/docs-md/export-menu.md +86 -0
  93. package/agent/docs-md/feedback-prompt.md +88 -0
  94. package/agent/docs-md/field-mapping.md +78 -0
  95. package/agent/docs-md/file-drop.md +110 -0
  96. package/agent/docs-md/filter-bar.md +138 -0
  97. package/agent/docs-md/filter-panel.md +103 -0
  98. package/agent/docs-md/filtering-search.md +79 -0
  99. package/agent/docs-md/follow-ups-starters.md +79 -0
  100. package/agent/docs-md/form-validation.md +95 -0
  101. package/agent/docs-md/generated-columns.md +65 -0
  102. package/agent/docs-md/generated-drafts-files.md +71 -0
  103. package/agent/docs-md/grouped-list.md +89 -0
  104. package/agent/docs-md/guide-cue.md +170 -0
  105. package/agent/docs-md/help-panel.md +103 -0
  106. package/agent/docs-md/help-system.md +74 -0
  107. package/agent/docs-md/iconography.md +49 -0
  108. package/agent/docs-md/inbox.md +108 -0
  109. package/agent/docs-md/inline-editing-validation.md +80 -0
  110. package/agent/docs-md/inline-input-in-answer.md +70 -0
  111. package/agent/docs-md/items-palette.md +116 -0
  112. package/agent/docs-md/keyboard-model.md +47 -0
  113. package/agent/docs-md/keyboard-navigation.md +68 -0
  114. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  115. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  116. package/agent/docs-md/labelling-ai-output.md +66 -0
  117. package/agent/docs-md/layout.md +47 -0
  118. package/agent/docs-md/list-table.md +254 -0
  119. package/agent/docs-md/loading-skeleton.md +91 -0
  120. package/agent/docs-md/managing-agents.md +71 -0
  121. package/agent/docs-md/message-composer.md +105 -0
  122. package/agent/docs-md/migrating.md +49 -0
  123. package/agent/docs-md/motion.md +93 -0
  124. package/agent/docs-md/navigation.md +76 -0
  125. package/agent/docs-md/notification-channels.md +65 -0
  126. package/agent/docs-md/notifications-decision.md +100 -0
  127. package/agent/docs-md/onboarding-step.md +114 -0
  128. package/agent/docs-md/order-book.md +108 -0
  129. package/agent/docs-md/page-header.md +96 -0
  130. package/agent/docs-md/pagination.md +88 -0
  131. package/agent/docs-md/pane-layout.md +105 -0
  132. package/agent/docs-md/period-comparison.md +66 -0
  133. package/agent/docs-md/plan-card.md +120 -0
  134. package/agent/docs-md/popover-rules.md +88 -0
  135. package/agent/docs-md/prompt-editor.md +90 -0
  136. package/agent/docs-md/propose-apply.md +65 -0
  137. package/agent/docs-md/provider-logo.md +97 -0
  138. package/agent/docs-md/radio.md +83 -0
  139. package/agent/docs-md/ranked-list.md +102 -0
  140. package/agent/docs-md/reasoning-progress.md +72 -0
  141. package/agent/docs-md/record-detail.md +71 -0
  142. package/agent/docs-md/regenerating-answers.md +70 -0
  143. package/agent/docs-md/run-status-bar.md +94 -0
  144. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  145. package/agent/docs-md/saved-views.md +149 -0
  146. package/agent/docs-md/saving-views.md +68 -0
  147. package/agent/docs-md/scheduler.md +258 -0
  148. package/agent/docs-md/secondary-panels.md +72 -0
  149. package/agent/docs-md/segmented-control.md +88 -0
  150. package/agent/docs-md/selection-bulk-actions.md +70 -0
  151. package/agent/docs-md/separator.md +75 -0
  152. package/agent/docs-md/setup-checklist.md +92 -0
  153. package/agent/docs-md/shape-elevation.md +55 -0
  154. package/agent/docs-md/sheet-grid.md +211 -0
  155. package/agent/docs-md/side-panel.md +134 -0
  156. package/agent/docs-md/sign-in.md +114 -0
  157. package/agent/docs-md/spacing.md +55 -0
  158. package/agent/docs-md/split-button.md +100 -0
  159. package/agent/docs-md/stat-compare.md +82 -0
  160. package/agent/docs-md/static-dashboard.md +75 -0
  161. package/agent/docs-md/status-badge.md +140 -0
  162. package/agent/docs-md/step-card.md +75 -0
  163. package/agent/docs-md/stepper.md +92 -0
  164. package/agent/docs-md/suggested-changes.md +91 -0
  165. package/agent/docs-md/switch.md +78 -0
  166. package/agent/docs-md/table-settings.md +81 -0
  167. package/agent/docs-md/tabs.md +85 -0
  168. package/agent/docs-md/tag-input.md +108 -0
  169. package/agent/docs-md/tag.md +79 -0
  170. package/agent/docs-md/task-builder.md +87 -0
  171. package/agent/docs-md/team-instructions.md +72 -0
  172. package/agent/docs-md/templates-gallery.md +85 -0
  173. package/agent/docs-md/text-input.md +160 -0
  174. package/agent/docs-md/thinking-steps.md +89 -0
  175. package/agent/docs-md/timeline.md +189 -0
  176. package/agent/docs-md/timestamp.md +84 -0
  177. package/agent/docs-md/timestamps.md +70 -0
  178. package/agent/docs-md/toast.md +117 -0
  179. package/agent/docs-md/tooltip.md +90 -0
  180. package/agent/docs-md/typography.md +62 -0
  181. package/agent/docs-md/undo-destructive.md +66 -0
  182. package/agent/docs-md/unsaved-changes.md +71 -0
  183. package/agent/docs-md/usage-meter.md +80 -0
  184. package/agent/docs-md/user-feedback.md +71 -0
  185. package/agent/docs-md/voice-and-tone.md +60 -0
  186. package/agent/docs-md/why-choice.md +93 -0
  187. package/agent/docs-md/workspace-onboarding.md +74 -0
  188. package/agent/docs-md/writing.md +48 -0
  189. package/agent/llms-full.txt +18369 -0
  190. package/agent/llms.txt +198 -0
  191. package/agent/skills/twoux-audit/SKILL.md +49 -0
  192. package/agent/skills/twoux-build/SKILL.md +39 -0
  193. package/agent/skills/twoux-design/SKILL.md +54 -0
  194. package/agent/skills/twoux-write/SKILL.md +32 -0
  195. package/bin/check.mjs +123 -0
  196. package/bin/docs.mjs +24 -0
  197. package/bin/guard.mjs +27 -0
  198. package/bin/init.mjs +113 -0
  199. package/bin/knowledge.mjs +87 -0
  200. package/bin/mcp.mjs +116 -0
  201. package/bin/theme.mjs +168 -0
  202. package/bin/twoux.mjs +25 -0
  203. package/package.json +80 -3
  204. package/registry/jobs.json +1028 -0
  205. package/registry/palette.json +770 -0
  206. package/registry/rules.json +913 -0
  207. package/src/components/agent-review.tsx +364 -0
  208. package/src/components/ai-basics.tsx +212 -0
  209. package/src/components/ai-work.tsx +304 -0
  210. package/src/components/ai.tsx +227 -0
  211. package/src/components/alert-dialog.tsx +203 -0
  212. package/src/components/alert.tsx +86 -0
  213. package/src/components/avatar.tsx +114 -0
  214. package/src/components/badge.tsx +59 -0
  215. package/src/components/board.tsx +397 -0
  216. package/src/components/breadcrumb.tsx +114 -0
  217. package/src/components/button-group.tsx +90 -0
  218. package/src/components/button.tsx +109 -0
  219. package/src/components/calendar.tsx +247 -0
  220. package/src/components/card.tsx +102 -0
  221. package/src/components/chart-map.tsx +336 -0
  222. package/src/components/charts-more.tsx +1331 -0
  223. package/src/components/charts-radial.tsx +328 -0
  224. package/src/components/charts.tsx +390 -0
  225. package/src/components/checkbox.tsx +40 -0
  226. package/src/components/code.tsx +139 -0
  227. package/src/components/collapsible.tsx +37 -0
  228. package/src/components/combobox.tsx +339 -0
  229. package/src/components/command-extras.tsx +11 -0
  230. package/src/components/command.tsx +196 -0
  231. package/src/components/data-grid.tsx +213 -0
  232. package/src/components/data-table.tsx +334 -0
  233. package/src/components/data-views.tsx +740 -0
  234. package/src/components/date-picker.tsx +686 -0
  235. package/src/components/dense-table.tsx +207 -0
  236. package/src/components/dialog.tsx +162 -0
  237. package/src/components/dropdown-menu.tsx +264 -0
  238. package/src/components/empty.tsx +114 -0
  239. package/src/components/feedback-prompt.tsx +80 -0
  240. package/src/components/field.tsx +254 -0
  241. package/src/components/fields.tsx +112 -0
  242. package/src/components/flow.tsx +840 -0
  243. package/src/components/guide-cue.tsx +273 -0
  244. package/src/components/inbox.tsx +113 -0
  245. package/src/components/input-group.tsx +176 -0
  246. package/src/components/input.tsx +26 -0
  247. package/src/components/kbd.tsx +34 -0
  248. package/src/components/label.tsx +27 -0
  249. package/src/components/labels.tsx +176 -0
  250. package/src/components/motion.tsx +136 -0
  251. package/src/components/onboarding.tsx +454 -0
  252. package/src/components/order-book.tsx +185 -0
  253. package/src/components/pane-layout.tsx +417 -0
  254. package/src/components/popover.tsx +96 -0
  255. package/src/components/provider-logos.data.ts +49 -0
  256. package/src/components/radio-group.tsx +52 -0
  257. package/src/components/scheduler.tsx +997 -0
  258. package/src/components/segmented-control.tsx +28 -0
  259. package/src/components/select.tsx +195 -0
  260. package/src/components/separator.tsx +31 -0
  261. package/src/components/sheet.tsx +148 -0
  262. package/src/components/shell.tsx +426 -0
  263. package/src/components/shortcut-sheet.tsx +83 -0
  264. package/src/components/skeleton.tsx +18 -0
  265. package/src/components/sonner.tsx +107 -0
  266. package/src/components/spinner.tsx +13 -0
  267. package/src/components/split-button.tsx +58 -0
  268. package/src/components/stepper.tsx +78 -0
  269. package/src/components/switch.tsx +43 -0
  270. package/src/components/table.tsx +126 -0
  271. package/src/components/tabs.tsx +118 -0
  272. package/src/components/tag-input.tsx +253 -0
  273. package/src/components/textarea.tsx +21 -0
  274. package/src/components/theme.tsx +80 -0
  275. package/src/components/timeline.tsx +408 -0
  276. package/src/components/toggle-group.tsx +87 -0
  277. package/src/components/toggle.tsx +54 -0
  278. package/src/components/tooltip.tsx +65 -0
  279. package/src/components/workspace-actions.tsx +294 -0
  280. package/src/components/workspace-import.tsx +465 -0
  281. package/src/components/workspace.tsx +1884 -0
  282. package/src/index.ts +80 -0
  283. package/src/lib/active-indicator.tsx +53 -0
  284. package/src/lib/chart-kit.tsx +72 -0
  285. package/src/lib/cn.ts +6 -0
  286. package/src/lib/shortcuts.tsx +61 -0
  287. package/src/lib/workspace.tsx +10 -0
  288. package/src/shadcn/alert-dialog.tsx +196 -0
  289. package/src/shadcn/alert.tsx +65 -0
  290. package/src/shadcn/avatar.tsx +108 -0
  291. package/src/shadcn/badge.tsx +47 -0
  292. package/src/shadcn/breadcrumb.tsx +108 -0
  293. package/src/shadcn/button-group.tsx +83 -0
  294. package/src/shadcn/button.tsx +63 -0
  295. package/src/shadcn/calendar.tsx +220 -0
  296. package/src/shadcn/card.tsx +91 -0
  297. package/src/shadcn/checkbox.tsx +31 -0
  298. package/src/shadcn/collapsible.tsx +33 -0
  299. package/src/shadcn/command.tsx +184 -0
  300. package/src/shadcn/dialog.tsx +158 -0
  301. package/src/shadcn/dropdown-menu.tsx +256 -0
  302. package/src/shadcn/empty.tsx +103 -0
  303. package/src/shadcn/field.tsx +248 -0
  304. package/src/shadcn/input-group.tsx +170 -0
  305. package/src/shadcn/input.tsx +20 -0
  306. package/src/shadcn/kbd.tsx +28 -0
  307. package/src/shadcn/label.tsx +23 -0
  308. package/src/shadcn/popover.tsx +88 -0
  309. package/src/shadcn/radio-group.tsx +44 -0
  310. package/src/shadcn/select.tsx +189 -0
  311. package/src/shadcn/separator.tsx +27 -0
  312. package/src/shadcn/sheet.tsx +142 -0
  313. package/src/shadcn/skeleton.tsx +13 -0
  314. package/src/shadcn/sonner.tsx +40 -0
  315. package/src/shadcn/switch.tsx +34 -0
  316. package/src/shadcn/table.tsx +115 -0
  317. package/src/shadcn/tabs.tsx +90 -0
  318. package/src/shadcn/textarea.tsx +17 -0
  319. package/src/shadcn/toggle-group.tsx +83 -0
  320. package/src/shadcn/toggle.tsx +46 -0
  321. package/src/shadcn/tooltip.tsx +56 -0
  322. package/src/styles/palette.css +127 -0
  323. package/src/styles/theme.css +35 -0
  324. package/src/styles/twoux.css +236 -0
  325. package/theme.json +14 -0
@@ -0,0 +1,997 @@
1
+ /**
2
+ * TwoUX Scheduler: interviews, shifts and bookings on a day, week or month grid, adapted from MUI X Scheduler.
3
+ * TwoUX changes to the reference:
4
+ * - Three views of the same data, switched with SegmentedControl; T, J/K and D/W/M follow Google Calendar's keys
5
+ * - The grid is one tab stop: arrows walk free times and events in time order, Enter opens an event or adds one at a free time
6
+ * - Every drag has a keyboard path (Space picks up, arrows move, Shift+arrows change length, Enter drops, Esc cancels)
7
+ * and a menu path (the event's ⋮: earlier, later, other day, longer, shorter, another resource), WCAG 2.5.7
8
+ * - Resource colour is a left edge stripe only (seriesColor); text stays ink and the resource is always named in words
9
+ * - Below 768px the grid becomes an agenda list grouped by day, so it reflows to 320px (WCAG 1.4.10)
10
+ * - 24-hour UK times, weeks start on Monday, and the time zone is written once in the toolbar
11
+ * - The app opens events in SidePanel, creates in a small dialog and confirms moves with an Undo toast
12
+ * - No recurrence editor: the app expands a series into events
13
+ */
14
+ import { ChevronLeft, ChevronRight, MoreVertical, RotateCw, AlertCircle, PanelRightOpen, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ArrowDownToLine, ArrowUpToLine, UserRound } from "lucide-react"
15
+ import * as React from "react"
16
+ import type { VariantProps } from "class-variance-authority"
17
+ import { cn } from "../lib/cn"
18
+ import { useShortcut } from "../lib/shortcuts"
19
+ import { useWidth } from "../lib/chart-kit"
20
+ import { Badge, type badgeVariants } from "./badge"
21
+ import { Button } from "./button"
22
+ import { seriesColor } from "./charts"
23
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./dropdown-menu"
24
+ import { Kbd } from "./kbd"
25
+ import { Popover, PopoverAnchor, PopoverContent } from "./popover"
26
+ import { Segment, SegmentedControl } from "./segmented-control"
27
+ import { Skeleton } from "./skeleton"
28
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
29
+
30
+ /* ---------------------------------------------------------------- types */
31
+
32
+ export type SchedulerView = "day" | "week" | "month"
33
+ export type SchedulerEvent = {
34
+ id: string
35
+ title: string
36
+ start: Date | string
37
+ end: Date | string
38
+ /** Person, room or team the event belongs to. Its colour is the stripe; its name is always written. */
39
+ resourceId?: string
40
+ allDay?: boolean
41
+ /** A word, shown in a Badge: Confirmed, Tentative, Cancelled. */
42
+ status?: string
43
+ /** Second line, such as the candidate: “Priya Shah”. */
44
+ detail?: string
45
+ }
46
+ export type SchedulerResource = { id: string; label: string }
47
+ export type SchedulerSlot = { start: Date; end: Date; resourceId?: string }
48
+ type BadgeVariant = VariantProps<typeof badgeVariants>["variant"]
49
+
50
+ /* ---------------------------------------------------------------- dates */
51
+
52
+ const toDate = (v: Date | string) => (typeof v === "string" ? new Date(v) : v)
53
+ const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate())
54
+ const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n, d.getHours(), d.getMinutes())
55
+ const addMin = (d: Date, n: number) => new Date(d.getTime() + n * 60000)
56
+ const startOfWeek = (d: Date) => { const s = startOfDay(d); return addDays(s, -((s.getDay() + 6) % 7)) }
57
+ const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
58
+ const minutesOf = (d: Date) => d.getHours() * 60 + d.getMinutes()
59
+ const atMinutes = (day: Date, m: number) => new Date(day.getFullYear(), day.getMonth(), day.getDate(), Math.floor(m / 60), m % 60)
60
+ const dayKey = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
61
+ const fromKey = (k: string) => { const [y, m, d] = k.split("-").map(Number); return new Date(y, m - 1, d) }
62
+
63
+ const f = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat("en-GB", o)
64
+ const fTime = f({ hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
65
+ const fDay = f({ weekday: "long", day: "numeric", month: "long" })
66
+ const fDayYear = { format: (d: Date) => `${fDay.format(d)} ${d.getFullYear()}` }
67
+ const fWeekday = f({ weekday: "short" })
68
+ const fMonth = f({ month: "long", year: "numeric" })
69
+ const fDayMonth = f({ day: "numeric", month: "long" })
70
+
71
+ /** 24-hour UK time: “09:30”. */
72
+ export const formatTime = (d: Date) => fTime.format(d)
73
+ /** “09:30–10:15”, with an en dash. */
74
+ export const formatTimeRange = (s: Date, e: Date) => `${formatTime(s)}–${formatTime(e)}`
75
+ const timeZoneName = (d: Date) => f({ timeZoneName: "short" }).formatToParts(d).find((p) => p.type === "timeZoneName")?.value ?? ""
76
+
77
+ function periodLabel(view: SchedulerView, date: Date, days: number) {
78
+ if (view === "day") return fDayYear.format(date)
79
+ if (view === "month") return fMonth.format(date)
80
+ const s = startOfWeek(date), e = addDays(s, days - 1)
81
+ if (s.getMonth() === e.getMonth()) return `${s.getDate()}–${e.getDate()} ${fMonth.format(e)}`
82
+ if (s.getFullYear() === e.getFullYear()) return `${fDayMonth.format(s)} – ${fDayMonth.format(e)} ${e.getFullYear()}`
83
+ return `${fDayMonth.format(s)} ${s.getFullYear()} – ${fDayMonth.format(e)} ${e.getFullYear()}`
84
+ }
85
+
86
+ /** First day shown and the number of days, for a view. */
87
+ function rangeOf(view: SchedulerView, date: Date, workWeek: boolean) {
88
+ if (view === "day") return { from: startOfDay(date), count: 1 }
89
+ if (view === "week") return { from: startOfWeek(date), count: workWeek ? 5 : 7 }
90
+ const first = new Date(date.getFullYear(), date.getMonth(), 1)
91
+ const last = new Date(date.getFullYear(), date.getMonth() + 1, 0)
92
+ const from = startOfWeek(first)
93
+ const weeks = Math.round((startOfWeek(last).getTime() - from.getTime()) / (7 * 86400000)) + 1
94
+ return { from, count: weeks * 7 }
95
+ }
96
+
97
+ function shiftPeriod(view: SchedulerView, date: Date, dir: 1 | -1) {
98
+ if (view === "day") return addDays(date, dir)
99
+ if (view === "week") return addDays(date, 7 * dir)
100
+ return new Date(date.getFullYear(), date.getMonth() + dir, 1)
101
+ }
102
+
103
+ /* ---------------------------------------------------------------- table helper */
104
+
105
+ /** The same events as a table, for a Table view or an export: date, start, end, event, resource, status. */
106
+ export const schedulerTable = (events: SchedulerEvent[], resources: SchedulerResource[] = [], resourceTitle = "Resource") => ({
107
+ columns: ["Date", "Start", "End", "Event", resourceTitle, "Status"],
108
+ rows: [...events]
109
+ .sort((a, b) => toDate(a.start).getTime() - toDate(b.start).getTime())
110
+ .map((e) => {
111
+ const s = toDate(e.start), en = toDate(e.end)
112
+ return [
113
+ fDayYear.format(s),
114
+ e.allDay ? "All day" : formatTime(s),
115
+ e.allDay ? "" : formatTime(en),
116
+ e.detail ? `${e.title}, ${e.detail}` : e.title,
117
+ resources.find((r) => r.id === e.resourceId)?.label ?? "",
118
+ e.status ?? "",
119
+ ]
120
+ }),
121
+ })
122
+
123
+ /* ---------------------------------------------------------------- shared bits */
124
+
125
+ type Norm = { ev: SchedulerEvent; start: Date; end: Date; resourceId?: string; color: string; resource?: string }
126
+ type Move = { id: string; start: Date; end: Date; resourceId?: string }
127
+
128
+ const stripe = "absolute inset-y-0 left-0 w-1 bg-(--c)"
129
+ const evSurface = "rounded-xs border border-line bg-surface shadow-card hover:bg-hover"
130
+
131
+ function eventLabel(n: Norm) {
132
+ const when = n.ev.allDay ? `${fDay.format(n.start)}, all day` : `${fDay.format(n.start)}, ${formatTimeRange(n.start, n.end)}`
133
+ return [n.ev.title, n.ev.detail, when, n.resource, n.ev.status].filter(Boolean).join(", ")
134
+ }
135
+
136
+ function Stripe({ color }: { color: string }) {
137
+ return <span aria-hidden="true" className={stripe} style={{ "--c": color } as React.CSSProperties} />
138
+ }
139
+
140
+ /** The menu path for every drag: the same moves as the pointer and the keyboard. */
141
+ function EventMenu({
142
+ n, slotMinutes, resources, open, onOpenChange, onSelect, onMove, trigger,
143
+ }: {
144
+ n: Norm
145
+ slotMinutes: number
146
+ resources: SchedulerResource[]
147
+ open?: boolean
148
+ onOpenChange?: (o: boolean) => void
149
+ onSelect?: (e: SchedulerEvent) => void
150
+ onMove?: (e: SchedulerEvent, to: SchedulerSlot) => void
151
+ trigger: React.ReactNode
152
+ }) {
153
+ const move = (dMin: number, dDays: number, dEnd = dMin, resourceId = n.resourceId) =>
154
+ onMove?.(n.ev, { start: addDays(addMin(n.start, dMin), dDays), end: addDays(addMin(n.end, dEnd), dDays), resourceId })
155
+ const dur = (n.end.getTime() - n.start.getTime()) / 60000
156
+ return (
157
+ <DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
158
+ <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
159
+ <DropdownMenuContent align="start" className="w-56">
160
+ {onSelect && <DropdownMenuItem onSelect={() => onSelect(n.ev)}><PanelRightOpen aria-hidden="true" />Open</DropdownMenuItem>}
161
+ {onMove && (
162
+ <>
163
+ {onSelect && <DropdownMenuSeparator />}
164
+ <DropdownMenuLabel className="flex items-center justify-between">Change time <Kbd>Space</Kbd></DropdownMenuLabel>
165
+ {!n.ev.allDay && <DropdownMenuItem onSelect={() => move(-slotMinutes, 0)}><ArrowUp aria-hidden="true" />{slotMinutes} min earlier</DropdownMenuItem>}
166
+ {!n.ev.allDay && <DropdownMenuItem onSelect={() => move(slotMinutes, 0)}><ArrowDown aria-hidden="true" />{slotMinutes} min later</DropdownMenuItem>}
167
+ <DropdownMenuItem onSelect={() => move(0, -1)}><ArrowLeft aria-hidden="true" />Previous day</DropdownMenuItem>
168
+ <DropdownMenuItem onSelect={() => move(0, 1)}><ArrowRight aria-hidden="true" />Next day</DropdownMenuItem>
169
+ {!n.ev.allDay && <DropdownMenuItem onSelect={() => move(0, 0, slotMinutes)}><ArrowDownToLine aria-hidden="true" />{slotMinutes} min longer</DropdownMenuItem>}
170
+ {!n.ev.allDay && dur > slotMinutes && <DropdownMenuItem onSelect={() => move(0, 0, -slotMinutes)}><ArrowUpToLine aria-hidden="true" />{slotMinutes} min shorter</DropdownMenuItem>}
171
+ {resources.filter((r) => r.id !== n.resourceId).length > 0 && <DropdownMenuSeparator />}
172
+ {resources.filter((r) => r.id !== n.resourceId).map((r) => (
173
+ <DropdownMenuItem key={r.id} onSelect={() => move(0, 0, 0, r.id)}><UserRound aria-hidden="true" />Move to {r.label}</DropdownMenuItem>
174
+ ))}
175
+ </>
176
+ )}
177
+ </DropdownMenuContent>
178
+ </DropdownMenu>
179
+ )
180
+ }
181
+
182
+ /* ---------------------------------------------------------------- layout */
183
+
184
+ type Placed = Norm & { top: number; height: number; lane: number; lanes: number; startSlot: number }
185
+
186
+ /** Side-by-side lanes for overlapping events, cluster by cluster. */
187
+ function place(list: Norm[], dayStartMin: number, dayEndMin: number, slotMinutes: number, slotPx: number): Placed[] {
188
+ const sorted = [...list].sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime())
189
+ const out: Placed[] = []
190
+ let cluster: Placed[] = [], laneEnds: number[] = [], clusterEnd = -Infinity
191
+ const close = () => { cluster.forEach((p) => (p.lanes = laneEnds.length)); cluster = []; laneEnds = []; clusterEnd = -Infinity }
192
+ for (const n of sorted) {
193
+ const s = Math.max(dayStartMin, minutesOf(n.start)), e = Math.min(dayEndMin, Math.max(s + slotMinutes / 2, minutesOf(n.end) || 24 * 60))
194
+ if (s >= dayEndMin || e <= dayStartMin) continue
195
+ if (s >= clusterEnd && cluster.length) close()
196
+ let lane = laneEnds.findIndex((le) => le <= s)
197
+ if (lane < 0) { lane = laneEnds.length; laneEnds.push(e) } else laneEnds[lane] = e
198
+ const p: Placed = { ...n, top: ((s - dayStartMin) / slotMinutes) * slotPx, height: Math.max(slotPx - 2, ((e - s) / slotMinutes) * slotPx - 2), lane, lanes: 1, startSlot: Math.min(Math.floor((s - dayStartMin) / slotMinutes), Math.round((dayEndMin - dayStartMin) / slotMinutes) - 1) }
199
+ cluster.push(p); out.push(p); clusterEnd = Math.max(clusterEnd, e)
200
+ }
201
+ close()
202
+ return out
203
+ }
204
+
205
+ const SLOT_PX = 24
206
+
207
+ /* ---------------------------------------------------------------- scheduler */
208
+
209
+ export function Scheduler({
210
+ events,
211
+ resources = [],
212
+ view,
213
+ onViewChange,
214
+ date,
215
+ onDateChange,
216
+ onSelectEvent,
217
+ onCreate,
218
+ onMove,
219
+ selectedId,
220
+ status = "ready",
221
+ onRetry,
222
+ layout = "auto",
223
+ dayStart = 8,
224
+ dayEnd = 18,
225
+ slotMinutes = 30,
226
+ createMinutes = 60,
227
+ workWeek = false,
228
+ now: nowProp,
229
+ height = 520,
230
+ noun = "events",
231
+ resourceTitle = "Resource",
232
+ statusVariant,
233
+ className,
234
+ "aria-label": label,
235
+ }: {
236
+ events: SchedulerEvent[]
237
+ /** People, rooms or teams. Day view shows one column each; other views mark them with a stripe and their name. */
238
+ resources?: SchedulerResource[]
239
+ view: SchedulerView
240
+ onViewChange: (v: SchedulerView) => void
241
+ /** Any day inside the period shown. */
242
+ date: Date
243
+ onDateChange: (d: Date) => void
244
+ /** Open the event, usually in SidePanel. */
245
+ onSelectEvent?: (e: SchedulerEvent) => void
246
+ /** A free time was clicked or chosen with Enter: open a small create dialog. Omit for a read-only schedule. */
247
+ onCreate?: (slot: SchedulerSlot) => void
248
+ /** Drag, keyboard or menu moved or resized an event. Apply it and confirm with an Undo toast. Omit to turn moving off. */
249
+ onMove?: (e: SchedulerEvent, to: SchedulerSlot) => void
250
+ selectedId?: string
251
+ status?: "ready" | "loading" | "error"
252
+ onRetry?: () => void
253
+ /** auto: the grid from 768px, the agenda list below. */
254
+ layout?: "auto" | "grid" | "agenda"
255
+ /** Hours shown in day and week views, 24-hour. */
256
+ dayStart?: number
257
+ dayEnd?: number
258
+ slotMinutes?: number
259
+ /** Length of an event created from a free time. */
260
+ createMinutes?: number
261
+ /** Monday to Friday only. */
262
+ workWeek?: boolean
263
+ /** For tests and demos; defaults to the real time, updated each minute. */
264
+ now?: Date
265
+ height?: number
266
+ /** Plural noun for the empty message: “interviews”. */
267
+ noun?: string
268
+ /** Column title for resources in the table helper and the agenda, such as “Interviewer”. */
269
+ resourceTitle?: string
270
+ statusVariant?: (status: string) => BadgeVariant
271
+ className?: string
272
+ "aria-label": string
273
+ }) {
274
+ const [wrap, width] = useWidth<HTMLElement>()
275
+ const [liveNow, setLiveNow] = React.useState(() => new Date())
276
+ React.useEffect(() => {
277
+ if (nowProp) return
278
+ const t = setInterval(() => setLiveNow(new Date()), 60000)
279
+ return () => clearInterval(t)
280
+ }, [nowProp])
281
+ const now = nowProp ?? liveNow
282
+ const agenda = layout === "agenda" || (layout === "auto" && width > 0 && width < 768)
283
+ const days = workWeek ? 5 : 7
284
+ const unit = view === "day" ? "day" : view === "week" ? "week" : "month"
285
+ const [announce, setAnnounce] = React.useState("")
286
+
287
+ const resIndex = (id?: string) => resources.findIndex((r) => r.id === id)
288
+ const norm: Norm[] = React.useMemo(() => events.map((ev) => {
289
+ const i = resIndex(ev.resourceId)
290
+ return { ev, start: toDate(ev.start), end: toDate(ev.end), resourceId: ev.resourceId, color: seriesColor(Math.max(0, i)), resource: i >= 0 ? resources[i].label : undefined }
291
+ // eslint-disable-next-line react-hooks/exhaustive-deps
292
+ }), [events, resources])
293
+
294
+ const { from, count } = rangeOf(view, date, workWeek)
295
+ const rangeDays = Array.from({ length: count }, (_, i) => addDays(from, i)).filter((d) => !workWeek || (d.getDay() !== 0 && d.getDay() !== 6))
296
+ const inRange = norm.filter((n) => rangeDays.some((d) => sameDay(d, n.start)) && (view !== "month" || n.start.getMonth() === date.getMonth()))
297
+ const emptyText = `No ${noun} this ${unit}`
298
+
299
+ const go = (d: Date) => onDateChange(d)
300
+ const viewRefs = { day: React.useRef<HTMLButtonElement>(null), week: React.useRef<HTMLButtonElement>(null), month: React.useRef<HTMLButtonElement>(null) }
301
+ useShortcut("D", viewRefs.day)
302
+ useShortcut("W", viewRefs.week)
303
+ useShortcut("M", viewRefs.month)
304
+
305
+ const toolbar = (
306
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-2 border-b border-line px-3 py-2">
307
+ <Button variant="outline" size="sm" shortcut="T" onClick={() => go(now)}>Today</Button>
308
+ <div className="flex items-center">
309
+ <Tooltip>
310
+ <TooltipTrigger asChild>
311
+ <Button variant="ghost" size="icon-sm" shortcut="K" aria-label={`Previous ${unit}`} onClick={() => go(shiftPeriod(view, date, -1))}><ChevronLeft className="size-4" /></Button>
312
+ </TooltipTrigger>
313
+ <TooltipContent>Previous {unit} <Kbd>K</Kbd></TooltipContent>
314
+ </Tooltip>
315
+ <Tooltip>
316
+ <TooltipTrigger asChild>
317
+ <Button variant="ghost" size="icon-sm" shortcut="J" aria-label={`Next ${unit}`} onClick={() => go(shiftPeriod(view, date, 1))}><ChevronRight className="size-4" /></Button>
318
+ </TooltipTrigger>
319
+ <TooltipContent>Next {unit} <Kbd>J</Kbd></TooltipContent>
320
+ </Tooltip>
321
+ </div>
322
+ <h2 aria-live="polite" className="min-w-0 text-md font-semibold text-ink">{periodLabel(view, date, days)}</h2>
323
+ <span className="text-sm text-ink-3">Times in {timeZoneName(date)}</span>
324
+ <SegmentedControl value={view} onValueChange={(v) => onViewChange(v as SchedulerView)} size="sm" aria-label="View" className="ml-auto">
325
+ {/* Tooltip sits on the label, not the segment: a trigger would overwrite the segment's data-state */}
326
+ {(["day", "week", "month"] as const).map((v) => (
327
+ <Segment key={v} ref={viewRefs[v]} value={v} aria-keyshortcuts={v[0].toUpperCase()}>
328
+ <Tooltip>
329
+ <TooltipTrigger asChild><span>{v[0].toUpperCase() + v.slice(1)}</span></TooltipTrigger>
330
+ <TooltipContent>{v[0].toUpperCase() + v.slice(1)} view <Kbd>{v[0].toUpperCase()}</Kbd></TooltipContent>
331
+ </Tooltip>
332
+ </Segment>
333
+ ))}
334
+ </SegmentedControl>
335
+ </div>
336
+ )
337
+
338
+ const legend = resources.length > 1 && !(view === "day" && !agenda) && (
339
+ <ul aria-label={`${resourceTitle}s`} className="flex flex-wrap gap-x-4 gap-y-1 border-b border-line px-3 py-2">
340
+ {resources.map((r, i) => (
341
+ <li key={r.id} className="relative inline-flex items-center pl-2 text-sm text-ink-2">
342
+ <span aria-hidden="true" className="absolute inset-y-0.5 left-0 w-1 rounded-full bg-(--c)" style={{ "--c": seriesColor(i) } as React.CSSProperties} />
343
+ {r.label}
344
+ </li>
345
+ ))}
346
+ </ul>
347
+ )
348
+
349
+ const shared = { norm, inRange, resources, onSelectEvent, onCreate, onMove, selectedId, slotMinutes, statusVariant, setAnnounce }
350
+
351
+ let body: React.ReactNode
352
+ if (status === "loading") {
353
+ body = (
354
+ <div aria-busy="true" aria-label={`Loading ${label.toLowerCase()}`} className="grid gap-2 p-3">
355
+ {Array.from({ length: 6 }, (_, i) => <Skeleton key={i} className="h-10" />)}
356
+ </div>
357
+ )
358
+ } else if (status === "error") {
359
+ body = (
360
+ <div className="grid place-items-center gap-2 px-4 py-12 text-center">
361
+ <AlertCircle className="size-4 text-bad" aria-hidden="true" />
362
+ <p className="text-base font-medium text-ink">Couldn't load {label.toLowerCase()}. Check your connection, then try again.</p>
363
+ {onRetry && <Button variant="outline" size="sm" onClick={onRetry}><RotateCw />Try again</Button>}
364
+ </div>
365
+ )
366
+ } else if (agenda) {
367
+ body = <Agenda {...shared} days={rangeDays.filter((d) => view !== "month" || d.getMonth() === date.getMonth())} now={now} emptyText={emptyText} resourceTitle={resourceTitle} />
368
+ } else if (view === "month") {
369
+ body = <MonthGrid {...shared} date={date} from={from} count={count} workWeek={workWeek} now={now} createMinutes={createMinutes} dayStart={dayStart} onOpenDay={(d) => { onDateChange(d); onViewChange("day") }} label={label} />
370
+ } else {
371
+ const cols: Col[] = view === "day" && resources.length
372
+ ? resources.map((r, i) => ({ key: r.id, day: startOfDay(date), resourceId: r.id, title: r.label, color: seriesColor(i) }))
373
+ : rangeDays.map((d) => ({ key: dayKey(d), day: d, title: fWeekday.format(d), num: d.getDate() }))
374
+ body = (
375
+ <TimeGrid
376
+ {...shared}
377
+ cols={cols}
378
+ byResource={view === "day" && resources.length > 0}
379
+ dayStart={dayStart}
380
+ dayEnd={dayEnd}
381
+ createMinutes={createMinutes}
382
+ now={now}
383
+ height={height}
384
+ label={`${label}, ${periodLabel(view, date, days)}`}
385
+ onPeriod={(dir) => go(shiftPeriod(view, date, dir))}
386
+ />
387
+ )
388
+ }
389
+
390
+ return (
391
+ <section ref={wrap} aria-label={label} data-slot="scheduler" className={cn("relative grid min-w-0 overflow-hidden rounded-sm border border-line bg-surface", className)}>
392
+ {toolbar}
393
+ {status === "ready" && legend}
394
+ {status === "ready" && !agenda && inRange.length === 0 && <p role="status" className="border-b border-line px-3 py-2 text-sm text-ink-2">{emptyText}{onCreate ? ". Choose a free time to add one." : "."}</p>}
395
+ {body}
396
+ <div aria-live="polite" className="sr-only">{announce}</div>
397
+ </section>
398
+ )
399
+ }
400
+
401
+ type Shared = {
402
+ norm: Norm[]
403
+ inRange: Norm[]
404
+ resources: SchedulerResource[]
405
+ onSelectEvent?: (e: SchedulerEvent) => void
406
+ onCreate?: (slot: SchedulerSlot) => void
407
+ onMove?: (e: SchedulerEvent, to: SchedulerSlot) => void
408
+ selectedId?: string
409
+ slotMinutes: number
410
+ statusVariant?: (s: string) => BadgeVariant
411
+ setAnnounce: (s: string) => void
412
+ }
413
+
414
+ /* ---------------------------------------------------------------- time grid (day, week) */
415
+
416
+ type Col = { key: string; day: Date; resourceId?: string; title: string; num?: number; color?: string }
417
+ type Stop = { key: string; slot: number; evId?: string }
418
+
419
+ function TimeGrid({
420
+ norm, resources, onSelectEvent, onCreate, onMove, selectedId, slotMinutes, statusVariant, setAnnounce,
421
+ cols, byResource, dayStart, dayEnd, createMinutes, now, height, label, onPeriod,
422
+ }: Shared & { cols: Col[]; byResource: boolean; dayStart: number; dayEnd: number; createMinutes: number; now: Date; height: number; label: string; onPeriod: (dir: 1 | -1) => void }) {
423
+ const d0 = dayStart * 60, d1 = dayEnd * 60
424
+ const nSlots = Math.round((d1 - d0) / slotMinutes)
425
+ const [moving, setMoving] = React.useState<(Move & { via: "key" | "pointer" }) | null>(null)
426
+ const [menuFor, setMenuFor] = React.useState<string | null>(null)
427
+ const gridRef = React.useRef<HTMLDivElement>(null)
428
+ const focusNext = React.useRef(false)
429
+ const drag = React.useRef<{ id: string; mode: "move" | "resize"; x: number; y: number; offset: number; started: boolean } | null>(null)
430
+
431
+ const effective = (n: Norm): Norm => (moving && moving.id === n.ev.id ? { ...n, start: moving.start, end: moving.end, resourceId: moving.resourceId, color: byResource || !moving.resourceId ? n.color : seriesColor(Math.max(0, resources.findIndex((r) => r.id === moving.resourceId))), resource: resources.find((r) => r.id === moving.resourceId)?.label ?? n.resource } : n)
432
+ const all = norm.map(effective)
433
+ const inCol = (c: Col, n: Norm) => sameDay(n.start, c.day) && (!byResource || n.resourceId === c.resourceId)
434
+ const placedBy = cols.map((c) => place(all.filter((n) => !n.ev.allDay && inCol(c, n)), d0, d1, slotMinutes, SLOT_PX))
435
+ const allDayBy = cols.map((c) => all.filter((n) => n.ev.allDay && inCol(c, n)))
436
+ const hasAllDay = allDayBy.some((l) => l.length)
437
+
438
+ const stopsBy: Stop[][] = cols.map((_, ci) => {
439
+ const s: Stop[] = allDayBy[ci].map((n) => ({ key: `e:${n.ev.id}`, slot: -1, evId: n.ev.id }))
440
+ for (let i = 0; i < nSlots; i++) {
441
+ s.push({ key: `s:${i}`, slot: i })
442
+ placedBy[ci].filter((p) => p.startSlot === i).forEach((p) => s.push({ key: `e:${p.ev.id}`, slot: i, evId: p.ev.id }))
443
+ }
444
+ return s
445
+ })
446
+
447
+ const todayCol = cols.findIndex((c) => sameDay(c.day, now))
448
+ const nowSlot = Math.max(0, Math.min(nSlots - 1, Math.floor((minutesOf(now) - d0) / slotMinutes)))
449
+ const defaultCursor = { col: Math.max(0, todayCol), key: `s:${todayCol >= 0 ? nowSlot : Math.min(nSlots - 1, Math.max(0, Math.floor((9 * 60 - d0) / slotMinutes)))}` }
450
+ const [cursorState, setCursor] = React.useState(defaultCursor)
451
+ const cursor = cursorState.col < cols.length && stopsBy[cursorState.col]?.some((s) => s.key === cursorState.key) ? cursorState : defaultCursor
452
+
453
+ React.useEffect(() => {
454
+ if (!focusNext.current) return
455
+ focusNext.current = false
456
+ gridRef.current?.querySelector<HTMLElement>(`[data-stop="${cursor.col}|${cursor.key}"]`)?.focus()
457
+ })
458
+
459
+ const slotTime = (c: Col, i: number) => atMinutes(c.day, d0 + i * slotMinutes)
460
+ const findN = (id: string) => norm.find((n) => n.ev.id === id)
461
+ const describe = (m: Move) => {
462
+ const res = resources.find((r) => r.id === m.resourceId)?.label
463
+ return `${formatTimeRange(m.start, m.end)} ${fDay.format(m.start)}${res ? `, ${res}` : ""}`
464
+ }
465
+ const commit = (m: Move) => {
466
+ const n = findN(m.id)
467
+ setMoving(null)
468
+ if (!n) return
469
+ if (m.start.getTime() === n.start.getTime() && m.end.getTime() === n.end.getTime() && m.resourceId === n.resourceId) { setAnnounce(`${n.ev.title} not moved.`); return }
470
+ setAnnounce(`${n.ev.title} moved to ${describe(m)}.`)
471
+ onMove?.(n.ev, { start: m.start, end: m.end, resourceId: m.resourceId })
472
+ }
473
+ const create = (c: Col, i: number) => {
474
+ const start = slotTime(c, i)
475
+ onCreate?.({ start, end: addMin(start, createMinutes), resourceId: c.resourceId })
476
+ }
477
+ const colOf = (m: Move) => cols.findIndex((c) => sameDay(c.day, m.start) && (!byResource || c.resourceId === m.resourceId))
478
+
479
+ const onKeyDown = (e: React.KeyboardEvent) => {
480
+ const stops = stopsBy[cursor.col] ?? []
481
+ const idx = stops.findIndex((s) => s.key === cursor.key)
482
+ const cur = stops[idx]
483
+ const to = (col: number, key: string) => { e.preventDefault(); focusNext.current = true; setCursor({ col, key }) }
484
+ if (moving && moving.via === "key") {
485
+ const n = findN(moving.id)
486
+ if (!n) return
487
+ const dur = moving.end.getTime() - moving.start.getTime()
488
+ const clampMove = (m: Move): Move => {
489
+ const sm = minutesOf(m.start), em = sm + (m.end.getTime() - m.start.getTime()) / 60000
490
+ if (sm < d0) return { ...m, start: atMinutes(m.start, d0), end: addMin(atMinutes(m.start, d0), (em - sm)) }
491
+ if (em > d1) return { ...m, start: addMin(atMinutes(m.start, d1), -(em - sm)), end: atMinutes(m.start, d1) }
492
+ return m
493
+ }
494
+ let next: Move | null = null
495
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
496
+ const d = e.key === "ArrowUp" ? -slotMinutes : slotMinutes
497
+ if (e.shiftKey) {
498
+ const end = addMin(moving.end, d)
499
+ if ((end.getTime() - moving.start.getTime()) / 60000 >= slotMinutes && minutesOf(end) <= d1 && minutesOf(end) > 0) next = { ...moving, end }
500
+ } else next = clampMove({ ...moving, start: addMin(moving.start, d), end: addMin(moving.start, d + dur / 60000) })
501
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
502
+ const dir = e.key === "ArrowLeft" ? -1 : 1
503
+ if (byResource) {
504
+ const ri = resources.findIndex((r) => r.id === moving.resourceId) + dir
505
+ if (ri >= 0 && ri < resources.length) next = { ...moving, resourceId: resources[ri].id }
506
+ } else {
507
+ const ci = colOf(moving) + dir
508
+ if (ci >= 0 && ci < cols.length) { const day = cols[ci].day; next = { ...moving, start: atMinutes(day, minutesOf(moving.start)), end: addMin(atMinutes(day, minutesOf(moving.start)), dur / 60000) } }
509
+ }
510
+ } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); focusNext.current = true; commit(moving); return }
511
+ else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setMoving(null); focusNext.current = true; setAnnounce(`Move cancelled. ${n.ev.title} is back at ${describe({ id: n.ev.id, start: n.start, end: n.end, resourceId: n.resourceId })}.`); return }
512
+ else if (e.key === "Tab") { setMoving(null); return }
513
+ if (next) {
514
+ e.preventDefault()
515
+ setMoving({ ...next, via: "key" })
516
+ const ci = colOf(next)
517
+ if (ci >= 0) { focusNext.current = true; setCursor({ col: ci, key: `e:${moving.id}` }) }
518
+ setAnnounce(describe(next))
519
+ } else if (e.key.startsWith("Arrow")) e.preventDefault()
520
+ return
521
+ }
522
+ if (!cur) return
523
+ if (e.key === "ArrowDown" && idx < stops.length - 1) to(cursor.col, stops[idx + 1].key)
524
+ else if (e.key === "ArrowUp" && idx > 0) to(cursor.col, stops[idx - 1].key)
525
+ else if ((e.key === "ArrowLeft" && cursor.col > 0) || (e.key === "ArrowRight" && cursor.col < cols.length - 1)) {
526
+ const col = cursor.col + (e.key === "ArrowLeft" ? -1 : 1)
527
+ to(col, `s:${Math.max(0, cur.slot)}`)
528
+ } else if (e.key === "Home") to(cursor.col, stops[0].key)
529
+ else if (e.key === "End") to(cursor.col, stops[stops.length - 1].key)
530
+ else if (e.key === "PageUp" || e.key === "PageDown") { e.preventDefault(); onPeriod(e.key === "PageUp" ? -1 : 1) }
531
+ else if (e.key === "Enter") {
532
+ e.preventDefault()
533
+ if (cur.evId) { const n = findN(cur.evId); if (n) onSelectEvent?.(n.ev) } else create(cols[cursor.col], cur.slot)
534
+ } else if (e.key === " ") {
535
+ e.preventDefault()
536
+ const n = cur.evId ? findN(cur.evId) : null
537
+ if (n && onMove && !n.ev.allDay) {
538
+ setMoving({ id: n.ev.id, start: n.start, end: n.end, resourceId: n.resourceId, via: "key" })
539
+ setAnnounce(`Picked up ${n.ev.title}, ${describe({ id: n.ev.id, start: n.start, end: n.end, resourceId: n.resourceId })}. Arrow keys move it, Shift and up or down change its length, Enter drops it, Esc cancels.`)
540
+ } else if (!cur.evId) create(cols[cursor.col], cur.slot)
541
+ } else if ((e.key === "F10" && e.shiftKey) || e.key === "ContextMenu") {
542
+ if (cur.evId) { e.preventDefault(); setMenuFor(cur.evId) }
543
+ }
544
+ }
545
+
546
+ /* pointer: drag to move, bottom edge to resize; a press without movement is a click */
547
+ const targetAt = (x: number, y: number) => {
548
+ const el = document.elementsFromPoint(x, y).find((n) => (n as HTMLElement).dataset?.col !== undefined) as HTMLElement | undefined
549
+ if (!el) return null
550
+ const ci = Number(el.dataset.col), r = el.getBoundingClientRect()
551
+ return { ci, min: d0 + Math.floor((y - r.top) / SLOT_PX) * slotMinutes }
552
+ }
553
+ const onPointerDown = (e: React.PointerEvent, n: Norm, mode: "move" | "resize") => {
554
+ if (e.button !== 0 || !onMove) return
555
+ e.stopPropagation()
556
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
557
+ const t = targetAt(e.clientX, e.clientY)
558
+ drag.current = { id: n.ev.id, mode, x: e.clientX, y: e.clientY, offset: t ? t.min - minutesOf(n.start) : 0, started: false }
559
+ }
560
+ const onPointerMove = (e: React.PointerEvent, n: Norm) => {
561
+ const d = drag.current
562
+ if (!d || d.id !== n.ev.id) return
563
+ if (!d.started && Math.hypot(e.clientX - d.x, e.clientY - d.y) < 4) return
564
+ d.started = true
565
+ const t = targetAt(e.clientX, e.clientY)
566
+ if (!t) return
567
+ const c = cols[t.ci]
568
+ const dur = (n.end.getTime() - n.start.getTime()) / 60000
569
+ if (d.mode === "resize") {
570
+ const endMin = Math.min(d1, Math.max(minutesOf(n.start) + slotMinutes, t.min + slotMinutes))
571
+ setMoving({ id: n.ev.id, start: n.start, end: atMinutes(n.start, endMin), resourceId: n.resourceId, via: "pointer" })
572
+ } else {
573
+ const sMin = Math.max(d0, Math.min(d1 - dur, Math.round((t.min - d.offset) / slotMinutes) * slotMinutes))
574
+ const start = atMinutes(c.day, sMin)
575
+ setMoving({ id: n.ev.id, start, end: addMin(start, dur), resourceId: byResource ? c.resourceId : n.resourceId, via: "pointer" })
576
+ }
577
+ }
578
+ const onPointerUp = (n: Norm) => {
579
+ const d = drag.current
580
+ drag.current = null
581
+ if (!d) return
582
+ if (d.started && moving) commit(moving)
583
+ else { setMoving(null); onSelectEvent?.(n.ev) }
584
+ }
585
+
586
+ const gridCols = `4rem repeat(${cols.length}, minmax(${byResource ? "8rem" : "6.5rem"}, 1fr))`
587
+ const nowTop = ((minutesOf(now) - d0) / slotMinutes) * SLOT_PX
588
+ const showNow = todayCol >= 0 && minutesOf(now) >= d0 && minutesOf(now) < d1
589
+ const helpId = React.useId()
590
+
591
+ const eventBlock = (p: Placed | Norm, ci: number, allDay = false) => {
592
+ const pl = p as Placed
593
+ const id = p.ev.id
594
+ const isCur = cursor.col === ci && cursor.key === `e:${id}`
595
+ const isMoving = moving?.id === id
596
+ const h = allDay ? SLOT_PX : pl.height
597
+ const pos = allDay ? undefined : ({ "--c": p.color, "--t": `${pl.top}px`, "--h": `${pl.height}px`, "--l": `${(pl.lane / pl.lanes) * 100}%`, "--w": `calc(${100 / pl.lanes}% - 4px)` } as React.CSSProperties)
598
+ return (
599
+ <div
600
+ key={id}
601
+ className={cn("group/ev", allDay ? "relative" : "absolute top-(--t) left-(--l) h-(--h) w-(--w)", isMoving && "z-20")}
602
+ style={allDay ? ({ "--c": p.color } as React.CSSProperties) : pos}
603
+ >
604
+ <div
605
+ role="button"
606
+ tabIndex={isCur ? 0 : -1}
607
+ data-stop={`${ci}|e:${id}`}
608
+ aria-label={eventLabel(p)}
609
+ aria-pressed={selectedId === id || undefined}
610
+ aria-describedby={helpId}
611
+ onFocus={() => setCursor({ col: ci, key: `e:${id}` })}
612
+ onPointerDown={(e) => !allDay && onPointerDown(e, p, "move")}
613
+ onPointerMove={(e) => onPointerMove(e, p)}
614
+ onPointerUp={() => onPointerUp(p)}
615
+ onPointerCancel={() => { drag.current = null; setMoving(null) }}
616
+ onClick={() => { if (allDay || !onMove) onSelectEvent?.(p.ev) }}
617
+ className={cn(
618
+ "relative flex size-full min-w-0 touch-none flex-col overflow-hidden py-0.5 pr-6 pl-2 text-left text-sm outline-none select-none",
619
+ evSurface,
620
+ onMove && !allDay ? "cursor-grab active:cursor-grabbing" : "cursor-pointer",
621
+ selectedId === id && "bg-selected hover:bg-selected",
622
+ isMoving && "shadow-pop ring-2 ring-primary",
623
+ "focus-visible:ring-2 focus-visible:ring-primary",
624
+ )}
625
+ >
626
+ <Stripe color={p.color} />
627
+ {h < 40 || allDay ? (
628
+ <span className="truncate text-ink"><span className="tabular text-ink-2">{allDay ? "" : `${formatTime(p.start)} `}</span><span className="font-medium">{p.ev.title}</span>{p.ev.detail && <span className="text-ink-2">, {p.ev.detail}</span>}</span>
629
+ ) : (
630
+ <>
631
+ <span className="truncate font-medium text-ink">{p.ev.title}</span>
632
+ {p.ev.detail && h >= 56 && <span className="truncate text-ink">{p.ev.detail}</span>}
633
+ <span className="tabular truncate text-ink-2">{formatTimeRange(p.start, p.end)}{!byResource && p.resource ? ` · ${p.resource}` : ""}</span>
634
+ {p.ev.status && h >= 88 && <Badge variant={statusVariant?.(p.ev.status) ?? "secondary"} className="mt-0.5">{p.ev.status}</Badge>}
635
+ </>
636
+ )}
637
+ {onMove && !allDay && (
638
+ <span
639
+ aria-hidden="true"
640
+ onPointerDown={(e) => onPointerDown(e, p, "resize")}
641
+ className="absolute inset-x-0 bottom-0 h-1.5 cursor-ns-resize"
642
+ />
643
+ )}
644
+ </div>
645
+ {(onMove || onSelectEvent) && (
646
+ <EventMenu
647
+ n={p}
648
+ slotMinutes={slotMinutes}
649
+ resources={resources}
650
+ open={menuFor === id}
651
+ onOpenChange={(o) => setMenuFor(o ? id : null)}
652
+ onSelect={onSelectEvent}
653
+ onMove={onMove}
654
+ trigger={
655
+ <Button
656
+ variant="ghost"
657
+ size="icon-xs"
658
+ tabIndex={-1}
659
+ aria-label={`Actions for ${p.ev.title}`}
660
+ onPointerDown={(e) => e.stopPropagation()}
661
+ className="absolute top-0.5 right-0.5 opacity-0 group-hover/ev:opacity-100 group-focus-within/ev:opacity-100 pointer-coarse:opacity-100 data-[state=open]:opacity-100"
662
+ >
663
+ <MoreVertical className="size-3.5 text-ink-3" />
664
+ </Button>
665
+ }
666
+ />
667
+ )}
668
+ </div>
669
+ )
670
+ }
671
+
672
+ return (
673
+ <div className="max-h-(--sh) overflow-auto" style={{ "--sh": `${height}px` } as React.CSSProperties}>
674
+ <span id={helpId} className="sr-only">
675
+ Arrow keys move between times and events. Enter opens an event{onCreate ? " or adds one at a free time" : ""}.{onMove ? " Space picks an event up to move it; Shift+F10 opens its menu." : ""} Page up and Page down change the period.
676
+ </span>
677
+ <div
678
+ ref={gridRef}
679
+ role="group"
680
+ aria-roledescription="schedule"
681
+ aria-label={label}
682
+ onKeyDown={onKeyDown}
683
+ onBlur={(e) => { if (moving?.via === "key" && !e.currentTarget.contains(e.relatedTarget as Node)) setMoving(null) }}
684
+ className="grid min-w-fit grid-cols-(--cols)"
685
+ style={{ "--cols": gridCols } as React.CSSProperties}
686
+ >
687
+ {/* header */}
688
+ <div className="sticky top-0 z-30 col-span-full grid grid-cols-subgrid border-b border-line bg-surface">
689
+ <div aria-hidden="true" />
690
+ {cols.map((c) => {
691
+ const today = !byResource && sameDay(c.day, now)
692
+ return (
693
+ <div key={c.key} className="relative flex min-w-0 items-center gap-control border-l border-line px-2 py-2 text-sm">
694
+ {byResource ? (
695
+ <>
696
+ <span aria-hidden="true" className="h-3 w-1 shrink-0 rounded-full bg-(--c)" style={{ "--c": c.color } as React.CSSProperties} />
697
+ <span className="truncate font-medium text-ink">{c.title}</span>
698
+ </>
699
+ ) : (
700
+ <>
701
+ <span className="text-ink-2">{c.title}</span>
702
+ <span className={cn("tabular grid h-5 min-w-5 place-items-center rounded-xs px-1 font-medium", today ? "bg-primary text-primary-foreground" : "text-ink")}>{c.num}</span>
703
+ {today && <span className="sr-only">, today</span>}
704
+ </>
705
+ )}
706
+ </div>
707
+ )
708
+ })}
709
+ {hasAllDay && (
710
+ <>
711
+ <div className="border-t border-line px-1 py-1 text-right text-xs text-ink-3">All day</div>
712
+ {cols.map((c, ci) => (
713
+ <div key={c.key} className="grid min-w-0 content-start gap-0.5 border-t border-l border-line p-0.5">
714
+ {allDayBy[ci].map((n) => eventBlock(n, ci, true))}
715
+ </div>
716
+ ))}
717
+ </>
718
+ )}
719
+ </div>
720
+
721
+ {/* gutter */}
722
+ <div aria-hidden="true" className="relative h-(--bh)" style={{ "--bh": `${nSlots * SLOT_PX}px` } as React.CSSProperties}>
723
+ {Array.from({ length: nSlots }, (_, i) => i).filter((i) => (d0 + i * slotMinutes) % 60 === 0).map((i) => (
724
+ <span key={i} className="tabular absolute right-2 top-(--y) text-xs text-ink-3" style={{ "--y": `${i * SLOT_PX + 2}px` } as React.CSSProperties}>{formatTime(atMinutes(new Date(2000, 0, 1), d0 + i * slotMinutes))}</span>
725
+ ))}
726
+ {showNow && (
727
+ <span className="tabular absolute right-1 top-(--y) z-10 -translate-y-1/2 rounded-xs bg-surface px-0.5 text-xs font-medium whitespace-nowrap text-primary-ink" style={{ "--y": `${nowTop}px` } as React.CSSProperties}>
728
+ Now {formatTime(now)}
729
+ </span>
730
+ )}
731
+ </div>
732
+
733
+ {/* columns */}
734
+ {cols.map((c, ci) => (
735
+ <div key={c.key} data-col={ci} className="relative border-l border-line">
736
+ {Array.from({ length: nSlots }, (_, i) => {
737
+ const key = `s:${i}`
738
+ const st = slotTime(c, i)
739
+ const busy = placedBy[ci].filter((p) => minutesOf(p.start) < d0 + (i + 1) * slotMinutes && minutesOf(p.end) > d0 + i * slotMinutes)
740
+ const hour = (d0 + (i + 1) * slotMinutes) % 60 === 0
741
+ return (
742
+ <div
743
+ key={key}
744
+ role="button"
745
+ tabIndex={cursor.col === ci && cursor.key === key ? 0 : -1}
746
+ data-stop={`${ci}|${key}`}
747
+ aria-label={`${formatTime(st)}, ${fDay.format(st)}${byResource ? `, ${c.title}` : ""}, ${busy.length ? `during ${busy.map((b) => b.ev.title).join(" and ")}` : "free"}`}
748
+ aria-describedby={helpId}
749
+ onFocus={() => setCursor({ col: ci, key })}
750
+ onClick={() => { setCursor({ col: ci, key }); create(c, i) }}
751
+ className={cn(
752
+ "h-6 border-b outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset",
753
+ hour ? "border-line" : "border-line border-dashed",
754
+ onCreate ? "cursor-cell hover:bg-hover" : "cursor-default",
755
+ )}
756
+ />
757
+ )
758
+ })}
759
+ {sameDay(c.day, now) && showNow && (
760
+ <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-(--y) z-10 h-0.5 bg-primary" style={{ "--y": `${nowTop - 1}px` } as React.CSSProperties}>
761
+ <span className="absolute -top-1 -left-1 size-2.5 rounded-full bg-primary" />
762
+ </div>
763
+ )}
764
+ <div className="pointer-events-none absolute inset-0 right-1 left-0.5 *:pointer-events-auto">
765
+ {placedBy[ci].map((p) => eventBlock(p, ci))}
766
+ </div>
767
+ </div>
768
+ ))}
769
+ </div>
770
+ </div>
771
+ )
772
+ }
773
+
774
+ /* ---------------------------------------------------------------- month */
775
+
776
+ function MonthGrid({
777
+ inRange, resources, onSelectEvent, onCreate, onMove, selectedId, slotMinutes, statusVariant, setAnnounce,
778
+ date, from, count, workWeek, now, createMinutes, dayStart, onOpenDay, label,
779
+ }: Shared & { date: Date; from: Date; count: number; workWeek: boolean; now: Date; createMinutes: number; dayStart: number; onOpenDay: (d: Date) => void; label: string }) {
780
+ const perRow = workWeek ? 5 : 7
781
+ const allDays = Array.from({ length: count }, (_, i) => addDays(from, i)).filter((d) => !workWeek || (d.getDay() !== 0 && d.getDay() !== 6))
782
+ const weeks = Array.from({ length: allDays.length / perRow }, (_, w) => allDays.slice(w * perRow, w * perRow + perRow))
783
+ const byDay = (d: Date) => inRange.filter((n) => sameDay(n.start, d)).sort((a, b) => Number(!a.ev.allDay) - Number(!b.ev.allDay) || a.start.getTime() - b.start.getTime())
784
+ const nowInMonth = now.getMonth() === date.getMonth() && now.getFullYear() === date.getFullYear()
785
+ const initial = allDays.findIndex((d) => sameDay(d, nowInMonth ? now : new Date(date.getFullYear(), date.getMonth(), 1)))
786
+ const [cursorState, setCursor] = React.useState(Math.max(0, initial))
787
+ const cursor = cursorState < allDays.length ? cursorState : Math.max(0, initial)
788
+ const [openDay, setOpenDay] = React.useState<string | null>(null)
789
+ const gridRef = React.useRef<HTMLDivElement>(null)
790
+ const focusNext = React.useRef(false)
791
+ const drag = React.useRef<{ id: string; x: number; y: number; started: boolean; over?: string } | null>(null)
792
+ const [dropKey, setDropKey] = React.useState<string | null>(null)
793
+ React.useEffect(() => {
794
+ if (!focusNext.current) return
795
+ focusNext.current = false
796
+ gridRef.current?.querySelector<HTMLElement>(`[data-cell="${cursor}"]`)?.focus()
797
+ })
798
+ // keep the cursor inside the month shown
799
+ React.useEffect(() => { setCursor(Math.max(0, initial)) }, [date.getMonth(), date.getFullYear()]) // eslint-disable-line react-hooks/exhaustive-deps
800
+
801
+ const onKeyDown = (e: React.KeyboardEvent) => {
802
+ if (openDay) return
803
+ const to = (i: number) => { e.preventDefault(); if (i >= 0 && i < allDays.length) { focusNext.current = true; setCursor(i) } }
804
+ if (e.key === "ArrowRight") to(cursor + 1)
805
+ else if (e.key === "ArrowLeft") to(cursor - 1)
806
+ else if (e.key === "ArrowDown") to(cursor + perRow)
807
+ else if (e.key === "ArrowUp") to(cursor - perRow)
808
+ else if (e.key === "Home") to(cursor - (cursor % perRow))
809
+ else if (e.key === "End") to(cursor - (cursor % perRow) + perRow - 1)
810
+ else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenDay(dayKey(allDays[cursor])) }
811
+ }
812
+
813
+ const findDrop = (x: number, y: number) => (document.elementsFromPoint(x, y).find((n) => (n as HTMLElement).dataset?.day) as HTMLElement | undefined)?.dataset.day
814
+ const moveToDay = (n: Norm, key: string) => {
815
+ const day = fromKey(key)
816
+ if (sameDay(day, n.start)) return
817
+ const start = atMinutes(day, minutesOf(n.start))
818
+ const to = { start, end: addMin(start, (n.end.getTime() - n.start.getTime()) / 60000), resourceId: n.resourceId }
819
+ setAnnounce(`${n.ev.title} moved to ${fDay.format(start)}.`)
820
+ onMove?.(n.ev, to)
821
+ }
822
+
823
+ const list = (d: Date, items: Norm[]) => (
824
+ <ul className="grid gap-0.5">
825
+ {items.map((n) => (
826
+ <li key={n.ev.id} className="group/ev relative flex items-center">
827
+ <button
828
+ type="button"
829
+ onClick={() => { setOpenDay(null); onSelectEvent?.(n.ev) }}
830
+ aria-label={eventLabel(n)}
831
+ className={cn("relative flex min-w-0 flex-1 items-center gap-2 rounded-xs py-1 pr-7 pl-3 text-left text-sm outline-none hover:bg-hover focus-visible:ring-2 focus-visible:ring-primary", selectedId === n.ev.id && "bg-selected")}
832
+ >
833
+ <Stripe color={n.color} />
834
+ <span className="tabular w-11 shrink-0 text-ink-2">{n.ev.allDay ? "All day" : formatTime(n.start)}</span>
835
+ <span className="grid min-w-0">
836
+ <span className="truncate font-medium text-ink">{n.ev.title}</span>
837
+ <span className="truncate text-ink-2">{[n.ev.detail, n.resource].filter(Boolean).join(" · ")}</span>
838
+ </span>
839
+ {n.ev.status && <Badge variant={statusVariant?.(n.ev.status) ?? "secondary"} className="ml-auto">{n.ev.status}</Badge>}
840
+ </button>
841
+ {onMove && (
842
+ <EventMenu n={n} slotMinutes={slotMinutes} resources={resources} onMove={(ev, to) => { setOpenDay(null); onMove(ev, to) }} trigger={
843
+ <Button variant="ghost" size="icon-xs" aria-label={`Actions for ${n.ev.title}`} className="absolute right-0.5"><MoreVertical className="size-3.5 text-ink-3" /></Button>
844
+ } />
845
+ )}
846
+ </li>
847
+ ))}
848
+ {items.length === 0 && <li className="px-1 py-1 text-sm text-ink-2">Nothing scheduled.</li>}
849
+ </ul>
850
+ )
851
+
852
+ return (
853
+ <div className="overflow-x-auto">
854
+ <div ref={gridRef} role="grid" aria-label={`${label}, ${fMonth.format(date)}`} onKeyDown={onKeyDown} className="grid min-w-fit">
855
+ <div role="row" className="grid grid-cols-(--cols) border-b border-line" style={{ "--cols": `repeat(${perRow}, minmax(6.5rem, 1fr))` } as React.CSSProperties}>
856
+ {weeks[0]?.map((d) => <div key={d.getDay()} role="columnheader" className="px-2 py-2 text-sm text-ink-2">{fWeekday.format(d)}</div>)}
857
+ </div>
858
+ {weeks.map((w, wi) => (
859
+ <div key={wi} role="row" className="grid grid-cols-(--cols) border-b border-line last:border-b-0" style={{ "--cols": `repeat(${perRow}, minmax(6.5rem, 1fr))` } as React.CSSProperties}>
860
+ {w.map((d, di) => {
861
+ const i = wi * perRow + di
862
+ const key = dayKey(d)
863
+ const items = byDay(d)
864
+ const outside = d.getMonth() !== date.getMonth()
865
+ const today = sameDay(d, now)
866
+ const shown = items.length > 3 ? items.slice(0, 2) : items
867
+ const more = items.length - shown.length
868
+ return (
869
+ <Popover key={key} open={openDay === key} onOpenChange={(o) => { setOpenDay(o ? key : null); if (!o) focusNext.current = true }}>
870
+ <PopoverAnchor asChild>
871
+ <div
872
+ role="gridcell"
873
+ tabIndex={cursor === i ? 0 : -1}
874
+ data-cell={i}
875
+ data-day={outside ? undefined : key}
876
+ aria-label={`${fDay.format(d)}${today ? ", today" : ""}, ${items.length ? `${items.length} ${items.length === 1 ? "event" : "events"}` : "nothing scheduled"}`}
877
+ aria-selected={cursor === i}
878
+ onFocus={() => setCursor(i)}
879
+ className={cn(
880
+ "grid min-h-24 min-w-0 content-start gap-0.5 border-l border-line p-1 outline-none first:border-l-0 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset",
881
+ outside && "bg-surface-2",
882
+ dropKey === key && "bg-selected",
883
+ )}
884
+ >
885
+ <span className="flex items-center justify-between">
886
+ <span
887
+ aria-hidden="true"
888
+ onClick={() => !outside && onOpenDay(d)}
889
+ className={cn("tabular grid h-5 min-w-5 cursor-pointer place-items-center rounded-xs px-1 text-sm font-medium hover:underline", today ? "bg-primary text-primary-foreground" : outside ? "text-ink-3" : "text-ink")}
890
+ >
891
+ {d.getDate()}
892
+ </span>
893
+ </span>
894
+ {!outside && shown.map((n) => (
895
+ <span
896
+ key={n.ev.id}
897
+ aria-hidden="true"
898
+ onPointerDown={(e) => { if (e.button !== 0 || !onMove || n.ev.allDay) return; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); drag.current = { id: n.ev.id, x: e.clientX, y: e.clientY, started: false } }}
899
+ onPointerMove={(e) => {
900
+ const dr = drag.current
901
+ if (!dr || dr.id !== n.ev.id) return
902
+ if (!dr.started && Math.hypot(e.clientX - dr.x, e.clientY - dr.y) < 4) return
903
+ dr.started = true
904
+ dr.over = findDrop(e.clientX, e.clientY)
905
+ setDropKey(dr.over ?? null)
906
+ }}
907
+ onPointerUp={() => {
908
+ const dr = drag.current
909
+ drag.current = null
910
+ setDropKey(null)
911
+ if (dr?.started) { if (dr.over) moveToDay(n, dr.over) } else onSelectEvent?.(n.ev)
912
+ }}
913
+ onPointerCancel={() => { drag.current = null; setDropKey(null) }}
914
+ onClick={() => { if (!onMove || n.ev.allDay) onSelectEvent?.(n.ev) }}
915
+ className={cn("relative flex min-w-0 touch-none items-center gap-1 py-px pr-1 pl-2 text-sm select-none", evSurface, onMove && !n.ev.allDay ? "cursor-grab" : "cursor-pointer", selectedId === n.ev.id && "bg-selected")}
916
+ >
917
+ <Stripe color={n.color} />
918
+ {!n.ev.allDay && <span className="tabular shrink-0 text-ink-2">{formatTime(n.start)}</span>}
919
+ <span className="truncate text-ink">{n.ev.title}</span>
920
+ </span>
921
+ ))}
922
+ {!outside && more > 0 && (
923
+ <Button variant="ghost" size="xs" tabIndex={-1} onClick={() => setOpenDay(key)} className="justify-self-start">+{more} more</Button>
924
+ )}
925
+ </div>
926
+ </PopoverAnchor>
927
+ <PopoverContent align="start" className="w-80" onOpenAutoFocus={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).querySelector<HTMLElement>("button")?.focus() }}>
928
+ <div className="grid gap-2">
929
+ <p className="text-base font-medium text-ink">{fDay.format(d)}</p>
930
+ {list(d, items)}
931
+ <div className="flex items-center gap-2 border-t border-line pt-2">
932
+ <Button variant="link" size="xs" onClick={() => { setOpenDay(null); onOpenDay(d) }}>Open day</Button>
933
+ {onCreate && (
934
+ <Button variant="outline" size="xs" className="ml-auto" onClick={() => { setOpenDay(null); const s = atMinutes(d, dayStart * 60 + 60); onCreate({ start: s, end: addMin(s, createMinutes) }) }}>Add at {formatTime(atMinutes(d, dayStart * 60 + 60))}</Button>
935
+ )}
936
+ </div>
937
+ </div>
938
+ </PopoverContent>
939
+ </Popover>
940
+ )
941
+ })}
942
+ </div>
943
+ ))}
944
+ </div>
945
+ <p className="sr-only">Arrow keys move between days. Enter lists the day's events, with their menus to change the time.</p>
946
+ </div>
947
+ )
948
+ }
949
+
950
+ /* ---------------------------------------------------------------- agenda (narrow screens, and the list alternative) */
951
+
952
+ function Agenda({
953
+ inRange, resources, onSelectEvent, onMove, selectedId, slotMinutes, statusVariant, days, now, emptyText, resourceTitle,
954
+ }: Shared & { days: Date[]; now: Date; emptyText: string; resourceTitle: string }) {
955
+ const groups = days.map((d) => ({ d, items: inRange.filter((n) => sameDay(n.start, d)).sort((a, b) => Number(!a.ev.allDay) - Number(!b.ev.allDay) || a.start.getTime() - b.start.getTime()) })).filter((g) => g.items.length)
956
+ if (!groups.length) return <p role="status" className="px-3 py-10 text-center text-base text-ink-2">{emptyText}.</p>
957
+ return (
958
+ <div className="grid">
959
+ {groups.map(({ d, items }) => (
960
+ <section key={dayKey(d)} aria-labelledby={`ag-${dayKey(d)}`} className="border-b border-line last:border-b-0">
961
+ <h3 id={`ag-${dayKey(d)}`} className="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm font-medium text-ink">
962
+ {fDay.format(d)}
963
+ {sameDay(d, now) && <Badge variant="default">Today</Badge>}
964
+ </h3>
965
+ <ul className="grid">
966
+ {items.map((n) => (
967
+ <li key={n.ev.id} className="relative flex items-stretch border-t border-line first:border-t-0">
968
+ <button
969
+ type="button"
970
+ onClick={() => onSelectEvent?.(n.ev)}
971
+ aria-label={eventLabel(n)}
972
+ className={cn("relative grid min-h-9 min-w-0 flex-1 grid-cols-(--ag) items-start gap-x-3 py-2 pr-9 pl-4 text-left text-base outline-none hover:bg-hover focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset", selectedId === n.ev.id && "bg-selected hover:bg-selected")}
973
+ style={{ "--ag": "5.5rem minmax(0,1fr)" } as React.CSSProperties}
974
+ >
975
+ <Stripe color={n.color} />
976
+ <span className="tabular text-sm text-ink-2">{n.ev.allDay ? "All day" : formatTimeRange(n.start, n.end)}</span>
977
+ <span className="grid min-w-0">
978
+ <span className="font-medium text-ink">{n.ev.title}{n.ev.detail && <span className="font-normal">, {n.ev.detail}</span>}</span>
979
+ <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-ink-2">
980
+ {n.resource && <span><span className="sr-only">{resourceTitle}: </span>{n.resource}</span>}
981
+ {n.ev.status && <Badge variant={statusVariant?.(n.ev.status) ?? "secondary"}>{n.ev.status}</Badge>}
982
+ </span>
983
+ </span>
984
+ </button>
985
+ {onMove && (
986
+ <EventMenu n={n} slotMinutes={slotMinutes} resources={resources} onSelect={onSelectEvent} onMove={onMove} trigger={
987
+ <Button variant="ghost" size="icon-sm" aria-label={`Actions for ${n.ev.title}`} className="absolute top-1 right-1"><MoreVertical className="size-4 text-ink-3" /></Button>
988
+ } />
989
+ )}
990
+ </li>
991
+ ))}
992
+ </ul>
993
+ </section>
994
+ ))}
995
+ </div>
996
+ )
997
+ }