@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.
- package/LICENSE.md +61 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE.md +64 -0
- package/README.md +211 -2
- package/SHADCN-DIFF.md +228 -0
- package/agent/AGENTS.md +358 -0
- package/agent/docs-md/accessibility.md +49 -0
- package/agent/docs-md/action-catalogue.md +81 -0
- package/agent/docs-md/actions.md +74 -0
- package/agent/docs-md/agent-activity-log.md +70 -0
- package/agent/docs-md/agent-permissions.md +82 -0
- package/agent/docs-md/agent-run.md +90 -0
- package/agent/docs-md/agents-on-behalf.md +74 -0
- package/agent/docs-md/ai-column.md +85 -0
- package/agent/docs-md/ai-origin-label.md +70 -0
- package/agent/docs-md/announcing-features.md +73 -0
- package/agent/docs-md/answer-actions.md +81 -0
- package/agent/docs-md/app-shell.md +114 -0
- package/agent/docs-md/artifact-card.md +89 -0
- package/agent/docs-md/ask-on-screen.md +72 -0
- package/agent/docs-md/assistant-page.md +100 -0
- package/agent/docs-md/assistant-panel.md +142 -0
- package/agent/docs-md/avatar.md +82 -0
- package/agent/docs-md/banner.md +106 -0
- package/agent/docs-md/board.md +163 -0
- package/agent/docs-md/breadcrumb.md +89 -0
- package/agent/docs-md/breakdown-table.md +97 -0
- package/agent/docs-md/bulk-action-bar.md +112 -0
- package/agent/docs-md/button.md +293 -0
- package/agent/docs-md/card.md +115 -0
- package/agent/docs-md/change-list.md +99 -0
- package/agent/docs-md/change-request.md +166 -0
- package/agent/docs-md/chart-area.md +87 -0
- package/agent/docs-md/chart-bar.md +100 -0
- package/agent/docs-md/chart-box-plot.md +88 -0
- package/agent/docs-md/chart-bullet.md +89 -0
- package/agent/docs-md/chart-candlestick.md +112 -0
- package/agent/docs-md/chart-card-line.md +120 -0
- package/agent/docs-md/chart-donut.md +90 -0
- package/agent/docs-md/chart-drill-down.md +72 -0
- package/agent/docs-md/chart-funnel.md +71 -0
- package/agent/docs-md/chart-heatmap.md +76 -0
- package/agent/docs-md/chart-histogram.md +81 -0
- package/agent/docs-md/chart-map.md +165 -0
- package/agent/docs-md/chart-mixed.md +88 -0
- package/agent/docs-md/chart-price.md +92 -0
- package/agent/docs-md/chart-radial-bars.md +109 -0
- package/agent/docs-md/chart-radial-lines.md +111 -0
- package/agent/docs-md/chart-sankey.md +104 -0
- package/agent/docs-md/chart-scatter.md +87 -0
- package/agent/docs-md/chart-share-bar.md +89 -0
- package/agent/docs-md/chart-treemap.md +98 -0
- package/agent/docs-md/chart-waterfall.md +93 -0
- package/agent/docs-md/checkbox.md +131 -0
- package/agent/docs-md/choosing-selection-control.md +71 -0
- package/agent/docs-md/citations-sources.md +79 -0
- package/agent/docs-md/citing-sources.md +63 -0
- package/agent/docs-md/clarify-before-acting.md +69 -0
- package/agent/docs-md/clarifying-question.md +92 -0
- package/agent/docs-md/code-block.md +174 -0
- package/agent/docs-md/colours.md +87 -0
- package/agent/docs-md/columns-menu.md +140 -0
- package/agent/docs-md/combobox.md +161 -0
- package/agent/docs-md/command-palette.md +126 -0
- package/agent/docs-md/confidence-note.md +87 -0
- package/agent/docs-md/configurable-dashboard.md +76 -0
- package/agent/docs-md/confirmation-dialog.md +93 -0
- package/agent/docs-md/connected-tools.md +80 -0
- package/agent/docs-md/connector-picker.md +127 -0
- package/agent/docs-md/consistency.md +53 -0
- package/agent/docs-md/content-density.md +61 -0
- package/agent/docs-md/conversation-history.md +78 -0
- package/agent/docs-md/create-record.md +80 -0
- package/agent/docs-md/csv-import.md +88 -0
- package/agent/docs-md/dashboard-items.md +78 -0
- package/agent/docs-md/data-export.md +61 -0
- package/agent/docs-md/data-grid.md +137 -0
- package/agent/docs-md/data-vis-colours.md +76 -0
- package/agent/docs-md/data-visualisation.md +76 -0
- package/agent/docs-md/date-picker.md +178 -0
- package/agent/docs-md/delete-record.md +75 -0
- package/agent/docs-md/dense-table.md +495 -0
- package/agent/docs-md/design-tokens.md +53 -0
- package/agent/docs-md/disabled-read-only.md +75 -0
- package/agent/docs-md/drag-and-drop.md +74 -0
- package/agent/docs-md/dropdown-menu.md +137 -0
- package/agent/docs-md/edit-record.md +73 -0
- package/agent/docs-md/empty-loading-error.md +78 -0
- package/agent/docs-md/empty-state.md +128 -0
- package/agent/docs-md/error-messages.md +75 -0
- package/agent/docs-md/error-state.md +123 -0
- package/agent/docs-md/export-menu.md +86 -0
- package/agent/docs-md/feedback-prompt.md +88 -0
- package/agent/docs-md/field-mapping.md +78 -0
- package/agent/docs-md/file-drop.md +110 -0
- package/agent/docs-md/filter-bar.md +138 -0
- package/agent/docs-md/filter-panel.md +103 -0
- package/agent/docs-md/filtering-search.md +79 -0
- package/agent/docs-md/follow-ups-starters.md +79 -0
- package/agent/docs-md/form-validation.md +95 -0
- package/agent/docs-md/generated-columns.md +65 -0
- package/agent/docs-md/generated-drafts-files.md +71 -0
- package/agent/docs-md/grouped-list.md +89 -0
- package/agent/docs-md/guide-cue.md +170 -0
- package/agent/docs-md/help-panel.md +103 -0
- package/agent/docs-md/help-system.md +74 -0
- package/agent/docs-md/iconography.md +49 -0
- package/agent/docs-md/inbox.md +108 -0
- package/agent/docs-md/inline-editing-validation.md +80 -0
- package/agent/docs-md/inline-input-in-answer.md +70 -0
- package/agent/docs-md/items-palette.md +116 -0
- package/agent/docs-md/keyboard-model.md +47 -0
- package/agent/docs-md/keyboard-navigation.md +68 -0
- package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
- package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
- package/agent/docs-md/labelling-ai-output.md +66 -0
- package/agent/docs-md/layout.md +47 -0
- package/agent/docs-md/list-table.md +254 -0
- package/agent/docs-md/loading-skeleton.md +91 -0
- package/agent/docs-md/managing-agents.md +71 -0
- package/agent/docs-md/message-composer.md +105 -0
- package/agent/docs-md/migrating.md +49 -0
- package/agent/docs-md/motion.md +93 -0
- package/agent/docs-md/navigation.md +76 -0
- package/agent/docs-md/notification-channels.md +65 -0
- package/agent/docs-md/notifications-decision.md +100 -0
- package/agent/docs-md/onboarding-step.md +114 -0
- package/agent/docs-md/order-book.md +108 -0
- package/agent/docs-md/page-header.md +96 -0
- package/agent/docs-md/pagination.md +88 -0
- package/agent/docs-md/pane-layout.md +105 -0
- package/agent/docs-md/period-comparison.md +66 -0
- package/agent/docs-md/plan-card.md +120 -0
- package/agent/docs-md/popover-rules.md +88 -0
- package/agent/docs-md/prompt-editor.md +90 -0
- package/agent/docs-md/propose-apply.md +65 -0
- package/agent/docs-md/provider-logo.md +97 -0
- package/agent/docs-md/radio.md +83 -0
- package/agent/docs-md/ranked-list.md +102 -0
- package/agent/docs-md/reasoning-progress.md +72 -0
- package/agent/docs-md/record-detail.md +71 -0
- package/agent/docs-md/regenerating-answers.md +70 -0
- package/agent/docs-md/run-status-bar.md +94 -0
- package/agent/docs-md/saved-tasks-schedules.md +63 -0
- package/agent/docs-md/saved-views.md +149 -0
- package/agent/docs-md/saving-views.md +68 -0
- package/agent/docs-md/scheduler.md +258 -0
- package/agent/docs-md/secondary-panels.md +72 -0
- package/agent/docs-md/segmented-control.md +88 -0
- package/agent/docs-md/selection-bulk-actions.md +70 -0
- package/agent/docs-md/separator.md +75 -0
- package/agent/docs-md/setup-checklist.md +92 -0
- package/agent/docs-md/shape-elevation.md +55 -0
- package/agent/docs-md/sheet-grid.md +211 -0
- package/agent/docs-md/side-panel.md +134 -0
- package/agent/docs-md/sign-in.md +114 -0
- package/agent/docs-md/spacing.md +55 -0
- package/agent/docs-md/split-button.md +100 -0
- package/agent/docs-md/stat-compare.md +82 -0
- package/agent/docs-md/static-dashboard.md +75 -0
- package/agent/docs-md/status-badge.md +140 -0
- package/agent/docs-md/step-card.md +75 -0
- package/agent/docs-md/stepper.md +92 -0
- package/agent/docs-md/suggested-changes.md +91 -0
- package/agent/docs-md/switch.md +78 -0
- package/agent/docs-md/table-settings.md +81 -0
- package/agent/docs-md/tabs.md +85 -0
- package/agent/docs-md/tag-input.md +108 -0
- package/agent/docs-md/tag.md +79 -0
- package/agent/docs-md/task-builder.md +87 -0
- package/agent/docs-md/team-instructions.md +72 -0
- package/agent/docs-md/templates-gallery.md +85 -0
- package/agent/docs-md/text-input.md +160 -0
- package/agent/docs-md/thinking-steps.md +89 -0
- package/agent/docs-md/timeline.md +189 -0
- package/agent/docs-md/timestamp.md +84 -0
- package/agent/docs-md/timestamps.md +70 -0
- package/agent/docs-md/toast.md +117 -0
- package/agent/docs-md/tooltip.md +90 -0
- package/agent/docs-md/typography.md +62 -0
- package/agent/docs-md/undo-destructive.md +66 -0
- package/agent/docs-md/unsaved-changes.md +71 -0
- package/agent/docs-md/usage-meter.md +80 -0
- package/agent/docs-md/user-feedback.md +71 -0
- package/agent/docs-md/voice-and-tone.md +60 -0
- package/agent/docs-md/why-choice.md +93 -0
- package/agent/docs-md/workspace-onboarding.md +74 -0
- package/agent/docs-md/writing.md +48 -0
- package/agent/llms-full.txt +18369 -0
- package/agent/llms.txt +198 -0
- package/agent/skills/twoux-audit/SKILL.md +49 -0
- package/agent/skills/twoux-build/SKILL.md +39 -0
- package/agent/skills/twoux-design/SKILL.md +54 -0
- package/agent/skills/twoux-write/SKILL.md +32 -0
- package/bin/check.mjs +123 -0
- package/bin/docs.mjs +24 -0
- package/bin/guard.mjs +27 -0
- package/bin/init.mjs +113 -0
- package/bin/knowledge.mjs +87 -0
- package/bin/mcp.mjs +116 -0
- package/bin/theme.mjs +168 -0
- package/bin/twoux.mjs +25 -0
- package/package.json +80 -3
- package/registry/jobs.json +1028 -0
- package/registry/palette.json +770 -0
- package/registry/rules.json +913 -0
- package/src/components/agent-review.tsx +364 -0
- package/src/components/ai-basics.tsx +212 -0
- package/src/components/ai-work.tsx +304 -0
- package/src/components/ai.tsx +227 -0
- package/src/components/alert-dialog.tsx +203 -0
- package/src/components/alert.tsx +86 -0
- package/src/components/avatar.tsx +114 -0
- package/src/components/badge.tsx +59 -0
- package/src/components/board.tsx +397 -0
- package/src/components/breadcrumb.tsx +114 -0
- package/src/components/button-group.tsx +90 -0
- package/src/components/button.tsx +109 -0
- package/src/components/calendar.tsx +247 -0
- package/src/components/card.tsx +102 -0
- package/src/components/chart-map.tsx +336 -0
- package/src/components/charts-more.tsx +1331 -0
- package/src/components/charts-radial.tsx +328 -0
- package/src/components/charts.tsx +390 -0
- package/src/components/checkbox.tsx +40 -0
- package/src/components/code.tsx +139 -0
- package/src/components/collapsible.tsx +37 -0
- package/src/components/combobox.tsx +339 -0
- package/src/components/command-extras.tsx +11 -0
- package/src/components/command.tsx +196 -0
- package/src/components/data-grid.tsx +213 -0
- package/src/components/data-table.tsx +334 -0
- package/src/components/data-views.tsx +740 -0
- package/src/components/date-picker.tsx +686 -0
- package/src/components/dense-table.tsx +207 -0
- package/src/components/dialog.tsx +162 -0
- package/src/components/dropdown-menu.tsx +264 -0
- package/src/components/empty.tsx +114 -0
- package/src/components/feedback-prompt.tsx +80 -0
- package/src/components/field.tsx +254 -0
- package/src/components/fields.tsx +112 -0
- package/src/components/flow.tsx +840 -0
- package/src/components/guide-cue.tsx +273 -0
- package/src/components/inbox.tsx +113 -0
- package/src/components/input-group.tsx +176 -0
- package/src/components/input.tsx +26 -0
- package/src/components/kbd.tsx +34 -0
- package/src/components/label.tsx +27 -0
- package/src/components/labels.tsx +176 -0
- package/src/components/motion.tsx +136 -0
- package/src/components/onboarding.tsx +454 -0
- package/src/components/order-book.tsx +185 -0
- package/src/components/pane-layout.tsx +417 -0
- package/src/components/popover.tsx +96 -0
- package/src/components/provider-logos.data.ts +49 -0
- package/src/components/radio-group.tsx +52 -0
- package/src/components/scheduler.tsx +997 -0
- package/src/components/segmented-control.tsx +28 -0
- package/src/components/select.tsx +195 -0
- package/src/components/separator.tsx +31 -0
- package/src/components/sheet.tsx +148 -0
- package/src/components/shell.tsx +426 -0
- package/src/components/shortcut-sheet.tsx +83 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sonner.tsx +107 -0
- package/src/components/spinner.tsx +13 -0
- package/src/components/split-button.tsx +58 -0
- package/src/components/stepper.tsx +78 -0
- package/src/components/switch.tsx +43 -0
- package/src/components/table.tsx +126 -0
- package/src/components/tabs.tsx +118 -0
- package/src/components/tag-input.tsx +253 -0
- package/src/components/textarea.tsx +21 -0
- package/src/components/theme.tsx +80 -0
- package/src/components/timeline.tsx +408 -0
- package/src/components/toggle-group.tsx +87 -0
- package/src/components/toggle.tsx +54 -0
- package/src/components/tooltip.tsx +65 -0
- package/src/components/workspace-actions.tsx +294 -0
- package/src/components/workspace-import.tsx +465 -0
- package/src/components/workspace.tsx +1884 -0
- package/src/index.ts +80 -0
- package/src/lib/active-indicator.tsx +53 -0
- package/src/lib/chart-kit.tsx +72 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/shortcuts.tsx +61 -0
- package/src/lib/workspace.tsx +10 -0
- package/src/shadcn/alert-dialog.tsx +196 -0
- package/src/shadcn/alert.tsx +65 -0
- package/src/shadcn/avatar.tsx +108 -0
- package/src/shadcn/badge.tsx +47 -0
- package/src/shadcn/breadcrumb.tsx +108 -0
- package/src/shadcn/button-group.tsx +83 -0
- package/src/shadcn/button.tsx +63 -0
- package/src/shadcn/calendar.tsx +220 -0
- package/src/shadcn/card.tsx +91 -0
- package/src/shadcn/checkbox.tsx +31 -0
- package/src/shadcn/collapsible.tsx +33 -0
- package/src/shadcn/command.tsx +184 -0
- package/src/shadcn/dialog.tsx +158 -0
- package/src/shadcn/dropdown-menu.tsx +256 -0
- package/src/shadcn/empty.tsx +103 -0
- package/src/shadcn/field.tsx +248 -0
- package/src/shadcn/input-group.tsx +170 -0
- package/src/shadcn/input.tsx +20 -0
- package/src/shadcn/kbd.tsx +28 -0
- package/src/shadcn/label.tsx +23 -0
- package/src/shadcn/popover.tsx +88 -0
- package/src/shadcn/radio-group.tsx +44 -0
- package/src/shadcn/select.tsx +189 -0
- package/src/shadcn/separator.tsx +27 -0
- package/src/shadcn/sheet.tsx +142 -0
- package/src/shadcn/skeleton.tsx +13 -0
- package/src/shadcn/sonner.tsx +40 -0
- package/src/shadcn/switch.tsx +34 -0
- package/src/shadcn/table.tsx +115 -0
- package/src/shadcn/tabs.tsx +90 -0
- package/src/shadcn/textarea.tsx +17 -0
- package/src/shadcn/toggle-group.tsx +83 -0
- package/src/shadcn/toggle.tsx +46 -0
- package/src/shadcn/tooltip.tsx +56 -0
- package/src/styles/palette.css +127 -0
- package/src/styles/theme.css +35 -0
- package/src/styles/twoux.css +236 -0
- 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
|
+
}
|