@sanity/workflow-components 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanity, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @sanity/workflow-components
2
+
3
+ Reusable React controls for Editorial Workflows member, assignee, and date
4
+ fields. The package uses `@sanity/ui` but has no dependency on Sanity Studio or
5
+ the App SDK, so the same components can be used in either integration.
6
+
7
+ Render the components inside a Sanity UI `ThemeProvider`. Member controls take
8
+ project-scoped user ids: the same id namespace returned by GROQ `identity()` and
9
+ stored by `@sanity/workflow-engine` assignee values.
10
+
11
+ ## Member selection
12
+
13
+ Supply the project directory explicitly. An App SDK integration can load this
14
+ from its project-user data source; a Studio integration can adapt its existing
15
+ project-member query to the same shape.
16
+
17
+ ```tsx
18
+ import {MemberPicker, type ProjectMember} from '@sanity/workflow-components'
19
+
20
+ const members: ProjectMember[] = [
21
+ {
22
+ id: 'project-user-id',
23
+ displayName: 'Ada Lovelace',
24
+ email: 'ada@example.com',
25
+ roles: ['editor'],
26
+ },
27
+ ]
28
+
29
+ export function OwnerPicker() {
30
+ return (
31
+ <MemberPicker
32
+ error={undefined}
33
+ loading={false}
34
+ members={members}
35
+ onSelect={(member) => console.log(member?.id)}
36
+ selectedIds={new Set()}
37
+ unassignRow
38
+ />
39
+ )
40
+ }
41
+ ```
42
+
43
+ `MemberPicker` reports a `ProjectMember` or `null` for the optional unassigned
44
+ row. `AssigneePicker` reports the engine's user-or-role `Assignee` values and
45
+ leaves single- versus multi-value behavior to the caller. `AssigneeBadge`,
46
+ `AssigneeBadges`, `MemberAvatar`, and `MemberAvatarGroup` provide matching
47
+ read-only presentation.
48
+
49
+ ## Dates
50
+
51
+ `DatePicker` emits local JavaScript `Date` values after complete calendar or
52
+ time gestures. Storage remains the caller's responsibility, so the control
53
+ works with an engine field edit, an action parameter, or local application
54
+ state.
55
+
56
+ ```tsx
57
+ import {ClearableDatePicker} from '@sanity/workflow-components'
58
+ ;<ClearableDatePicker
59
+ clearLabel="Clear due date"
60
+ onClear={() => setDueDate(undefined)}
61
+ onPick={setDueDate}
62
+ value={dueDate}
63
+ />
64
+ ```
65
+
66
+ Set `selectTime` when time-of-day is part of the workflow field.
package/dist/index.cjs ADDED
@@ -0,0 +1,603 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: !0
5
+ });
6
+
7
+ var jsxRuntime = require("react/jsx-runtime"), ui = require("@sanity/ui"), Close = require("@sanity/icons/Close"), ChevronLeft = require("@sanity/icons/ChevronLeft"), ChevronRight = require("@sanity/icons/ChevronRight"), addDays = require("date-fns/addDays"), addMonths = require("date-fns/addMonths"), setDate = require("date-fns/setDate"), setHours = require("date-fns/setHours"), setMinutes = require("date-fns/setMinutes"), setMonth = require("date-fns/setMonth"), setYear = require("date-fns/setYear"), react = require("react"), eachWeekOfInterval = require("date-fns/eachWeekOfInterval"), isSameDay = require("date-fns/isSameDay"), isSameMonth = require("date-fns/isSameMonth"), lastDayOfMonth = require("date-fns/lastDayOfMonth"), startOfMonth = require("date-fns/startOfMonth"), Checkmark = require("@sanity/icons/Checkmark"), Empty = require("@sanity/icons/Empty"), Users = require("@sanity/icons/Users");
8
+
9
+ function roleListLabel(roles) {
10
+ return `${roles.length === 1 ? "Role" : "Roles"}: ${roles.join(", ")}`;
11
+ }
12
+
13
+ function AssigneeBadge({assignee: assignee, members: members}) {
14
+ if (assignee.type === "role") /* @__PURE__ */
15
+ return jsxRuntime.jsxs(ui.Badge, {
16
+ mode: "outline",
17
+ children: [ "Role: ", assignee.role ]
18
+ });
19
+ const member = members.find(candidate => candidate.id === assignee.id);
20
+ /* @__PURE__ */
21
+ return jsxRuntime.jsx(ui.Badge, {
22
+ mode: "outline",
23
+ children: member?.displayName ?? assignee.id
24
+ });
25
+ }
26
+
27
+ function AssigneeBadges({assignees: assignees, members: members}) {
28
+ const roles = assignees.flatMap(assignee => assignee.type === "role" ? [ assignee.role ] : []), users = assignees.filter(assignee => assignee.type === "user");
29
+ /* @__PURE__ */
30
+ return jsxRuntime.jsxs(ui.Flex, {
31
+ align: "center",
32
+ gap: 1,
33
+ wrap: "wrap",
34
+ children: [ users.map(assignee => /* @__PURE__ */ jsxRuntime.jsx(AssigneeBadge, {
35
+ assignee: assignee,
36
+ members: members
37
+ }, `user:${assignee.id}`)), roles.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(ui.Badge, {
38
+ mode: "outline",
39
+ children: roleListLabel(roles)
40
+ }) : null ]
41
+ });
42
+ }
43
+
44
+ const WEEK_DAY_NAMES = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ];
45
+
46
+ function weeksOfMonth(date) {
47
+ const first = startOfMonth.startOfMonth(date);
48
+ return eachWeekOfInterval.eachWeekOfInterval({
49
+ start: first,
50
+ end: lastDayOfMonth.lastDayOfMonth(first)
51
+ }).map(weekStart => [ weekStart, ...WEEK_DAY_NAMES.slice(1).map((_, i) => addDays.addDays(weekStart, i + 1)) ]);
52
+ }
53
+
54
+ function CalendarMonth({focused: focused, onSelect: onSelect, selected: selected}) {
55
+ /* @__PURE__ */
56
+ return jsxRuntime.jsxs(ui.Grid, {
57
+ gap: 1,
58
+ style: {
59
+ gridTemplateColumns: "repeat(7, minmax(44px, 46px))"
60
+ },
61
+ children: [ WEEK_DAY_NAMES.map(weekday => /* @__PURE__ */ jsxRuntime.jsx(ui.Box, {
62
+ paddingY: 2,
63
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, {
64
+ size: 1,
65
+ style: {
66
+ textAlign: "center"
67
+ },
68
+ weight: "medium",
69
+ children: weekday
70
+ })
71
+ }, weekday)), weeksOfMonth(focused).map(week => week.map(day => /* @__PURE__ */ jsxRuntime.jsx(CalendarDay, {
72
+ date: day,
73
+ focused: isSameDay.isSameDay(day, focused),
74
+ isCurrentMonth: isSameMonth.isSameMonth(day, focused),
75
+ isToday: isSameDay.isSameDay(day, /* @__PURE__ */ new Date),
76
+ onSelect: onSelect,
77
+ selected: selected !== void 0 && isSameDay.isSameDay(day, selected)
78
+ }, day.toISOString()))) ]
79
+ });
80
+ }
81
+
82
+ function CalendarDay({date: date, focused: focused, isCurrentMonth: isCurrentMonth, isToday: isToday, onSelect: onSelect, selected: selected}) {
83
+ /* @__PURE__ */
84
+ return jsxRuntime.jsx(ui.Card, {
85
+ __unstable_focusRing: !0,
86
+ "aria-label": date.toDateString(),
87
+ "aria-pressed": selected,
88
+ as: "button",
89
+ "data-focused": focused ? "true" : "",
90
+ "data-testid": `calendar-day-${date.toDateString().replaceAll(" ", "-")}`,
91
+ onClick: () => onSelect(date),
92
+ padding: 2,
93
+ radius: 2,
94
+ selected: selected,
95
+ tabIndex: -1,
96
+ tone: isToday || selected ? "primary" : "default",
97
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, {
98
+ muted: !selected && !isCurrentMonth,
99
+ size: 1,
100
+ style: {
101
+ textAlign: "center"
102
+ },
103
+ weight: isCurrentMonth ? "medium" : "regular",
104
+ children: date.getDate()
105
+ })
106
+ });
107
+ }
108
+
109
+ function pad2(value) {
110
+ return String(value).padStart(2, "0");
111
+ }
112
+
113
+ const LazyTextInput = react.forwardRef(function({onBlur: onBlur, onChange: onChange, onKeyDown: onKeyDown, value: value, ...rest}, forwardedRef) {
114
+ const [inputValue, setInputValue] = react.useState(), handleChange = react.useCallback(event => {
115
+ setInputValue(event.currentTarget.value);
116
+ }, []), emitIfChanged = react.useCallback(event => {
117
+ event.currentTarget.value !== `${value}` && onChange && onChange(event), setInputValue(void 0);
118
+ }, [ onChange, value ]), handleBlur = react.useCallback(event => {
119
+ emitIfChanged(event), onBlur?.(event);
120
+ }, [ emitIfChanged, onBlur ]), handleKeyDown = react.useCallback(event => {
121
+ event.key === "Enter" && emitIfChanged(event), onKeyDown?.(event);
122
+ }, [ emitIfChanged, onKeyDown ]);
123
+ /* @__PURE__ */
124
+ return jsxRuntime.jsx(ui.TextInput, {
125
+ ...rest,
126
+ onBlur: handleBlur,
127
+ onChange: handleChange,
128
+ onKeyDown: handleKeyDown,
129
+ ref: forwardedRef,
130
+ value: inputValue === void 0 ? value : inputValue
131
+ });
132
+ }), MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ], ARROW_KEYS = [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight" ], PRESERVE_FOCUS_ELEMENT = /* @__PURE__ */ jsxRuntime.jsx("span", {
133
+ "data-preserve-focus": !0,
134
+ style: {
135
+ outline: "none",
136
+ overflow: "hidden",
137
+ position: "absolute"
138
+ },
139
+ tabIndex: -1
140
+ });
141
+
142
+ function DatePicker({onSelect: onSelect, selectTime: selectTime = !1, value: value}) {
143
+ const base = react.useMemo(() => {
144
+ if (value) return value;
145
+ const now = /* @__PURE__ */ new Date;
146
+ return now.setSeconds(0, 0), now;
147
+ }, [ value ]), [focusedDate, setFocusedDate] = react.useState(base), ref = react.useRef(null), handleDaySelect = react.useCallback(date => onSelect(setMinutes.setMinutes(setHours.setHours(date, base.getHours()), base.getMinutes())), [ base, onSelect ]), handleTimeCommit = react.useCallback((hours, minutes) => onSelect(setHours.setHours(setMinutes.setMinutes(base, minutes), hours)), [ base, onSelect ]), handleNowClick = react.useCallback(() => {
148
+ const now = /* @__PURE__ */ new Date;
149
+ setFocusedDate(now), onSelect(now);
150
+ }, [ onSelect ]), handleMonthChange = react.useCallback(event => {
151
+ const month = Number(event.currentTarget.value);
152
+ setFocusedDate(current => setDate.setDate(setMonth.setMonth(current, month), 1));
153
+ }, []), moveFocusedDate = react.useCallback(byMonths => setFocusedDate(current => addMonths.addMonths(current, byMonths)), []), setFocusedYear = react.useCallback(year => setFocusedDate(current => setYear.setYear(current, year)), []), focusCurrentWeekDay = react.useCallback(() => {
154
+ ref.current?.querySelector('[data-focused="true"]')?.focus();
155
+ }, []), handleKeyDown = react.useCallback(event => {
156
+ if (!ARROW_KEYS.includes(event.key)) return;
157
+ if (event.preventDefault(), event.target instanceof HTMLElement && event.target.hasAttribute("data-calendar-grid")) {
158
+ focusCurrentWeekDay();
159
+ return;
160
+ }
161
+ const byDays = {
162
+ ArrowUp: -7,
163
+ ArrowDown: 7,
164
+ ArrowLeft: -1,
165
+ ArrowRight: 1
166
+ }[event.key] ?? 0;
167
+ setFocusedDate(current => addDays.addDays(current, byDays)), ref.current?.querySelector("[data-preserve-focus]")?.focus();
168
+ }, [ focusCurrentWeekDay ]);
169
+ return react.useEffect(() => {
170
+ document.activeElement?.matches("[data-calendar-grid], [data-calendar-grid] [data-preserve-focus]") && focusCurrentWeekDay();
171
+ }, [ focusCurrentWeekDay, focusedDate ]), /* @__PURE__ */ jsxRuntime.jsxs(ui.Box, {
172
+ "data-testid": "date-picker",
173
+ ref: ref,
174
+ children: [
175
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Box, {
176
+ padding: 2,
177
+ children: [
178
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, {
179
+ children: [
180
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Box, {
181
+ flex: 1,
182
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.Select, {
183
+ "aria-label": "Month",
184
+ fontSize: 1,
185
+ onChange: handleMonthChange,
186
+ padding: 2,
187
+ radius: 2,
188
+ value: focusedDate.getMonth(),
189
+ children: MONTH_NAMES.map((name, i) => /* @__PURE__ */ jsxRuntime.jsx("option", {
190
+ value: i,
191
+ children: name
192
+ }, name))
193
+ })
194
+ }),
195
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, {
196
+ align: "center",
197
+ gap: 1,
198
+ marginLeft: 2,
199
+ children: [
200
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Button, {
201
+ "aria-label": "Previous year",
202
+ icon: ChevronLeft.ChevronLeftIcon,
203
+ mode: "bleed",
204
+ onClick: () => moveFocusedDate(-12),
205
+ padding: 2
206
+ }),
207
+ /* @__PURE__ */ jsxRuntime.jsx(YearInput, {
208
+ onChange: setFocusedYear,
209
+ value: focusedDate.getFullYear()
210
+ }),
211
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Button, {
212
+ "aria-label": "Next year",
213
+ icon: ChevronRight.ChevronRightIcon,
214
+ mode: "bleed",
215
+ onClick: () => moveFocusedDate(12),
216
+ padding: 2
217
+ }) ]
218
+ }) ]
219
+ }),
220
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Box, {
221
+ "data-calendar-grid": !0,
222
+ marginTop: 2,
223
+ onKeyDown: handleKeyDown,
224
+ overflow: "hidden",
225
+ tabIndex: 0,
226
+ children: [
227
+ /* @__PURE__ */ jsxRuntime.jsx(CalendarMonth, {
228
+ focused: focusedDate,
229
+ onSelect: handleDaySelect,
230
+ selected: value
231
+ }), PRESERVE_FOCUS_ELEMENT ]
232
+ }) ]
233
+ }), selectTime ? /* @__PURE__ */ jsxRuntime.jsx(ui.Card, {
234
+ borderTop: !0,
235
+ padding: 2,
236
+ children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, {
237
+ align: "center",
238
+ gap: 2,
239
+ children: [
240
+ /* @__PURE__ */ jsxRuntime.jsx(TimeField, {
241
+ onCommit: handleTimeCommit,
242
+ value: `${pad2(base.getHours())}:${pad2(base.getMinutes())}`
243
+ }),
244
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Button, {
245
+ fontSize: 1,
246
+ mode: "bleed",
247
+ onClick: handleNowClick,
248
+ padding: 2,
249
+ text: "Set to current time"
250
+ }) ]
251
+ })
252
+ }) : null ]
253
+ });
254
+ }
255
+
256
+ function TimeField({onCommit: onCommit, value: value}) {
257
+ /* @__PURE__ */
258
+ return jsxRuntime.jsx(LazyTextInput, {
259
+ "aria-label": "Select time",
260
+ fontSize: 1,
261
+ onChange: event => {
262
+ const [hours, minutes] = event.currentTarget.value.split(":").map(Number);
263
+ hours !== void 0 && minutes !== void 0 && !Number.isNaN(hours + minutes) && onCommit(hours, minutes);
264
+ },
265
+ padding: 2,
266
+ type: "time",
267
+ value: value
268
+ });
269
+ }
270
+
271
+ function YearInput({onChange: onChange, value: value}) {
272
+ /* @__PURE__ */
273
+ return jsxRuntime.jsx(LazyTextInput, {
274
+ "aria-label": "Year",
275
+ fontSize: 1,
276
+ inputMode: "numeric",
277
+ onChange: event => {
278
+ const year = Number.parseInt(event.currentTarget.value, 10);
279
+ Number.isNaN(year) || onChange(year);
280
+ },
281
+ padding: 2,
282
+ radius: 2,
283
+ style: {
284
+ textAlign: "center",
285
+ width: 48
286
+ },
287
+ value: value
288
+ });
289
+ }
290
+
291
+ function ClearableDatePicker({value: value, selectTime: selectTime = !1, clearLabel: clearLabel, onPick: onPick, onClear: onClear}) {
292
+ /* @__PURE__ */
293
+ return jsxRuntime.jsxs(ui.Box, {
294
+ children: [
295
+ /* @__PURE__ */ jsxRuntime.jsx(DatePicker, {
296
+ onSelect: onPick,
297
+ selectTime: selectTime,
298
+ value: value
299
+ }), value ? /* @__PURE__ */ jsxRuntime.jsx(ui.Card, {
300
+ borderTop: !0,
301
+ padding: 1,
302
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, {
303
+ fontSize: 1,
304
+ icon: Close.CloseIcon,
305
+ mode: "bleed",
306
+ onClick: onClear,
307
+ padding: 2,
308
+ text: clearLabel
309
+ })
310
+ }) : null ]
311
+ });
312
+ }
313
+
314
+ function matchesMemberSearch(member, query) {
315
+ const normalized = query.trim().toLowerCase();
316
+ return normalized ? member.displayName.toLowerCase().includes(normalized) || (member.email?.toLowerCase().includes(normalized) ?? !1) : !0;
317
+ }
318
+
319
+ function memberInitials(name) {
320
+ return name.split(/\s+/).filter(Boolean).map(word => word[0]).slice(0, 2).join("").toUpperCase() || "?";
321
+ }
322
+
323
+ function MemberAvatar({name: name, imageUrl: imageUrl, size: size = 0}) {
324
+ /* @__PURE__ */
325
+ return jsxRuntime.jsx(ui.Avatar, {
326
+ __unstable_hideInnerStroke: !0,
327
+ initials: memberInitials(name),
328
+ size: size,
329
+ ...imageUrl ? {
330
+ src: imageUrl
331
+ } : {}
332
+ });
333
+ }
334
+
335
+ function MemberAvatarGroup({members: members, maxLength: maxLength = 3}) {
336
+ return members.length === 0 ? null : /* @__PURE__ */ jsxRuntime.jsx(ui.AvatarStack, {
337
+ maxLength: maxLength,
338
+ size: 0,
339
+ style: {
340
+ lineHeight: 0
341
+ },
342
+ children: members.map(member => /* @__PURE__ */ jsxRuntime.jsx(MemberAvatar, {
343
+ imageUrl: member.imageUrl,
344
+ name: member.displayName
345
+ }, member.id))
346
+ });
347
+ }
348
+
349
+ function PickerStatus({loading: loading, error: error}) {
350
+ return loading ? /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, {
351
+ align: "center",
352
+ gap: 2,
353
+ padding: 3,
354
+ children: [
355
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Spinner, {
356
+ muted: !0,
357
+ size: 1
358
+ }),
359
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, {
360
+ muted: !0,
361
+ size: 1,
362
+ children: "Loading members…"
363
+ }) ]
364
+ }) : error ? /* @__PURE__ */ jsxRuntime.jsx(ui.Box, {
365
+ padding: 3,
366
+ children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, {
367
+ muted: !0,
368
+ size: 1,
369
+ children: [ "Could not list members: ", error ]
370
+ })
371
+ }) : null;
372
+ }
373
+
374
+ const ActiveRowContext = react.createContext(void 0);
375
+
376
+ function PickerShell({children: children, empty: empty, error: error, loading: loading, placeholder: placeholder, query: query, onQueryChange: onQueryChange}) {
377
+ const listRef = react.useRef(null), listId = react.useId(), [activeIndex, setActiveIndex] = react.useState(-1), [activeRowId, setActiveRowId] = react.useState(), handleKeyDown = react.useCallback(event => {
378
+ const rows = listRef.current?.querySelectorAll("[data-picker-row]") ?? [];
379
+ if (rows.length === 0) return;
380
+ if (event.key === "Enter" && activeIndex >= 0) {
381
+ event.preventDefault(), rows[activeIndex]?.click();
382
+ return;
383
+ }
384
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
385
+ event.preventDefault();
386
+ const direction = event.key === "ArrowDown" ? 1 : -1;
387
+ setActiveIndex(current => {
388
+ let startingIndex = current;
389
+ current < 0 && (startingIndex = direction > 0 ? -1 : 0);
390
+ const nextIndex = (startingIndex + direction + rows.length) % rows.length, nextRow = rows[nextIndex];
391
+ return setActiveRowId(nextRow?.id), nextRow?.scrollIntoView?.({
392
+ block: "nearest"
393
+ }), nextIndex;
394
+ });
395
+ }, [ activeIndex ]);
396
+ return loading || error ? /* @__PURE__ */ jsxRuntime.jsx(PickerStatus, {
397
+ error: error,
398
+ loading: loading
399
+ }) : /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, {
400
+ direction: "column",
401
+ style: {
402
+ width: 300
403
+ },
404
+ children: [
405
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Box, {
406
+ padding: 1,
407
+ paddingBottom: 0,
408
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.TextInput, {
409
+ "aria-activedescendant": activeRowId,
410
+ "aria-controls": listId,
411
+ "aria-expanded": !0,
412
+ "aria-label": placeholder,
413
+ fontSize: 1,
414
+ onChange: event => {
415
+ setActiveIndex(-1), setActiveRowId(void 0), onQueryChange(event.currentTarget.value);
416
+ },
417
+ onKeyDown: handleKeyDown,
418
+ placeholder: placeholder,
419
+ radius: 2,
420
+ role: "combobox",
421
+ value: query
422
+ })
423
+ }), empty ? /* @__PURE__ */ jsxRuntime.jsx(ui.Box, {
424
+ padding: 3,
425
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, {
426
+ muted: !0,
427
+ size: 1,
428
+ children: "Nothing matches"
429
+ })
430
+ }) : /* @__PURE__ */ jsxRuntime.jsx(ui.Stack, {
431
+ as: "ul",
432
+ id: listId,
433
+ ref: listRef,
434
+ role: "listbox",
435
+ padding: 1,
436
+ space: 1,
437
+ style: {
438
+ listStyle: "none",
439
+ maxHeight: 360,
440
+ overflowY: "auto"
441
+ },
442
+ children: /* @__PURE__ */ jsxRuntime.jsx(ActiveRowContext, {
443
+ value: activeRowId,
444
+ children: children
445
+ })
446
+ }) ]
447
+ });
448
+ }
449
+
450
+ function PickerRow({icon: icon, label: label, onSelect: onSelect, rowId: rowId, selected: selected}) {
451
+ const active = react.useContext(ActiveRowContext) === rowId;
452
+ /* @__PURE__ */
453
+ return jsxRuntime.jsx(ui.Box, {
454
+ as: "li",
455
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, {
456
+ "aria-pressed": selected,
457
+ "aria-selected": active,
458
+ "data-picker-row": !0,
459
+ id: rowId,
460
+ justify: "flex-start",
461
+ mode: active ? "ghost" : "bleed",
462
+ onClick: onSelect,
463
+ padding: 2,
464
+ style: {
465
+ width: "100%"
466
+ },
467
+ role: "option",
468
+ children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Flex, {
469
+ align: "center",
470
+ gap: 2,
471
+ style: {
472
+ width: "100%"
473
+ },
474
+ children: [ icon,
475
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Box, {
476
+ flex: 1,
477
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, {
478
+ align: "left",
479
+ size: 1,
480
+ textOverflow: "ellipsis",
481
+ children: label
482
+ })
483
+ }), selected ? /* @__PURE__ */ jsxRuntime.jsx(Checkmark.CheckmarkIcon, {}) : null ]
484
+ })
485
+ })
486
+ });
487
+ }
488
+
489
+ function Glyph({children: children}) {
490
+ /* @__PURE__ */
491
+ return jsxRuntime.jsx(ui.Text, {
492
+ muted: !0,
493
+ size: 2,
494
+ children: children
495
+ });
496
+ }
497
+
498
+ function MemberPicker({error: error, loading: loading, members: members, onSelect: onSelect, selectedIds: selectedIds, unassignRow: unassignRow = !1}) {
499
+ const [query, setQuery] = react.useState(""), matchingMembers = react.useMemo(() => members.filter(member => matchesMemberSearch(member, query)), [ members, query ]), showUnassigned = unassignRow && query.trim() === "";
500
+ /* @__PURE__ */
501
+ return jsxRuntime.jsxs(PickerShell, {
502
+ empty: !showUnassigned && matchingMembers.length === 0,
503
+ error: error,
504
+ loading: loading,
505
+ members: members,
506
+ onQueryChange: setQuery,
507
+ placeholder: "Search members…",
508
+ query: query,
509
+ children: [ showUnassigned ? /* @__PURE__ */ jsxRuntime.jsx(PickerRow, {
510
+ icon: /* @__PURE__ */ jsxRuntime.jsx(Glyph, {
511
+ children: /* @__PURE__ */ jsxRuntime.jsx(Empty.EmptyIcon, {})
512
+ }),
513
+ label: "No assignee",
514
+ onSelect: () => onSelect(null),
515
+ rowId: "workflow-member-unassigned",
516
+ selected: selectedIds.size === 0
517
+ }) : null, matchingMembers.map(member => /* @__PURE__ */ jsxRuntime.jsx(PickerRow, {
518
+ icon: /* @__PURE__ */ jsxRuntime.jsx(MemberAvatar, {
519
+ imageUrl: member.imageUrl,
520
+ name: member.displayName
521
+ }),
522
+ label: member.displayName,
523
+ onSelect: () => onSelect(member),
524
+ rowId: `workflow-member-${encodeURIComponent(member.id)}`,
525
+ selected: selectedIds.has(member.id)
526
+ }, member.id)) ]
527
+ });
528
+ }
529
+
530
+ function GroupHeading({children: children}) {
531
+ /* @__PURE__ */
532
+ return jsxRuntime.jsx(ui.Card, {
533
+ as: "li",
534
+ borderTop: !0,
535
+ padding: 2,
536
+ children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, {
537
+ muted: !0,
538
+ size: 0,
539
+ weight: "medium",
540
+ children: children
541
+ })
542
+ });
543
+ }
544
+
545
+ function AssigneePicker({error: error, loading: loading, members: members, onToggle: onToggle, value: value}) {
546
+ const [query, setQuery] = react.useState(""), normalized = query.trim().toLowerCase(), matchingRoles = react.useMemo(() => [ ...new Set(members.flatMap(member => member.roles)) ].toSorted(), [ members ]).filter(role => role.toLowerCase().includes(normalized)), matchingMembers = members.filter(member => matchesMemberSearch(member, query)), selectedRoles = new Set(value.flatMap(item => item.type === "role" ? [ item.role ] : [])), selectedUsers = new Set(value.flatMap(item => item.type === "user" ? [ item.id ] : []));
547
+ /* @__PURE__ */
548
+ return jsxRuntime.jsxs(PickerShell, {
549
+ empty: matchingRoles.length === 0 && matchingMembers.length === 0,
550
+ error: error,
551
+ loading: loading,
552
+ members: members,
553
+ onQueryChange: setQuery,
554
+ placeholder: "Search roles and members…",
555
+ query: query,
556
+ children: [ matchingRoles.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(GroupHeading, {
557
+ children: "Roles"
558
+ }) : null, matchingRoles.map(role => /* @__PURE__ */ jsxRuntime.jsx(PickerRow, {
559
+ icon: /* @__PURE__ */ jsxRuntime.jsx(Glyph, {
560
+ children: /* @__PURE__ */ jsxRuntime.jsx(Users.UsersIcon, {})
561
+ }),
562
+ label: role,
563
+ onSelect: () => onToggle({
564
+ type: "role",
565
+ role: role
566
+ }),
567
+ rowId: `workflow-role-${encodeURIComponent(role)}`,
568
+ selected: selectedRoles.has(role)
569
+ }, `role:${role}`)), matchingMembers.length > 0 ? /* @__PURE__ */ jsxRuntime.jsx(GroupHeading, {
570
+ children: "Members"
571
+ }) : null, matchingMembers.map(member => /* @__PURE__ */ jsxRuntime.jsx(PickerRow, {
572
+ icon: /* @__PURE__ */ jsxRuntime.jsx(MemberAvatar, {
573
+ imageUrl: member.imageUrl,
574
+ name: member.displayName
575
+ }),
576
+ label: member.displayName,
577
+ onSelect: () => onToggle({
578
+ type: "user",
579
+ id: member.id
580
+ }),
581
+ rowId: `workflow-assignee-${encodeURIComponent(member.id)}`,
582
+ selected: selectedUsers.has(member.id)
583
+ }, `user:${member.id}`)) ]
584
+ });
585
+ }
586
+
587
+ exports.AssigneeBadge = AssigneeBadge;
588
+
589
+ exports.AssigneeBadges = AssigneeBadges;
590
+
591
+ exports.AssigneePicker = AssigneePicker;
592
+
593
+ exports.ClearableDatePicker = ClearableDatePicker;
594
+
595
+ exports.DatePicker = DatePicker;
596
+
597
+ exports.MemberAvatar = MemberAvatar;
598
+
599
+ exports.MemberAvatarGroup = MemberAvatarGroup;
600
+
601
+ exports.MemberPicker = MemberPicker;
602
+
603
+ exports.roleListLabel = roleListLabel;
package/dist/index.js ADDED
@@ -0,0 +1,621 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+
3
+ import { Badge, Flex, Grid, Box, Text, Card, TextInput, Select, Button, Avatar, AvatarStack, Stack, Spinner } from "@sanity/ui";
4
+
5
+ import { CloseIcon } from "@sanity/icons/Close";
6
+
7
+ import { ChevronLeftIcon } from "@sanity/icons/ChevronLeft";
8
+
9
+ import { ChevronRightIcon } from "@sanity/icons/ChevronRight";
10
+
11
+ import { addDays } from "date-fns/addDays";
12
+
13
+ import { addMonths } from "date-fns/addMonths";
14
+
15
+ import { setDate } from "date-fns/setDate";
16
+
17
+ import { setHours } from "date-fns/setHours";
18
+
19
+ import { setMinutes } from "date-fns/setMinutes";
20
+
21
+ import { setMonth } from "date-fns/setMonth";
22
+
23
+ import { setYear } from "date-fns/setYear";
24
+
25
+ import { forwardRef, useState, useCallback, useMemo, useRef, useEffect, createContext, useId, useContext } from "react";
26
+
27
+ import { eachWeekOfInterval } from "date-fns/eachWeekOfInterval";
28
+
29
+ import { isSameDay } from "date-fns/isSameDay";
30
+
31
+ import { isSameMonth } from "date-fns/isSameMonth";
32
+
33
+ import { lastDayOfMonth } from "date-fns/lastDayOfMonth";
34
+
35
+ import { startOfMonth } from "date-fns/startOfMonth";
36
+
37
+ import { CheckmarkIcon } from "@sanity/icons/Checkmark";
38
+
39
+ import { EmptyIcon } from "@sanity/icons/Empty";
40
+
41
+ import { UsersIcon } from "@sanity/icons/Users";
42
+
43
+ function roleListLabel(roles) {
44
+ return `${roles.length === 1 ? "Role" : "Roles"}: ${roles.join(", ")}`;
45
+ }
46
+
47
+ function AssigneeBadge({assignee: assignee, members: members}) {
48
+ if (assignee.type === "role") /* @__PURE__ */
49
+ return jsxs(Badge, {
50
+ mode: "outline",
51
+ children: [ "Role: ", assignee.role ]
52
+ });
53
+ const member = members.find(candidate => candidate.id === assignee.id);
54
+ /* @__PURE__ */
55
+ return jsx(Badge, {
56
+ mode: "outline",
57
+ children: member?.displayName ?? assignee.id
58
+ });
59
+ }
60
+
61
+ function AssigneeBadges({assignees: assignees, members: members}) {
62
+ const roles = assignees.flatMap(assignee => assignee.type === "role" ? [ assignee.role ] : []), users = assignees.filter(assignee => assignee.type === "user");
63
+ /* @__PURE__ */
64
+ return jsxs(Flex, {
65
+ align: "center",
66
+ gap: 1,
67
+ wrap: "wrap",
68
+ children: [ users.map(assignee => /* @__PURE__ */ jsx(AssigneeBadge, {
69
+ assignee: assignee,
70
+ members: members
71
+ }, `user:${assignee.id}`)), roles.length > 0 ? /* @__PURE__ */ jsx(Badge, {
72
+ mode: "outline",
73
+ children: roleListLabel(roles)
74
+ }) : null ]
75
+ });
76
+ }
77
+
78
+ const WEEK_DAY_NAMES = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ];
79
+
80
+ function weeksOfMonth(date) {
81
+ const first = startOfMonth(date);
82
+ return eachWeekOfInterval({
83
+ start: first,
84
+ end: lastDayOfMonth(first)
85
+ }).map(weekStart => [ weekStart, ...WEEK_DAY_NAMES.slice(1).map((_, i) => addDays(weekStart, i + 1)) ]);
86
+ }
87
+
88
+ function CalendarMonth({focused: focused, onSelect: onSelect, selected: selected}) {
89
+ /* @__PURE__ */
90
+ return jsxs(Grid, {
91
+ gap: 1,
92
+ style: {
93
+ gridTemplateColumns: "repeat(7, minmax(44px, 46px))"
94
+ },
95
+ children: [ WEEK_DAY_NAMES.map(weekday => /* @__PURE__ */ jsx(Box, {
96
+ paddingY: 2,
97
+ children: /* @__PURE__ */ jsx(Text, {
98
+ size: 1,
99
+ style: {
100
+ textAlign: "center"
101
+ },
102
+ weight: "medium",
103
+ children: weekday
104
+ })
105
+ }, weekday)), weeksOfMonth(focused).map(week => week.map(day => /* @__PURE__ */ jsx(CalendarDay, {
106
+ date: day,
107
+ focused: isSameDay(day, focused),
108
+ isCurrentMonth: isSameMonth(day, focused),
109
+ isToday: isSameDay(day, /* @__PURE__ */ new Date),
110
+ onSelect: onSelect,
111
+ selected: selected !== void 0 && isSameDay(day, selected)
112
+ }, day.toISOString()))) ]
113
+ });
114
+ }
115
+
116
+ function CalendarDay({date: date, focused: focused, isCurrentMonth: isCurrentMonth, isToday: isToday, onSelect: onSelect, selected: selected}) {
117
+ /* @__PURE__ */
118
+ return jsx(Card, {
119
+ __unstable_focusRing: !0,
120
+ "aria-label": date.toDateString(),
121
+ "aria-pressed": selected,
122
+ as: "button",
123
+ "data-focused": focused ? "true" : "",
124
+ "data-testid": `calendar-day-${date.toDateString().replaceAll(" ", "-")}`,
125
+ onClick: () => onSelect(date),
126
+ padding: 2,
127
+ radius: 2,
128
+ selected: selected,
129
+ tabIndex: -1,
130
+ tone: isToday || selected ? "primary" : "default",
131
+ children: /* @__PURE__ */ jsx(Text, {
132
+ muted: !selected && !isCurrentMonth,
133
+ size: 1,
134
+ style: {
135
+ textAlign: "center"
136
+ },
137
+ weight: isCurrentMonth ? "medium" : "regular",
138
+ children: date.getDate()
139
+ })
140
+ });
141
+ }
142
+
143
+ function pad2(value) {
144
+ return String(value).padStart(2, "0");
145
+ }
146
+
147
+ const LazyTextInput = forwardRef(function({onBlur: onBlur, onChange: onChange, onKeyDown: onKeyDown, value: value, ...rest}, forwardedRef) {
148
+ const [inputValue, setInputValue] = useState(), handleChange = useCallback(event => {
149
+ setInputValue(event.currentTarget.value);
150
+ }, []), emitIfChanged = useCallback(event => {
151
+ event.currentTarget.value !== `${value}` && onChange && onChange(event), setInputValue(void 0);
152
+ }, [ onChange, value ]), handleBlur = useCallback(event => {
153
+ emitIfChanged(event), onBlur?.(event);
154
+ }, [ emitIfChanged, onBlur ]), handleKeyDown = useCallback(event => {
155
+ event.key === "Enter" && emitIfChanged(event), onKeyDown?.(event);
156
+ }, [ emitIfChanged, onKeyDown ]);
157
+ /* @__PURE__ */
158
+ return jsx(TextInput, {
159
+ ...rest,
160
+ onBlur: handleBlur,
161
+ onChange: handleChange,
162
+ onKeyDown: handleKeyDown,
163
+ ref: forwardedRef,
164
+ value: inputValue === void 0 ? value : inputValue
165
+ });
166
+ }), MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ], ARROW_KEYS = [ "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight" ], PRESERVE_FOCUS_ELEMENT = /* @__PURE__ */ jsx("span", {
167
+ "data-preserve-focus": !0,
168
+ style: {
169
+ outline: "none",
170
+ overflow: "hidden",
171
+ position: "absolute"
172
+ },
173
+ tabIndex: -1
174
+ });
175
+
176
+ function DatePicker({onSelect: onSelect, selectTime: selectTime = !1, value: value}) {
177
+ const base = useMemo(() => {
178
+ if (value) return value;
179
+ const now = /* @__PURE__ */ new Date;
180
+ return now.setSeconds(0, 0), now;
181
+ }, [ value ]), [focusedDate, setFocusedDate] = useState(base), ref = useRef(null), handleDaySelect = useCallback(date => onSelect(setMinutes(setHours(date, base.getHours()), base.getMinutes())), [ base, onSelect ]), handleTimeCommit = useCallback((hours, minutes) => onSelect(setHours(setMinutes(base, minutes), hours)), [ base, onSelect ]), handleNowClick = useCallback(() => {
182
+ const now = /* @__PURE__ */ new Date;
183
+ setFocusedDate(now), onSelect(now);
184
+ }, [ onSelect ]), handleMonthChange = useCallback(event => {
185
+ const month = Number(event.currentTarget.value);
186
+ setFocusedDate(current => setDate(setMonth(current, month), 1));
187
+ }, []), moveFocusedDate = useCallback(byMonths => setFocusedDate(current => addMonths(current, byMonths)), []), setFocusedYear = useCallback(year => setFocusedDate(current => setYear(current, year)), []), focusCurrentWeekDay = useCallback(() => {
188
+ ref.current?.querySelector('[data-focused="true"]')?.focus();
189
+ }, []), handleKeyDown = useCallback(event => {
190
+ if (!ARROW_KEYS.includes(event.key)) return;
191
+ if (event.preventDefault(), event.target instanceof HTMLElement && event.target.hasAttribute("data-calendar-grid")) {
192
+ focusCurrentWeekDay();
193
+ return;
194
+ }
195
+ const byDays = {
196
+ ArrowUp: -7,
197
+ ArrowDown: 7,
198
+ ArrowLeft: -1,
199
+ ArrowRight: 1
200
+ }[event.key] ?? 0;
201
+ setFocusedDate(current => addDays(current, byDays)), ref.current?.querySelector("[data-preserve-focus]")?.focus();
202
+ }, [ focusCurrentWeekDay ]);
203
+ return useEffect(() => {
204
+ document.activeElement?.matches("[data-calendar-grid], [data-calendar-grid] [data-preserve-focus]") && focusCurrentWeekDay();
205
+ }, [ focusCurrentWeekDay, focusedDate ]), /* @__PURE__ */ jsxs(Box, {
206
+ "data-testid": "date-picker",
207
+ ref: ref,
208
+ children: [
209
+ /* @__PURE__ */ jsxs(Box, {
210
+ padding: 2,
211
+ children: [
212
+ /* @__PURE__ */ jsxs(Flex, {
213
+ children: [
214
+ /* @__PURE__ */ jsx(Box, {
215
+ flex: 1,
216
+ children: /* @__PURE__ */ jsx(Select, {
217
+ "aria-label": "Month",
218
+ fontSize: 1,
219
+ onChange: handleMonthChange,
220
+ padding: 2,
221
+ radius: 2,
222
+ value: focusedDate.getMonth(),
223
+ children: MONTH_NAMES.map((name, i) => /* @__PURE__ */ jsx("option", {
224
+ value: i,
225
+ children: name
226
+ }, name))
227
+ })
228
+ }),
229
+ /* @__PURE__ */ jsxs(Flex, {
230
+ align: "center",
231
+ gap: 1,
232
+ marginLeft: 2,
233
+ children: [
234
+ /* @__PURE__ */ jsx(Button, {
235
+ "aria-label": "Previous year",
236
+ icon: ChevronLeftIcon,
237
+ mode: "bleed",
238
+ onClick: () => moveFocusedDate(-12),
239
+ padding: 2
240
+ }),
241
+ /* @__PURE__ */ jsx(YearInput, {
242
+ onChange: setFocusedYear,
243
+ value: focusedDate.getFullYear()
244
+ }),
245
+ /* @__PURE__ */ jsx(Button, {
246
+ "aria-label": "Next year",
247
+ icon: ChevronRightIcon,
248
+ mode: "bleed",
249
+ onClick: () => moveFocusedDate(12),
250
+ padding: 2
251
+ }) ]
252
+ }) ]
253
+ }),
254
+ /* @__PURE__ */ jsxs(Box, {
255
+ "data-calendar-grid": !0,
256
+ marginTop: 2,
257
+ onKeyDown: handleKeyDown,
258
+ overflow: "hidden",
259
+ tabIndex: 0,
260
+ children: [
261
+ /* @__PURE__ */ jsx(CalendarMonth, {
262
+ focused: focusedDate,
263
+ onSelect: handleDaySelect,
264
+ selected: value
265
+ }), PRESERVE_FOCUS_ELEMENT ]
266
+ }) ]
267
+ }), selectTime ? /* @__PURE__ */ jsx(Card, {
268
+ borderTop: !0,
269
+ padding: 2,
270
+ children: /* @__PURE__ */ jsxs(Flex, {
271
+ align: "center",
272
+ gap: 2,
273
+ children: [
274
+ /* @__PURE__ */ jsx(TimeField, {
275
+ onCommit: handleTimeCommit,
276
+ value: `${pad2(base.getHours())}:${pad2(base.getMinutes())}`
277
+ }),
278
+ /* @__PURE__ */ jsx(Button, {
279
+ fontSize: 1,
280
+ mode: "bleed",
281
+ onClick: handleNowClick,
282
+ padding: 2,
283
+ text: "Set to current time"
284
+ }) ]
285
+ })
286
+ }) : null ]
287
+ });
288
+ }
289
+
290
+ function TimeField({onCommit: onCommit, value: value}) {
291
+ /* @__PURE__ */
292
+ return jsx(LazyTextInput, {
293
+ "aria-label": "Select time",
294
+ fontSize: 1,
295
+ onChange: event => {
296
+ const [hours, minutes] = event.currentTarget.value.split(":").map(Number);
297
+ hours !== void 0 && minutes !== void 0 && !Number.isNaN(hours + minutes) && onCommit(hours, minutes);
298
+ },
299
+ padding: 2,
300
+ type: "time",
301
+ value: value
302
+ });
303
+ }
304
+
305
+ function YearInput({onChange: onChange, value: value}) {
306
+ /* @__PURE__ */
307
+ return jsx(LazyTextInput, {
308
+ "aria-label": "Year",
309
+ fontSize: 1,
310
+ inputMode: "numeric",
311
+ onChange: event => {
312
+ const year = Number.parseInt(event.currentTarget.value, 10);
313
+ Number.isNaN(year) || onChange(year);
314
+ },
315
+ padding: 2,
316
+ radius: 2,
317
+ style: {
318
+ textAlign: "center",
319
+ width: 48
320
+ },
321
+ value: value
322
+ });
323
+ }
324
+
325
+ function ClearableDatePicker({value: value, selectTime: selectTime = !1, clearLabel: clearLabel, onPick: onPick, onClear: onClear}) {
326
+ /* @__PURE__ */
327
+ return jsxs(Box, {
328
+ children: [
329
+ /* @__PURE__ */ jsx(DatePicker, {
330
+ onSelect: onPick,
331
+ selectTime: selectTime,
332
+ value: value
333
+ }), value ? /* @__PURE__ */ jsx(Card, {
334
+ borderTop: !0,
335
+ padding: 1,
336
+ children: /* @__PURE__ */ jsx(Button, {
337
+ fontSize: 1,
338
+ icon: CloseIcon,
339
+ mode: "bleed",
340
+ onClick: onClear,
341
+ padding: 2,
342
+ text: clearLabel
343
+ })
344
+ }) : null ]
345
+ });
346
+ }
347
+
348
+ function matchesMemberSearch(member, query) {
349
+ const normalized = query.trim().toLowerCase();
350
+ return normalized ? member.displayName.toLowerCase().includes(normalized) || (member.email?.toLowerCase().includes(normalized) ?? !1) : !0;
351
+ }
352
+
353
+ function memberInitials(name) {
354
+ return name.split(/\s+/).filter(Boolean).map(word => word[0]).slice(0, 2).join("").toUpperCase() || "?";
355
+ }
356
+
357
+ function MemberAvatar({name: name, imageUrl: imageUrl, size: size = 0}) {
358
+ /* @__PURE__ */
359
+ return jsx(Avatar, {
360
+ __unstable_hideInnerStroke: !0,
361
+ initials: memberInitials(name),
362
+ size: size,
363
+ ...imageUrl ? {
364
+ src: imageUrl
365
+ } : {}
366
+ });
367
+ }
368
+
369
+ function MemberAvatarGroup({members: members, maxLength: maxLength = 3}) {
370
+ return members.length === 0 ? null : /* @__PURE__ */ jsx(AvatarStack, {
371
+ maxLength: maxLength,
372
+ size: 0,
373
+ style: {
374
+ lineHeight: 0
375
+ },
376
+ children: members.map(member => /* @__PURE__ */ jsx(MemberAvatar, {
377
+ imageUrl: member.imageUrl,
378
+ name: member.displayName
379
+ }, member.id))
380
+ });
381
+ }
382
+
383
+ function PickerStatus({loading: loading, error: error}) {
384
+ return loading ? /* @__PURE__ */ jsxs(Flex, {
385
+ align: "center",
386
+ gap: 2,
387
+ padding: 3,
388
+ children: [
389
+ /* @__PURE__ */ jsx(Spinner, {
390
+ muted: !0,
391
+ size: 1
392
+ }),
393
+ /* @__PURE__ */ jsx(Text, {
394
+ muted: !0,
395
+ size: 1,
396
+ children: "Loading members…"
397
+ }) ]
398
+ }) : error ? /* @__PURE__ */ jsx(Box, {
399
+ padding: 3,
400
+ children: /* @__PURE__ */ jsxs(Text, {
401
+ muted: !0,
402
+ size: 1,
403
+ children: [ "Could not list members: ", error ]
404
+ })
405
+ }) : null;
406
+ }
407
+
408
+ const ActiveRowContext = createContext(void 0);
409
+
410
+ function PickerShell({children: children, empty: empty, error: error, loading: loading, placeholder: placeholder, query: query, onQueryChange: onQueryChange}) {
411
+ const listRef = useRef(null), listId = useId(), [activeIndex, setActiveIndex] = useState(-1), [activeRowId, setActiveRowId] = useState(), handleKeyDown = useCallback(event => {
412
+ const rows = listRef.current?.querySelectorAll("[data-picker-row]") ?? [];
413
+ if (rows.length === 0) return;
414
+ if (event.key === "Enter" && activeIndex >= 0) {
415
+ event.preventDefault(), rows[activeIndex]?.click();
416
+ return;
417
+ }
418
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
419
+ event.preventDefault();
420
+ const direction = event.key === "ArrowDown" ? 1 : -1;
421
+ setActiveIndex(current => {
422
+ let startingIndex = current;
423
+ current < 0 && (startingIndex = direction > 0 ? -1 : 0);
424
+ const nextIndex = (startingIndex + direction + rows.length) % rows.length, nextRow = rows[nextIndex];
425
+ return setActiveRowId(nextRow?.id), nextRow?.scrollIntoView?.({
426
+ block: "nearest"
427
+ }), nextIndex;
428
+ });
429
+ }, [ activeIndex ]);
430
+ return loading || error ? /* @__PURE__ */ jsx(PickerStatus, {
431
+ error: error,
432
+ loading: loading
433
+ }) : /* @__PURE__ */ jsxs(Flex, {
434
+ direction: "column",
435
+ style: {
436
+ width: 300
437
+ },
438
+ children: [
439
+ /* @__PURE__ */ jsx(Box, {
440
+ padding: 1,
441
+ paddingBottom: 0,
442
+ children: /* @__PURE__ */ jsx(TextInput, {
443
+ "aria-activedescendant": activeRowId,
444
+ "aria-controls": listId,
445
+ "aria-expanded": !0,
446
+ "aria-label": placeholder,
447
+ fontSize: 1,
448
+ onChange: event => {
449
+ setActiveIndex(-1), setActiveRowId(void 0), onQueryChange(event.currentTarget.value);
450
+ },
451
+ onKeyDown: handleKeyDown,
452
+ placeholder: placeholder,
453
+ radius: 2,
454
+ role: "combobox",
455
+ value: query
456
+ })
457
+ }), empty ? /* @__PURE__ */ jsx(Box, {
458
+ padding: 3,
459
+ children: /* @__PURE__ */ jsx(Text, {
460
+ muted: !0,
461
+ size: 1,
462
+ children: "Nothing matches"
463
+ })
464
+ }) : /* @__PURE__ */ jsx(Stack, {
465
+ as: "ul",
466
+ id: listId,
467
+ ref: listRef,
468
+ role: "listbox",
469
+ padding: 1,
470
+ space: 1,
471
+ style: {
472
+ listStyle: "none",
473
+ maxHeight: 360,
474
+ overflowY: "auto"
475
+ },
476
+ children: /* @__PURE__ */ jsx(ActiveRowContext, {
477
+ value: activeRowId,
478
+ children: children
479
+ })
480
+ }) ]
481
+ });
482
+ }
483
+
484
+ function PickerRow({icon: icon, label: label, onSelect: onSelect, rowId: rowId, selected: selected}) {
485
+ const active = useContext(ActiveRowContext) === rowId;
486
+ /* @__PURE__ */
487
+ return jsx(Box, {
488
+ as: "li",
489
+ children: /* @__PURE__ */ jsx(Button, {
490
+ "aria-pressed": selected,
491
+ "aria-selected": active,
492
+ "data-picker-row": !0,
493
+ id: rowId,
494
+ justify: "flex-start",
495
+ mode: active ? "ghost" : "bleed",
496
+ onClick: onSelect,
497
+ padding: 2,
498
+ style: {
499
+ width: "100%"
500
+ },
501
+ role: "option",
502
+ children: /* @__PURE__ */ jsxs(Flex, {
503
+ align: "center",
504
+ gap: 2,
505
+ style: {
506
+ width: "100%"
507
+ },
508
+ children: [ icon,
509
+ /* @__PURE__ */ jsx(Box, {
510
+ flex: 1,
511
+ children: /* @__PURE__ */ jsx(Text, {
512
+ align: "left",
513
+ size: 1,
514
+ textOverflow: "ellipsis",
515
+ children: label
516
+ })
517
+ }), selected ? /* @__PURE__ */ jsx(CheckmarkIcon, {}) : null ]
518
+ })
519
+ })
520
+ });
521
+ }
522
+
523
+ function Glyph({children: children}) {
524
+ /* @__PURE__ */
525
+ return jsx(Text, {
526
+ muted: !0,
527
+ size: 2,
528
+ children: children
529
+ });
530
+ }
531
+
532
+ function MemberPicker({error: error, loading: loading, members: members, onSelect: onSelect, selectedIds: selectedIds, unassignRow: unassignRow = !1}) {
533
+ const [query, setQuery] = useState(""), matchingMembers = useMemo(() => members.filter(member => matchesMemberSearch(member, query)), [ members, query ]), showUnassigned = unassignRow && query.trim() === "";
534
+ /* @__PURE__ */
535
+ return jsxs(PickerShell, {
536
+ empty: !showUnassigned && matchingMembers.length === 0,
537
+ error: error,
538
+ loading: loading,
539
+ members: members,
540
+ onQueryChange: setQuery,
541
+ placeholder: "Search members…",
542
+ query: query,
543
+ children: [ showUnassigned ? /* @__PURE__ */ jsx(PickerRow, {
544
+ icon: /* @__PURE__ */ jsx(Glyph, {
545
+ children: /* @__PURE__ */ jsx(EmptyIcon, {})
546
+ }),
547
+ label: "No assignee",
548
+ onSelect: () => onSelect(null),
549
+ rowId: "workflow-member-unassigned",
550
+ selected: selectedIds.size === 0
551
+ }) : null, matchingMembers.map(member => /* @__PURE__ */ jsx(PickerRow, {
552
+ icon: /* @__PURE__ */ jsx(MemberAvatar, {
553
+ imageUrl: member.imageUrl,
554
+ name: member.displayName
555
+ }),
556
+ label: member.displayName,
557
+ onSelect: () => onSelect(member),
558
+ rowId: `workflow-member-${encodeURIComponent(member.id)}`,
559
+ selected: selectedIds.has(member.id)
560
+ }, member.id)) ]
561
+ });
562
+ }
563
+
564
+ function GroupHeading({children: children}) {
565
+ /* @__PURE__ */
566
+ return jsx(Card, {
567
+ as: "li",
568
+ borderTop: !0,
569
+ padding: 2,
570
+ children: /* @__PURE__ */ jsx(Text, {
571
+ muted: !0,
572
+ size: 0,
573
+ weight: "medium",
574
+ children: children
575
+ })
576
+ });
577
+ }
578
+
579
+ function AssigneePicker({error: error, loading: loading, members: members, onToggle: onToggle, value: value}) {
580
+ const [query, setQuery] = useState(""), normalized = query.trim().toLowerCase(), matchingRoles = useMemo(() => [ ...new Set(members.flatMap(member => member.roles)) ].toSorted(), [ members ]).filter(role => role.toLowerCase().includes(normalized)), matchingMembers = members.filter(member => matchesMemberSearch(member, query)), selectedRoles = new Set(value.flatMap(item => item.type === "role" ? [ item.role ] : [])), selectedUsers = new Set(value.flatMap(item => item.type === "user" ? [ item.id ] : []));
581
+ /* @__PURE__ */
582
+ return jsxs(PickerShell, {
583
+ empty: matchingRoles.length === 0 && matchingMembers.length === 0,
584
+ error: error,
585
+ loading: loading,
586
+ members: members,
587
+ onQueryChange: setQuery,
588
+ placeholder: "Search roles and members…",
589
+ query: query,
590
+ children: [ matchingRoles.length > 0 ? /* @__PURE__ */ jsx(GroupHeading, {
591
+ children: "Roles"
592
+ }) : null, matchingRoles.map(role => /* @__PURE__ */ jsx(PickerRow, {
593
+ icon: /* @__PURE__ */ jsx(Glyph, {
594
+ children: /* @__PURE__ */ jsx(UsersIcon, {})
595
+ }),
596
+ label: role,
597
+ onSelect: () => onToggle({
598
+ type: "role",
599
+ role: role
600
+ }),
601
+ rowId: `workflow-role-${encodeURIComponent(role)}`,
602
+ selected: selectedRoles.has(role)
603
+ }, `role:${role}`)), matchingMembers.length > 0 ? /* @__PURE__ */ jsx(GroupHeading, {
604
+ children: "Members"
605
+ }) : null, matchingMembers.map(member => /* @__PURE__ */ jsx(PickerRow, {
606
+ icon: /* @__PURE__ */ jsx(MemberAvatar, {
607
+ imageUrl: member.imageUrl,
608
+ name: member.displayName
609
+ }),
610
+ label: member.displayName,
611
+ onSelect: () => onToggle({
612
+ type: "user",
613
+ id: member.id
614
+ }),
615
+ rowId: `workflow-assignee-${encodeURIComponent(member.id)}`,
616
+ selected: selectedUsers.has(member.id)
617
+ }, `user:${member.id}`)) ]
618
+ });
619
+ }
620
+
621
+ export { AssigneeBadge, AssigneeBadges, AssigneePicker, ClearableDatePicker, DatePicker, MemberAvatar, MemberAvatarGroup, MemberPicker, roleListLabel };
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@sanity/workflow-components",
3
+ "version": "0.0.0",
4
+ "description": "Reusable React components for Editorial Workflows member, assignee, and date fields.",
5
+ "keywords": [
6
+ "react",
7
+ "sanity",
8
+ "sanity-io",
9
+ "sdk",
10
+ "workflow",
11
+ "workflows"
12
+ ],
13
+ "homepage": "https://github.com/sanity-io/workflows/tree/main/packages/workflow-components#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/sanity-io/workflows/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Sanity.io <hello@sanity.io>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sanity-io/workflows.git",
22
+ "directory": "packages/workflow-components"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "CHANGELOG.md"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "import": "./dist/index.js",
36
+ "require": "./dist/index.cjs",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "./package.json": "./package.json"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "@sanity/icons": "^4.1.0",
46
+ "date-fns": "^4.4.0",
47
+ "@sanity/workflow-engine": "0.17.0"
48
+ },
49
+ "devDependencies": {
50
+ "@sanity/pkg-utils": "^10.5.3",
51
+ "@sanity/ui": "^3.3.5",
52
+ "@testing-library/react": "^16.3.2",
53
+ "@types/react": "^19.2.17",
54
+ "jsdom": "^29.1.1",
55
+ "react": "^19.2.7",
56
+ "react-dom": "^19.2.7",
57
+ "vitest": "^4.1.8"
58
+ },
59
+ "peerDependencies": {
60
+ "@sanity/ui": "^3.3.5",
61
+ "react": "^19.2.7",
62
+ "react-dom": "^19.2.7"
63
+ },
64
+ "engines": {
65
+ "node": ">=20"
66
+ },
67
+ "scripts": {
68
+ "build": "pkg-utils build --clean",
69
+ "test": "vitest run",
70
+ "test:watch": "vitest",
71
+ "typecheck": "tsc --noEmit -p tsconfig.json"
72
+ }
73
+ }