@dxos/react-ui-task 0.11.0 → 0.12.0-next-2800d0376abcc62d5544afdd744b16b2b960434e

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 (27) hide show
  1. package/dist/lib/index.mjs +627 -96
  2. package/dist/lib/index.mjs.map +1 -1
  3. package/dist/lib/translations.mjs +17 -0
  4. package/dist/lib/translations.mjs.map +1 -0
  5. package/dist/types/src/components/TaskList/TaskList.d.ts +41 -12
  6. package/dist/types/src/components/TaskList/TaskList.d.ts.map +1 -1
  7. package/dist/types/src/components/TaskList/TaskList.stories.d.ts +31 -3
  8. package/dist/types/src/components/TaskList/TaskList.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/TaskList/dnd.d.ts +27 -0
  10. package/dist/types/src/components/TaskList/dnd.d.ts.map +1 -0
  11. package/dist/types/src/components/TaskList/hierarchy.d.ts +73 -0
  12. package/dist/types/src/components/TaskList/hierarchy.d.ts.map +1 -0
  13. package/dist/types/src/components/TaskList/hierarchy.test.d.ts +2 -0
  14. package/dist/types/src/components/TaskList/hierarchy.test.d.ts.map +1 -0
  15. package/dist/types/src/components/TaskList/index.d.ts +1 -0
  16. package/dist/types/src/components/TaskList/index.d.ts.map +1 -1
  17. package/dist/types/src/translations.d.ts +17 -0
  18. package/dist/types/src/translations.d.ts.map +1 -0
  19. package/dist/types/tsconfig.tsbuildinfo +1 -1
  20. package/package.json +26 -14
  21. package/src/components/TaskList/TaskList.stories.tsx +258 -14
  22. package/src/components/TaskList/TaskList.tsx +743 -100
  23. package/src/components/TaskList/dnd.ts +46 -0
  24. package/src/components/TaskList/hierarchy.test.ts +155 -0
  25. package/src/components/TaskList/hierarchy.ts +191 -0
  26. package/src/components/TaskList/index.ts +1 -0
  27. package/src/translations.ts +25 -0
@@ -1,60 +1,262 @@
1
+ import { TaskSet } from "@dxos/types";
2
+ import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
3
+ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
4
+ import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
5
+ import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
6
+ import { useComposedRefs } from "@radix-ui/react-compose-refs";
1
7
  import { createContext } from "@radix-ui/react-context";
2
- import { Fragment, useCallback, useMemo, useState } from "react";
8
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
9
  import { useObject } from "@dxos/echo-react";
4
- import { Icon, IconButton, Input, Tag, composable, composableProps } from "@dxos/react-ui";
5
- import { Listbox } from "@dxos/react-ui-list";
10
+ import { Icon, IconButton, Input, Tag, composable, composableProps, useTranslation } from "@dxos/react-ui";
11
+ import { Listbox, TreeDropIndicator, TreeItemToggle, paddingIndentation, useListDisclosure } from "@dxos/react-ui-list";
12
+ import { MarkdownView } from "@dxos/react-ui-markdown";
6
13
  import { mx } from "@dxos/ui-theme";
14
+ import { translationKey } from "#translations";
7
15
  import { jsx, jsxs } from "react/jsx-runtime";
