@anchrd/intel-ui 0.18.0 → 0.20.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.
- package/package.json +2 -9
- package/src/app/app-tree/app-tree.tsx +68 -147
- package/src/app/header-search/header-search.tsx +2 -1
- package/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/src/app/settings-dialog/settings-dialog.tsx +10 -32
- package/src/app/user-footer/user-footer.tsx +19 -0
- package/src/archive/archive.tsx +2 -1
- package/src/data/intel-data-provider/intel-data-provider.ts +42 -191
- package/src/data/intel-data-provider/intel-data-provider.types.ts +36 -120
- package/src/document-link/document-link.tsx +1 -1
- package/src/entry-picker/entry-picker.tsx +7 -7
- package/src/flow-runs/flow-runs.tsx +1 -1
- package/src/flows/flows.tsx +3 -2
- package/src/flows/node-icon/node-icon.ts +1 -1
- package/src/flows/node-palette/node-palette.tsx +1 -1
- package/src/flows/node-palette/node-palette.types.ts +1 -1
- package/src/graph-pane/graph-pane.tsx +1 -1
- package/src/i18n/de.json +14 -217
- package/src/i18n/en.json +14 -217
- package/src/i18n/es.json +14 -217
- package/src/import-bundle/import-bundle.tsx +152 -0
- package/src/kind-icon.ts +1 -15
- package/src/main.tsx +7 -10
- package/src/node-editor/node-editor.tsx +1 -1
- package/src/node-graph/graph-notice.tsx +1 -1
- package/src/node-graph/node-graph.ts +1 -1
- package/src/node-graph/node-graph.tsx +1 -1
- package/src/node-table/node-table.tsx +3 -3
- package/src/nodes/nodes.tsx +1 -35
- package/src/resource-menu/resource-menu.tsx +45 -21
- package/src/section-hint/section-hint.tsx +2 -2
- package/src/title-row/title-row.tsx +5 -22
- package/src/tools/tools.tsx +1 -3
- package/vite.config.ts +0 -4
- package/src/agent/agent-avatar/agent-avatar.tsx +0 -34
- package/src/agent/agent-calendar/agent-calendar.tsx +0 -160
- package/src/agent/agent-chat/agent-chat.tsx +0 -118
- package/src/agent/agent-costs/agent-costs.ts +0 -95
- package/src/agent/agent-cron/agent-cron.ts +0 -68
- package/src/agent/agent-definition/agent-definition.ts +0 -96
- package/src/agent/agent-delegation-notice/agent-delegation-notice.ts +0 -48
- package/src/agent/agent-entry-title/agent-entry-title.ts +0 -72
- package/src/agent/agent-log/agent-log.tsx +0 -308
- package/src/agent/agent-models/agent-models.ts +0 -166
- package/src/agent/agent-models/agent-models.types.ts +0 -24
- package/src/agent/agent-profile/agent-profile.tsx +0 -1006
- package/src/agent/agent-state/agent-state.ts +0 -85
- package/src/agent/agent-status-dot/agent-status-dot.ts +0 -73
- package/src/agent/agent.tsx +0 -507
- package/src/board/board-calendar/board-calendar.tsx +0 -89
- package/src/board/board-card/board-card.tsx +0 -106
- package/src/board/board-data/board-data.ts +0 -241
- package/src/board/board-data/board-data.types.ts +0 -63
- package/src/board/board-detail/board-detail.tsx +0 -629
- package/src/board/board-gantt/board-gantt.ts +0 -545
- package/src/board/board-gantt/board-gantt.tsx +0 -286
- package/src/board/board-graph/board-graph.ts +0 -174
- package/src/board/board-graph/board-graph.tsx +0 -168
- package/src/board/board-items/board-items.ts +0 -183
- package/src/board/board-kanban/board-kanban.ts +0 -137
- package/src/board/board-kanban/board-kanban.tsx +0 -257
- package/src/board/board-status/board-status.ts +0 -59
- package/src/board/board-statuses/board-statuses.ts +0 -63
- package/src/board/board-statuses/board-statuses.tsx +0 -228
- package/src/board/board-table/board-table.ts +0 -33
- package/src/board/board-table/board-table.tsx +0 -413
- package/src/board/board-views/board-views.tsx +0 -68
- package/src/board/board-views/board-views.types.ts +0 -29
- package/src/board/board.tsx +0 -251
- package/src/components/ui/item-calendar.tsx +0 -181
- package/src/components/ui/item-gantt.tsx +0 -463
- package/src/components/ui/kanban.tsx +0 -282
- package/src/data/agent-runtime/agent-runtime.ts +0 -131
- package/src/hooks/use-capabilities.ts +0 -22
- package/src/hooks/use-model-catalog.ts +0 -26
- package/src/timezone/timezone-combobox/timezone-combobox.tsx +0 -149
- package/src/timezone/timezone-context.tsx +0 -65
- package/src/timezone/timezone.ts +0 -174
package/src/board/board.tsx
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,181 +0,0 @@
|
|
|
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
|
-
}
|