@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/dist/lib/index.mjs +121 -66
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/components/TaskList/TaskList.d.ts +50 -18
- package/dist/types/src/components/TaskList/TaskList.d.ts.map +1 -1
- package/dist/types/src/components/TaskList/TaskList.stories.d.ts +1 -1
- package/dist/types/src/components/TaskList/TaskList.stories.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -10
- package/src/components/TaskList/TaskList.stories.tsx +44 -10
- package/src/components/TaskList/TaskList.tsx +238 -107
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/react-ui-task",
|
|
3
|
-
"version": "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
|
-
"@
|
|
28
|
-
"@dxos/echo
|
|
29
|
-
"@dxos/react-ui": "0.
|
|
30
|
-
"@dxos/
|
|
31
|
-
"@dxos/
|
|
32
|
-
"@dxos/ui-theme": "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.
|
|
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.
|
|
51
|
-
"@dxos/ui-theme": "^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
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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:
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
67
|
+
/** Enables `Create`; called with the trimmed title. */
|
|
34
68
|
onTaskCreate?: (title: string) => void;
|
|
35
|
-
/** Enables the done toggle
|
|
69
|
+
/** Enables the done toggle. Every mutation is delegated — the list never writes. */
|
|
36
70
|
onTaskUpdate?: (task: Task.Task, patch: TaskPatch) => void;
|
|
37
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
<
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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='
|
|
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
|
|
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
|
|
210
|
+
classNames={mx('justify-self-center', done && 'text-success-text')}
|
|
124
211
|
size={4}
|
|
125
212
|
/>
|
|
126
213
|
)}
|
|
127
214
|
<span
|
|
128
|
-
className={
|
|
129
|
-
onClick={
|
|
215
|
+
className={onTaskSelect ? 'truncate cursor-pointer' : 'truncate'}
|
|
216
|
+
onClick={onTaskSelect ? () => onTaskSelect(task) : undefined}
|
|
130
217
|
>
|
|
131
|
-
{
|
|
218
|
+
{current.title}
|
|
132
219
|
</span>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
152
|
-
placeholder: string;
|
|
153
|
-
onCreate: (title: string) => void;
|
|
154
|
-
};
|
|
240
|
+
TaskListItem.displayName = 'TaskList.Item';
|
|
155
241
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
};
|