16
+ //#region src/components/TaskList/hierarchy.ts
17
+ /**
18
+ * Walks the set's tree in sibling order, omitting the descendants of any collapsed task.
19
+ * Cycle-safe: a malformed `parentTask` loop is visited once and then skipped, so a corrupt set
20
+ * renders short rather than hanging the list.
21
+ */
22
+ var walkTaskTree = (tasks, collapsed) => {
23
+ const rows = [];
24
+ const seen = /* @__PURE__ */ new Set();
25
+ const visit = (siblings, level, ancestors) => {
26
+ siblings.forEach((task, index) => {
27
+ if (seen.has(task.id)) return;
28
+ seen.add(task.id);
29
+ const children = TaskSet.subTasks(tasks, task);
30
+ rows.push({
31
+ task,
32
+ level,
33
+ branch: children.length > 0,
34
+ position: index + 1,
35
+ setSize: siblings.length,
36
+ ancestors
37
+ });
38
+ if (children.length > 0 && !collapsed?.has(task.id)) visit(children, level + 1, [...ancestors, task.id]);
39
+ });
40
+ };
41
+ visit(TaskSet.rootTasks(tasks), 1, []);
42
+ return rows;
43
+ };
44
+ /** Every task transitively under `task`, including `task` itself. Cycle-safe. */
45
+ var subtreeIds = (tasks, task) => {
46
+ const ids = /* @__PURE__ */ new Set();
47
+ const visit = (current) => {
48
+ if (ids.has(current.id)) return;
49
+ ids.add(current.id);
50
+ for (const child of TaskSet.subTasks(tasks, current)) visit(child);
51
+ };
52
+ visit(task);
53
+ return ids;
54
+ };
55
+ /**
56
+ * Resolves a drop into a single `MoveTask` call, or `undefined` when the gesture is rejected.
57
+ *
58
+ * Rejected rather than silently ignored, so the caller can say no with the cursor:
59
+ * - onto itself, or into its own subtree (the cycle `UpdateTask` rejects);
60
+ * - onto a task from another set — a membership transfer, not a reorder.
61
+ */
62
+ var resolveTaskPlacement = ({ tasks, source, target, intent }) => {
63
+ const present = new Set(tasks.map((task) => task.id));
64
+ if (!present.has(source.id) || !present.has(target.id)) return;
65
+ if (subtreeIds(tasks, source).has(target.id)) return;
66
+ if (intent === "make-child") return {
67
+ parentTask: target,
68
+ before: void 0
69
+ };
70
+ const parentId = TaskSet.parentTaskId(target);
71
+ const parent = parentId === void 0 ? null : tasks.find((task) => task.id === parentId) ?? null;
72
+ const siblings = parent === null ? TaskSet.rootTasks(tasks) : TaskSet.subTasks(tasks, parent);
73
+ const index = siblings.findIndex((task) => task.id === target.id);
74
+ if (intent === "reorder-above") return {
75
+ parentTask: parent,
76
+ before: target
77
+ };
78
+ return {
79
+ parentTask: parent,
80
+ before: siblings.slice(index + 1).find((task) => task.id !== source.id)
81
+ };
82
+ };
83
+ /** A task's siblings (in sibling order) and its index among them. */
84
+ var siblingsOf = (tasks, task) => {
85
+ const parentId = TaskSet.parentTaskId(task);
86
+ const parent = parentId === void 0 ? null : tasks.find((candidate) => candidate.id === parentId) ?? null;
87
+ const siblings = parent === null ? TaskSet.rootTasks(tasks) : TaskSet.subTasks(tasks, parent);
88
+ return {
89
+ parent,
90
+ siblings,
91
+ index: siblings.findIndex((candidate) => candidate.id === task.id)
92
+ };
93
+ };
94
+ /**
95
+ * Indent: the task becomes the last child of its previous sibling — the outliner's `Tab`. Undefined
96
+ * when it is the first among its siblings and so has nothing to indent under.
97
+ */
98
+ var resolveIndent = (tasks, task) => {
99
+ const { siblings, index } = siblingsOf(tasks, task);
100
+ const previous = index > 0 ? siblings[index - 1] : void 0;
101
+ return previous ? {
102
+ parentTask: previous,
103
+ before: void 0
104
+ } : void 0;
105
+ };
106
+ /**
107
+ * Outdent: the task becomes the next sibling of its parent — the outliner's `Shift-Tab`. Undefined
108
+ * for a task already at the root.
109
+ */
110
+ var resolveOutdent = (tasks, task) => {
111
+ const { parent } = siblingsOf(tasks, task);
112
+ if (!parent) return;
113
+ const { parent: grandparent, siblings, index } = siblingsOf(tasks, parent);
114
+ return {
115
+ parentTask: grandparent,
116
+ before: siblings[index + 1]
117
+ };
118
+ };
119
+ /**
120
+ * Move within the current parent — `Alt-ArrowUp` / `Alt-ArrowDown`. Undefined at either end, so the
121
+ * key is a no-op rather than silently jumping the task out of its branch.
122
+ */
123
+ var resolveNudge = (tasks, task, direction) => {
124
+ const { parent, siblings, index } = siblingsOf(tasks, task);
125
+ if (direction === "up") return index > 0 ? {
126
+ parentTask: parent,
127
+ before: siblings[index - 1]
128
+ } : void 0;
129
+ return index < siblings.length - 1 ? {
130
+ parentTask: parent,
131
+ before: siblings[index + 2]
132
+ } : void 0;
133
+ };
134
+ //#endregion
135
+ //#region src/components/TaskList/dnd.ts
136
+ /**
137
+ * Drag payload for a task row. Tagged so a drop target can tell a task from any other draggable
138
+ * sharing the page (the navtree, a mosaic tile) rather than reading a bare `id` off it.
139
+ */
140
+ var TASK_DRAG_TYPE = "dxos/task";
141
+ var isTaskDragData = (data) => data.type === "dxos/task" && typeof data.taskId === "string";
142
+ /**
143
+ * The hitbox's item mode, which decides where its bands sit: the last row of a group gets a
144
+ * shallower `reorder-below` band (there is nothing beneath it to reorder against), and an expanded
145
+ * branch gets none at all — below it is its own first child.
146
+ */
147
+ var itemMode = ({ branch, open, last }) => branch && open ? "expanded" : last ? "last-in-group" : "standard";
148
+ /** The drop intent an instruction expresses, or undefined when it carries none (blocked). */
149
+ var dropIntent = (instruction) => {
150
+ switch (instruction?.type) {
151
+ case "reorder-above":
152
+ case "reorder-below":
153
+ case "make-child": return instruction.type;
154
+ default: return;
155
+ }
156
+ };
157
+ //#endregion
8
158
  //#region src/components/TaskList/TaskList.tsx
9
159
  var TASK_LIST_NAME = "TaskList.Root";
10
160
  /** Linear-style status groups, most active first. */
11
161
  var STATUS_ORDER = [
12
- "in-progress",
162
+ "started",
13
163
  "todo",
14
164
  "done",
15
165
  "failed",
16
166
  "cancelled"
17
167
  ];
168
+ /** Fallback status labels. */
18
169
  var DEFAULT_STATUS_LABELS = {
19
- "in-progress": "In progress",
20
- "todo": "Todo",
21
- "done": "Done",
22
- "failed": "Failed",
23
- "cancelled": "Cancelled"
170
+ started: "Started",
171
+ todo: "To Do",
172
+ done: "Done",
173
+ failed: "Failed",
174
+ cancelled: "Cancelled"
24
175
  };
