@anchrd/intel-ui 0.16.1 → 0.17.0

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 (33) hide show
  1. package/package.json +10 -2
  2. package/src/board/board-calendar/board-calendar.tsx +89 -0
  3. package/src/board/board-card/board-card.tsx +106 -0
  4. package/src/board/board-data/board-data.ts +241 -0
  5. package/src/board/board-data/board-data.types.ts +63 -0
  6. package/src/board/board-detail/board-detail.tsx +629 -0
  7. package/src/board/board-gantt/board-gantt.ts +545 -0
  8. package/src/board/board-gantt/board-gantt.tsx +286 -0
  9. package/src/board/board-graph/board-graph.ts +174 -0
  10. package/src/board/board-graph/board-graph.tsx +168 -0
  11. package/src/board/board-items/board-items.ts +183 -0
  12. package/src/board/board-kanban/board-kanban.ts +97 -0
  13. package/src/board/board-kanban/board-kanban.tsx +211 -0
  14. package/src/board/board-status/board-status.ts +59 -0
  15. package/src/board/board-statuses/board-statuses.ts +63 -0
  16. package/src/board/board-statuses/board-statuses.tsx +228 -0
  17. package/src/board/board-table/board-table.ts +33 -0
  18. package/src/board/board-table/board-table.tsx +413 -0
  19. package/src/board/board-views/board-views.tsx +68 -0
  20. package/src/board/board-views/board-views.types.ts +29 -0
  21. package/src/board/board.tsx +251 -0
  22. package/src/components/ui/dropdown-menu.tsx +25 -0
  23. package/src/components/ui/item-calendar.tsx +181 -0
  24. package/src/components/ui/item-gantt.tsx +463 -0
  25. package/src/components/ui/kanban.tsx +245 -0
  26. package/src/components/ui/switch.tsx +25 -0
  27. package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
  28. package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
  29. package/src/i18n/de.json +86 -1
  30. package/src/i18n/en.json +86 -1
  31. package/src/i18n/es.json +86 -1
  32. package/src/nodes/nodes.tsx +16 -0
  33. package/src/styles.css +33 -0
