@dxos/react-ui-task 0.10.0 → 0.11.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-task",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Task list components over the durable Task/TaskSet model.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -24,12 +24,14 @@
24
24
  "src"
25
25
  ],
26
26
  "dependencies": {
27
- "@dxos/echo": "0.10.0",
28
- "@dxos/echo-react": "0.10.0",
29
- "@dxos/react-ui": "0.10.0",
30
- "@dxos/types": "0.10.0",
31
- "@dxos/react-ui-list": "0.10.0",
32
- "@dxos/ui-theme": "0.10.0"
27
+ "@radix-ui/react-context": "1.1.1",
28
+ "@dxos/echo": "0.11.1",
29
+ "@dxos/react-ui": "0.11.1",
30
+ "@dxos/react-ui-list": "0.11.1",
31
+ "@dxos/types": "0.11.1",
32
+ "@dxos/ui-theme": "0.11.1",
33
+ "@dxos/echo-react": "0.11.1",
34
+ "@dxos/ui-types": "0.11.1"
33
35
  },
34
36
  "devDependencies": {
35
37
  "@testing-library/jest-dom": "^6.9.1",
@@ -41,14 +43,14 @@
41
43
  "react-dom": "~19.2.7",
42
44
  "resize-observer-polyfill": "^1.5.1",
43
45
  "vite": "^8.1.4",
44
- "@dxos/storybook-utils": "0.10.0"
46
+ "@dxos/storybook-utils": "0.11.1"
45
47
  },
46
48
  "peerDependencies": {
47
49
  "effect": "3.21.4",
48
50
  "react": "~19.2.7",
49
51
  "react-dom": "~19.2.7",
50
- "@dxos/react-ui": "^0.10.0",
51
- "@dxos/ui-theme": "^0.10.0"
52
+ "@dxos/react-ui": "^0.11.1",
53
+ "@dxos/ui-theme": "^0.11.1"
52
54
  },
53
55
  "publishConfig": {
54
56
  "access": "public"
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import React, { useCallback, useState } from 'react';
7
+ import { expect } from 'storybook/test';
7
8
 
8
9
  import { Obj } from '@dxos/echo';
9
10
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
@@ -38,20 +39,23 @@ const DefaultStory = ({ readonly }: { readonly?: boolean }) => {
38
39
  }, []);
39
40
 
40
41
  return (
41
- <div className='w-[36rem]'>
42
- <TaskList
43
- tasks={tasks}
44
- onTaskCreate={readonly ? undefined : handleCreate}
45
- onTaskUpdate={readonly ? undefined : handleUpdate}
46
- onTaskDelete={readonly ? undefined : handleDelete}
47
- />
48
- </div>
42
+ <TaskList.Root
43
+ tasks={tasks}
44
+ onTaskCreate={readonly ? undefined : handleCreate}
45
+ onTaskUpdate={readonly ? undefined : handleUpdate}
46
+ onTaskDelete={readonly ? undefined : handleDelete}
47
+ >
48
+ <TaskList.Viewport>
49
+ <TaskList.Content />
50
+ </TaskList.Viewport>
51
+ <TaskList.Create />
52
+ </TaskList.Root>
49
53
  );
50
54
  };
51
55
 
52
56
  const meta = {
53
57
  title: 'ui/react-ui-task/TaskList',
54
- render: (args) => <DefaultStory {...args} />,
58
+ render: DefaultStory,
55
59
  decorators: [withTheme(), withLayout({ layout: 'column' })],
56
60
  } satisfies Meta<typeof DefaultStory>;
57
61
 
@@ -59,7 +63,37 @@ export default meta;
59
63
 
60
64
  type Story = StoryObj<typeof meta>;
61
65
 
