@anchrd/intel-ui 0.16.0 → 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.
- package/package.json +10 -2
- package/src/agent/agent-profile/agent-profile.tsx +2 -0
- package/src/board/board-calendar/board-calendar.tsx +89 -0
- package/src/board/board-card/board-card.tsx +106 -0
- package/src/board/board-data/board-data.ts +241 -0
- package/src/board/board-data/board-data.types.ts +63 -0
- package/src/board/board-detail/board-detail.tsx +629 -0
- package/src/board/board-gantt/board-gantt.ts +545 -0
- package/src/board/board-gantt/board-gantt.tsx +286 -0
- package/src/board/board-graph/board-graph.ts +174 -0
- package/src/board/board-graph/board-graph.tsx +168 -0
- package/src/board/board-items/board-items.ts +183 -0
- package/src/board/board-kanban/board-kanban.ts +97 -0
- package/src/board/board-kanban/board-kanban.tsx +211 -0
- package/src/board/board-status/board-status.ts +59 -0
- package/src/board/board-statuses/board-statuses.ts +63 -0
- package/src/board/board-statuses/board-statuses.tsx +228 -0
- package/src/board/board-table/board-table.ts +33 -0
- package/src/board/board-table/board-table.tsx +413 -0
- package/src/board/board-views/board-views.tsx +68 -0
- package/src/board/board-views/board-views.types.ts +29 -0
- package/src/board/board.tsx +251 -0
- package/src/components/ui/dropdown-menu.tsx +25 -0
- package/src/components/ui/item-calendar.tsx +181 -0
- package/src/components/ui/item-gantt.tsx +463 -0
- package/src/components/ui/kanban.tsx +245 -0
- package/src/components/ui/switch.tsx +25 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +52 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +31 -0
- package/src/i18n/de.json +88 -1
- package/src/i18n/en.json +88 -1
- package/src/i18n/es.json +88 -1
- package/src/kind-icon.ts +12 -1
- package/src/nodes/nodes.tsx +16 -0
- 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
|
+
}
|