@@ -0,0 +1,68 @@
1
+ import { CalendarDays, ChartGantt, KanbanSquare, Share2, Table2 } from "lucide-react";
2
+ import { lazy, Suspense } from "react";
3
+ import { BoardCalendar } from "@/board/board-calendar/board-calendar.tsx";
4
+ import { BoardGantt } from "@/board/board-gantt/board-gantt.tsx";
5
+ import { BoardKanban } from "@/board/board-kanban/board-kanban.tsx";
6
+ import { BoardTable } from "@/board/board-table/board-table.tsx";
7
+ import type { BoardView, BoardViewProps } from "./board-views.types.ts";
8
+
9
+ // Sigma and graphology are the heaviest thing on this screen and only one view needs them, the
10
+ // same arrangement `graph-pane` already has for the relation graph.
11
+ const BoardGraph = lazy(async () => ({
12
+ default: (await import("@/board/board-graph/board-graph.tsx")).BoardGraph,
13
+ }));
14
+
15
+ /**
16
+ * Which views a board has (anchrd/intel#286).
17
+ *
18
+ * ⚠️ A LIST, and the switch above it renders whatever is in the list — there is deliberately no
19
+ * `switch (view)` with one case per view anywhere. Gantt (anchrd/intel#293) proved it: it is one
20
+ * more entry here and nothing else, reading the same `BoardHandle` and the same dated items
21
+ * (`board-items.ts`), so the work it cost was a renderer. Hard-wired cases would have made it a
22
+ * change to the screen, the switch, the URL parser and the tests instead.
23
+ *
24
+ * ⚠️ The order is the order the switch draws. Kanban first because it is what a board is opened
25
+ * for; the graph last because it answers a question one has after looking, not before.
26
+ */
27
+ export const boardViews: readonly BoardView[] = [
28
+ {
29
+ id: "kanban",
30
+ labelKey: "board.view.kanban",
31
+ icon: KanbanSquare,
32
+ render: (props: BoardViewProps) => <BoardKanban {...props} />,
33
+ },
34
+ {
35
+ id: "table",
36
+ labelKey: "board.view.table",
37
+ icon: Table2,
38
+ render: (props: BoardViewProps) => <BoardTable {...props} />,
39
+ },
40
+ {
41
+ id: "calendar",
42
+ labelKey: "board.view.calendar",
43
+ icon: CalendarDays,
44
+ render: (props: BoardViewProps) => <BoardCalendar {...props} />,
45
+ },
46
+ {
47
+ id: "gantt",
48
+ labelKey: "board.view.gantt",
49
+ icon: ChartGantt,
50
+ render: (props: BoardViewProps) => <BoardGantt {...props} />,
51
+ },
52
+ {
53
+ id: "graph",
54
+ labelKey: "board.view.graph",
55
+ icon: Share2,
56
+ render: (props: BoardViewProps) => (
57
+ <Suspense fallback={null}>
58
+ <BoardGraph {...props} />
59
+ </Suspense>
60
+ ),
61
+ },
62
+ ];
63
+
64
+ export function boardViewById(id: string | null): BoardView {
65
+ // The first entry is the fallback rather than a named one: a view removed from the list must not
66
+ // leave a screen pointing at nothing, and an unknown id in a bookmarked URL is the same case.
67
+ return boardViews.find((view) => view.id === id) ?? (boardViews[0] as BoardView);
68
+ }
@@ -0,0 +1,29 @@
1
+ import type { LucideIcon } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
4
+
5
+ /**
6
+ * Everything a view of a board gets, and the whole of it.
7
+ *
8
+ * ⚠️ It carries no loader and no writer of its own — `board` is the one data layer, and a view that
9
+ * reached past it would be a second cache entry for the same board.
10
+ */
11
+ export interface BoardViewProps {
12
+ board: BoardHandle;
13
+ // How a view asks for the detail panel. `null` closes it. There is deliberately no "which task is
14
+ // open" alongside it: no view highlights the selection today, and a prop threaded through four
15
+ // renderers that none of them reads is a prop that will be wrong before it is used.
16
+ select(taskId: string | null): void;
17
+ // Whether the shelf is on screen. One switch above every view rather than one per view: it is a
18
+ // question about the board, not about how the board is drawn.
19
+ showArchived: boolean;
20
+ }
21
+
22
+ export interface BoardView {
23
+ // What stands in the URL and in the switch. A view keeps its id forever; a bookmarked board must
24
+ // not open on a different view because a label was rewritten.
25
+ id: string;
26
+ labelKey: string;
27
+ icon: LucideIcon;
28
+ render(props: BoardViewProps): ReactNode;
29
+ }
@@ -0,0 +1,251 @@
1
+ import type { Node } from "@anchrd/intel-contract";
2
+ import { Plus, SlidersHorizontal } from "lucide-react";
3
+ import { useEffect, useId, useState } from "react";
4
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
5
+ import { useBoard } from "@/board/board-data/board-data.ts";
6
+ import { BoardDetail } from "@/board/board-detail/board-detail.tsx";
7
+ import { BoardStatuses } from "@/board/board-statuses/board-statuses.tsx";
8
+ import { boardViewById, boardViews } from "@/board/board-views/board-views.tsx";
9
+ import { Switch } from "@/components/ui/switch";
10
+ import { useI18n } from "@/i18n/i18n-context.tsx";
11
+
12
+ /**
13
+ * A board's screen (anchrd/intel#286).
14
+ *
15
+ * ⚠️ The views are rendered from `boardViews` — a list, walked. There is no `switch` on a view
16
+ * name here or anywhere else, and that is what made Gantt (anchrd/intel#293) one entry in that list
17
+ * and nothing more: the fifth view cost this file no line. It also means the switch itself is
18
+ * generated, so a new view cannot be added without appearing in it.
19
+ *
20
+ * ⚠️ One data layer above all of them (`useBoard`), one detail panel beside them, one archived
21
+ * switch over them. Every view is a renderer: none loads, none writes, and none has an opinion
22
+ * about what selecting a task means.
23
+ */
24
+ export function BoardPanel({ node }: { node: Node }) {
25
+ const i18n = useI18n();
26
+ const board = useBoard(node.id);
27
+ const [viewId, setViewId] = useState<string>(boardViews[0]?.id ?? "kanban");
28
+ const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
29
+ const [showArchived, setShowArchived] = useState(false);
30
+ const [statusesOpen, setStatusesOpen] = useState(false);
31
+ const [newTask, setNewTask] = useState("");
32
+ const archivedId = useId();
33
+ const tabsId = useId();
34
+ const view = boardViewById(viewId);
35
+ /**
36
+ * ⚠️ The delete report clears itself.
37
+ *
38
+ * `board.remove` keeps its answer until the next delete, so without this the line sat under the
39
+ * toolbar for the rest of the session — permanently shifting the layout and reporting, hours
40
+ * later, on a task nobody remembers. A status message about a completed action has a lifetime;
41
+ * this is it.
42
+ */
43
+ useEffect(() => {
44
+ if (!board.remove.isSuccess) return;
45
+ const timer = setTimeout(() => board.remove.reset(), 8_000);
46
+ return () => clearTimeout(timer);
47
+ }, [board.remove.isSuccess, board.remove]);
48
+ const selected = selectedTaskId === null ? null : (board.byId.get(selectedTaskId) ?? null);
49
+
50
+ if (board.query.isPending) {
51
+ return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
52
+ }
53
+ if (board.query.isError) {
54
+ return (
55
+ <div role="alert" className="grid flex-1 place-items-center p-8 text-center text-sm">
56
+ <div className="space-y-3">
57
+ <p className="text-destructive">{i18n.t("node.loadFailed")}</p>
58
+ <button
59
+ type="button"
60
+ onClick={() => void board.query.refetch()}
61
+ className="rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
62
+ >
63
+ {i18n.t("common.retry")}
64
+ </button>
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ return (
71
+ <>
72
+ <ActionSlot name="title-meta">
73
+ {i18n.t("board.summary", {
74
+ tasks: board.tasks.length,
75
+ statuses: board.statuses.length,
76
+ })}
77
+ </ActionSlot>
78
+ <ActionSlot name="title-actions">
79
+ <button
80
+ type="button"
81
+ onClick={() => setStatusesOpen(true)}
82
+ className="inline-flex h-8 items-center gap-2 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
83
+ >
84
+ <SlidersHorizontal aria-hidden="true" className="size-4" />
85
+ {i18n.t("board.statuses")}
86
+ </button>
87
+ </ActionSlot>
88
+
89
+ {/* The file-specific bar: the switch, the shelf, and the one way to put something new on the
90
+ board. It stands under the title line and above the view, the same order the agent page's
91
+ tabs and the flow screen's switch already use (#19). */}
92
+ <div className="flex flex-wrap items-center gap-3 border-b px-6 py-2">
93
+ {/* ⚠️ The tab roles come with a keyboard contract, and it is kept here rather than only
94
+ announced. A reader is told "tab 2 of 4"; the arrow keys therefore have to move between
95
+ them, exactly one tab may be in the tab order (roving `tabIndex`), and each one has to
96
+ name the panel it controls — a `role="tab"` pointing at no `tabpanel` promises a
97
+ relationship the page does not have. */}
98
+ <div
99
+ role="tablist"
100
+ aria-label={i18n.t("board.views")}
101
+ onKeyDown={(event) => {
102
+ const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
103
+ if (step === 0) return;
104
+ event.preventDefault();
105
+ const at = boardViews.findIndex((entry) => entry.id === view.id);
106
+ const next = boardViews[(at + step + boardViews.length) % boardViews.length];
107
+ if (!next) return;
108
+ setViewId(next.id);
109
+ // The focus has to follow the selection, or the reader is told about a tab they are not
110
+ // standing on.
111
+ document.getElementById(`${tabsId}-${next.id}`)?.focus();
112
+ }}
113
+ className="inline-flex items-center gap-1 rounded-lg bg-muted p-[3px]"
114
+ >
115
+ {boardViews.map((entry) => {
116
+ const Icon = entry.icon;
117
+ const current = entry.id === view.id;
118
+ return (
119
+ <button
120
+ key={entry.id}
121
+ id={`${tabsId}-${entry.id}`}
122
+ type="button"
123
+ role="tab"
124
+ aria-selected={current}
125
+ aria-controls={`${tabsId}-panel`}
126
+ tabIndex={current ? 0 : -1}
127
+ onClick={() => setViewId(entry.id)}
128
+ className={`inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring ${
129
+ current
130
+ ? "bg-background font-medium text-foreground shadow-sm"
131
+ : "text-muted-foreground hover:text-foreground"
132
+ }`}
133
+ >
134
+ <Icon aria-hidden="true" className="size-4" />
135
+ {i18n.t(entry.labelKey)}
136
+ </button>
137
+ );
138
+ })}
139
+ </div>
140
+ <div className="flex items-center gap-2">
141
+ {/* ⚠️ One switch for every view. The shelf is a question about the board, not about
142
+ how it is drawn, and one toggle per view would let the table and the graph disagree
143
+ about what a board contains. */}
144
+ <Switch id={archivedId} checked={showArchived} onCheckedChange={setShowArchived} />
145
+ <label htmlFor={archivedId} className="text-sm text-muted-foreground">
146
+ {i18n.t("board.showArchived")}
147
+ </label>
148
+ </div>
149
+ <form
150
+ className="ml-auto flex items-center gap-2"
151
+ onSubmit={(event) => {
152
+ event.preventDefault();
153
+ const title = newTask.trim();
154
+ if (title === "") return;
155
+ // No status: the server puts a new task in the first column that is not the shelf
156
+ // (#285). Naming one here would be this screen repeating a rule it does not own.
157
+ //
158
+ // ⚠️ The field is cleared on SUCCESS, like the panel's subtask form: emptied first, a
159
+ // refusal would look like a task that was created and then vanished, and would take the
160
+ // typed title with it.
161
+ board.add.mutate(
162
+ {
163
+ title,
164
+ assignee: null,
165
+ labels: [],
166
+ startDate: null,
167
+ dueDate: null,
168
+ parentId: null,
169
+ dependsOn: [],
170
+ description: "",
171
+ references: [],
172
+ afterTaskId: null,
173
+ beforeTaskId: null,
174
+ },
175
+ { onSuccess: () => setNewTask("") },
176
+ );
177
+ }}
178
+ >
179
+ <input
180
+ value={newTask}
181
+ onChange={(event) => setNewTask(event.currentTarget.value)}
182
+ placeholder={i18n.t("board.addTask")}
183
+ aria-label={i18n.t("board.addTask")}
184
+ className="h-8 w-56 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
185
+ />
186
+ <button
187
+ type="submit"
188
+ disabled={board.add.isPending}
189
+ className="inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
190
+ >
191
+ <Plus aria-hidden="true" className="size-4" />
192
+ {i18n.t("common.create")}
193
+ </button>
194
+ </form>
195
+ </div>
196
+
197
+ {/* ⚠️ The bar's own refusal, reported here because nothing else can: a root task is added from
198
+ this form alone, and `parentId: null` is what tells it apart from the detail panel's
199
+ subtask form (which reports its own). Unreported, a refused create was a typed title that
200
+ simply stayed put with no reason given. */}
201
+ {board.add.isError && board.add.variables?.parentId === null ? (
202
+ <p role="alert" className="border-b px-6 py-1.5 text-sm text-destructive">
203
+ {i18n.t("node.operationFailed")}
204
+ </p>
205
+ ) : null}
206
+
207
+ {/* ⚠️ The server's own count, reported by the SCREEN and not by the detail panel: a successful
208
+ delete closes that panel in the same commit, so a line inside it could never be read for
209
+ the task it describes. Here it outlives the delete, and it is cleared the moment anything
210
+ else is deleted because the mutation carries only its latest answer. */}
211
+ {board.remove.isSuccess && board.remove.data ? (
212
+ <p role="status" className="border-b px-6 py-1.5 text-xs text-muted-foreground">
213
+ {i18n.t("board.deleted", { count: board.remove.data.deleted })}
214
+ </p>
215
+ ) : null}
216
+
217
+ <div
218
+ id={`${tabsId}-panel`}
219
+ role="tabpanel"
220
+ aria-label={i18n.t(view.labelKey)}
221
+ className="flex min-h-0 flex-1 flex-col"
222
+ >
223
+ {board.tasks.length === 0 ? (
224
+ <div className="grid flex-1 place-items-center p-8 text-center text-sm text-muted-foreground">
225
+ {i18n.t("board.empty")}
226
+ </div>
227
+ ) : (
228
+ view.render({ board, select: setSelectedTaskId, showArchived })
229
+ )}
230
+ </div>
231
+
232
+ {selected ? (
233
+ <BoardDetail
234
+ // ⚠️ Keyed on the task, so pointing the panel at another card remounts it. The title and
235
+ // the description are drafts held in state; without the key the second task would open
236
+ // holding the first one's unsaved text.
237
+ key={selected.id}
238
+ board={board}
239
+ task={selected}
240
+ select={setSelectedTaskId}
241
+ close={() => setSelectedTaskId(null)}
242
+ />
243
+ ) : null}
244
+ {/* ⚠️ Mounted only while it is open, so its draft of the status list is seeded from
245
+ `board.statuses` every time it opens. Held across a close, the draft would be whatever the
246
+ dialog captured the first time — and since `board_configure` writes the list WHOLE, saving
247
+ it would silently revert a column somebody else added meanwhile over MCP. */}
248
+ {statusesOpen ? <BoardStatuses board={board} onClose={() => setStatusesOpen(false)} /> : null}
249
+ </>
250
+ );
251
+ }
@@ -134,6 +134,30 @@ function DropdownMenuRadioItem({
134
134
  );
135
135
  }
136
136
 
137
+ function DropdownMenuCheckboxItem({
138
+ className,
139
+ children,
140
+ ...props
141
+ }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
142
+ return (
143
+ <DropdownMenuPrimitive.CheckboxItem
144
+ data-slot="dropdown-menu-checkbox-item"
145
+ className={cn(
146
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
147
+ className,
148
+ )}
149
+ {...props}
150
+ >
151
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
152
+ <DropdownMenuPrimitive.ItemIndicator>
153
+ <Check aria-hidden="true" />
154
+ </DropdownMenuPrimitive.ItemIndicator>
155
+ </span>
156
+ {children}
157
+ </DropdownMenuPrimitive.CheckboxItem>
158
+ );
159
+ }
160
+
137
161
  function DropdownMenuSeparator({
138
162
  className,
139
163
  ...props
@@ -149,6 +173,7 @@ function DropdownMenuSeparator({
149
173
 
150
174
  export {
151
175
  DropdownMenu,
176
+ DropdownMenuCheckboxItem,
152
177
  DropdownMenuContent,
153
178
  DropdownMenuItem,
154
179
  DropdownMenuLabel,
@@ -0,0 +1,181 @@
1
+ import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ /**
6
+ * A month grid with items in its days, from the Kibo UI calendar (MIT) and changed where it had to
7
+ * be.
8
+ *
9
+ * ⚠️ **`jotai` is gone, and it had to go.** The registry kept the displayed month and year in two
10
+ * atoms declared at MODULE scope — `atom(new Date().getMonth())`. That is not component state that
11
+ * happens to be stored elsewhere; it is one month shared by every calendar the bundle ever renders,
12
+ * outliving unmount, so two boards open side by side would page each other, and a board closed in
13
+ * March would open again in March in a different tab of the same session. Both of those are bugs,
14
+ * and neither needs a state library to fix: the month is view state of one component and now lives
15
+ * where it is used. Taking the atoms out is also what kept a second store out of Intel — server
16
+ * state is TanStack Query's and nothing else here has ever needed a third.
17
+ *
18
+ * ⚠️ **`date-fns` is gone with it.** The registry used exactly three functions of it —
19
+ * `getDay`, `getDaysInMonth`, `isSameDay` — and each is one line of `Date`. A dependency for that
20
+ * is a dependency to keep updated for that.
21
+ *
22
+ * ⚠️ **The month and year pickers were two comboboxes over `Command` and `Popover`.** They existed
23
+ * so the pickers could reach the atoms from anywhere in the tree, which was the compound API's
24
+ * whole reason. Without the atoms the calendar is one component with props, and paging by month
25
+ * plus "today" is what a due-date grid is actually read with.
26
+ *
27
+ * ⚠️ **A status colour is a token, never a value.** The registry drew its dot from
28
+ * `feature.status.color`, a raw string. Intel has no hard-coded colours (`lint:tokens`), so the
29
+ * caller renders the item itself and takes its colour from the board's ramp.
30
+ */
31
+
32
+ export interface CalendarItem {
33
+ id: string;
34
+ // The day the item is filed under. Local midnight — see `board-items.ts` for why that matters.
35
+ endAt: Date;
36
+ }
37
+
38
+ function daysInMonth(year: number, month: number): number {
39
+ // Day zero of the next month is the last day of this one.
40
+ return new Date(year, month + 1, 0).getDate();
41
+ }
42
+
43
+ // The weekday names in the reader's language, starting on the day their week starts on.
44
+ export function weekdayNames(locale: string, weekStartsOn: number): string[] {
45
+ const format = new Intl.DateTimeFormat(locale, { weekday: "short" });
46
+ // 2024-01-07 was a Sunday, so adding the index lands on each weekday in order.
47
+ return Array.from({ length: 7 }, (_, index) =>
48
+ format.format(new Date(2024, 0, 7 + ((weekStartsOn + index) % 7))),
49
+ );
50
+ }
51
+
52
+ export function ItemCalendar<T extends CalendarItem>({
53
+ items,
54
+ month,
55
+ onMonthChange,
56
+ locale,
57
+ weekStartsOn,
58
+ renderItem,
59
+ labels,
60
+ maxPerDay = 3,
61
+ }: {
62
+ items: T[];
63
+ // The first of the displayed month. A `Date` rather than a pair of numbers, so paging over a year
64
+ // boundary is arithmetic instead of two branches.
65
+ month: Date;
66
+ onMonthChange(month: Date): void;
67
+ locale: string;
68
+ weekStartsOn: number;
69
+ renderItem(item: T): ReactNode;
70
+ labels: { previous: string; next: string; today: string; more(count: number): string };
71
+ maxPerDay?: number;
72
+ }) {
73
+ const year = month.getFullYear();
74
+ const monthIndex = month.getMonth();
75
+ const total = daysInMonth(year, monthIndex);
76
+ const leading = (new Date(year, monthIndex, 1).getDay() - weekStartsOn + 7) % 7;
77
+ const trailing = (7 - ((leading + total) % 7)) % 7;
78
+
79
+ const byDay = new Map<number, T[]>();
80
+ for (const item of items) {
81
+ if (item.endAt.getFullYear() !== year || item.endAt.getMonth() !== monthIndex) continue;
82
+ const day = item.endAt.getDate();
83
+ const existing = byDay.get(day);
84
+ if (existing) existing.push(item);
85
+ else byDay.set(day, [item]);
86
+ }
87
+
88
+ const today = new Date();
89
+ const cells: ReactNode[] = [];
90
+ for (let index = 0; index < leading; index += 1) {
91
+ // Outside the month and deliberately empty rather than filled with the previous month's
92
+ // numbers: those days carry no items here, and a number that looks like a day one can drop on
93
+ // is a promise the grid does not keep.
94
+ cells.push(<div key={`lead-${index}`} aria-hidden="true" className="bg-muted/30" />);
95
+ }
96
+ for (let day = 1; day <= total; day += 1) {
97
+ const forDay = byDay.get(day) ?? [];
98
+ const isToday =
99
+ today.getFullYear() === year && today.getMonth() === monthIndex && today.getDate() === day;
100
+ cells.push(
101
+ <div key={`day-${day}`} className="flex min-h-24 flex-col gap-1 p-1.5">
102
+ <span
103
+ className={cn(
104
+ "self-end text-xs tabular-nums",
105
+ isToday
106
+ ? "grid size-5 place-items-center rounded-full bg-primary font-semibold text-primary-foreground"
107
+ : "text-muted-foreground",
108
+ )}
109
+ >
110
+ {day}
111
+ </span>
112
+ <ul className="flex min-w-0 flex-col gap-1">
113
+ {forDay.slice(0, maxPerDay).map((item) => (
114
+ <li key={item.id} className="min-w-0">
115
+ {renderItem(item)}
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ {forDay.length > maxPerDay ? (
120
+ <span className="text-xs text-muted-foreground">
121
+ {labels.more(forDay.length - maxPerDay)}
122
+ </span>
123
+ ) : null}
124
+ </div>,
125
+ );
126
+ }
127
+ for (let index = 0; index < trailing; index += 1) {
128
+ cells.push(<div key={`trail-${index}`} aria-hidden="true" className="bg-muted/30" />);
129
+ }
130
+
131
+ const page = (delta: number) => onMonthChange(new Date(year, monthIndex + delta, 1));
132
+
133
+ return (
134
+ <div className="flex min-h-0 flex-1 flex-col">
135
+ <div className="flex items-center gap-2 px-1 pb-3">
136
+ <h3 className="text-sm font-semibold">
137
+ {new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)}
138
+ </h3>
139
+ <div className="ml-auto flex items-center gap-1">
140
+ <button
141
+ type="button"
142
+ aria-label={labels.previous}
143
+ onClick={() => page(-1)}
144
+ className="grid size-8 place-items-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
145
+ >
146
+ <ChevronLeftIcon aria-hidden="true" className="size-4" />
147
+ </button>
148
+ <button
149
+ type="button"
150
+ onClick={() => onMonthChange(new Date(today.getFullYear(), today.getMonth(), 1))}
151
+ className="h-8 rounded-md border px-3 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
152
+ >
153
+ {labels.today}
154
+ </button>
155
+ <button
156
+ type="button"
157
+ aria-label={labels.next}
158
+ onClick={() => page(1)}
159
+ className="grid size-8 place-items-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
160
+ >
161
+ <ChevronRightIcon aria-hidden="true" className="size-4" />
162
+ </button>
163
+ </div>
164
+ </div>
165
+ <div className="grid grid-cols-7 border-b">
166
+ {/* Keyed by position, not by the name: `short` weekday names are distinct in the three
167
+ catalogs that ship, but this is a generic component and a customer locale where two of
168
+ them coincide would hand React duplicate keys. */}
169
+ {weekdayNames(locale, weekStartsOn).map((day, index) => (
170
+ // biome-ignore lint/suspicious/noArrayIndexKey: the position IS the identity of a weekday
171
+ <div key={index} className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
172
+ {day}
173
+ </div>
174
+ ))}
175
+ </div>
176
+ <div className="grid min-h-0 flex-1 auto-rows-fr grid-cols-7 overflow-y-auto rounded-b-lg border-x border-b [&>*]:border-t [&>*]:border-r [&>*:nth-child(7n)]:border-r-0">
177
+ {cells}
178
+ </div>
179
+ </div>
180
+ );
181
+ }