25
- /**
26
- * One row geometry shared by `Item` and `Create` so their affordances land in the same columns:
27
- * a fixed icon gutter (the status toggle above the add `+`), the flexible label, then trailing
28
- * chips/actions pinned to the far edge. `w-full` is load-bearing — the listbox item is a flex
29
- * container, so without it the grid shrinks to its content and the trailing actions float
30
- * mid-row. `px-3` matches the listbox item's own inset, which `Create` does not inherit.
31
- */
32
- var ROW_GRID = "grid grid-cols-[1.5rem_1fr_auto] items-center gap-2 w-full min-w-0 px-3 h-8";
33
176
  var [TaskListProvider, useTaskListContext] = createContext(TASK_LIST_NAME);
34
- var TaskListRoot = ({ children, tasks, groupByStatus = true, statusLabel = (status) => DEFAULT_STATUS_LABELS[status], onTaskCreate, onTaskUpdate, onTaskDelete, onTaskSelect }) => /* @__PURE__ */ jsx(TaskListProvider, {
35
- tasks,
36
- groupByStatus,
37
- statusLabel,
38
- onTaskCreate,
39
- onTaskUpdate,
40
- onTaskDelete,
41
- onTaskSelect,
42
- children: /* @__PURE__ */ jsx(Listbox.Root, { children })
43
- });
177
+ /** Shared empty set, so a list with nothing in flight does not allocate one per render. */
178
+ var EMPTY_IDS = /* @__PURE__ */ new Set();
179
+ var TaskListRoot = ({ children, tasks, groupByStatus = true, showGroupLabels = true, showOrdinals = false, showDescriptions = false, statusLabel = (status) => DEFAULT_STATUS_LABELS[status], onTaskCreate, onTaskUpdate, onTaskDelete, onTaskSelect, onTaskMove, selected: selectedProp, hierarchical = false, collapsed, onCollapsedChange }) => {
180
+ const [selectedState, setSelectedState] = useState(selectedProp);
181
+ const selected = selectedProp ?? selectedState;
182
+ const selectable = !!onTaskSelect || selectedProp !== void 0;
183
+ const handleValueChange = useCallback((id) => {
184
+ setSelectedState(id);
185
+ const task = tasks.find((task) => task.id === id);
186
+ if (task) onTaskSelect?.(task);
187
+ }, [tasks, onTaskSelect]);
188
+ const disclosure = useListDisclosure({
189
+ mode: "multi",
190
+ ...collapsed !== void 0 || onCollapsedChange ? { value: collapsed } : {},
191
+ defaultValue: /* @__PURE__ */ new Set(),
192
+ onValueChange: onCollapsedChange
193
+ });
194
+ const isCollapsed = useCallback((id) => disclosure.bind(id).expanded, [disclosure]);
195
+ const onCollapseToggle = useCallback((id) => disclosure.bind(id).toggle(), [disclosure]);
196
+ const [draggingTask, setDraggingTask] = useState();
197
+ const dragging = useMemo(() => draggingTask ? subtreeIds(tasks, draggingTask) : EMPTY_IDS, [tasks, draggingTask]);
198
+ return /* @__PURE__ */ jsx(TaskListProvider, {
199
+ tasks,
200
+ groupByStatus: groupByStatus && !hierarchical,
201
+ showGroupLabels,
202
+ showOrdinals,
203
+ showDescriptions,
204
+ hierarchical,
205
+ showGutter: showOrdinals || !!onTaskMove,
206
+ statusLabel,
207
+ isCollapsed,
208
+ onCollapseToggle,
209
+ dragging,
210
+ onDraggingChange: setDraggingTask,
211
+ onTaskCreate,
212
+ onTaskUpdate,
213
+ onTaskDelete,
214
+ onTaskSelect,
215
+ onTaskMove,
216
+ selected,
217
+ children: /* @__PURE__ */ jsx(Listbox.Root, {
218
+ ...selectable ? {
219
+ value: selected,
220
+ onValueChange: handleValueChange
221
+ } : {},
222
+ children
223
+ })
224
+ });
225
+ };
44
226
  TaskListRoot.displayName = "TaskList.Root";
45
227
  var TaskListViewport = composable(({ children, ...props }, forwardedRef) => {
46
228
  const { className, ...rest } = composableProps(props);
47
229
  return /* @__PURE__ */ jsx(Listbox.Viewport, {
48
230
  ...rest,
49
- classNames: mx("min-w-0", className),
231
+ classNames: mx("min-w-0 min-h-0", className),
50
232
  ref: forwardedRef,
51
233
  children
52
234
  });
53
235
  });
54
236
  TaskListViewport.displayName = "TaskList.Viewport";