62
- export const Default: Story = {};
66
+ export const Default: Story = {
67
+ // The status toggle and the add-`+` share one row grid; assert their icon gutters actually line
68
+ // up, since only geometry (not the DOM) shows the misalignment.
69
+ play: async ({ canvasElement }) => {
70
+ const row = canvasElement.querySelector<HTMLElement>('[data-testid="taskList.item"]');
71
+ const create = canvasElement.querySelector<HTMLElement>('[data-testid="taskList.create"]');
72
+ if (!row || !create) {
73
+ throw new Error('Task rows not found.');
74
+ }
75
+
76
+ const center = (element: Element) => {
77
+ const { left, width } = element.getBoundingClientRect();
78
+ return left + width / 2;
79
+ };
80
+
81
+ const rowIcon = row.firstElementChild;
82
+ const createIcon = create.firstElementChild;
83
+ if (!rowIcon || !createIcon) {
84
+ throw new Error('Row icons not found.');
85
+ }
86
+
87
+ // Same icon column ⇒ same horizontal centre (sub-pixel tolerance for rounding).
88
+ await expect(Math.abs(center(rowIcon) - center(createIcon))).toBeLessThan(1);
89
+ // ...and the labels start at the same x.
90
+ await expect(
91
+ Math.abs(row.children[1].getBoundingClientRect().left - create.children[1].getBoundingClientRect().left),
92
+ ).toBeLessThan(1);
93
+ // The row spans the full width, so trailing actions sit at the far edge.
94
+ await expect(row.getBoundingClientRect().width).toBeGreaterThan(create.getBoundingClientRect().width * 0.9);
95
+ },
96
+ };
63
97
 
