@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,463 @@
|
|
|
1
|
+
import { DndContext, PointerSensor, useDraggable, useSensor, useSensors } from "@dnd-kit/core";
|
|
2
|
+
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A timeline with bars, arrows between them, and edges you can drag — from the Kibo UI Gantt (MIT)
|
|
7
|
+
* and changed where it had to be (anchrd/intel#293).
|
|
8
|
+
*
|
|
9
|
+
* Registry code is our code. Four dependencies came with the original and none of them survived
|
|
10
|
+
* contact with this board; the reasoning is below, because "we dropped it" is not a reason anybody
|
|
11
|
+
* can check later.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ **`jotai` is gone, for the same fault the calendar had.** The original keeps `draggingAtom` and
|
|
14
|
+
* `scrollXAtom` at MODULE scope. That is not component state stored elsewhere — it is one scroll
|
|
15
|
+
* position and one drag flag shared by every Gantt the bundle renders, outliving unmount. Two boards
|
|
16
|
+
* side by side would report each other's scroll, and here that is worse than the calendar's shared
|
|
17
|
+
* month was: the original computes a resize from `mouseX - ganttLeft + scrollX`, so a second chart
|
|
18
|
+
* scrolled elsewhere would make the first one commit a date the reader never dragged to. A wrong
|
|
19
|
+
* picture is a bug; a wrong picture that WRITES is a different kind. The drag state below is one
|
|
20
|
+
* `useState` in one component.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ **`lodash.throttle` is gone with it.** It threw the scroll handler, and that handler was the
|
|
23
|
+
* only caller. The original also wraps it in `useCallback(throttle(...), [])` over an empty
|
|
24
|
+
* dependency list while reading `timelineData` inside — so the infinite-scroll extension it exists
|
|
25
|
+
* for reads the first render's timeline for ever. This chart has no infinite scroll to throttle: a
|
|
26
|
+
* board's own dates say how wide it is, which is a bounded window and one that arrows can be drawn
|
|
27
|
+
* across (the whole point of the ticket) without their coordinates moving underneath them.
|
|
28
|
+
*
|
|
29
|
+
* ⚠️ **`@uidotdev/usehooks` is gone.** It supplied `useMouse` for the drag arithmetic — a global
|
|
30
|
+
* pointer position, corrected afterwards by the container's rectangle and the scroll offset. dnd-kit
|
|
31
|
+
* already reports the displacement of the drag itself (`delta.x`), which needs no correcting and is
|
|
32
|
+
* right inside a scrolled container. Reading only `x` is also what `@dnd-kit/modifiers`'
|
|
33
|
+
* `restrictToHorizontalAxis` was in the dependency list for, so that package is not needed either.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ **`date-fns` is gone**, as it went from the calendar. The dates here are days (`BoardTaskDate`
|
|
36
|
+
* carries no time and no zone), so every offset is a day count times a day's width — arithmetic that
|
|
37
|
+
* lives in `board-gantt.ts` where it can be asserted without rendering anything.
|
|
38
|
+
*
|
|
39
|
+
* Two more differences from the original, both about writing rather than drawing:
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ **A drag reports ONCE, when it is let go.** The original moves the bar by writing to component
|
|
42
|
+
* state on every pointer move and calls `onMove` at the end with whatever that state reached. Against
|
|
43
|
+
* a server the risk is the same one #286 found in the date fields: a handler on the wrong event turns
|
|
44
|
+
* one gesture into a write per frame. The preview here is local, `onEdit` fires once, and the day it
|
|
45
|
+
* reports is the day the reader saw — the preview snaps to whole days for exactly that reason.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ **`Card` and `ContextMenu` are not pulled in.** They are registry dependencies of the original
|
|
48
|
+
* for a bar wrapper and a right-click menu this board does not have. Two shadcn primitives added for
|
|
49
|
+
* decoration are two more files to keep in step with the rest.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export interface GanttChartBar {
|
|
53
|
+
// Where the bar starts and how long it is, in whole days from the axis start. Pixels are this
|
|
54
|
+
// times `dayWidth`, and nothing here computes a date.
|
|
55
|
+
from: number;
|
|
56
|
+
days: number;
|
|
57
|
+
// The status colour, as a class. A colour is never a value in this package (`lint:tokens`), so the
|
|
58
|
+
// caller resolves it from the board's own ramp and hands the class in.
|
|
59
|
+
className: string;
|
|
60
|
+
/**
|
|
61
|
+
* A bar rolled up from a subtree rather than a task's own dates.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ It is drawn and never dragged. One gesture on a summary bar would have to write every task
|
|
64
|
+
* under it — several versions, several audit entries, and no way to say which of them was meant.
|
|
65
|
+
*/
|
|
66
|
+
derived: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface GanttChartRow {
|
|
70
|
+
id: string;
|
|
71
|
+
// The left column of this row. The caller draws it: only it knows what a status, a blocked chip or
|
|
72
|
+
// a click on a title means.
|
|
73
|
+
sidebar: ReactNode;
|
|
74
|
+
bar: GanttChartBar | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface GanttChartLink {
|
|
78
|
+
id: string;
|
|
79
|
+
// Already in the timeline's own pixel space — the caller owns the geometry (`board-gantt.ts`), so
|
|
80
|
+
// the shape of an arrow can be asserted without a browser.
|
|
81
|
+
path: string;
|
|
82
|
+
blocking: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type GanttChartEdit = "move" | "start" | "end";
|
|
86
|
+
|
|
87
|
+
interface DragState {
|
|
88
|
+
rowId: string;
|
|
89
|
+
edit: GanttChartEdit;
|
|
90
|
+
days: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function ItemGantt({
|
|
94
|
+
rows,
|
|
95
|
+
links,
|
|
96
|
+
groups,
|
|
97
|
+
ticks,
|
|
98
|
+
days,
|
|
99
|
+
dayWidth,
|
|
100
|
+
rowHeight,
|
|
101
|
+
today,
|
|
102
|
+
labels,
|
|
103
|
+
onEdit,
|
|
104
|
+
footer,
|
|
105
|
+
}: {
|
|
106
|
+
rows: GanttChartRow[];
|
|
107
|
+
links: GanttChartLink[];
|
|
108
|
+
// The two header rows, each tick measured in days so a month keeps its own width.
|
|
109
|
+
groups: { key: string; label: string; days: number }[];
|
|
110
|
+
ticks: { key: string; label: string; days: number }[];
|
|
111
|
+
days: number;
|
|
112
|
+
dayWidth: number;
|
|
113
|
+
rowHeight: number;
|
|
114
|
+
today: number | null;
|
|
115
|
+
labels: {
|
|
116
|
+
today: string;
|
|
117
|
+
move(row: string): string;
|
|
118
|
+
start(row: string): string;
|
|
119
|
+
end(row: string): string;
|
|
120
|
+
};
|
|
121
|
+
onEdit(rowId: string, edit: GanttChartEdit, deltaDays: number): void;
|
|
122
|
+
footer?: ReactNode;
|
|
123
|
+
}) {
|
|
124
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
125
|
+
const [drag, setDrag] = useState<DragState | null>(null);
|
|
126
|
+
const markerId = useId();
|
|
127
|
+
const width = days * dayWidth;
|
|
128
|
+
// ⚠️ A distance before a drag counts, the same four pixels the kanban needs and for the same
|
|
129
|
+
// reason: without it every press on a bar is a drag of zero days, and `ganttWrite` would be asked
|
|
130
|
+
// what a gesture nobody made is worth.
|
|
131
|
+
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
|
132
|
+
|
|
133
|
+
// Opening on today rather than on the first day of the window, which is a month of deliberate
|
|
134
|
+
// air. Once, on mount: a chart that scrolled itself back every time the board changed would take
|
|
135
|
+
// the reader's place away mid-read.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const node = scroller.current;
|
|
138
|
+
if (node === null || today === null) return;
|
|
139
|
+
node.scrollLeft = Math.max(0, today * dayWidth - node.clientWidth / 3);
|
|
140
|
+
}, [today, dayWidth]);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
144
|
+
<DndContext
|
|
145
|
+
sensors={sensors}
|
|
146
|
+
onDragStart={(event) => {
|
|
147
|
+
const [edit, rowId] = splitDragId(String(event.active.id));
|
|
148
|
+
if (edit !== null) setDrag({ rowId, edit, days: 0 });
|
|
149
|
+
}}
|
|
150
|
+
// ⚠️ Only `x`. A timeline moves in one direction, and reading `y` would let a bar drift into
|
|
151
|
+
// a row that means another task.
|
|
152
|
+
onDragMove={(event) =>
|
|
153
|
+
setDrag((current) =>
|
|
154
|
+
current === null ? current : { ...current, days: Math.round(event.delta.x / dayWidth) },
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
onDragCancel={() => setDrag(null)}
|
|
158
|
+
onDragEnd={(event) => {
|
|
159
|
+
const [edit, rowId] = splitDragId(String(event.active.id));
|
|
160
|
+
setDrag(null);
|
|
161
|
+
if (edit === null) return;
|
|
162
|
+
// The day count the READER saw, recomputed from the same rounding the preview used rather
|
|
163
|
+
// than carried out of state — so what is written and what was shown cannot come apart.
|
|
164
|
+
onEdit(rowId, edit, Math.round(event.delta.x / dayWidth));
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<div ref={scroller} className="min-h-0 flex-1 overflow-auto rounded-lg border">
|
|
168
|
+
<div className="w-max min-w-full">
|
|
169
|
+
<div className="sticky top-0 z-20 flex bg-card">
|
|
170
|
+
<div className="sticky left-0 z-10 w-64 shrink-0 border-r border-b bg-card" />
|
|
171
|
+
<div style={{ width }}>
|
|
172
|
+
<div className="flex border-b">
|
|
173
|
+
{groups.map((group) => (
|
|
174
|
+
<div
|
|
175
|
+
key={group.key}
|
|
176
|
+
style={{ width: group.days * dayWidth }}
|
|
177
|
+
className="shrink-0 border-r py-1 text-xs font-medium"
|
|
178
|
+
>
|
|
179
|
+
{/* ⚠️ The label sticks to the left edge of what is VISIBLE, not to the left
|
|
180
|
+
edge of its own cell. A month is up to 31 columns wide, so scrolled into
|
|
181
|
+
the middle of one the reader would see a ruler of day numbers with no
|
|
182
|
+
month named anywhere — and a day number without a month is not a date.
|
|
183
|
+
The offset clears the sidebar, which is sticky over the same coordinates.
|
|
184
|
+
|
|
185
|
+
⚠️ No `overflow-hidden` on the cell around it, and that is not a style
|
|
186
|
+
choice. An element with a clipping overflow becomes the scrollport its
|
|
187
|
+
sticky descendants are measured against, and that box never scrolls — so
|
|
188
|
+
the label would sit at a fixed inset instead of following, which is worse
|
|
189
|
+
than leaving it alone, because the inset then hides it under the sidebar.
|
|
190
|
+
The cell still bounds it: sticky cannot leave its containing block, so the
|
|
191
|
+
label stops at the end of its own month. */}
|
|
192
|
+
<span className="sticky left-[16.5rem] inline-block max-w-full truncate px-2 align-top">
|
|
193
|
+
{group.label}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
<div className="flex border-b">
|
|
199
|
+
{ticks.map((tick) => (
|
|
200
|
+
<div
|
|
201
|
+
key={tick.key}
|
|
202
|
+
style={{ width: tick.days * dayWidth }}
|
|
203
|
+
className="shrink-0 truncate border-r px-1 py-1 text-center text-xs text-muted-foreground"
|
|
204
|
+
>
|
|
205
|
+
{tick.label}
|
|
206
|
+
</div>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div className="flex">
|
|
213
|
+
<div className="sticky left-0 z-10 w-64 shrink-0 border-r bg-card">
|
|
214
|
+
{rows.map((row) => (
|
|
215
|
+
<div
|
|
216
|
+
key={row.id}
|
|
217
|
+
style={{ height: rowHeight }}
|
|
218
|
+
className="flex items-center gap-1 border-b px-2 last:border-b-0"
|
|
219
|
+
>
|
|
220
|
+
{row.sidebar}
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div className="relative" style={{ width, height: rows.length * rowHeight }}>
|
|
226
|
+
{/* The day grid, drawn from the ticks so a month's own width is what is striped. */}
|
|
227
|
+
<div className="absolute inset-0 flex">
|
|
228
|
+
{ticks.map((tick) => (
|
|
229
|
+
<div
|
|
230
|
+
key={tick.key}
|
|
231
|
+
style={{ width: tick.days * dayWidth }}
|
|
232
|
+
className="h-full shrink-0 border-r border-border/60"
|
|
233
|
+
/>
|
|
234
|
+
))}
|
|
235
|
+
</div>
|
|
236
|
+
{rows.map((row, index) => (
|
|
237
|
+
<div
|
|
238
|
+
key={row.id}
|
|
239
|
+
style={{ top: index * rowHeight, height: rowHeight }}
|
|
240
|
+
className="absolute right-0 left-0 border-b border-border/60"
|
|
241
|
+
/>
|
|
242
|
+
))}
|
|
243
|
+
{today === null ? null : (
|
|
244
|
+
<div
|
|
245
|
+
aria-hidden="true"
|
|
246
|
+
style={{ left: today * dayWidth }}
|
|
247
|
+
className="absolute top-0 bottom-0 w-px bg-primary"
|
|
248
|
+
title={labels.today}
|
|
249
|
+
/>
|
|
250
|
+
)}
|
|
251
|
+
|
|
252
|
+
{/* ⚠️ The arrows are decoration for a screen reader and information for everybody
|
|
253
|
+
else. What they SAY — that this task waits for that one — is in the row beside
|
|
254
|
+
them in words, so nothing is only in the picture. */}
|
|
255
|
+
<svg
|
|
256
|
+
aria-hidden="true"
|
|
257
|
+
className={cn(
|
|
258
|
+
"pointer-events-none absolute inset-0 transition-opacity",
|
|
259
|
+
// Stale while a bar is in the air: the bars move and these do not, and a line
|
|
260
|
+
// still touching where a bar WAS is a claim about a schedule that no longer
|
|
261
|
+
// holds. Faded rather than hidden, so the reader can see they will come back.
|
|
262
|
+
drag === null ? "opacity-100" : "opacity-20",
|
|
263
|
+
)}
|
|
264
|
+
width={width}
|
|
265
|
+
height={rows.length * rowHeight}
|
|
266
|
+
>
|
|
267
|
+
<defs>
|
|
268
|
+
<marker
|
|
269
|
+
id={`${markerId}-open`}
|
|
270
|
+
viewBox="0 0 8 8"
|
|
271
|
+
refX="7"
|
|
272
|
+
refY="4"
|
|
273
|
+
markerWidth="6"
|
|
274
|
+
markerHeight="6"
|
|
275
|
+
orient="auto-start-reverse"
|
|
276
|
+
>
|
|
277
|
+
<path d="M0 0 L8 4 L0 8 z" className="fill-destructive" />
|
|
278
|
+
</marker>
|
|
279
|
+
<marker
|
|
280
|
+
id={`${markerId}-settled`}
|
|
281
|
+
viewBox="0 0 8 8"
|
|
282
|
+
refX="7"
|
|
283
|
+
refY="4"
|
|
284
|
+
markerWidth="6"
|
|
285
|
+
markerHeight="6"
|
|
286
|
+
orient="auto-start-reverse"
|
|
287
|
+
>
|
|
288
|
+
<path d="M0 0 L8 4 L0 8 z" className="fill-muted-foreground" />
|
|
289
|
+
</marker>
|
|
290
|
+
</defs>
|
|
291
|
+
{links.map((link) => (
|
|
292
|
+
<path
|
|
293
|
+
key={link.id}
|
|
294
|
+
data-slot="gantt-link"
|
|
295
|
+
data-blocking={link.blocking}
|
|
296
|
+
d={link.path}
|
|
297
|
+
fill="none"
|
|
298
|
+
strokeWidth={link.blocking ? 2 : 1}
|
|
299
|
+
markerEnd={`url(#${markerId}-${link.blocking ? "open" : "settled"})`}
|
|
300
|
+
className={link.blocking ? "stroke-destructive" : "stroke-muted-foreground"}
|
|
301
|
+
/>
|
|
302
|
+
))}
|
|
303
|
+
</svg>
|
|
304
|
+
|
|
305
|
+
{rows.map((row, index) =>
|
|
306
|
+
row.bar === null ? null : (
|
|
307
|
+
<Bar
|
|
308
|
+
key={row.id}
|
|
309
|
+
rowId={row.id}
|
|
310
|
+
bar={row.bar}
|
|
311
|
+
top={index * rowHeight}
|
|
312
|
+
rowHeight={rowHeight}
|
|
313
|
+
dayWidth={dayWidth}
|
|
314
|
+
drag={drag?.rowId === row.id ? drag : null}
|
|
315
|
+
labels={labels}
|
|
316
|
+
/>
|
|
317
|
+
),
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</DndContext>
|
|
324
|
+
{footer}
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// `move:task-1` and back. The edit is part of the draggable's id because one context holds three
|
|
330
|
+
// kinds of grab and the difference between them is what gets written.
|
|
331
|
+
function splitDragId(id: string): [GanttChartEdit | null, string] {
|
|
332
|
+
const at = id.indexOf(":");
|
|
333
|
+
const edit = id.slice(0, at);
|
|
334
|
+
if (edit !== "move" && edit !== "start" && edit !== "end") return [null, id];
|
|
335
|
+
return [edit, id.slice(at + 1)];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function Bar({
|
|
339
|
+
rowId,
|
|
340
|
+
bar,
|
|
341
|
+
top,
|
|
342
|
+
rowHeight,
|
|
343
|
+
dayWidth,
|
|
344
|
+
drag,
|
|
345
|
+
labels,
|
|
346
|
+
}: {
|
|
347
|
+
rowId: string;
|
|
348
|
+
bar: GanttChartBar;
|
|
349
|
+
top: number;
|
|
350
|
+
rowHeight: number;
|
|
351
|
+
dayWidth: number;
|
|
352
|
+
drag: DragState | null;
|
|
353
|
+
labels: { move(row: string): string; start(row: string): string; end(row: string): string };
|
|
354
|
+
}) {
|
|
355
|
+
// ⚠️ The preview clamps at one day, and `ganttWrite` clamps at the same place. A bar that stopped
|
|
356
|
+
// shrinking while the write carried on past it would save a span the reader never saw.
|
|
357
|
+
const shift = drag === null ? 0 : drag.days;
|
|
358
|
+
const from = drag?.edit === "move" || drag?.edit === "start" ? bar.from + shift : bar.from;
|
|
359
|
+
const length =
|
|
360
|
+
drag?.edit === "start"
|
|
361
|
+
? Math.max(1, bar.days - shift)
|
|
362
|
+
: drag?.edit === "end"
|
|
363
|
+
? Math.max(1, bar.days + shift)
|
|
364
|
+
: bar.days;
|
|
365
|
+
// Shrinking from the left cannot push the left edge past the right one.
|
|
366
|
+
const left = drag?.edit === "start" ? Math.min(from, bar.from + bar.days - 1) : from;
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<div
|
|
370
|
+
data-slot="gantt-bar"
|
|
371
|
+
data-row={rowId}
|
|
372
|
+
data-derived={bar.derived}
|
|
373
|
+
style={{
|
|
374
|
+
top: top + 4,
|
|
375
|
+
height: rowHeight - 8,
|
|
376
|
+
left: left * dayWidth + 1,
|
|
377
|
+
width: length * dayWidth - 2,
|
|
378
|
+
}}
|
|
379
|
+
className="absolute"
|
|
380
|
+
>
|
|
381
|
+
<div
|
|
382
|
+
className={cn(
|
|
383
|
+
"size-full rounded-md opacity-90",
|
|
384
|
+
bar.className,
|
|
385
|
+
// A summary bar reads as a bracket over the work rather than as work of its own, and it
|
|
386
|
+
// has no grab handles at all — see `derived` above.
|
|
387
|
+
bar.derived && "h-2 self-center rounded-sm opacity-60",
|
|
388
|
+
)}
|
|
389
|
+
/>
|
|
390
|
+
{bar.derived ? null : (
|
|
391
|
+
<>
|
|
392
|
+
<Grip
|
|
393
|
+
id={`move:${rowId}`}
|
|
394
|
+
edit="move"
|
|
395
|
+
label={labels.move(rowId)}
|
|
396
|
+
className="inset-0 cursor-grab"
|
|
397
|
+
/>
|
|
398
|
+
{/* ⚠️ The two edge grips only exist while there is a bar left between them.
|
|
399
|
+
They are eight pixels wide and hang a little over each end, so on a bar narrower than
|
|
400
|
+
that they overlap each other and cover the whole of it — and since the last one drawn
|
|
401
|
+
wins the pointer, a short bar could then only ever be RESIZED, never moved. At month
|
|
402
|
+
scale a one-day task is four pixels, so this is the ordinary case there rather than a
|
|
403
|
+
corner of one. Below the threshold the bar can still be dragged as a whole, and its
|
|
404
|
+
dates are still typed in the detail panel; above it, both edges are reachable. A
|
|
405
|
+
control that quietly does something other than what it looks like is worse than one
|
|
406
|
+
that is not offered. */}
|
|
407
|
+
{length * dayWidth < 24 ? null : (
|
|
408
|
+
<>
|
|
409
|
+
<Grip
|
|
410
|
+
id={`start:${rowId}`}
|
|
411
|
+
edit="start"
|
|
412
|
+
label={labels.start(rowId)}
|
|
413
|
+
className="top-0 bottom-0 -left-1 w-2 cursor-col-resize rounded-l bg-foreground/0 hover:bg-foreground/20"
|
|
414
|
+
/>
|
|
415
|
+
<Grip
|
|
416
|
+
id={`end:${rowId}`}
|
|
417
|
+
edit="end"
|
|
418
|
+
label={labels.end(rowId)}
|
|
419
|
+
className="top-0 -right-1 bottom-0 w-2 cursor-col-resize rounded-r bg-foreground/0 hover:bg-foreground/20"
|
|
420
|
+
/>
|
|
421
|
+
</>
|
|
422
|
+
)}
|
|
423
|
+
</>
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* One place a bar can be taken hold of.
|
|
431
|
+
*
|
|
432
|
+
* ⚠️ Out of the tab order and hidden from a screen reader on purpose, and this is the one decision
|
|
433
|
+
* here that needs defending rather than explaining. A row already has a tab stop — its title, which
|
|
434
|
+
* opens the detail panel — and the panel is where a date is TYPED, with the whole keyboard contract
|
|
435
|
+
* #286 built for it (one write when the field is left, not one per keystroke). Three more stops per
|
|
436
|
+
* row would put four controls with four names on every task, three of which a keyboard cannot use
|
|
437
|
+
* for anything the second one does not already do. The drag is a pointer shortcut for an edit that
|
|
438
|
+
* has a keyboard route, which is the arrangement the kanban settled on for the same reason.
|
|
439
|
+
*/
|
|
440
|
+
function Grip({
|
|
441
|
+
id,
|
|
442
|
+
edit,
|
|
443
|
+
label,
|
|
444
|
+
className,
|
|
445
|
+
}: {
|
|
446
|
+
id: string;
|
|
447
|
+
edit: GanttChartEdit;
|
|
448
|
+
label: string;
|
|
449
|
+
className: string;
|
|
450
|
+
}) {
|
|
451
|
+
const { listeners, setNodeRef } = useDraggable({ id });
|
|
452
|
+
return (
|
|
453
|
+
<div
|
|
454
|
+
ref={setNodeRef}
|
|
455
|
+
data-slot="gantt-grip"
|
|
456
|
+
data-edit={edit}
|
|
457
|
+
aria-hidden="true"
|
|
458
|
+
title={label}
|
|
459
|
+
{...listeners}
|
|
460
|
+
className={cn("absolute touch-none", className)}
|
|
461
|
+
/>
|
|
462
|
+
);
|
|
463
|
+
}
|