@remit/web-client 0.0.138 → 0.0.139

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,76 +0,0 @@
1
- /**
2
- * Which surface a draft reopens in, and when a mode switch is refused.
3
- *
4
- * The mode is derived from `htmlBody`, with no field of its own. It has to be
5
- * "a non-empty string" and not "truthy": the rich editor serializes an empty
6
- * document to `<p><br></p>` and a plain draft clears the column to `""`, so a
7
- * falsy check opens a plain draft correctly by accident and an absent column
8
- * — an old draft, a partial write — the wrong way round.
9
- */
10
- import assert from "node:assert/strict";
11
- import { describe, it } from "node:test";
12
- import {
13
- conversionOutcome,
14
- modeOfDraft,
15
- switchNeedsWarning,
16
- } from "./compose-mode.js";
17
-
18
- describe("the mode a draft reopens in", () => {
19
- it("opens a draft with HTML as rich", () => {
20
- assert.equal(modeOfDraft("<p>Hello</p>"), "rich");
21
- });
22
-
23
- it("opens an empty rich document as rich", () => {
24
- assert.equal(modeOfDraft("<p><br></p>"), "rich");
25
- });
26
-
27
- it("opens a draft whose HTML was cleared as plain", () => {
28
- assert.equal(modeOfDraft(""), "plain");
29
- });
30
-
31
- it("opens a draft that never had HTML as plain", () => {
32
- assert.equal(modeOfDraft(undefined), "plain");
33
- });
34
- });
35
-
36
- describe("whether the switch warns first", () => {
37
- it("warns when the document holds formatting", () => {
38
- assert.equal(switchNeedsWarning("plain", ["table"]), true);
39
- });
40
-
41
- it("says nothing over plain paragraphs", () => {
42
- assert.equal(switchNeedsWarning("plain", []), false);
43
- });
44
-
45
- it("never warns on the way back to rich", () => {
46
- assert.equal(switchNeedsWarning("rich", ["table", "bold"]), false);
47
- });
48
- });
49
-
50
- describe("a conversion that would empty a written message", () => {
51
- it("goes ahead when the conversion carried the message across", () => {
52
- assert.deepEqual(conversionOutcome("plain", "Due Friday.", "Due Friday."), {
53
- outcome: "switch",
54
- });
55
- });
56
-
57
- it("goes ahead when there was nothing to carry", () => {
58
- assert.deepEqual(conversionOutcome("plain", "", ""), { outcome: "switch" });
59
- });
60
-
61
- it("refuses, naming the direction, when plain text came back empty", () => {
62
- assert.deepEqual(conversionOutcome("plain", "Due Friday.", " "), {
63
- outcome: "blocked",
64
- title: "Couldn't switch to plain text",
65
- detail: "The conversion came back empty, so your message is unchanged.",
66
- });
67
- });
68
-
69
- it("refuses, naming the direction, when rich text came back empty", () => {
70
- assert.deepEqual(conversionOutcome("rich", "Due Friday.", ""), {
71
- outcome: "blocked",
72
- title: "Couldn't switch to rich text",
73
- detail: "The conversion came back empty, so your message is unchanged.",
74
- });
75
- });
76
- });
@@ -1,50 +0,0 @@
1
- import type { ComposeBodyMode } from "@remit/ui/rich-text";
2
-
3
- /**
4
- * Which surface a draft reopens in, with no field of its own. `htmlBody` a
5
- * non-empty string is a rich draft; anything else is plain.
6
- *
7
- * Not "falsy": the rich editor serializes an empty document to `<p><br></p>`,
8
- * so a rich draft's `htmlBody` is never absent, and a plain draft clears the
9
- * column to the empty string rather than omitting it — absent means "leave
10
- * alone" at every layer below this one.
11
- */
12
- export const modeOfDraft = (htmlBody: string | undefined): ComposeBodyMode =>
13
- typeof htmlBody === "string" && htmlBody.length > 0 ? "rich" : "plain";
14
-
15
- /**
16
- * Whether switching to plain text destroys something. True for any node type or
17
- * text format the document holds that plain text cannot carry.
18
- */
19
- export const switchNeedsWarning = (
20
- target: ComposeBodyMode,
21
- formatting: readonly string[],
22
- ): boolean => target === "plain" && formatting.length > 0;
23
-
24
- export type ConversionOutcome =
25
- | { outcome: "switch" }
26
- | { outcome: "blocked"; title: string; detail: string };
27
-
28
- const BLOCKED_TITLES: Record<ComposeBodyMode, string> = {
29
- plain: "Couldn't switch to plain text",
30
- rich: "Couldn't switch to rich text",
31
- };
32
-
33
- /**
34
- * A conversion that empties a written message does not happen. Autosave would
35
- * persist the blank body a moment later, so the draft would be gone with
36
- * nothing said. An empty body converting to an empty body is not this case.
37
- */
38
- export const conversionOutcome = (
39
- target: ComposeBodyMode,
40
- source: string,
41
- converted: string,
42
- ): ConversionOutcome => {
43
- if (source.trim() === "" || converted.trim() !== "")
44
- return { outcome: "switch" };
45
- return {
46
- outcome: "blocked",
47
- title: BLOCKED_TITLES[target],
48
- detail: "The conversion came back empty, so your message is unchanged.",
49
- };
50
- };
@@ -1,54 +0,0 @@
1
- import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import { ConfirmDialog } from "./ConfirmDialog";
3
-
4
- const meta: Meta<typeof ConfirmDialog> = {
5
- title: "Screens/WebClient/ConfirmDialog",
6
- component: ConfirmDialog,
7
- parameters: { layout: "centered" },
8
- args: {
9
- isOpen: true,
10
- title: "Move 3,412 messages to Trash?",
11
- description: "You can restore them from Trash later.",
12
- confirmLabel: "Move to Trash",
13
- destructive: true,
14
- onConfirm: () => undefined,
15
- onCancel: () => undefined,
16
- },
17
- };
18
- export default meta;
19
-
20
- type Story = StoryObj<typeof ConfirmDialog>;
21
-
22
- /**
23
- * A single corner tap on the bar's delete icon used to fall straight through
24
- * to a delete with nothing in between — this is what now sits in the way.
25
- * Wording says "Move … to Trash", not "Delete": the operation is reversible
26
- * (IMAP delete moves to Trash), and the confirmation copy says so rather than
27
- * reading as final.
28
- */
29
- export const Default: Story = {};
30
-
31
- export const OneMessage: Story = {
32
- args: {
33
- title: "Move 1 message to Trash?",
34
- },
35
- };
36
-
37
- /** The mutation is in flight: the confirm button disables rather than
38
- * allowing a second concurrent delete request. */
39
- export const Busy: Story = {
40
- args: {
41
- isBusy: true,
42
- },
43
- };
44
-
45
- /** A non-destructive confirmation (no `destructive`) uses the accent
46
- * affirmative styling instead of danger. */
47
- export const NonDestructive: Story = {
48
- args: {
49
- title: "Archive 12 messages?",
50
- description: undefined,
51
- confirmLabel: "Archive",
52
- destructive: false,
53
- },
54
- };
@@ -1,140 +0,0 @@
1
- import { useCallback, useEffect, useRef } from "react";
2
- import { cn } from "@/lib/utils";
3
-
4
- interface ConfirmDialogProps {
5
- isOpen: boolean;
6
- title: string;
7
- /** Optional supporting line under the title. */
8
- description?: string;
9
- confirmLabel: string;
10
- cancelLabel?: string;
11
- /** Style the confirm button as a destructive action. */
12
- destructive?: boolean;
13
- /** Disable the confirm button (e.g. while a mutation is in flight). */
14
- isBusy?: boolean;
15
- onConfirm: () => void;
16
- onCancel: () => void;
17
- }
18
-
19
- /**
20
- * Minimal accessible confirmation dialog. No existing Dialog/ConfirmDialog
21
- * primitive ships in the web client (only the bespoke KeyboardShortcutsModal
22
- * and SlidePanel), so this is a small reusable one matching their Tailwind +
23
- * overlay conventions. Esc cancels, the backdrop cancels, Cancel is focused on
24
- * open, and the confirm/cancel pair is the only focusable content so the focus
25
- * stays within the dialog.
26
- */
27
- export const ConfirmDialog = ({
28
- isOpen,
29
- title,
30
- description,
31
- confirmLabel,
32
- cancelLabel = "Cancel",
33
- destructive = false,
34
- isBusy = false,
35
- onConfirm,
36
- onCancel,
37
- }: ConfirmDialogProps) => {
38
- const cancelRef = useRef<HTMLButtonElement>(null);
39
-
40
- const handleKeyDown = useCallback(
41
- (event: KeyboardEvent) => {
42
- if (event.key === "Escape") {
43
- event.preventDefault();
44
- event.stopPropagation();
45
- event.stopImmediatePropagation();
46
- onCancel();
47
- }
48
- },
49
- [onCancel],
50
- );
51
-
52
- useEffect(() => {
53
- if (!isOpen) return;
54
- // Capture phase so Esc closes the dialog before any list-level Esc
55
- // handler (e.g. clearSelection) also fires on the same keystroke.
56
- window.addEventListener("keydown", handleKeyDown, true);
57
- return () => window.removeEventListener("keydown", handleKeyDown, true);
58
- }, [isOpen, handleKeyDown]);
59
-
60
- // Whoever opened the dialog gets the focus back when it closes. Without this
61
- // a cancelled confirmation drops focus to the body, and the control the user
62
- // was on — the compose mode toggle, a row's delete button — is gone from
63
- // under the keyboard.
64
- useEffect(() => {
65
- if (!isOpen) return;
66
- const opener =
67
- document.activeElement instanceof HTMLElement
68
- ? document.activeElement
69
- : null;
70
- cancelRef.current?.focus();
71
- return () => {
72
- if (opener?.isConnected) opener.focus();
73
- };
74
- }, [isOpen]);
75
-
76
- if (!isOpen) return null;
77
-
78
- return (
79
- // Above every other overlay, the mobile compose sheet included: a
80
- // confirmation is the decision blocking whatever is under it, and a drawer
81
- // portalled to the body at the same level would cover it.
82
- <div className="fixed inset-0 z-[60] flex items-center justify-center">
83
- {/* Backdrop. It carries the click-to-dismiss and the aria-hidden: the
84
- dialog itself must stay in the accessibility tree, and an
85
- aria-hidden ancestor would take it out. */}
86
- <div
87
- className="absolute inset-0 bg-canvas/80 backdrop-blur-sm"
88
- aria-hidden="true"
89
- onClick={onCancel}
90
- />
91
-
92
- {/* Dialog */}
93
- <div
94
- role="dialog"
95
- aria-modal="true"
96
- aria-label={title}
97
- className={cn(
98
- "relative z-10 w-full max-w-sm",
99
- "bg-surface border border-line rounded-sm shadow-lg",
100
- "p-6",
101
- )}
102
- onClick={(e) => e.stopPropagation()}
103
- onKeyDown={(e) => e.stopPropagation()}
104
- >
105
- <h2 className="text-lg font-semibold">{title}</h2>
106
- {description && (
107
- <p className="mt-2 text-sm text-fg-muted">{description}</p>
108
- )}
109
-
110
- <div className="mt-6 flex items-center justify-end gap-2">
111
- <button
112
- ref={cancelRef}
113
- type="button"
114
- onClick={onCancel}
115
- className={cn(
116
- "min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
117
- "border border-line hover:bg-surface-raised",
118
- )}
119
- >
120
- {cancelLabel}
121
- </button>
122
- <button
123
- type="button"
124
- onClick={onConfirm}
125
- disabled={isBusy}
126
- className={cn(
127
- "min-h-11 inline-flex items-center justify-center px-4 rounded text-sm font-medium transition-colors",
128
- destructive
129
- ? "bg-danger text-canvas hover:bg-danger/90"
130
- : "bg-accent text-accent-fg hover:bg-accent-hover",
131
- "disabled:opacity-50 disabled:cursor-not-allowed",
132
- )}
133
- >
134
- {confirmLabel}
135
- </button>
136
- </div>
137
- </div>
138
- </div>
139
- );
140
- };