@workerdeck/ui 3.4.0 → 3.6.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.
@@ -1,6 +1,6 @@
1
- import { useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import type { DragEvent, HTMLAttributes, ReactNode } from 'react'
3
- import { GripVertical, Pencil, Plus, Trash2 } from 'lucide-react'
3
+ import { GripVertical, Image as ImageIcon, Pencil, Plus, Trash2 } from 'lucide-react'
4
4
  import {
5
5
  addCustomGroup,
6
6
  moveCustomGroup,
@@ -8,21 +8,52 @@ import {
8
8
  newCustomGroupId,
9
9
  removeCustomGroup,
10
10
  renameCustomGroup,
11
+ sessionKey,
12
+ styleCustomGroup,
13
+ customGroupColor,
14
+ PROJECT_ACCENTS,
11
15
  } from '@workerdeck/protocol'
12
- import type { CustomGroup, SessionGroup } from '@workerdeck/protocol'
16
+ import type { CustomGroup, SessionGroup, SessionRow } from '@workerdeck/protocol'
17
+ import { dropZone, joinDrop, memberDrop, type DropZone, type TeamMove } from '../../lib/team-drop.ts'
13
18
  import { Button } from '../ui/Button.tsx'
14
19
  import { Input } from '../ui/Input.tsx'
20
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/Popover.tsx'
15
21
  import { cn } from '../../lib/utils.ts'
16
22
 
17
23
  type DragItem = { kind: 'session'; key: string } | { kind: 'group'; id: string }
18
24
 
19
25
  export type GroupDrag = ReturnType<typeof useGroupDrag>
20
26
 
27
+ // Where a card sits: `lead` is set for a member, the team it is drawn under.
28
+ export type DragPlace = { row: SessionRow; lead?: SessionRow }
29
+
30
+ export type TeamDrag = {
31
+ rowOf: (key: string) => SessionRow | undefined
32
+ // A rejection's message is drawn under the card it was dropped on: a gateway 409 is user-facing copy.
33
+ onMove: (move: TeamMove) => Promise<void> | void
34
+ }
35
+
36
+ type Over = { spot: string; refusal?: string }
37
+
38
+ export type DropCue = { zone?: DropZone | 'refused'; message?: string }
39
+ type Landing = Over & { run?: () => void }
40
+
41
+ const ERROR_MS = 5000
42
+
21
43
  // The payload lives in state rather than `dataTransfer`, which is unreadable during `dragover`; `setData` is still
22
- // written because some engines refuse to start a drag without it.
23
- export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups: CustomGroup[]) => void) {
44
+ // written because some engines refuse to start a drag without it. A card is split in three: the middle joins its
45
+ // team, the edges reorder (custom groups) or leave a team.
46
+ export function useGroupDrag(
47
+ groups: readonly CustomGroup[],
48
+ onChange: (groups: CustomGroup[]) => void,
49
+ options: { custom: boolean; team?: TeamDrag },
50
+ ) {
51
+ const { custom, team } = options
24
52
  const [item, setItem] = useState<DragItem>()
25
- const [over, setOver] = useState<string>()
53
+ const [over, setOver] = useState<Over>()
54
+ const [failure, setFailure] = useState<{ key: string; message: string }>()
55
+ const failureTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
56
+ useEffect(() => () => clearTimeout(failureTimer.current), [])
26
57
  const end = () => {
27
58
  setItem(undefined)
28
59
  setOver(undefined)
@@ -33,49 +64,109 @@ export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups:
33
64
  e.dataTransfer.setData('text/plain', next.kind === 'session' ? next.key : next.id)
34
65
  setItem(next)
35
66
  }
36
- const accept = (e: DragEvent, spot: string) => {
67
+ const accept = (e: DragEvent, next: Over) => {
37
68
  e.preventDefault()
38
69
  e.stopPropagation()
39
- e.dataTransfer.dropEffect = 'move'
40
- setOver(spot)
70
+ e.dataTransfer.dropEffect = next.refusal ? 'none' : 'move'
71
+ if (over?.spot !== next.spot || over.refusal !== next.refusal) {
72
+ setOver(next)
73
+ }
74
+ }
75
+ const fail = (key: string, err: unknown) => {
76
+ clearTimeout(failureTimer.current)
77
+ setFailure({ key, message: err instanceof Error ? err.message : String(err) })
78
+ failureTimer.current = setTimeout(() => setFailure(undefined), ERROR_MS)
79
+ }
80
+ const move = (key: string, next: TeamMove) => () => {
81
+ setFailure(undefined)
82
+ void Promise.resolve()
83
+ .then(() => team!.onMove(next))
84
+ .catch((err: unknown) => fail(key, err))
85
+ }
86
+
87
+ function land(e: DragEvent<HTMLDivElement>, key: string, group: SessionGroup, place?: DragPlace): Landing | undefined {
88
+ if (item?.kind !== 'session' || item.key === key) {
89
+ return undefined
90
+ }
91
+ const reorder = custom
92
+ ? { spot: `row:${key}`, run: () => onChange(moveToCustomGroup(groups, item.key, group.custom, group.custom ? key : undefined)) }
93
+ : undefined
94
+ const dragged = team?.rowOf(item.key)
95
+ if (!team || !dragged || !place) {
96
+ return reorder
97
+ }
98
+ const rect = e.currentTarget.getBoundingClientRect()
99
+ const zone = dropZone(e.clientY - rect.top, rect.height)
100
+ if (place.lead) {
101
+ const result = memberDrop(dragged, place.lead, place.row, zone)
102
+ return typeof result === 'string'
103
+ ? { spot: `refused:${key}`, refusal: result }
104
+ : { spot: `${zone === 'before' ? 'before' : 'after'}:${key}`, run: move(key, result) }
105
+ }
106
+ if (zone === 'join') {
107
+ const result = joinDrop(dragged, place.row)
108
+ if (typeof result !== 'string') {
109
+ return { spot: `join:${key}`, run: move(key, result) }
110
+ }
111
+ return reorder ?? { spot: `refused:${key}`, refusal: result }
112
+ }
113
+ if (dragged.info.agent?.lead !== undefined) {
114
+ const leave = move(key, { row: dragged, lead: null })
115
+ return {
116
+ spot: `${zone}:${key}`,
117
+ run: () => {
118
+ leave()
119
+ reorder?.run()
120
+ },
121
+ }
122
+ }
123
+ return reorder ? { ...reorder, spot: `${zone}:${key}` } : undefined
41
124
  }
42
125
 
43
- function session(key: string, group: SessionGroup): HTMLAttributes<HTMLDivElement> {
126
+ function session(key: string, group: SessionGroup, place?: DragPlace): HTMLAttributes<HTMLDivElement> {
44
127
  return {
45
128
  draggable: true,
46
129
  onDragStart: (e) => start(e, { kind: 'session', key }),
47
130
  onDragEnd: end,
48
131
  onDragOver: (e) => {
49
- if (item?.kind === 'session') {
50
- accept(e, `row:${key}`)
132
+ const landing = land(e, key, group, place)
133
+ if (landing) {
134
+ accept(e, landing)
51
135
  }
52
136
  },
53
137
  onDrop: (e) => {
54
- if (item?.kind !== 'session') {
138
+ const landing = land(e, key, group, place)
139
+ if (!landing) {
55
140
  return
56
141
  }
57
142
  e.preventDefault()
58
143
  e.stopPropagation()
59
- if (item.key !== key) {
60
- onChange(moveToCustomGroup(groups, item.key, group.custom, group.custom ? key : undefined))
61
- }
144
+ landing.run?.()
62
145
  end()
63
146
  },
64
147
  }
65
148
  }
66
149
 
67
150
  function container(group: SessionGroup): HTMLAttributes<HTMLDivElement> {
151
+ const leaving = () => {
152
+ const dragged = item?.kind === 'session' ? team?.rowOf(item.key) : undefined
153
+ return dragged?.info.agent?.lead !== undefined ? dragged : undefined
154
+ }
68
155
  return {
69
156
  onDragOver: (e) => {
70
- if (item) {
71
- accept(e, `group:${group.key}`)
157
+ if (item && (custom || leaving())) {
158
+ accept(e, { spot: `group:${group.key}` })
72
159
  }
73
160
  },
74
161
  onDrop: (e) => {
75
162
  e.preventDefault()
76
- if (item?.kind === 'session') {
163
+ const member = leaving()
164
+ if (member) {
165
+ move(sessionKey(member), { row: member, lead: null })()
166
+ }
167
+ if (custom && item?.kind === 'session') {
77
168
  onChange(moveToCustomGroup(groups, item.key, group.custom))
78
- } else if (item?.kind === 'group') {
169
+ } else if (custom && item?.kind === 'group') {
79
170
  onChange(moveCustomGroup(groups, item.id, group.custom))
80
171
  }
81
172
  end()
@@ -91,9 +182,32 @@ export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups:
91
182
  return { draggable: true, onDragStart: (e) => start(e, { kind: 'group', id }), onDragEnd: end }
92
183
  }
93
184
 
185
+ // What to draw on a card while something hovers it, and a rejected drop's message for a while after.
186
+ function cue(key: string): DropCue {
187
+ if (failure?.key === key) {
188
+ return { zone: 'refused', message: failure.message }
189
+ }
190
+ const spot = over?.spot
191
+ if (!spot || spot.slice(spot.indexOf(':') + 1) !== key) {
192
+ return {}
193
+ }
194
+ const kind = spot.slice(0, spot.indexOf(':'))
195
+ if (kind === 'refused') {
196
+ return { zone: 'refused', message: over?.refusal }
197
+ }
198
+ return kind === 'row' || kind === 'before'
199
+ ? { zone: 'before' }
200
+ : kind === 'after'
201
+ ? { zone: 'after' }
202
+ : kind === 'join'
203
+ ? { zone: 'join' }
204
+ : {}
205
+ }
206
+
94
207
  return {
95
208
  dragging: item !== undefined,
96
- isOver: (spot: string) => over === spot,
209
+ isOver: (spot: string) => over?.spot === spot,
210
+ cue,
97
211
  session,
98
212
  container,
99
213
  header,
@@ -103,23 +217,29 @@ export function useGroupDrag(groups: readonly CustomGroup[], onChange: (groups:
103
217
  return id
104
218
  },
105
219
  rename: (id: string, name: string) => onChange(renameCustomGroup(groups, id, name)),
220
+ style: (id: string, look: { color?: string | null; icon?: string | null }) => onChange(styleCustomGroup(groups, id, look)),
106
221
  remove: (id: string) => onChange(removeCustomGroup(groups, id)),
107
222
  }
108
223
  }
109
224
 
110
225
  export function CustomGroupHeader({
111
226
  group,
227
+ look,
112
228
  editing,
113
229
  onEditingChange,
114
230
  onRename,
115
231
  onRemove,
232
+ onStyle,
116
233
  dragProps,
117
234
  }: {
118
235
  group: SessionGroup
236
+ // The stored group, for its colour and image; absent for the ungrouped bucket.
237
+ look?: CustomGroup
119
238
  editing: boolean
120
239
  onEditingChange: (editing: boolean) => void
121
240
  onRename: (name: string) => void
122
241
  onRemove: () => void
242
+ onStyle?: (look: { color?: string | null; icon?: string | null }) => void
123
243
  dragProps: HTMLAttributes<HTMLDivElement>
124
244
  }) {
125
245
  if (editing) {
@@ -132,9 +252,17 @@ export function CustomGroupHeader({
132
252
  {...dragProps}
133
253
  label={group.label ?? ''}
134
254
  count={group.rows.length}
255
+ caps={!custom}
135
256
  className={cn('group/heading', custom && 'cursor-grab')}
136
257
  onDoubleClick={custom ? () => onEditingChange(true) : undefined}
137
- leading={custom ? <GripVertical className="-ml-1.5 size-3 opacity-0 group-hover/heading:opacity-60" /> : undefined}
258
+ leading={
259
+ custom ? (
260
+ <>
261
+ <GripVertical className="-mr-1.5 -ml-1.5 size-3 opacity-0 group-hover/heading:opacity-60" />
262
+ {look ? <GroupBadge group={look} onStyle={onStyle} /> : null}
263
+ </>
264
+ ) : undefined
265
+ }
138
266
  actions={
139
267
  custom ? (
140
268
  <>
@@ -188,6 +316,7 @@ export function GroupHeading({
188
316
  count,
189
317
  leading,
190
318
  actions,
319
+ caps = true,
191
320
  className,
192
321
  ...rest
193
322
  }: {
@@ -195,11 +324,13 @@ export function GroupHeading({
195
324
  count: number
196
325
  leading?: ReactNode
197
326
  actions?: ReactNode
327
+ // Off for a name the operator typed, which keeps its own case.
328
+ caps?: boolean
198
329
  } & HTMLAttributes<HTMLDivElement>) {
199
330
  return (
200
331
  <div {...rest} className={cn('group/heading flex min-h-5 items-center gap-2 px-2 text-label font-medium text-fg-4', className)}>
201
332
  {leading}
202
- <span className="min-w-0 truncate uppercase tracking-wide">{label}</span>
333
+ <span className={cn('min-w-0 truncate', caps ? 'uppercase tracking-wide' : 'text-fg-3')}>{label}</span>
203
334
  <span className="text-fg-4/70">{count}</span>
204
335
  {actions ? (
205
336
  <span className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover/heading:opacity-100 focus-within:opacity-100">
@@ -246,3 +377,105 @@ export function NewGroupButton({ onClick }: { onClick: () => void }) {
246
377
  </Button>
247
378
  )
248
379
  }
380
+
381
+ const ICON_PX = 64
382
+
383
+ // A group's badge: its image, else its colour; pressing it picks either.
384
+ export function GroupBadge({
385
+ group,
386
+ onStyle,
387
+ }: {
388
+ group: CustomGroup
389
+ onStyle?: (look: { color?: string | null; icon?: string | null }) => void
390
+ }) {
391
+ const [error, setError] = useState<string>()
392
+ const file = useRef<HTMLInputElement>(null)
393
+ const mark = group.icon ? (
394
+ <img src={group.icon} alt="" className="size-3.5 rounded-[3px] object-cover" draggable={false} />
395
+ ) : (
396
+ <span className="size-2 rounded-[2px]" style={{ background: customGroupColor(group) }} />
397
+ )
398
+ if (!onStyle) {
399
+ return <span className="flex size-3.5 shrink-0 items-center justify-center">{mark}</span>
400
+ }
401
+ return (
402
+ <Popover>
403
+ <PopoverTrigger
404
+ render={
405
+ <button
406
+ type="button"
407
+ aria-label={`Colour or image for ${group.name}`}
408
+ title="Colour or image"
409
+ onClick={(e) => e.stopPropagation()}
410
+ onDoubleClick={(e) => e.stopPropagation()}
411
+ className="flex size-3.5 shrink-0 items-center justify-center rounded-[3px] hover:ring-1 hover:ring-border"
412
+ >
413
+ {mark}
414
+ </button>
415
+ }
416
+ />
417
+ <PopoverContent align="start" className="flex w-44 flex-col gap-2">
418
+ <div className="grid grid-cols-4 gap-1.5">
419
+ {PROJECT_ACCENTS.map((color) => (
420
+ <button
421
+ key={color}
422
+ type="button"
423
+ aria-label={`Colour ${color}`}
424
+ onClick={() => onStyle({ color, icon: null })}
425
+ className={cn(
426
+ 'h-6 rounded-[4px] ring-offset-1 ring-offset-surface',
427
+ !group.icon && customGroupColor(group) === color && 'ring-2 ring-fg-3',
428
+ )}
429
+ style={{ background: color }}
430
+ />
431
+ ))}
432
+ </div>
433
+ <input
434
+ ref={file}
435
+ type="file"
436
+ accept="image/*"
437
+ className="hidden"
438
+ onChange={(e) => {
439
+ const picked = e.target.files?.[0]
440
+ e.target.value = ''
441
+ if (!picked) {
442
+ return
443
+ }
444
+ setError(undefined)
445
+ void iconFromFile(picked)
446
+ .then((icon) => onStyle({ icon }))
447
+ .catch(() => setError('That image could not be read'))
448
+ }}
449
+ />
450
+ <Button variant="outline" size="xs" onClick={() => file.current?.click()}>
451
+ <ImageIcon />
452
+ Choose image…
453
+ </Button>
454
+ {group.icon ? (
455
+ <Button variant="ghost" size="xs" onClick={() => onStyle({ icon: null })}>
456
+ Remove image
457
+ </Button>
458
+ ) : null}
459
+ {error ? <span className="text-label text-danger">{error}</span> : null}
460
+ </PopoverContent>
461
+ </Popover>
462
+ )
463
+ }
464
+
465
+ // Scaled to cover a 64 px square and re-encoded, so a photo from disk stores as a few kB in the view config.
466
+ async function iconFromFile(file: File): Promise<string> {
467
+ const bitmap = await createImageBitmap(file)
468
+ const canvas = document.createElement('canvas')
469
+ canvas.width = ICON_PX
470
+ canvas.height = ICON_PX
471
+ const context = canvas.getContext('2d')
472
+ if (!context) {
473
+ throw new Error('no canvas')
474
+ }
475
+ const scale = Math.max(ICON_PX / bitmap.width, ICON_PX / bitmap.height)
476
+ const width = bitmap.width * scale
477
+ const height = bitmap.height * scale
478
+ context.drawImage(bitmap, (ICON_PX - width) / 2, (ICON_PX - height) / 2, width, height)
479
+ bitmap.close()
480
+ return canvas.toDataURL('image/png')
481
+ }