@remit/ui 0.0.60 → 0.0.61

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,496 +0,0 @@
1
- import {
2
- ChevronUp,
3
- Loader2,
4
- MailOpen,
5
- ShieldAlert,
6
- Trash2,
7
- X,
8
- } from "lucide-react";
9
- import type { ReactNode } from "react";
10
- import {
11
- useCallback,
12
- useEffect,
13
- useLayoutEffect,
14
- useRef,
15
- useState,
16
- } from "react";
17
- import { cn } from "../lib/cn.js";
18
- import { Banner, type BannerTone } from "./banner.js";
19
- import { Button } from "./button.js";
20
- import { Checkbox } from "./checkbox.js";
21
- import { ProgressBar } from "./progress-bar.js";
22
-
23
- const formatCount = (n: number): string => n.toLocaleString();
24
-
25
- /** The peek height of the collapsed teaser row (px). */
26
- export const SELECTION_SHEET_TEASER_HEIGHT = 56;
27
- /** Ceiling on the expanded sheet height, as a share of the dynamic viewport.
28
- * The sheet is otherwise sized by its own content, so the ceiling binds only on
29
- * a viewport too short to hold the actions — and there they scroll. */
30
- const EXPANDED_MAX_DVH = 70;
31
- /** Assumed expanded height until the sheet has been measured; only sets the
32
- * collapsed translate for the first frame. */
33
- const EXPANDED_HEIGHT_FALLBACK = 320;
34
-
35
- const SNAP_MS = 320;
36
- const SNAP_EASE = "cubic-bezier(0.32, 0.9, 0.3, 1)";
37
- const FLICK_VELOCITY = 0.5; // px/ms
38
-
39
- function rubberBand(overshoot: number): number {
40
- return Math.sign(overshoot) * Math.sqrt(Math.abs(overshoot)) * 4;
41
- }
42
-
43
- export interface SheetSnapInput {
44
- /** The snap state the drag started from. */
45
- expanded: boolean;
46
- /** Net vertical travel over the drag (px); positive is downward. */
47
- delta: number;
48
- /** Terminal pointer velocity (px/ms); positive is downward. */
49
- velocity: number;
50
- /** Measured full height of the expanded sheet (px). */
51
- expandedHeight: number;
52
- /** Peek height of the collapsed teaser (px). */
53
- teaserHeight: number;
54
- /** Speed past which a drag is a flick, snapping in its direction. */
55
- flickVelocity?: number;
56
- }
57
-
58
- /**
59
- * The two-snap decision the sheet makes when a drag ends: a flick snaps in its
60
- * own direction; otherwise the sheet settles to whichever snap point the drag
61
- * crossed the midpoint toward. Pure so the snap behaviour is testable without a
62
- * pointer or a DOM.
63
- */
64
- export function resolveSheetSnap({
65
- expanded,
66
- delta,
67
- velocity,
68
- expandedHeight,
69
- teaserHeight,
70
- flickVelocity = FLICK_VELOCITY,
71
- }: SheetSnapInput): boolean {
72
- if (Math.abs(velocity) > flickVelocity) return velocity < 0;
73
- const midpoint = (expandedHeight - teaserHeight) / 2;
74
- return expanded ? delta < midpoint : delta < -midpoint;
75
- }
76
-
77
- export type SelectionSheetMode = "idle" | "counting" | "running" | "escalated";
78
-
79
- export interface SelectionSheetNoticeAction {
80
- label: string;
81
- onClick: () => void;
82
- }
83
-
84
- export interface SelectionSheetNotice {
85
- tone: BannerTone;
86
- text: string;
87
- action?: SelectionSheetNoticeAction;
88
- }
89
-
90
- export interface SelectionSheetProps {
91
- count: number;
92
- /**
93
- * Which content the sheet routes to. `idle` shows the quick actions and the
94
- * smart-flow rows; `counting`/`running` replace them with the paging status,
95
- * progress and notice; `escalated` keeps the quick actions over the whole
96
- * predicate. Defaults to `idle`.
97
- */
98
- mode?: SelectionSheetMode;
99
- /** The X / stop control — exits selection, or stops a run in progress. */
100
- onCancel: () => void;
101
- onDelete: () => void;
102
- /** Move to the Junk mailbox. Omitted (hidden) in the Junk folder itself, or
103
- * when no Junk folder is appointed. */
104
- onJunk?: () => void;
105
- /** Optional — hidden while a run is in flight or the total is still counting. */
106
- onMarkRead?: () => void;
107
- /** Widen the selection to similar mail, then open Organize. */
108
- onSelectSimilar?: () => void;
109
- /** Open Organize with the current selection to choose an action. */
110
- onSomethingElse?: () => void;
111
- /**
112
- * Move-to-folder trigger, rendered as the middle quick action. Kept as a
113
- * render prop so the caller owns the folder-picker data and API deps.
114
- */
115
- moveSlot?: ReactNode;
116
- /** True while a delete or move mutation is in flight. */
117
- isBusy?: boolean;
118
- /** Select-all-loaded control, rendered above the quick actions when present. */
119
- selectAll?: {
120
- checked: boolean;
121
- indeterminate?: boolean;
122
- onChange: () => void;
123
- };
124
- /** Overrides the default "{count} messages selected" status text. */
125
- statusLabel?: string;
126
- /** Determinate progress for a bulk run in flight. */
127
- progress?: { value: number; max: number; tone?: BannerTone };
128
- /** At-most-one toned status line: an escalation offer, a Stop, a Retry, or a
129
- * cross-account move restriction. */
130
- notice?: SelectionSheetNotice;
131
- /** Start expanded rather than at the teaser — for stories and the counting /
132
- * running states, which need their status visible. */
133
- startExpanded?: boolean;
134
- }
135
-
136
- /**
137
- * The mobile multi-select surface: a peeking bottom sheet that teases at ~56px
138
- * with the selection count, and expands by drag or tap to carry the bulk verbs
139
- * (Delete / Move / Junk), the select-similar → organize entries, and every
140
- * escalation state (counting, running progress, partial-failure) the selection
141
- * can be in. Drag or tap the grabber to collapse back to the teaser; the
142
- * selection is untouched.
143
- *
144
- * Expanded, it takes the height its own content needs, up to
145
- * {@link EXPANDED_MAX_DVH}% of the dynamic viewport, past which the actions
146
- * scroll. A fixed ceiling sat below the content on a short phone viewport and
147
- * cut the last action off with no way to reach it (#405).
148
- *
149
- * Sits absolutely against the bottom of the nearest positioned ancestor, so the
150
- * list it belongs to must be a `relative` container and pad its own bottom by
151
- * {@link SELECTION_SHEET_TEASER_HEIGHT} so no row hides behind the teaser.
152
- */
153
- export function SelectionSheet({
154
- count,
155
- mode = "idle",
156
- onCancel,
157
- onDelete,
158
- onJunk,
159
- onMarkRead,
160
- onSelectSimilar,
161
- onSomethingElse,
162
- moveSlot,
163
- isBusy = false,
164
- selectAll,
165
- statusLabel,
166
- progress,
167
- notice,
168
- startExpanded = false,
169
- }: SelectionSheetProps) {
170
- const [expanded, setExpanded] = useState(startExpanded);
171
- const containerRef = useRef<HTMLDivElement>(null);
172
- const [expandedHeight, setExpandedHeight] = useState(
173
- EXPANDED_HEIGHT_FALLBACK,
174
- );
175
-
176
- // A run or a live count owns the sheet: it stays open so the progress and
177
- // status can't be dragged out of sight mid-operation.
178
- const locked = mode === "counting" || mode === "running";
179
- useEffect(() => {
180
- if (locked) setExpanded(true);
181
- }, [locked]);
182
-
183
- useLayoutEffect(() => {
184
- const el = containerRef.current;
185
- if (!el) return;
186
- const measure = () => setExpandedHeight(el.offsetHeight);
187
- measure();
188
- const ro = new ResizeObserver(measure);
189
- ro.observe(el);
190
- return () => ro.disconnect();
191
- }, []);
192
-
193
- // Offset from the current snap position (positive = dragged down).
194
- const [dragOffset, setDragOffset] = useState<number | null>(null);
195
- const pointer = useRef<{
196
- startY: number;
197
- lastY: number;
198
- lastT: number;
199
- velocity: number;
200
- } | null>(null);
201
- // True once a pointer-down has actually moved, so the click the browser fires
202
- // on pointer-up after a drag doesn't also toggle the snap state and undo it.
203
- const movedRef = useRef(false);
204
-
205
- const onPointerDown = useCallback(
206
- (e: React.PointerEvent) => {
207
- if (locked) return;
208
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
209
- pointer.current = {
210
- startY: e.clientY,
211
- lastY: e.clientY,
212
- lastT: e.timeStamp,
213
- velocity: 0,
214
- };
215
- movedRef.current = false;
216
- setDragOffset(0);
217
- },
218
- [locked],
219
- );
220
-
221
- const onPointerMove = useCallback(
222
- (e: React.PointerEvent) => {
223
- const p = pointer.current;
224
- if (!p) return;
225
- const dt = e.timeStamp - p.lastT;
226
- if (dt > 0) p.velocity = (e.clientY - p.lastY) / dt;
227
- p.lastY = e.clientY;
228
- p.lastT = e.timeStamp;
229
- const delta = e.clientY - p.startY;
230
- if (Math.abs(delta) > 4) movedRef.current = true;
231
- const range = expandedHeight - SELECTION_SHEET_TEASER_HEIGHT;
232
- const clamped = expanded
233
- ? delta < 0
234
- ? rubberBand(delta)
235
- : Math.min(delta, range + rubberBand(Math.max(0, delta - range)))
236
- : delta > 0
237
- ? rubberBand(delta)
238
- : Math.max(delta, -range + rubberBand(Math.min(0, delta + range)));
239
- setDragOffset(clamped);
240
- },
241
- [expanded, expandedHeight],
242
- );
243
-
244
- const finishDrag = useCallback(() => {
245
- const p = pointer.current;
246
- pointer.current = null;
247
- if (!p) return;
248
- setDragOffset(null);
249
- setExpanded(
250
- resolveSheetSnap({
251
- expanded,
252
- delta: p.lastY - p.startY,
253
- velocity: p.velocity,
254
- expandedHeight,
255
- teaserHeight: SELECTION_SHEET_TEASER_HEIGHT,
256
- }),
257
- );
258
- }, [expanded, expandedHeight]);
259
-
260
- const collapsedTranslate = expandedHeight - SELECTION_SHEET_TEASER_HEIGHT;
261
- const baseTranslate = expanded ? 0 : collapsedTranslate;
262
- const dragging = dragOffset !== null;
263
- const translate = baseTranslate + (dragOffset ?? 0);
264
- const transition = dragging ? "none" : `transform ${SNAP_MS}ms ${SNAP_EASE}`;
265
-
266
- const defaultLabel = selectAll?.checked
267
- ? `All ${formatCount(count)} loaded selected`
268
- : `${formatCount(count)} ${count === 1 ? "message" : "messages"} selected`;
269
-
270
- const showQuickActions = mode === "idle" || mode === "escalated";
271
- const showSmartRows = mode === "idle";
272
-
273
- return (
274
- <div
275
- ref={containerRef}
276
- data-selection-sheet=""
277
- className="absolute inset-x-0 bottom-0 z-30 flex select-none flex-col rounded-t-2xl border-t border-line bg-surface shadow-2xl shadow-black/40"
278
- style={{
279
- maxHeight: `${EXPANDED_MAX_DVH}dvh`,
280
- minHeight: `${SELECTION_SHEET_TEASER_HEIGHT}px`,
281
- transform: `translateY(${translate}px)`,
282
- transition,
283
- }}
284
- >
285
- {/* Grabber / teaser — always visible at the peek. Tapping toggles the
286
- snap state; dragging snaps between the two heights. */}
287
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: keyboard users reach every action via the buttons below; the grabber is a pointer-drag affordance */}
288
- <div
289
- role="slider"
290
- aria-label={
291
- expanded ? "Collapse selection actions" : "Expand selection actions"
292
- }
293
- aria-valuemin={0}
294
- aria-valuemax={1}
295
- aria-valuenow={expanded ? 1 : 0}
296
- tabIndex={0}
297
- onPointerDown={onPointerDown}
298
- onPointerMove={onPointerMove}
299
- onPointerUp={finishDrag}
300
- onPointerCancel={finishDrag}
301
- onClick={() => {
302
- // A drag already settled the snap in finishDrag; swallow the click
303
- // the browser fires after it so it doesn't toggle straight back.
304
- if (movedRef.current) {
305
- movedRef.current = false;
306
- return;
307
- }
308
- if (!dragging && !locked) setExpanded((v) => !v);
309
- }}
310
- className={cn(
311
- "flex touch-none flex-col items-center pt-2",
312
- locked ? "" : "cursor-grab active:cursor-grabbing",
313
- )}
314
- >
315
- <div className="mb-1.5 h-1 w-10 rounded-full bg-fg-subtle/40" />
316
- <div className="flex w-full items-center gap-2 px-4 pb-3">
317
- <span
318
- className="min-w-0 flex-1 truncate text-sm font-semibold text-fg"
319
- role="status"
320
- aria-live="polite"
321
- >
322
- {statusLabel ?? defaultLabel}
323
- </span>
324
- {expanded ? (
325
- <>
326
- {onMarkRead && !isBusy && mode !== "counting" && (
327
- <Button
328
- variant="ghost"
329
- size="touch"
330
- icon={<MailOpen className="size-4" />}
331
- // These buttons sit inside the grabber's drag surface. Its
332
- // pointer-down capture would otherwise swallow the button's
333
- // own click (the grabber ends up the click target and just
334
- // toggles the snap), so stop the pointer-down here — the tap
335
- // then lands on the button and runs its action.
336
- onPointerDown={(e) => e.stopPropagation()}
337
- onClick={(e) => {
338
- e.stopPropagation();
339
- onMarkRead();
340
- }}
341
- aria-label="Mark as read"
342
- className="-my-2 shrink-0"
343
- />
344
- )}
345
- <Button
346
- variant="ghost"
347
- size="touch"
348
- icon={<X className="size-4" />}
349
- onPointerDown={(e) => e.stopPropagation()}
350
- onClick={(e) => {
351
- e.stopPropagation();
352
- onCancel();
353
- }}
354
- aria-label="Cancel selection"
355
- className="-my-2 -mr-2 shrink-0"
356
- />
357
- </>
358
- ) : (
359
- <span className="flex shrink-0 items-center gap-1 text-xs text-fg-subtle">
360
- <span>Swipe up for actions</span>
361
- <ChevronUp className="size-4" />
362
- </span>
363
- )}
364
- </div>
365
- </div>
366
-
367
- {/* Expanded content — clipped by the translate when collapsed. It stays
368
- in the DOM at the teaser, so `inert` when collapsed keeps its offscreen
369
- verbs out of the tab order and the a11y tree until the sheet opens.
370
- Scrolls rather than clips, so every verb stays reachable on a viewport
371
- the actions don't fit in (#405). */}
372
- <div
373
- inert={!expanded ? true : undefined}
374
- className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4"
375
- >
376
- {progress && (
377
- <div className="mb-3">
378
- <ProgressBar
379
- value={progress.value}
380
- max={progress.max}
381
- tone={progress.tone}
382
- />
383
- </div>
384
- )}
385
-
386
- {selectAll && mode !== "running" && (
387
- // biome-ignore lint/a11y/noLabelWithoutControl: the label wraps Checkbox's own input, giving the 20px control a 44px hit area
388
- <label className="mb-3 flex min-h-11 cursor-pointer items-center gap-3 text-sm font-medium text-fg-muted">
389
- <Checkbox
390
- aria-label="Select all"
391
- checked={selectAll.checked}
392
- indeterminate={selectAll.indeterminate}
393
- onChange={selectAll.onChange}
394
- />
395
- Select all loaded
396
- </label>
397
- )}
398
-
399
- {showQuickActions && (
400
- <div className="mb-3 flex items-stretch justify-around gap-1 border-b border-line pb-3">
401
- <Button
402
- variant="ghost"
403
- onClick={onDelete}
404
- icon={
405
- isBusy ? (
406
- <Loader2 className="size-5 animate-spin" />
407
- ) : (
408
- <Trash2 className="size-5 text-danger" />
409
- )
410
- }
411
- aria-label="Move selected messages to Trash"
412
- aria-busy={isBusy || undefined}
413
- className="h-auto flex-1 flex-col gap-1 px-0 py-1.5 text-[11px]"
414
- >
415
- Delete
416
- </Button>
417
- {moveSlot && (
418
- <div className="flex flex-1 flex-col items-center gap-1">
419
- {moveSlot}
420
- <span aria-hidden="true" className="text-[11px] text-fg-muted">
421
- Move
422
- </span>
423
- </div>
424
- )}
425
- {onJunk && (
426
- <Button
427
- variant="ghost"
428
- onClick={onJunk}
429
- icon={<ShieldAlert className="size-5" />}
430
- aria-label="Move selected messages to Junk"
431
- className="h-auto flex-1 flex-col gap-1 px-0 py-1.5 text-[11px]"
432
- >
433
- Junk
434
- </Button>
435
- )}
436
- </div>
437
- )}
438
-
439
- {showSmartRows && (onSelectSimilar || onSomethingElse) && (
440
- <div className="flex flex-col gap-2">
441
- {onSelectSimilar && (
442
- <Button
443
- variant="primary"
444
- onClick={onSelectSimilar}
445
- className="h-auto flex-col items-start gap-0 px-4 py-2.5 text-left"
446
- >
447
- <span className="text-sm font-semibold leading-tight">
448
- Select similar messages
449
- </span>
450
- <span className="text-xs opacity-80">find more like these</span>
451
- </Button>
452
- )}
453
- {onSomethingElse && (
454
- <Button
455
- variant="secondary"
456
- onClick={onSomethingElse}
457
- className="h-auto flex-col items-start gap-0 px-4 py-2.5 text-left"
458
- >
459
- <span className="text-sm font-medium leading-tight">
460
- Something else
461
- </span>
462
- <span className="text-xs text-fg-subtle">
463
- just deal with these
464
- </span>
465
- </Button>
466
- )}
467
- </div>
468
- )}
469
-
470
- {notice && (
471
- <Banner
472
- tone={notice.tone}
473
- variant="soft"
474
- role="status"
475
- aria-live="polite"
476
- className={cn(showQuickActions || progress ? "mt-1" : "mt-0")}
477
- >
478
- <div className="flex items-center justify-between gap-2">
479
- {notice.text && <span>{notice.text}</span>}
480
- {notice.action && (
481
- <Button
482
- variant="ghost"
483
- size="md"
484
- onClick={notice.action.onClick}
485
- className="-my-1 min-h-11 shrink-0"
486
- >
487
- {notice.action.label}
488
- </Button>
489
- )}
490
- </div>
491
- </Banner>
492
- )}
493
- </div>
494
- </div>
495
- );
496
- }