64
98
  export const Readonly: Story = {
65
99
  args: { readonly: true },
@@ -2,12 +2,17 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import React, { type KeyboardEvent, useCallback, useMemo, useState } from 'react';
5
+ import { createContext } from '@radix-ui/react-context';
6
+ import React, { Fragment, type KeyboardEvent, type PropsWithChildren, useCallback, useMemo, useState } from 'react';
6
7
 
7
8
  import { useObject } from '@dxos/echo-react';
8
- import { Icon, IconButton, Input, Tag, type ThemedClassName } from '@dxos/react-ui';
9
+ import { Icon, IconButton, Input, Tag, composable, composableProps } from '@dxos/react-ui';
9
10
  import { Listbox } from '@dxos/react-ui-list';
10
11
  import { type Actor, type Task } from '@dxos/types';
12
+ import { mx } from '@dxos/ui-theme';
13
+ import { type ComposableProps } from '@dxos/ui-types';
14
+
15
+ const TASK_LIST_NAME = 'TaskList.Root';
11
16
 
12
17
  export type TaskStatus = NonNullable<Task.Task['status']>;
13
18
 
@@ -24,29 +29,53 @@ const DEFAULT_STATUS_LABELS: Record<TaskStatus, string> = {
24
29
 
25
30
  export type TaskPatch = Partial<Pick<Task.Task, 'title' | 'status' | 'priority' | 'estimate' | 'assignee'>>;
26
31
 
27
- export type TaskListProps = ThemedClassName<{
32
+ /**
33
+ * One row geometry shared by `Item` and `Create` so their affordances land in the same columns:
34
+ * a fixed icon gutter (the status toggle above the add `+`), the flexible label, then trailing
35
+ * chips/actions pinned to the far edge. `w-full` is load-bearing — the listbox item is a flex
36
+ * container, so without it the grid shrinks to its content and the trailing actions float
37
+ * mid-row. `px-3` matches the listbox item's own inset, which `Create` does not inherit.
38
+ */
39
+ const ROW_GRID = 'grid grid-cols-[1.5rem_1fr_auto] items-center gap-2 w-full min-w-0 px-3 h-8';
40
+
41
+ //
42
+ // Context — plain Radix context (un-scoped); nesting task lists has no meaning today.
43
+ //
44
+
45
+ type TaskListContextValue = {
46
+ tasks: readonly Task.Task[];
47
+ groupByStatus: boolean;
48
+ statusLabel: (status: TaskStatus) => string;
49
+ onTaskCreate?: (title: string) => void;
50
+ onTaskUpdate?: (task: Task.Task, patch: TaskPatch) => void;
51
+ onTaskDelete?: (task: Task.Task) => void;
52
+ onTaskSelect?: (task: Task.Task) => void;
53
+ };
54
+
55
+ const [TaskListProvider, useTaskListContext] = createContext<TaskListContextValue>(TASK_LIST_NAME);
56
+
57
+ //
58
+ // Root — headless context provider. Renders no DOM.
59
+ //
60
+
61
+ type TaskListRootProps = PropsWithChildren<{
28
62
  tasks: readonly Task.Task[];
29
63
  /** Group rows into status sections (Linear order); flat list otherwise. */
30
64
  groupByStatus?: boolean;
31
65
  /** i18n hook for group headings; defaults to English labels. */
32
66
  statusLabel?: (status: TaskStatus) => string;
33
- /** Renders an add row; called with the trimmed title on Enter. */
67
+ /** Enables `Create`; called with the trimmed title. */
34
68
  onTaskCreate?: (title: string) => void;
35
- /** Enables the done toggle (status patch) and any future field affordances. */
69
+ /** Enables the done toggle. Every mutation is delegated — the list never writes. */
36
70
  onTaskUpdate?: (task: Task.Task, patch: TaskPatch) => void;
37
- /** Renders a per-row delete affordance. */
71
+ /** Enables the per-row delete affordance. */
38
72
  onTaskDelete?: (task: Task.Task) => void;
39
73
  /** Row click. */
40
74
  onTaskSelect?: (task: Task.Task) => void;
41
- createPlaceholder?: string;
42
75
  }>;
43
76
 
44
- /**
45
- * List of durable {@link Task} objects with optional CRUD affordances — every mutation is
46
- * delegated to a callback (typically a `TaskOperation` invocation), never performed here.
47
- */
48
- export const TaskList = ({
49
- classNames,
77
+ const TaskListRoot = ({
78
+ children,
50
79
  tasks,
51
80
  groupByStatus = true,
52
81
  statusLabel = (status) => DEFAULT_STATUS_LABELS[status],
@@ -54,12 +83,55 @@ export const TaskList = ({
54
83
  onTaskUpdate,
55
84
  onTaskDelete,
56
85
  onTaskSelect,
57
- createPlaceholder = 'Add task',
58
- }: TaskListProps) => {
86
+ }: TaskListRootProps) => (
87
+ <TaskListProvider
88
+ tasks={tasks}
89
+ groupByStatus={groupByStatus}
90
+ statusLabel={statusLabel}
91
+ onTaskCreate={onTaskCreate}
92
+ onTaskUpdate={onTaskUpdate}
93
+ onTaskDelete={onTaskDelete}
94
+ onTaskSelect={onTaskSelect}
95
+ >
96
+ {/* Both roots are headless, so the pair renders no DOM of its own. */}
97
+ <Listbox.Root>{children}</Listbox.Root>
98
+ </TaskListProvider>
99
+ );
100
+
101
+ TaskListRoot.displayName = 'TaskList.Root';
102
+
103
+ //
104
+ // Viewport — the scrolling region (the listbox's own viewport). `Create` sits outside it, so the
105
+ // add row stays pinned while the rows scroll.
106
+ //
107
+
108
+ type TaskListViewportProps = ComposableProps;
109
+
110
+ const TaskListViewport = composable<HTMLDivElement>(({ children, ...props }, forwardedRef) => {
111
+ const { className, ...rest } = composableProps(props);
112
+ return (
113
+ <Listbox.Viewport {...rest} classNames={mx('min-w-0', className)} ref={forwardedRef}>
114
+ {children}
115
+ </Listbox.Viewport>
116
+ );
117
+ });
118
+
119
+ TaskListViewport.displayName = 'TaskList.Viewport';
120
+
121
+ //
122
+ // Content — the rows, grouped by status when the root says so.
123
+ //
124
+
125
+ type TaskListContentProps = ComposableProps;
126
+
127
+ const TaskListContent = composable<HTMLUListElement>((props, forwardedRef) => {
128
+ const { tasks, groupByStatus, statusLabel } = useTaskListContext('TaskList.Content');
129
+ const { className, ...rest } = composableProps(props);
59
130
  const groups = useMemo(() => {
60
131
  if (!groupByStatus) {
61
132
  return tasks.length > 0 ? [{ status: undefined, tasks }] : [];
62
133
  }
134
+
63
135
  return STATUS_ORDER.map((status) => ({
64
136
  status,
65
137
  tasks: tasks.filter((task) => (task.status ?? 'todo') === status),
@@ -67,126 +139,159 @@ export const TaskList = ({
67
139
  }, [tasks, groupByStatus]);
68
140
 
69
141
  return (
70
- <div className='flex flex-col min-w-0'>
71
- <Listbox.Root>
72
- <Listbox.Viewport classNames={['dx-container', classNames]}>
73
- <Listbox.Content aria-label='Tasks'>
74
- {groups.map(({ status, tasks }) => (
75
- <React.Fragment key={status ?? 'all'}>
76
- {status && <div className='px-2 pt-3 pb-1 text-xs text-subdued uppercase'>{statusLabel(status)}</div>}
77
- {tasks.map((task) => (
78
- <TaskRow
79
- key={task.id}
80
- task={task}
81
- onUpdate={onTaskUpdate}
82
- onDelete={onTaskDelete}
83
- onSelect={onTaskSelect}
84
- />
85
- ))}
86
- </React.Fragment>
87
- ))}
88
- </Listbox.Content>
89
- </Listbox.Viewport>
90
- </Listbox.Root>
91
- {onTaskCreate && <TaskCreateRow placeholder={createPlaceholder} onCreate={onTaskCreate} />}
142
+ <Listbox.Content {...rest} aria-label='Tasks' classNames={className} ref={forwardedRef}>
143
+ {groups.map(({ status, tasks }) => (
144
+ <Fragment key={status ?? 'all'}>
145
+ {status && <TaskListGroupLabel>{statusLabel(status)}</TaskListGroupLabel>}
146
+ {tasks.map((task) => (
147
+ <TaskListItem key={task.id} task={task} />
148
+ ))}
149
+ </Fragment>
150
+ ))}
151
+ </Listbox.Content>
152
+ );
153
+ });
154
+
155
+ TaskListContent.displayName = 'TaskList.Content';
156
+
157
+ //
158
+ // GroupLabel
159
+ //
160
+
161
+ type TaskListGroupLabelProps = ComposableProps;
162
+
163
+ const TaskListGroupLabel = composable<HTMLDivElement>(({ children, ...props }, forwardedRef) => {
164
+ const { className, ...rest } = composableProps(props);
165
+ return (
166
+ <div {...rest} className={mx('px-2 pt-3 pb-1 text-xs text-subdued uppercase', className)} ref={forwardedRef}>
167
+ {children}
92
168
  </div>
93
169
  );
94
- };
170
+ });
95
171
 
96
- type TaskRowProps = {
97
- task: Task.Task;
98
- onUpdate?: TaskListProps['onTaskUpdate'];
99
- onDelete?: TaskListProps['onTaskDelete'];
100
- onSelect?: TaskListProps['onTaskSelect'];
101
- };
172
+ TaskListGroupLabel.displayName = 'TaskList.GroupLabel';
173
+
174
+ //
175
+ // Item — one row. Exported so a host can render its own selection of tasks.
176
+ //
102
177
 
103
- const TaskRow = ({ task, onUpdate, onDelete, onSelect }: TaskRowProps) => {
104
- const done = task.status === 'done';
105
- const handleToggle = useCallback(() => onUpdate?.(task, { status: done ? 'todo' : 'done' }), [onUpdate, task, done]);
178
+ type TaskListItemProps = ComposableProps<{ task: Task.Task }>;
179
+
180
+ const TaskListItem = composable<HTMLLIElement, { task: Task.Task }>(({ task, ...props }, forwardedRef) => {
181
+ const { onTaskUpdate, onTaskDelete, onTaskSelect } = useTaskListContext('TaskList.Item');
182
+ const { className, ...rest } = composableProps(props);
183
+ // Subscribe per row: a query re-emits when membership changes, not when a task's own fields do,
184
+ // so a rename elsewhere (task form, agent, sync) would otherwise leave the row stale.
185
+ const [snapshot] = useObject(task);
186
+ const current = snapshot ?? task;
187
+ const done = current.status === 'done';
188
+ const handleToggle = useCallback(
189
+ () => onTaskUpdate?.(task, { status: done ? 'todo' : 'done' }),
190
+ [onTaskUpdate, task, done],
191
+ );
106
192
 
107
193
  return (
108
- <Listbox.Item id={task.id} classNames='py-0 group'>
109
- <div className='flex items-center gap-2 min-w-0'>
110
- {onUpdate ? (
194
+ // `px-0` hands the horizontal inset to `ROW_GRID`, so the row shares the create row's columns.
195
+ <Listbox.Item {...rest} id={task.id} classNames={mx('px-0 py-0 group', className)} ref={forwardedRef}>
196
+ <div role='none' data-testid='taskList.item' className={ROW_GRID}>
197
+ {onTaskUpdate ? (
111
198
  <IconButton
112
199
  variant='ghost'
113
- density='xs'
200
+ density='sm'
114
201
  icon={done ? 'ph--check--regular' : 'ph--circle--regular'}
115
202
  iconOnly
116
203
  label={done ? 'Mark todo' : 'Mark done'}
117
- classNames={done ? 'text-success-text' : undefined}
204
+ classNames={mx('justify-self-center text-subdued', done && 'text-success-text')}
118
205
  onClick={handleToggle}
119
206
  />
120
207
  ) : (
121
208
  <Icon
122
209
  icon={done ? 'ph--check--regular' : 'ph--circle--regular'}
123
- classNames={done ? 'text-success-text' : undefined}
210
+ classNames={mx('justify-self-center', done && 'text-success-text')}
124
211
  size={4}
125
212
  />
126
213
  )}
127
214
  <span
128
- className={onSelect ? 'truncate flex-1 cursor-pointer' : 'truncate flex-1'}
129
- onClick={onSelect ? () => onSelect(task) : undefined}
215
+ className={onTaskSelect ? 'truncate cursor-pointer' : 'truncate'}
216
+ onClick={onTaskSelect ? () => onTaskSelect(task) : undefined}
130
217
  >
131
- {task.title}
218
+ {current.title}
132
219
  </span>
133
- {task.priority && task.priority !== 'none' && <Tag hue='neutral'>{task.priority}</Tag>}
134
- {task.assignee && <AssigneeChip assignee={task.assignee} />}
135
- {onDelete && (
136
- <IconButton
137
- variant='ghost'
138
- density='xs'
139
- icon='ph--x--regular'
140
- iconOnly
141
- label='Delete task'
142
- classNames='invisible group-hover:visible'
143
- onClick={() => onDelete(task)}
144
- />
145
- )}
220
+ <div className='flex items-center gap-1'>
221
+ {current.priority && current.priority !== 'none' && <Tag hue='neutral'>{current.priority}</Tag>}
222
+ {current.assignee && <TaskListAssignee assignee={current.assignee} />}
223
+ {onTaskDelete && (
224
+ <IconButton
225
+ variant='ghost'
226
+ density='sm'
227
+ icon='ph--x--regular'
228
+ iconOnly
229
+ label='Delete task'
230
+ classNames='invisible group-hover:visible'
231
+ onClick={() => onTaskDelete(task)}
232
+ />
233
+ )}
234
+ </div>
146
235
  </div>
147
236
  </Listbox.Item>
148
237
  );
149
- };
238
+ });
150
239
 
151
- type TaskCreateRowProps = {
152
- placeholder: string;
153
- onCreate: (title: string) => void;
154
- };
240
+ TaskListItem.displayName = 'TaskList.Item';
155
241
 
156
- const TaskCreateRow = ({ placeholder, onCreate }: TaskCreateRowProps) => {
157
- const [title, setTitle] = useState('');
158
- const handleKeyDown = useCallback(
159
- (event: KeyboardEvent<HTMLInputElement>) => {
160
- const trimmed = title.trim();
161
- if (event.key === 'Enter' && trimmed.length > 0) {
162
- onCreate(trimmed);
163
- setTitle('');
164
- }
165
- },
166
- [title, onCreate],
167
- );
242
+ //
243
+ // Create — the add row; renders nothing unless the root supplies `onTaskCreate`.
244
+ //
168
245
 
169
- return (
170
- <div className='flex items-center gap-2 px-2 py-1'>
171
- <Icon icon='ph--plus--regular' size={4} classNames='text-subdued' />
172
- <Input.Root>
173
- <Input.TextInput
174
- variant='subdued'
175
- placeholder={placeholder}
176
- value={title}
177
- onChange={(event) => setTitle(event.target.value)}
178
- onKeyDown={handleKeyDown}
179
- />
180
- </Input.Root>
181
- </div>
182
- );
183
- };
246
+ type TaskListCreateProps = ComposableProps<{ placeholder?: string }>;
184
247
 
185
- /**
186
- * Actor-aware assignee chip: a Person ref resolves to the contact's name; otherwise fall back to
187
- * name, email, or a shortened DID; agents (`role: 'assistant'`) are marked with a sparkle.
188
- */
189
- export const AssigneeChip = ({ assignee }: { assignee: Actor.Actor }) => {
248
+ const TaskListCreate = composable<HTMLDivElement, { placeholder?: string }>(
249
+ ({ placeholder = 'Add task', ...props }, forwardedRef) => {
250
+ const { onTaskCreate } = useTaskListContext('TaskList.Create');
251
+ const { className, ...rest } = composableProps(props);
252
+ const [title, setTitle] = useState('');
253
+ const handleKeyDown = useCallback(
254
+ (event: KeyboardEvent<HTMLInputElement>) => {
255
+ const trimmed = title.trim();
256
+ if (event.key === 'Enter' && trimmed.length > 0) {
257
+ onTaskCreate?.(trimmed);
258
+ setTitle('');
259
+ }
260
+ },
261
+ [title, onTaskCreate],
262
+ );
263
+
264
+ if (!onTaskCreate) {
265
+ return null;
266
+ }
267
+
268
+ return (
269
+ <div {...rest} data-testid='taskList.create' className={mx(ROW_GRID, className)} ref={forwardedRef}>
270
+ <Icon icon='ph--plus--regular' size={4} classNames='justify-self-center text-subdued' />
271
+ <Input.Root>
272
+ <Input.TextInput
273
+ variant='subdued'
274
+ placeholder={placeholder}
275
+ value={title}
276
+ onChange={(event) => setTitle(event.target.value)}
277
+ onKeyDown={handleKeyDown}
278
+ />
279
+ </Input.Root>
280
+ </div>
281
+ );
282
+ },
283
+ );
284
+
285
+ TaskListCreate.displayName = 'TaskList.Create';
286
+
287
+ //
288
+ // Assignee — actor-aware chip: a Person ref resolves to the contact's name; otherwise fall back to
289
+ // name, email, or a shortened DID; agents (`role: 'assistant'`) are marked with a sparkle.
290
+ //
291
+
292
+ type TaskListAssigneeProps = { assignee: Actor.Actor };
293
+
294
+ const TaskListAssignee = composable<HTMLSpanElement, TaskListAssigneeProps>(({ assignee }, _forwardedRef) => {
190
295
  const [contact] = useObject(assignee.contact);
191
296
  const label =
192
297
  contact?.fullName ??
@@ -204,6 +309,32 @@ export const AssigneeChip = ({ assignee }: { assignee: Actor.Actor }) => {
204
309
  {label ?? 'agent'}
205
310
  </Tag>
206
311
  );
207
- };
312
+ });
313
+
314
+ TaskListAssignee.displayName = 'TaskList.Assignee';
208
315
 
209
316
  const shortDid = (did: string): string => `${did.slice(0, 12)}…`;
317
+
318
+ //
319
+ // TaskList
320
+ //
321
+
322
+ export const TaskList = {
323
+ Root: TaskListRoot,
324
+ Viewport: TaskListViewport,
325
+ Content: TaskListContent,
326
+ GroupLabel: TaskListGroupLabel,
327
+ Item: TaskListItem,
328
+ Create: TaskListCreate,
329
+ Assignee: TaskListAssignee,
330
+ };
331
+
332
+ export type {
333
+ TaskListAssigneeProps,
334
+ TaskListContentProps,
335
+ TaskListCreateProps,
336
+ TaskListGroupLabelProps,
337
+ TaskListItemProps,
338
+ TaskListRootProps,
339
+ TaskListViewportProps,
340
+ };