@dxos/react-ui-task 0.10.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 ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@dxos/react-ui-task",
3
+ "version": "0.10.0",
4
+ "description": "Task list components over the durable Task/TaskSet model.",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
12
+ "author": "DXOS.org",
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "source": "./src/index.ts",
17
+ "types": "./dist/types/src/index.d.ts",
18
+ "import": "./dist/lib/index.mjs"
19
+ }
20
+ },
21
+ "types": "dist/types/src/index.d.ts",
22
+ "files": [
23
+ "dist",
24
+ "src"
25
+ ],
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"
33
+ },
34
+ "devDependencies": {
35
+ "@testing-library/jest-dom": "^6.9.1",
36
+ "@testing-library/react": "^16.3.2",
37
+ "@types/react": "~19.2.17",
38
+ "@types/react-dom": "~19.2.3",
39
+ "effect": "3.21.4",
40
+ "react": "~19.2.7",
41
+ "react-dom": "~19.2.7",
42
+ "resize-observer-polyfill": "^1.5.1",
43
+ "vite": "^8.1.4",
44
+ "@dxos/storybook-utils": "0.10.0"
45
+ },
46
+ "peerDependencies": {
47
+ "effect": "3.21.4",
48
+ "react": "~19.2.7",
49
+ "react-dom": "~19.2.7",
50
+ "@dxos/react-ui": "^0.10.0",
51
+ "@dxos/ui-theme": "^0.10.0"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ }
56
+ }
@@ -0,0 +1,66 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { useCallback, useState } from 'react';
7
+
8
+ import { Obj } from '@dxos/echo';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+ import { Task } from '@dxos/types';
11
+
12
+ import { TaskList, type TaskPatch } from './TaskList';
13
+
14
+ const seed = (): Task.Task[] => [
15
+ Task.make({ title: 'Source green coffee', status: 'done', priority: 'high' }),
16
+ Task.make({ title: 'Finalize roast curve', status: 'in-progress', priority: 'high' }),
17
+ Task.make({ title: 'Draft launch email', status: 'in-progress', assignee: { role: 'assistant', name: 'Scout' } }),
18
+ Task.make({ title: 'Design label', status: 'todo', assignee: { email: 'riley@example.com' } }),
19
+ Task.make({ title: 'Print run v1', status: 'cancelled' }),
20
+ ];
21
+
22
+ const DefaultStory = ({ readonly }: { readonly?: boolean }) => {
23
+ const [tasks, setTasks] = useState<Task.Task[]>(seed);
24
+
25
+ const handleCreate = useCallback((title: string) => {
26
+ setTasks((tasks) => [...tasks, Task.make({ title, status: 'todo' })]);
27
+ }, []);
28
+
29
+ const handleUpdate = useCallback((task: Task.Task, patch: TaskPatch) => {
30
+ Obj.update(task, (task) => {
31
+ Object.assign(task, patch);
32
+ });
33
+ setTasks((tasks) => [...tasks]);
34
+ }, []);
35
+
36
+ const handleDelete = useCallback((task: Task.Task) => {
37
+ setTasks((tasks) => tasks.filter(({ id }) => id !== task.id));
38
+ }, []);
39
+
40
+ 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>
49
+ );
50
+ };
51
+
52
+ const meta = {
53
+ title: 'ui/react-ui-task/TaskList',
54
+ render: (args) => <DefaultStory {...args} />,
55
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
56
+ } satisfies Meta<typeof DefaultStory>;
57
+
58
+ export default meta;
59
+
60
+ type Story = StoryObj<typeof meta>;
61
+
62
+ export const Default: Story = {};
63
+
64
+ export const Readonly: Story = {
65
+ args: { readonly: true },
66
+ };
@@ -0,0 +1,209 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { type KeyboardEvent, useCallback, useMemo, useState } from 'react';
6
+
7
+ import { useObject } from '@dxos/echo-react';
8
+ import { Icon, IconButton, Input, Tag, type ThemedClassName } from '@dxos/react-ui';
9
+ import { Listbox } from '@dxos/react-ui-list';
10
+ import { type Actor, type Task } from '@dxos/types';
11
+
12
+ export type TaskStatus = NonNullable<Task.Task['status']>;
13
+
14
+ /** Linear-style status groups, most active first. */
15
+ export const STATUS_ORDER: TaskStatus[] = ['in-progress', 'todo', 'done', 'failed', 'cancelled'];
16
+
17
+ const DEFAULT_STATUS_LABELS: Record<TaskStatus, string> = {
18
+ 'in-progress': 'In progress',
19
+ 'todo': 'Todo',
20
+ 'done': 'Done',
21
+ 'failed': 'Failed',
22
+ 'cancelled': 'Cancelled',
23
+ };
24
+
25
+ export type TaskPatch = Partial<Pick<Task.Task, 'title' | 'status' | 'priority' | 'estimate' | 'assignee'>>;
26
+
27
+ export type TaskListProps = ThemedClassName<{
28
+ tasks: readonly Task.Task[];
29
+ /** Group rows into status sections (Linear order); flat list otherwise. */
30
+ groupByStatus?: boolean;
31
+ /** i18n hook for group headings; defaults to English labels. */
32
+ statusLabel?: (status: TaskStatus) => string;
33
+ /** Renders an add row; called with the trimmed title on Enter. */
34
+ onTaskCreate?: (title: string) => void;
35
+ /** Enables the done toggle (status patch) and any future field affordances. */
36
+ onTaskUpdate?: (task: Task.Task, patch: TaskPatch) => void;
37
+ /** Renders a per-row delete affordance. */
38
+ onTaskDelete?: (task: Task.Task) => void;
39
+ /** Row click. */
40
+ onTaskSelect?: (task: Task.Task) => void;
41
+ createPlaceholder?: string;
42
+ }>;
43
+
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,
50
+ tasks,
51
+ groupByStatus = true,
52
+ statusLabel = (status) => DEFAULT_STATUS_LABELS[status],
53
+ onTaskCreate,
54
+ onTaskUpdate,
55
+ onTaskDelete,
56
+ onTaskSelect,
57
+ createPlaceholder = 'Add task',
58
+ }: TaskListProps) => {
59
+ const groups = useMemo(() => {
60
+ if (!groupByStatus) {
61
+ return tasks.length > 0 ? [{ status: undefined, tasks }] : [];
62
+ }
63
+ return STATUS_ORDER.map((status) => ({
64
+ status,
65
+ tasks: tasks.filter((task) => (task.status ?? 'todo') === status),
66
+ })).filter((group) => group.tasks.length > 0);
67
+ }, [tasks, groupByStatus]);
68
+
69
+ 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} />}
92
+ </div>
93
+ );
94
+ };
95
+
96
+ type TaskRowProps = {
97
+ task: Task.Task;
98
+ onUpdate?: TaskListProps['onTaskUpdate'];
99
+ onDelete?: TaskListProps['onTaskDelete'];
100
+ onSelect?: TaskListProps['onTaskSelect'];
101
+ };
102
+
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]);
106
+
107
+ return (
108
+ <Listbox.Item id={task.id} classNames='py-0 group'>
109
+ <div className='flex items-center gap-2 min-w-0'>
110
+ {onUpdate ? (
111
+ <IconButton
112
+ variant='ghost'
113
+ density='xs'
114
+ icon={done ? 'ph--check--regular' : 'ph--circle--regular'}
115
+ iconOnly
116
+ label={done ? 'Mark todo' : 'Mark done'}
117
+ classNames={done ? 'text-success-text' : undefined}
118
+ onClick={handleToggle}
119
+ />
120
+ ) : (
121
+ <Icon
122
+ icon={done ? 'ph--check--regular' : 'ph--circle--regular'}
123
+ classNames={done ? 'text-success-text' : undefined}
124
+ size={4}
125
+ />
126
+ )}
127
+ <span
128
+ className={onSelect ? 'truncate flex-1 cursor-pointer' : 'truncate flex-1'}
129
+ onClick={onSelect ? () => onSelect(task) : undefined}
130
+ >
131
+ {task.title}
132
+ </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
+ )}
146
+ </div>
147
+ </Listbox.Item>
148
+ );
149
+ };
150
+
151
+ type TaskCreateRowProps = {
152
+ placeholder: string;
153
+ onCreate: (title: string) => void;
154
+ };
155
+
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
+ );
168
+
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
+ };
184
+
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 }) => {
190
+ const [contact] = useObject(assignee.contact);
191
+ const label =
192
+ contact?.fullName ??
193
+ assignee.name ??
194
+ assignee.email ??
195
+ (assignee.identityDid ? shortDid(assignee.identityDid) : undefined);
196
+ const agent = assignee.role === 'assistant';
197
+ if (!label && !agent) {
198
+ return null;
199
+ }
200
+
201
+ return (
202
+ <Tag hue={agent ? 'purple' : 'indigo'}>
203
+ {agent && <Icon icon='ph--sparkle--regular' size={3} classNames='inline-block me-1' />}
204
+ {label ?? 'agent'}
205
+ </Tag>
206
+ );
207
+ };
208
+
209
+ const shortDid = (did: string): string => `${did.slice(0, 12)}…`;
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './TaskList';
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './components/TaskList';
@@ -0,0 +1,11 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // https://github.com/testing-library/jest-dom#with-vitest
6
+ import '@testing-library/jest-dom/vitest';
7
+
8
+ // https://github.com/jsdom/jsdom/issues/3368#issuecomment-1396749033
9
+ import ResizeObserver from 'resize-observer-polyfill';
10
+
11
+ global.ResizeObserver = ResizeObserver;