237
+ /**
238
+ * The rows and the create row are separate grids (the create row sits outside the scrolling
239
+ * viewport), so their leading gutters — ordinal, then status — are declared once here: only
240
+ * matching templates keep the `+` under the status control and the input under the titles.
241
+ * Tailwind scans for whole class names, hence four literals rather than a composed prefix. The
242
+ * status gutter is `1.5rem` because that is the width of the `density='sm'` icon button it holds;
243
+ * a narrower track makes the button overflow it and align left instead of centring.
244
+ */
245
+ var GRID_COLS = {
246
+ content: "grid-cols-[1.5rem_1fr_min-content_min-content_2rem]",
247
+ contentWithOrdinals: "grid-cols-[2rem_1.5rem_1fr_min-content_min-content_2rem]",
248
+ create: "grid-cols-[1.5rem_1fr]",
249
+ createWithOrdinals: "grid-cols-[2rem_1.5rem_1fr]"
250
+ };
55
251
  var TaskListContent = composable((props, forwardedRef) => {
56
- const { tasks, groupByStatus, statusLabel } = useTaskListContext("TaskList.Content");
57
- const { className, ...rest } = composableProps(props);
252
+ const { tasks, groupByStatus, showGroupLabels, showOrdinals, showDescriptions, hierarchical, showGutter, statusLabel, isCollapsed } = useTaskListContext("TaskList.Content");
253
+ const ordinals = useMemo(() => new Map(tasks.map((task, index) => [task.id, index + 1])), [tasks]);
254
+ const collapsed = useMemo(() => new Set(tasks.map((task) => task.id).filter(isCollapsed)), [tasks, isCollapsed]);
255
+ const rows = useMemo(() => hierarchical ? walkTaskTree(tasks, collapsed) : void 0, [
256
+ hierarchical,
257
+ tasks,
258
+ collapsed
259
+ ]);
58
260
  const groups = useMemo(() => {
59
261
  if (!groupByStatus) return tasks.length > 0 ? [{
60
262
  status: void 0,
@@ -66,11 +268,17 @@ var TaskListContent = composable((props, forwardedRef) => {
66
268
  })).filter((group) => group.tasks.length > 0);
67
269
  }, [tasks, groupByStatus]);
68
270
  return /* @__PURE__ */ jsx(Listbox.Content, {
69
- ...rest,
271
+ ...composableProps(props, { classNames: mx("group grid gap-x-1 w-full min-w-0", showDescriptions ? "auto-rows-min items-start" : "auto-rows-[2rem] items-center", showGutter ? GRID_COLS.contentWithOrdinals : GRID_COLS.content) }),
70
272
  "aria-label": "Tasks",
71
- classNames: className,
72
273
  ref: forwardedRef,
73
- children: groups.map(({ status, tasks }) => /* @__PURE__ */ jsxs(Fragment, { children: [status && /* @__PURE__ */ jsx(TaskListGroupLabel, { children: statusLabel(status) }), tasks.map((task) => /* @__PURE__ */ jsx(TaskListItem, { task }, task.id))] }, status ?? "all"))
274
+ children: rows ? rows.map((row) => /* @__PURE__ */ jsx(TaskListItem, {
275
+ task: row.task,
276
+ ordinal: showOrdinals ? ordinals.get(row.task.id) : void 0,
277
+ row
278
+ }, row.task.id)) : groups.map(({ status, tasks }) => /* @__PURE__ */ jsxs(Fragment, { children: [status && showGroupLabels && /* @__PURE__ */ jsx(TaskListGroupLabel, { children: statusLabel(status) }), tasks.map((task) => /* @__PURE__ */ jsx(TaskListItem, {
279
+ task,
280
+ ordinal: showOrdinals ? ordinals.get(task.id) : void 0
281
+ }, task.id))] }, status ?? "all"))
74
282
  });
75
283
  });
76
284
  TaskListContent.displayName = "TaskList.Content";
@@ -78,77 +286,395 @@ var TaskListGroupLabel = composable(({ children, ...props }, forwardedRef) => {
78
286
  const { className, ...rest } = composableProps(props);
79
287
  return /* @__PURE__ */ jsx("div", {
80
288
  ...rest,
81
- className: mx("px-2 pt-3 pb-1 text-xs text-subdued uppercase", className),
289
+ className: mx("col-span-full min-h-(--dx-control) flex items-center text-sm text-description", className),
82
290
  ref: forwardedRef,
83
- children
291
+ children: /* @__PURE__ */ jsx("span", { children })
84
292
  });
85
293
  });
86
294
  TaskListGroupLabel.displayName = "TaskList.GroupLabel";
87
- var TaskListItem = composable(({ task, ...props }, forwardedRef) => {
88
- const { onTaskUpdate, onTaskDelete, onTaskSelect } = useTaskListContext("TaskList.Item");
295
+ /** How long the cursor must rest on a collapsed branch before it opens, so crossing one does not. */
296
+ var EXPAND_DWELL = 600;
297
+ /**
298
+ * Distance from the title cell's own inline start to the title text: the disclosure toggle (`w-6`)
299
+ * plus the cell's `gap-1`. The description sits in a different grid row and has to clear the same
300
+ * distance to line up under the title.
301
+ */
302
+ var TOGGLE_INSET = "1.75rem";
303
+ var useTaskDrag = ({ task, row, tasks, onTaskMove, isCollapsed, onCollapseToggle, onDraggingChange }) => {
304
+ const rowRef = useRef(null);
305
+ const dragHandleRef = useRef(null);
306
+ const [instruction, setInstruction] = useState(null);
307
+ const expandTimeout = useRef(null);
308
+ const liftFrame = useRef(null);
309
+ const latest = useRef({
310
+ tasks,
311
+ onTaskMove,
312
+ isCollapsed,
313
+ onCollapseToggle,
314
+ onDraggingChange,
315
+ row
316
+ });
317
+ latest.current = {
318
+ tasks,
319
+ onTaskMove,
320
+ isCollapsed,
321
+ onCollapseToggle,
322
+ onDraggingChange,
323
+ row
324
+ };
325
+ const cancelLift = useCallback(() => {
326
+ if (liftFrame.current !== null) {
327
+ cancelAnimationFrame(liftFrame.current);
328
+ liftFrame.current = null;
329
+ }
330
+ }, []);
331
+ const cancelExpand = useCallback(() => {
332
+ if (expandTimeout.current) {
333
+ clearTimeout(expandTimeout.current);
334
+ expandTimeout.current = null;
335
+ }
336
+ }, []);
337
+ const enabled = !!onTaskMove && !!row;
338
+ useEffect(() => {
339
+ const element = rowRef.current;
340
+ const handle = dragHandleRef.current;
341
+ if (!enabled || !element || !handle) return;
342
+ const data = {
343
+ type: TASK_DRAG_TYPE,
344
+ taskId: task.id
345
+ };
346
+ /** The placement a drag over this row currently means, or undefined when the drop is refused. */
347
+ const placementFor = (instruction) => {
348
+ const intent = dropIntent(instruction);
349
+ const source = latest.current.tasks.find(({ id }) => id === draggingId.current);
350
+ if (!intent || !source) return;
351
+ return resolveTaskPlacement({
352
+ tasks: latest.current.tasks,
353
+ source,
354
+ target: task,
355
+ intent
356
+ });
357
+ };
358
+ return combine(draggable({
359
+ element: handle,
360
+ getInitialData: () => data,
361
+ onGenerateDragPreview: ({ location, nativeSetDragImage }) => {
362
+ const rows = subtreeRows(element, latest.current.tasks, task);
363
+ const { left, top } = element.getBoundingClientRect();
364
+ const { clientX, clientY } = location.initial.input;
365
+ const offset = {
366
+ x: clientX - left,
367
+ y: clientY - top
368
+ };
369
+ if (rows.length < 2) {
370
+ nativeSetDragImage?.(element, offset.x, offset.y);
371
+ return;
372
+ }
373
+ setCustomNativeDragPreview({
374
+ nativeSetDragImage,
375
+ getOffset: () => offset,
376
+ render: ({ container }) => renderSubtreePreview(container, element, rows)
377
+ });
378
+ },
379
+ onDragStart: () => {
380
+ draggingId.current = task.id;
381
+ liftFrame.current = requestAnimationFrame(() => latest.current.onDraggingChange(task));
382
+ },
383
+ onDrop: () => {
384
+ draggingId.current = void 0;
385
+ cancelLift();
386
+ latest.current.onDraggingChange(void 0);
387
+ }
388
+ }), dropTargetForElements({
389
+ element,
390
+ getData: ({ input, element }) => attachInstruction(data, {
391
+ input,
392
+ element,
393
+ indentPerLevel: 8,
394
+ currentLevel: (latest.current.row?.level ?? 1) - 1,
395
+ mode: itemMode({
396
+ branch: latest.current.row?.branch ?? false,
397
+ open: !latest.current.isCollapsed(task.id),
398
+ last: (latest.current.row?.position ?? 1) === (latest.current.row?.setSize ?? 1)
399
+ }),
400
+ block: []
401
+ }),
402
+ canDrop: ({ source }) => isTaskDragData(source.data) && source.data.taskId !== task.id,
403
+ getIsSticky: () => true,
404
+ onDrag: ({ self }) => {
405
+ const desired = extractInstruction(self.data);
406
+ const next = desired !== null && desired.type !== "instruction-blocked" && !placementFor(desired) ? {
407
+ type: "instruction-blocked",
408
+ desired
409
+ } : desired;
410
+ setInstruction(next);
411
+ const { row, isCollapsed, onCollapseToggle } = latest.current;
412
+ if (next?.type === "make-child" && row?.branch && isCollapsed(task.id) && !expandTimeout.current) expandTimeout.current = setTimeout(() => {
413
+ expandTimeout.current = null;
414
+ onCollapseToggle(task.id);
415
+ }, EXPAND_DWELL);
416
+ else if (next?.type !== "make-child") cancelExpand();
417
+ },
418
+ onDragLeave: () => {
419
+ cancelExpand();
420
+ setInstruction(null);
421
+ },
422
+ onDrop: ({ self, source }) => {
423
+ cancelExpand();
424
+ setInstruction(null);
425
+ if (!isTaskDragData(source.data)) return;
426
+ const moved = latest.current.tasks.find(({ id }) => id === source.data.taskId);
427
+ const intent = dropIntent(extractInstruction(self.data));
428
+ if (!moved || !intent) return;
429
+ const placement = resolveTaskPlacement({
430
+ tasks: latest.current.tasks,
431
+ source: moved,
432
+ target: task,
433
+ intent
434
+ });
435
+ if (placement) latest.current.onTaskMove?.(moved, placement);
436
+ }
437
+ }));
438
+ }, [
439
+ enabled,
440
+ task.id,
441
+ cancelExpand,
442
+ cancelLift
443
+ ]);
444
+ useEffect(() => () => {
445
+ cancelExpand();
446
+ cancelLift();
447
+ }, [cancelExpand, cancelLift]);
448
+ return {
449
+ instruction,
450
+ rowRef,
451
+ dragHandleRef,
452
+ dragHandle: enabled
453
+ };
454
+ };
455
+ /**
456
+ * The rendered rows of `task`'s subtree, in document order: the row itself plus every visible
457
+ * descendant. A collapsed branch contributes only its own row, which is what the reader sees.
458
+ */
459
+ var subtreeRows = (element, tasks, task) => {
460
+ const list = element.parentElement;
461
+ const ids = subtreeIds(tasks, task);
462
+ return list ? Array.from(list.querySelectorAll("[data-task-id]")).filter((row) => ids.has(row.dataset.taskId ?? "")) : [element];
463
+ };
464
+ /**
465
+ * Clones the subtree into the drag preview. The container is made a grid carrying the list's own
466
+ * track sizes: the rows are `grid-cols-subgrid`, so without them a detached copy collapses to its
467
+ * content width and the columns no longer line up.
468
+ */
469
+ var renderSubtreePreview = (container, element, rows) => {
470
+ const list = element.parentElement;
471
+ const listStyle = list && getComputedStyle(list);
472
+ container.style.display = "grid";
473
+ container.style.width = `${element.getBoundingClientRect().width}px`;
474
+ if (listStyle) {
475
+ container.style.gridTemplateColumns = listStyle.gridTemplateColumns;
476
+ container.style.columnGap = listStyle.columnGap;
477
+ container.style.gridAutoRows = listStyle.gridAutoRows;
478
+ container.style.alignItems = listStyle.alignItems;
479
+ }
480
+ container.classList.add("bg-base-surface");
481
+ for (const row of rows) container.appendChild(row.cloneNode(true));
482
+ };
483
+ /**
484
+ * Id of the task currently being dragged. Module-level because the drag source and the row under
485
+ * the cursor are different components, and the hitbox reports only the target's own data mid-drag;
486
+ * a rejected drop has to be refused while the cursor is over it, not after the drop.
487
+ */
488
+ var draggingId = { current: void 0 };
489
+ /** A description is a line in a row, not a document: no paragraph block, no heading scale. */
490
+ var DESCRIPTION_COMPONENTS = { p: ({ children }) => /* @__PURE__ */ jsx("span", { children }) };
491
+ var STATUS_ICONS = {
492
+ todo: {
493
+ icon: "ph--square--regular",
494
+ classNames: "text-subdued"
495
+ },
496
+ started: {
497
+ icon: "ph--hourglass--regular",
498
+ classNames: "text-info-text"
499
+ },
500
+ done: {
501
+ icon: "ph--check--regular",
502
+ classNames: "text-success-text"
503
+ },
504
+ failed: {
505
+ icon: "ph--x--regular",
506
+ classNames: "text-error-text"
507
+ },
508
+ cancelled: {
509
+ icon: "ph--x--regular",
510
+ classNames: "text-error-text"
511
+ }
512
+ };
513
+ var TaskListItem = composable(({ task, ordinal, row, ...props }, forwardedRef) => {
514
+ const { t } = useTranslation(translationKey);
515
+ const { tasks, showDescriptions, showGutter, onTaskUpdate, onTaskDelete, onTaskSelect, onTaskMove, isCollapsed, onCollapseToggle, dragging, onDraggingChange } = useTaskListContext("TaskList.Item");
89
516
  const { className, ...rest } = composableProps(props);
517
+ const { instruction, rowRef, dragHandleRef, dragHandle } = useTaskDrag({
518
+ task,
519
+ row,
520
+ tasks,
521
+ onTaskMove,
522
+ onCollapseToggle,
523
+ isCollapsed,
524
+ onDraggingChange
525
+ });
526
+ const open = row ? !isCollapsed(task.id) : void 0;
90
527
  const [snapshot] = useObject(task);
91
528
  const current = snapshot ?? task;
92
529
  const done = current.status === "done";
530
+ const error = current.status === "failed";
531
+ const description = showDescriptions ? current.description?.trim() || void 0 : void 0;
532
+ const blocked = (current.status ?? "todo") === "todo" && !TaskSet.isTaskReady(tasks, task);
533
+ const { icon, classNames: iconClassNames } = current.status === "started" && current.assignee?.role === "assistant" ? {
534
+ icon: "ph--spinner--regular",
535
+ classNames: "text-info-text animate-spin"
536
+ } : STATUS_ICONS[current.status ?? "todo"];
93
537
  const handleToggle = useCallback(() => onTaskUpdate?.(task, { status: done ? "todo" : "done" }), [
94
538
  onTaskUpdate,
95
539
  task,
96
540
  done
97
541
  ]);
98
- return /* @__PURE__ */ jsx(Listbox.Item, {
542
+ const handleKeyDown = useCallback((event) => {
543
+ if (!onTaskMove || !row || !event.altKey) return;
544
+ const placement = (() => {
545
+ switch (event.key) {
546
+ case "ArrowRight": return resolveIndent(tasks, task);
547
+ case "ArrowLeft": return resolveOutdent(tasks, task);
548
+ case "ArrowUp": return resolveNudge(tasks, task, "up");
549
+ case "ArrowDown": return resolveNudge(tasks, task, "down");
550
+ default: return;
551
+ }
552
+ })();
553
+ if (placement) {
554
+ event.preventDefault();
555
+ event.stopPropagation();
556
+ onTaskMove(task, placement);
557
+ }
558
+ }, [
559
+ onTaskMove,
560
+ row,
561
+ tasks,
562
+ task
563
+ ]);
564
+ return /* @__PURE__ */ jsxs(Listbox.Item, {
99
565
  ...rest,
100
566
  id: task.id,
101
- classNames: mx("px-0 py-0 group", className),
102
- ref: forwardedRef,
103
- children: /* @__PURE__ */ jsxs("div", {
104
- role: "none",
105
- "data-testid": "taskList.item",
106
- className: ROW_GRID,
107
- children: [
108
- onTaskUpdate ? /* @__PURE__ */ jsx(IconButton, {
109
- variant: "ghost",
110
- density: "sm",
111
- icon: done ? "ph--check--regular" : "ph--circle--regular",
112
- iconOnly: true,
113
- label: done ? "Mark todo" : "Mark done",
114
- classNames: mx("justify-self-center text-subdued", done && "text-success-text"),
115
- onClick: handleToggle
116
- }) : /* @__PURE__ */ jsx(Icon, {
117
- icon: done ? "ph--check--regular" : "ph--circle--regular",
118
- classNames: mx("justify-self-center", done && "text-success-text"),
567
+ "data-testid": "taskList.item",
568
+ "data-task-id": task.id,
569
+ classNames: mx("group/row col-span-full grid grid-cols-subgrid px-0 items-start relative", dragging.has(task.id) && "hidden", className),
570
+ "aria-level": row?.level,
571
+ "aria-posinset": row && row.position,
572
+ "aria-setsize": row && row.setSize,
573
+ "aria-expanded": row?.branch ? open : void 0,
574
+ style: row ? {
575
+ "--task-indent": paddingIndentation(row.level, 8).paddingInlineStart,
576
+ "--task-title-inset": `calc(var(--task-indent) + ${TOGGLE_INSET})`
577
+ } : void 0,
578
+ onKeyDown: handleKeyDown,
579
+ ref: useComposedRefs(rowRef, forwardedRef),
580
+ children: [
581
+ showGutter && /* @__PURE__ */ jsxs("div", {
582
+ className: "relative flex h-8 items-center justify-center",
583
+ children: [ordinal !== void 0 && /* @__PURE__ */ jsx(Tag, {
584
+ hue: done ? "green" : error ? "rose" : "neutral",
585
+ classNames: mx("tabular-nums", dragHandle && "group-hover/row:invisible group-has-[:focus-visible]/row:invisible"),
586
+ children: ordinal
587
+ }), dragHandle && /* @__PURE__ */ jsx("span", {
588
+ "data-testid": "taskList.dragHandle",
589
+ "aria-hidden": true,
590
+ className: mx("absolute inset-0 grid place-items-center text-subdued cursor-grab active:cursor-grabbing", ordinal !== void 0 && "invisible group-hover/row:visible group-has-[:focus-visible]/row:visible"),
591
+ ref: dragHandleRef,
592
+ children: /* @__PURE__ */ jsx(Icon, {
593
+ icon: "ph--dots-six-vertical--regular",
594
+ size: 4
595
+ })
596
+ })]
597
+ }),
598
+ onTaskUpdate ? /* @__PURE__ */ jsx(IconButton, {
599
+ classNames: mx("justify-self-center my-1", iconClassNames),
600
+ variant: "ghost",
601
+ density: "sm",
602
+ icon,
603
+ iconOnly: true,
604
+ label: done ? t("mark-todo.label") : t("mark-done.label"),
605
+ onClick: handleToggle
606
+ }) : /* @__PURE__ */ jsxs("span", {
607
+ className: "grid h-8 place-items-center justify-self-center",
608
+ children: [/* @__PURE__ */ jsx(Icon, {
609
+ icon,
610
+ classNames: iconClassNames,
119
611
  size: 4
120
- }),
121
- /* @__PURE__ */ jsx("span", {
122
- className: onTaskSelect ? "truncate cursor-pointer" : "truncate",
123
- onClick: onTaskSelect ? () => onTaskSelect(task) : void 0,
612
+ }), /* @__PURE__ */ jsx("span", {
613
+ className: "sr-only",
614
+ children: t(`status-${current.status ?? "todo"}.label`)
615
+ })]
616
+ }),
617
+ /* @__PURE__ */ jsxs("span", {
618
+ className: mx("flex h-8 items-center gap-1 min-w-0", row && "ps-(--task-indent)", onTaskSelect && "cursor-pointer"),
619
+ children: [row && /* @__PURE__ */ jsx(TreeItemToggle, {
620
+ isBranch: row.branch,
621
+ open,
622
+ onClick: (event) => {
623
+ event.stopPropagation();
624
+ onCollapseToggle(task.id);
625
+ }
626
+ }), /* @__PURE__ */ jsx("span", {
627
+ className: "truncate",
124
628
  children: current.title
125
- }),
126
- /* @__PURE__ */ jsxs("div", {
127
- className: "flex items-center gap-1",
128
- children: [
129
- current.priority && current.priority !== "none" && /* @__PURE__ */ jsx(Tag, {
130
- hue: "neutral",
131
- children: current.priority
132
- }),
133
- current.assignee && /* @__PURE__ */ jsx(TaskListAssignee, { assignee: current.assignee }),
134
- onTaskDelete && /* @__PURE__ */ jsx(IconButton, {
135
- variant: "ghost",
136
- density: "sm",
137
- icon: "ph--x--regular",
138
- iconOnly: true,
139
- label: "Delete task",
140
- classNames: "invisible group-hover:visible",
141
- onClick: () => onTaskDelete(task)
142
- })
143
- ]
144
- })
145
- ]
146
- })
629
+ })]
630
+ }),
631
+ current.assignee ? /* @__PURE__ */ jsx("span", {
632
+ className: "flex h-8 items-center",
633
+ children: /* @__PURE__ */ jsx(TaskListAssignee, { assignee: current.assignee })
634
+ }) : /* @__PURE__ */ jsx("div", {}),
635
+ /* @__PURE__ */ jsxs("div", {
636
+ className: "flex h-8 items-center gap-1",
637
+ children: [blocked && /* @__PURE__ */ jsx(Tag, {
638
+ hue: "indigo",
639
+ children: t("task-blocked.label")
640
+ }), current.priority && current.priority !== "none" && /* @__PURE__ */ jsx(Tag, {
641
+ hue: "neutral",
642
+ children: current.priority
643
+ })]
644
+ }),
645
+ onTaskDelete && /* @__PURE__ */ jsx(CompactIconButton, {
646
+ variant: "ghost",
647
+ icon: "ph--x--regular",
648
+ label: t("delete-task.label"),
649
+ classNames: "invisible group-hover/row:visible group-has-[:focus-visible]/row:visible",
650
+ onClick: () => onTaskDelete(task)
651
+ }),
652
+ instruction && /* @__PURE__ */ jsx(TreeDropIndicator, {
653
+ instruction,
654
+ gap: 0
655
+ }),
656
+ description && /* @__PURE__ */ jsx(MarkdownView, {
657
+ content: description,
658
+ classNames: mx(showGutter ? "col-start-3" : "col-start-2", row && "ps-(--task-title-inset)", "col-span-3 pb-1 text-sm text-description line-clamp-3"),
659
+ components: DESCRIPTION_COMPONENTS
660
+ })
661
+ ]
147
662
  });
148
663
  });
149
664
  TaskListItem.displayName = "TaskList.Item";
665
+ var CompactIconButton = (props) => {
666
+ return /* @__PURE__ */ jsx("span", {
667
+ className: "grid size-8 shrink-0 place-items-center",
668
+ children: /* @__PURE__ */ jsx(IconButton, {
669
+ variant: "ghost",
670
+ iconOnly: true,
671
+ density: "sm",
672
+ ...props
673
+ })
674
+ });
675
+ };
150
676
  var TaskListCreate = composable(({ placeholder = "Add task", ...props }, forwardedRef) => {
151
- const { onTaskCreate } = useTaskListContext("TaskList.Create");
677
+ const { onTaskCreate, showGutter } = useTaskListContext("TaskList.Create");
152
678
  const { className, ...rest } = composableProps(props);
153
679
  const [title, setTitle] = useState("");
154
680
  const handleKeyDown = useCallback((event) => {
@@ -162,19 +688,24 @@ var TaskListCreate = composable(({ placeholder = "Add task", ...props }, forward
162
688
  return /* @__PURE__ */ jsxs("div", {
163
689
  ...rest,
164
690
  "data-testid": "taskList.create",
165
- className: mx(ROW_GRID, className),
691
+ className: mx("grid gap-x-1 items-center w-full min-w-0 h-8 shrink-0", showGutter ? GRID_COLS.createWithOrdinals : GRID_COLS.create, className),
166
692
  ref: forwardedRef,
167
- children: [/* @__PURE__ */ jsx(Icon, {
168
- icon: "ph--plus--regular",
169
- size: 4,
170
- classNames: "justify-self-center text-subdued"
171
- }), /* @__PURE__ */ jsx(Input.Root, { children: /* @__PURE__ */ jsx(Input.TextInput, {
172
- variant: "subdued",
173
- placeholder,
174
- value: title,
175
- onChange: (event) => setTitle(event.target.value),
176
- onKeyDown: handleKeyDown
177
- }) })]
693
+ children: [
694
+ showGutter && /* @__PURE__ */ jsx("span", {}),
695
+ /* @__PURE__ */ jsx(Icon, {
696
+ icon: "ph--plus--regular",
697
+ size: 4,
698
+ classNames: "justify-self-center text-subdued"
699
+ }),
700
+ /* @__PURE__ */ jsx(Input.Root, { children: /* @__PURE__ */ jsx(Input.TextInput, {
701
+ variant: "subdued",
702
+ classNames: "px-0",
703
+ placeholder,
704
+ value: title,
705
+ onChange: (event) => setTitle(event.target.value),
706
+ onKeyDown: handleKeyDown
707
+ }) })
708
+ ]
178
709
  });
179
710
  });
180
711
  TaskListCreate.displayName = "TaskList.Create";
@@ -204,6 +735,6 @@ var TaskList = {
204
735
  Assignee: TaskListAssignee
205
736
  };
206
737
  //#endregion
207
- export { STATUS_ORDER, TaskList };
738
+ export { STATUS_ORDER, TaskList, resolveIndent, resolveNudge, resolveOutdent, resolveTaskPlacement, subtreeIds, walkTaskTree };
208
739
 
209
740
  //# sourceMappingURL=index.mjs.map