@remit/ui 0.0.46 → 0.0.48
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/package.json +1 -1
- package/src/components/filter-rule-editor.create.test.ts +249 -0
- package/src/components/filter-rule-editor.stories.tsx +26 -0
- package/src/components/filter-rule-editor.tsx +157 -12
- package/src/components/move-mailbox-picker.create.test.ts +210 -0
- package/src/components/move-mailbox-picker.stories.tsx +38 -0
- package/src/components/move-mailbox-picker.tsx +65 -0
- package/src/components/swipeable-row.stories.tsx +104 -0
- package/src/components/swipeable-row.tsx +5 -5
- package/src/lib/use-long-press.test.ts +81 -17
- package/src/lib/use-long-press.ts +46 -2
package/package.json
CHANGED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The move-to "New folder…" affordance: the option shows only when
|
|
3
|
+
* `onCreateFolder` is wired; choosing it reveals a name field, and a resolved
|
|
4
|
+
* create picks the new folder as the destination. Mounted against jsdom for the
|
|
5
|
+
* select interaction, the inline field state, and the async resolve. React is
|
|
6
|
+
* imported after the jsdom globals are installed so the controlled value tracker
|
|
7
|
+
* binds to jsdom's prototypes.
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
11
|
+
import type { JSDOM } from "jsdom";
|
|
12
|
+
import type {
|
|
13
|
+
act as reactAct,
|
|
14
|
+
createElement as reactCreateElement,
|
|
15
|
+
} from "react";
|
|
16
|
+
import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
|
|
17
|
+
import type { FilterRule, FolderOption, PreviewCount } from "./filter-rule.js";
|
|
18
|
+
import type { FilterRuleEditor as FilterRuleEditorType } from "./filter-rule-editor.js";
|
|
19
|
+
|
|
20
|
+
const folders: FolderOption[] = [
|
|
21
|
+
{ id: "mbx-inbox", label: "Inbox" },
|
|
22
|
+
{ id: "mbx-archive", label: "Archive" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const rule: FilterRule = {
|
|
26
|
+
clauses: [{ id: "c1", field: "From", value: "a@example.com" }],
|
|
27
|
+
matchOperator: "all",
|
|
28
|
+
scope: "once",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const preview: PreviewCount = { status: "ready", count: 3 };
|
|
32
|
+
|
|
33
|
+
let dom: JSDOM;
|
|
34
|
+
let container: HTMLElement;
|
|
35
|
+
let root: Root;
|
|
36
|
+
let act: typeof reactAct;
|
|
37
|
+
let createElement: typeof reactCreateElement;
|
|
38
|
+
let createRoot: typeof reactCreateRoot;
|
|
39
|
+
let FilterRuleEditor: typeof FilterRuleEditorType;
|
|
40
|
+
|
|
41
|
+
before(async () => {
|
|
42
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
43
|
+
dom = new JSDOMCtor(
|
|
44
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
45
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
46
|
+
);
|
|
47
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
48
|
+
globalThis.document = dom.window.document;
|
|
49
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
50
|
+
globalThis.Element = dom.window.Element;
|
|
51
|
+
globalThis.Event = dom.window.Event;
|
|
52
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
53
|
+
value: dom.window.navigator,
|
|
54
|
+
configurable: true,
|
|
55
|
+
});
|
|
56
|
+
(
|
|
57
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
58
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
59
|
+
|
|
60
|
+
const react = await import("react");
|
|
61
|
+
act = react.act;
|
|
62
|
+
createElement = react.createElement;
|
|
63
|
+
({ createRoot } = await import("react-dom/client"));
|
|
64
|
+
({ FilterRuleEditor } = await import("./filter-rule-editor.js"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
after(() => {
|
|
68
|
+
dom.window.close();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
container = dom.window.document.getElementById(
|
|
73
|
+
"root",
|
|
74
|
+
) as unknown as HTMLElement;
|
|
75
|
+
container.innerHTML = "";
|
|
76
|
+
root = createRoot(container);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
act(() => {
|
|
81
|
+
root.unmount();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function destinationSelect(): HTMLSelectElement {
|
|
86
|
+
return container.querySelector(
|
|
87
|
+
'select[aria-label="Destination folder"]',
|
|
88
|
+
) as HTMLSelectElement;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createOption(): HTMLOptionElement | undefined {
|
|
92
|
+
return Array.from(destinationSelect().querySelectorAll("option")).find(
|
|
93
|
+
(option) => option.textContent?.includes("New folder"),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function chooseCreateOption() {
|
|
98
|
+
const select = destinationSelect();
|
|
99
|
+
const value = createOption()?.value ?? "";
|
|
100
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
101
|
+
dom.window.HTMLSelectElement.prototype,
|
|
102
|
+
"value",
|
|
103
|
+
)?.set;
|
|
104
|
+
setter?.call(select, value);
|
|
105
|
+
act(() => {
|
|
106
|
+
select.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function setInput(el: HTMLInputElement, value: string) {
|
|
111
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
112
|
+
dom.window.HTMLInputElement.prototype,
|
|
113
|
+
"value",
|
|
114
|
+
)?.set;
|
|
115
|
+
setter?.call(el, value);
|
|
116
|
+
act(() => {
|
|
117
|
+
el.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function button(text: string): HTMLButtonElement | undefined {
|
|
122
|
+
return Array.from(container.querySelectorAll("button")).find(
|
|
123
|
+
(candidate) => candidate.textContent?.trim() === text,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function mount(overrides: {
|
|
128
|
+
onCreateFolder?: (name: string) => Promise<FolderOption>;
|
|
129
|
+
onChangeMove?: (id: string) => void;
|
|
130
|
+
}) {
|
|
131
|
+
act(() => {
|
|
132
|
+
root.render(
|
|
133
|
+
createElement(FilterRuleEditor, {
|
|
134
|
+
rule,
|
|
135
|
+
folders,
|
|
136
|
+
preview,
|
|
137
|
+
onCommit: () => {},
|
|
138
|
+
onCancel: () => {},
|
|
139
|
+
...overrides,
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe("FilterRuleEditor new-folder option", () => {
|
|
146
|
+
it("does not offer the create option without onCreateFolder", () => {
|
|
147
|
+
mount({});
|
|
148
|
+
assert.equal(createOption(), undefined);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("offers the create option when onCreateFolder is wired", () => {
|
|
152
|
+
mount({ onCreateFolder: async () => ({ id: "x", label: "x" }) });
|
|
153
|
+
assert.ok(createOption(), "the + New folder… option is present");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("reveals the name field only after the option is chosen", () => {
|
|
157
|
+
mount({ onCreateFolder: async () => ({ id: "x", label: "x" }) });
|
|
158
|
+
assert.equal(
|
|
159
|
+
container.querySelector('input[aria-label="New folder name"]'),
|
|
160
|
+
null,
|
|
161
|
+
);
|
|
162
|
+
chooseCreateOption();
|
|
163
|
+
assert.ok(
|
|
164
|
+
container.querySelector('input[aria-label="New folder name"]'),
|
|
165
|
+
"the name field appears",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("creates the folder and selects it as the destination", async () => {
|
|
170
|
+
const moved: string[] = [];
|
|
171
|
+
mount({
|
|
172
|
+
onChangeMove: (id) => moved.push(id),
|
|
173
|
+
onCreateFolder: async (name) => ({ id: "mbx-created", label: name }),
|
|
174
|
+
});
|
|
175
|
+
chooseCreateOption();
|
|
176
|
+
const nameInput = container.querySelector(
|
|
177
|
+
'input[aria-label="New folder name"]',
|
|
178
|
+
) as HTMLInputElement;
|
|
179
|
+
setInput(nameInput, "Receipts");
|
|
180
|
+
await act(async () => {
|
|
181
|
+
button("Create folder")?.click();
|
|
182
|
+
});
|
|
183
|
+
assert.deepEqual(moved, ["mbx-created"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("keeps the name field open and surfaces the rejection message when create fails", async () => {
|
|
187
|
+
const moved: string[] = [];
|
|
188
|
+
mount({
|
|
189
|
+
onChangeMove: (id) => moved.push(id),
|
|
190
|
+
onCreateFolder: async () => {
|
|
191
|
+
throw new Error("A folder with that name already exists.");
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
chooseCreateOption();
|
|
195
|
+
const nameInput = container.querySelector(
|
|
196
|
+
'input[aria-label="New folder name"]',
|
|
197
|
+
) as HTMLInputElement;
|
|
198
|
+
setInput(nameInput, "Archive");
|
|
199
|
+
await act(async () => {
|
|
200
|
+
button("Create folder")?.click();
|
|
201
|
+
});
|
|
202
|
+
assert.deepEqual(moved, []);
|
|
203
|
+
assert.ok(
|
|
204
|
+
container.querySelector('input[aria-label="New folder name"]'),
|
|
205
|
+
"the name field stays open",
|
|
206
|
+
);
|
|
207
|
+
assert.match(
|
|
208
|
+
container.querySelector('[role="alert"]')?.textContent ?? "",
|
|
209
|
+
/already exists/,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("falls back to the generic message for a non-Error rejection", async () => {
|
|
214
|
+
mount({
|
|
215
|
+
onCreateFolder: async () => {
|
|
216
|
+
throw "opaque";
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
chooseCreateOption();
|
|
220
|
+
const nameInput = container.querySelector(
|
|
221
|
+
'input[aria-label="New folder name"]',
|
|
222
|
+
) as HTMLInputElement;
|
|
223
|
+
setInput(nameInput, "Receipts");
|
|
224
|
+
await act(async () => {
|
|
225
|
+
button("Create folder")?.click();
|
|
226
|
+
});
|
|
227
|
+
assert.match(
|
|
228
|
+
container.querySelector('[role="alert"]')?.textContent ?? "",
|
|
229
|
+
/Couldn't create that folder/,
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("cancels the create field and leaves the destination unchanged", () => {
|
|
234
|
+
const moved: string[] = [];
|
|
235
|
+
mount({
|
|
236
|
+
onChangeMove: (id) => moved.push(id),
|
|
237
|
+
onCreateFolder: async (name) => ({ id: "mbx-created", label: name }),
|
|
238
|
+
});
|
|
239
|
+
chooseCreateOption();
|
|
240
|
+
act(() => {
|
|
241
|
+
button("Cancel")?.click();
|
|
242
|
+
});
|
|
243
|
+
assert.equal(
|
|
244
|
+
container.querySelector('input[aria-label="New folder name"]'),
|
|
245
|
+
null,
|
|
246
|
+
);
|
|
247
|
+
assert.deepEqual(moved, []);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
demoSenderFallbackRule,
|
|
11
11
|
demoVocabularyRule,
|
|
12
12
|
type FilterRule,
|
|
13
|
+
type FolderOption,
|
|
13
14
|
type PreviewCount,
|
|
14
15
|
type RuleClause,
|
|
15
16
|
} from "./filter-rule.js";
|
|
@@ -50,9 +51,11 @@ const READY = (count: number, stale?: boolean): PreviewCount => ({
|
|
|
50
51
|
function LiveEditor({
|
|
51
52
|
initialRule,
|
|
52
53
|
semanticAvailable = true,
|
|
54
|
+
onCreateFolder,
|
|
53
55
|
}: {
|
|
54
56
|
initialRule: FilterRule;
|
|
55
57
|
semanticAvailable?: boolean;
|
|
58
|
+
onCreateFolder?: (name: string) => Promise<FolderOption>;
|
|
56
59
|
}) {
|
|
57
60
|
const [rule, setRule] = useState<FilterRule>(initialRule);
|
|
58
61
|
const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
|
|
@@ -132,6 +135,7 @@ function LiveEditor({
|
|
|
132
135
|
preview={preview}
|
|
133
136
|
semanticAvailable={semanticAvailable}
|
|
134
137
|
clauseEdit={clauseEdit}
|
|
138
|
+
onCreateFolder={onCreateFolder}
|
|
135
139
|
onCommit={() => {}}
|
|
136
140
|
onCancel={() => {}}
|
|
137
141
|
{...handlers}
|
|
@@ -144,6 +148,28 @@ export const Interactive: Story = {
|
|
|
144
148
|
render: () => <LiveEditor initialRule={demoRule} />,
|
|
145
149
|
};
|
|
146
150
|
|
|
151
|
+
let newFolderSeq = 0;
|
|
152
|
+
const mockCreateFolder = (name: string): Promise<FolderOption> =>
|
|
153
|
+
new Promise((resolve) => {
|
|
154
|
+
newFolderSeq += 1;
|
|
155
|
+
setTimeout(
|
|
156
|
+
() => resolve({ id: `mbx-new-${newFolderSeq}`, label: name }),
|
|
157
|
+
400,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The move destination offers a "+ New folder…" option because `onCreateFolder`
|
|
163
|
+
* is wired. Choosing it reveals a name field; on resolve the folder is added to
|
|
164
|
+
* the select and picked as the destination. Without the prop the option never
|
|
165
|
+
* shows — the editor stays data-agnostic.
|
|
166
|
+
*/
|
|
167
|
+
export const WithNewFolderOption: Story = {
|
|
168
|
+
render: () => (
|
|
169
|
+
<LiveEditor initialRule={demoRule} onCreateFolder={mockCreateFolder} />
|
|
170
|
+
),
|
|
171
|
+
};
|
|
172
|
+
|
|
147
173
|
/** Literal clauses joined with "or", including the ticket-B ListId and FromDomain fields. */
|
|
148
174
|
export const AnyOfTheseClauses: Story = {
|
|
149
175
|
render: () => <LiveEditor initialRule={demoVocabularyRule} />,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, type ReactNode } from "react";
|
|
1
|
+
import { Fragment, type ReactNode, useMemo, useState } from "react";
|
|
2
2
|
import { BottomSheet } from "./bottom-sheet.js";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
4
|
import { Dialog } from "./dialog.js";
|
|
@@ -77,6 +77,13 @@ export interface FilterRuleEditorProps {
|
|
|
77
77
|
onRemoveWiden?: () => void;
|
|
78
78
|
onChangeMatchOperator?: (operator: MatchOperator) => void;
|
|
79
79
|
onChangeMove?: (mailboxId: string) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Create a new destination folder from within the editor. Given a folder
|
|
82
|
+
* name, resolves to the created folder once the backend has queued it. When
|
|
83
|
+
* absent, the "New folder…" option is not offered — the editor stays
|
|
84
|
+
* data-agnostic, so stories and consumers without wiring render unchanged.
|
|
85
|
+
*/
|
|
86
|
+
onCreateFolder?: (name: string) => Promise<FolderOption>;
|
|
80
87
|
onChangeScope?: (scope: RuleScope) => void;
|
|
81
88
|
onChangeName?: (name: string) => void;
|
|
82
89
|
onChangeUntil?: (date: string) => void;
|
|
@@ -95,6 +102,149 @@ const scopeOptions: { value: RuleScope; label: string }[] = [
|
|
|
95
102
|
{ value: "until", label: "Until a date" },
|
|
96
103
|
];
|
|
97
104
|
|
|
105
|
+
const CREATE_FOLDER_VALUE = "__filter_create_folder__";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The move-to destination select, plus an inline "New folder…" affordance when
|
|
109
|
+
* the consumer wires `onCreateFolder`. Selecting the create option reveals a
|
|
110
|
+
* name field; on resolve the new folder is added to the local option set (so it
|
|
111
|
+
* is selectable even before the caller's folder list refetches) and picked as
|
|
112
|
+
* the destination. Without `onCreateFolder` this is the bare select.
|
|
113
|
+
*/
|
|
114
|
+
function MoveDestinationField({
|
|
115
|
+
folders,
|
|
116
|
+
value,
|
|
117
|
+
onChangeMove,
|
|
118
|
+
onCreateFolder,
|
|
119
|
+
}: {
|
|
120
|
+
folders: FolderOption[];
|
|
121
|
+
value: string;
|
|
122
|
+
onChangeMove?: (mailboxId: string) => void;
|
|
123
|
+
onCreateFolder?: (name: string) => Promise<FolderOption>;
|
|
124
|
+
}) {
|
|
125
|
+
const [creating, setCreating] = useState(false);
|
|
126
|
+
const [name, setName] = useState("");
|
|
127
|
+
const [pending, setPending] = useState(false);
|
|
128
|
+
const [error, setError] = useState<string>();
|
|
129
|
+
const [createdFolders, setCreatedFolders] = useState<FolderOption[]>([]);
|
|
130
|
+
|
|
131
|
+
const options = useMemo(() => {
|
|
132
|
+
const known = new Set(folders.map((folder) => folder.id));
|
|
133
|
+
return [
|
|
134
|
+
...folders,
|
|
135
|
+
...createdFolders.filter((folder) => !known.has(folder.id)),
|
|
136
|
+
];
|
|
137
|
+
}, [folders, createdFolders]);
|
|
138
|
+
|
|
139
|
+
const handleSelectChange = (next: string) => {
|
|
140
|
+
if (next === CREATE_FOLDER_VALUE) {
|
|
141
|
+
setError(undefined);
|
|
142
|
+
setCreating(true);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
onChangeMove?.(next);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const submit = () => {
|
|
149
|
+
if (!onCreateFolder) return;
|
|
150
|
+
const trimmed = name.trim();
|
|
151
|
+
if (trimmed === "") return;
|
|
152
|
+
setPending(true);
|
|
153
|
+
setError(undefined);
|
|
154
|
+
onCreateFolder(trimmed)
|
|
155
|
+
.then((folder) => {
|
|
156
|
+
setCreatedFolders((prev) =>
|
|
157
|
+
prev.some((entry) => entry.id === folder.id)
|
|
158
|
+
? prev
|
|
159
|
+
: [...prev, folder],
|
|
160
|
+
);
|
|
161
|
+
onChangeMove?.(folder.id);
|
|
162
|
+
setCreating(false);
|
|
163
|
+
setName("");
|
|
164
|
+
setPending(false);
|
|
165
|
+
})
|
|
166
|
+
.catch((error: unknown) => {
|
|
167
|
+
setError(
|
|
168
|
+
error instanceof Error
|
|
169
|
+
? error.message
|
|
170
|
+
: "Couldn't create that folder. Please try again.",
|
|
171
|
+
);
|
|
172
|
+
setPending(false);
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const cancel = () => {
|
|
177
|
+
setCreating(false);
|
|
178
|
+
setName("");
|
|
179
|
+
setError(undefined);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div className="space-y-2">
|
|
184
|
+
<Select
|
|
185
|
+
aria-label="Destination folder"
|
|
186
|
+
value={value}
|
|
187
|
+
onChange={(event) => handleSelectChange(event.target.value)}
|
|
188
|
+
>
|
|
189
|
+
<option value="">Choose a folder…</option>
|
|
190
|
+
{options.map((folder) => (
|
|
191
|
+
<option key={folder.id} value={folder.id}>
|
|
192
|
+
{folder.label}
|
|
193
|
+
</option>
|
|
194
|
+
))}
|
|
195
|
+
{onCreateFolder && (
|
|
196
|
+
<option value={CREATE_FOLDER_VALUE}>+ New folder…</option>
|
|
197
|
+
)}
|
|
198
|
+
</Select>
|
|
199
|
+
{creating && (
|
|
200
|
+
<div className="space-y-2 rounded-md border border-line bg-surface-sunken p-2">
|
|
201
|
+
<Input
|
|
202
|
+
value={name}
|
|
203
|
+
onChange={(event) => setName(event.target.value)}
|
|
204
|
+
placeholder="Folder name"
|
|
205
|
+
aria-label="New folder name"
|
|
206
|
+
disabled={pending}
|
|
207
|
+
autoFocus
|
|
208
|
+
onKeyDown={(event) => {
|
|
209
|
+
if (event.key === "Enter") {
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
submit();
|
|
212
|
+
}
|
|
213
|
+
if (event.key === "Escape") {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
cancel();
|
|
216
|
+
}
|
|
217
|
+
}}
|
|
218
|
+
/>
|
|
219
|
+
{error && (
|
|
220
|
+
<p className="text-2xs text-danger" role="alert">
|
|
221
|
+
{error}
|
|
222
|
+
</p>
|
|
223
|
+
)}
|
|
224
|
+
<div className="flex gap-2">
|
|
225
|
+
<Button
|
|
226
|
+
variant="primary"
|
|
227
|
+
size="sm"
|
|
228
|
+
onClick={submit}
|
|
229
|
+
disabled={pending || name.trim() === ""}
|
|
230
|
+
>
|
|
231
|
+
{pending ? "Creating…" : "Create folder"}
|
|
232
|
+
</Button>
|
|
233
|
+
<Button
|
|
234
|
+
variant="ghost"
|
|
235
|
+
size="sm"
|
|
236
|
+
onClick={cancel}
|
|
237
|
+
disabled={pending}
|
|
238
|
+
>
|
|
239
|
+
Cancel
|
|
240
|
+
</Button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
98
248
|
export function FilterRuleEditor({
|
|
99
249
|
rule,
|
|
100
250
|
folders,
|
|
@@ -115,6 +265,7 @@ export function FilterRuleEditor({
|
|
|
115
265
|
onRemoveWiden,
|
|
116
266
|
onChangeMatchOperator,
|
|
117
267
|
onChangeMove,
|
|
268
|
+
onCreateFolder,
|
|
118
269
|
onChangeScope,
|
|
119
270
|
onChangeName,
|
|
120
271
|
onChangeUntil,
|
|
@@ -204,18 +355,12 @@ export function FilterRuleEditor({
|
|
|
204
355
|
|
|
205
356
|
<section className="space-y-2">
|
|
206
357
|
<p className="text-xs font-medium text-fg-muted">Move matches to</p>
|
|
207
|
-
<
|
|
208
|
-
|
|
358
|
+
<MoveDestinationField
|
|
359
|
+
folders={folders}
|
|
209
360
|
value={rule.moveMailboxId ?? ""}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
{folders.map((folder) => (
|
|
214
|
-
<option key={folder.id} value={folder.id}>
|
|
215
|
-
{folder.label}
|
|
216
|
-
</option>
|
|
217
|
-
))}
|
|
218
|
-
</Select>
|
|
361
|
+
onChangeMove={onChangeMove}
|
|
362
|
+
onCreateFolder={onCreateFolder}
|
|
363
|
+
/>
|
|
219
364
|
<div className="flex items-center gap-2 pt-0.5">
|
|
220
365
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-sunken px-2 py-0.5 text-2xs font-medium text-fg-muted">
|
|
221
366
|
label them…
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The create-and-move affordance: a search that names no existing folder offers
|
|
3
|
+
* a create row only when `onCreateFolder` is wired; resolving it selects the new
|
|
4
|
+
* folder. Mounted against jsdom since it turns on typed search state and an
|
|
5
|
+
* async resolve. React is imported after the jsdom globals are installed so the
|
|
6
|
+
* controlled-input value tracker binds to jsdom's prototypes.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
10
|
+
import type { JSDOM } from "jsdom";
|
|
11
|
+
import type {
|
|
12
|
+
act as reactAct,
|
|
13
|
+
createElement as reactCreateElement,
|
|
14
|
+
} from "react";
|
|
15
|
+
import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
|
|
16
|
+
import type {
|
|
17
|
+
MoveMailboxOption,
|
|
18
|
+
MoveMailboxPicker as MoveMailboxPickerType,
|
|
19
|
+
} from "./move-mailbox-picker.js";
|
|
20
|
+
|
|
21
|
+
const mailboxes: MoveMailboxOption[] = [
|
|
22
|
+
{ id: "archive", label: "Archive" },
|
|
23
|
+
{ id: "trash", label: "Trash" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
let dom: JSDOM;
|
|
27
|
+
let container: HTMLElement;
|
|
28
|
+
let root: Root;
|
|
29
|
+
let act: typeof reactAct;
|
|
30
|
+
let createElement: typeof reactCreateElement;
|
|
31
|
+
let createRoot: typeof reactCreateRoot;
|
|
32
|
+
let MoveMailboxPicker: typeof MoveMailboxPickerType;
|
|
33
|
+
|
|
34
|
+
before(async () => {
|
|
35
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
36
|
+
dom = new JSDOMCtor(
|
|
37
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
38
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
39
|
+
);
|
|
40
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
41
|
+
globalThis.document = dom.window.document;
|
|
42
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
43
|
+
globalThis.Element = dom.window.Element;
|
|
44
|
+
globalThis.Event = dom.window.Event;
|
|
45
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
46
|
+
value: dom.window.navigator,
|
|
47
|
+
configurable: true,
|
|
48
|
+
});
|
|
49
|
+
(
|
|
50
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
51
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
52
|
+
|
|
53
|
+
const react = await import("react");
|
|
54
|
+
act = react.act;
|
|
55
|
+
createElement = react.createElement;
|
|
56
|
+
({ createRoot } = await import("react-dom/client"));
|
|
57
|
+
({ MoveMailboxPicker } = await import("./move-mailbox-picker.js"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
after(() => {
|
|
61
|
+
dom.window.close();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
container = dom.window.document.getElementById(
|
|
66
|
+
"root",
|
|
67
|
+
) as unknown as HTMLElement;
|
|
68
|
+
container.innerHTML = "";
|
|
69
|
+
root = createRoot(container);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
act(() => {
|
|
74
|
+
root.unmount();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function typeSearch(value: string) {
|
|
79
|
+
const input = container.querySelector(
|
|
80
|
+
'input[aria-label="Filter folders"]',
|
|
81
|
+
) as HTMLInputElement;
|
|
82
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
83
|
+
dom.window.HTMLInputElement.prototype,
|
|
84
|
+
"value",
|
|
85
|
+
)?.set;
|
|
86
|
+
setter?.call(input, value);
|
|
87
|
+
act(() => {
|
|
88
|
+
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createRow(): HTMLButtonElement | null {
|
|
93
|
+
return (
|
|
94
|
+
Array.from(container.querySelectorAll("button")).find((button) =>
|
|
95
|
+
button.textContent?.startsWith("Create "),
|
|
96
|
+
) ?? null
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("MoveMailboxPicker create-and-move", () => {
|
|
101
|
+
it("offers no create row without onCreateFolder, even when nothing matches", () => {
|
|
102
|
+
act(() => {
|
|
103
|
+
root.render(
|
|
104
|
+
createElement(MoveMailboxPicker, {
|
|
105
|
+
mailboxes,
|
|
106
|
+
onSelect: () => {},
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
typeSearch("Taxes");
|
|
111
|
+
assert.equal(createRow(), null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("offers a create row when the query names no existing folder", () => {
|
|
115
|
+
act(() => {
|
|
116
|
+
root.render(
|
|
117
|
+
createElement(MoveMailboxPicker, {
|
|
118
|
+
mailboxes,
|
|
119
|
+
onSelect: () => {},
|
|
120
|
+
onCreateFolder: async () => ({ id: "new", label: "Taxes" }),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
typeSearch("Taxes");
|
|
125
|
+
const row = createRow();
|
|
126
|
+
assert.ok(row, "the create row is present");
|
|
127
|
+
assert.match(row?.textContent ?? "", /Create "Taxes"/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("hides the create row when the query exactly names an existing folder", () => {
|
|
131
|
+
act(() => {
|
|
132
|
+
root.render(
|
|
133
|
+
createElement(MoveMailboxPicker, {
|
|
134
|
+
mailboxes,
|
|
135
|
+
onSelect: () => {},
|
|
136
|
+
onCreateFolder: async () => ({ id: "new", label: "Archive" }),
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
typeSearch("archive");
|
|
141
|
+
assert.equal(createRow(), null);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("creates the folder and selects it in one step", async () => {
|
|
145
|
+
const selected: string[] = [];
|
|
146
|
+
act(() => {
|
|
147
|
+
root.render(
|
|
148
|
+
createElement(MoveMailboxPicker, {
|
|
149
|
+
mailboxes,
|
|
150
|
+
onSelect: (id: string) => selected.push(id),
|
|
151
|
+
onCreateFolder: async (name: string) => ({
|
|
152
|
+
id: "created-taxes",
|
|
153
|
+
label: name,
|
|
154
|
+
}),
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
typeSearch("Taxes");
|
|
159
|
+
await act(async () => {
|
|
160
|
+
createRow()?.click();
|
|
161
|
+
});
|
|
162
|
+
assert.deepEqual(selected, ["created-taxes"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("surfaces a validation rejection message inline without selecting anything", async () => {
|
|
166
|
+
const selected: string[] = [];
|
|
167
|
+
act(() => {
|
|
168
|
+
root.render(
|
|
169
|
+
createElement(MoveMailboxPicker, {
|
|
170
|
+
mailboxes,
|
|
171
|
+
onSelect: (id: string) => selected.push(id),
|
|
172
|
+
onCreateFolder: async () => {
|
|
173
|
+
throw new Error('A folder name can\'t contain "/".');
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
typeSearch("Work/Receipts");
|
|
179
|
+
await act(async () => {
|
|
180
|
+
createRow()?.click();
|
|
181
|
+
});
|
|
182
|
+
assert.deepEqual(selected, []);
|
|
183
|
+
assert.match(
|
|
184
|
+
container.querySelector('[role="alert"]')?.textContent ?? "",
|
|
185
|
+
/can't contain/,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("falls back to the generic message for a non-Error rejection", async () => {
|
|
190
|
+
act(() => {
|
|
191
|
+
root.render(
|
|
192
|
+
createElement(MoveMailboxPicker, {
|
|
193
|
+
mailboxes,
|
|
194
|
+
onSelect: () => {},
|
|
195
|
+
onCreateFolder: async () => {
|
|
196
|
+
throw "opaque";
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
typeSearch("Taxes");
|
|
202
|
+
await act(async () => {
|
|
203
|
+
createRow()?.click();
|
|
204
|
+
});
|
|
205
|
+
assert.match(
|
|
206
|
+
container.querySelector('[role="alert"]')?.textContent ?? "",
|
|
207
|
+
/Couldn't create that folder/,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -74,3 +74,41 @@ export const Autofocus: Story = {
|
|
|
74
74
|
<MoveMailboxPicker mailboxes={mailboxes} onSelect={() => {}} autoFocus />
|
|
75
75
|
),
|
|
76
76
|
};
|
|
77
|
+
|
|
78
|
+
let createdSeq = 0;
|
|
79
|
+
const mockCreateFolder = (name: string): Promise<MoveMailboxOption> =>
|
|
80
|
+
new Promise((resolve) => {
|
|
81
|
+
createdSeq += 1;
|
|
82
|
+
setTimeout(
|
|
83
|
+
() => resolve({ id: `created-${createdSeq}`, label: name }),
|
|
84
|
+
400,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const CreatePicker = () => {
|
|
89
|
+
const [moved, setMoved] = useState<string | null>(null);
|
|
90
|
+
return (
|
|
91
|
+
<div className="flex flex-col">
|
|
92
|
+
<MoveMailboxPicker
|
|
93
|
+
mailboxes={mailboxes}
|
|
94
|
+
onSelect={setMoved}
|
|
95
|
+
onCreateFolder={mockCreateFolder}
|
|
96
|
+
/>
|
|
97
|
+
{moved && (
|
|
98
|
+
<p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
|
|
99
|
+
Moved to {moved}
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* With `onCreateFolder` wired, a search that names no existing folder offers a
|
|
108
|
+
* create-and-move row at the bottom. Type e.g. "Taxes", then pick the create
|
|
109
|
+
* row — the folder is created and the message moved into it in one step.
|
|
110
|
+
*/
|
|
111
|
+
export const CreateAndMove: Story = {
|
|
112
|
+
name: "Create folder from search",
|
|
113
|
+
render: () => <CreatePicker />,
|
|
114
|
+
};
|
|
@@ -42,6 +42,12 @@ export interface MoveMailboxPickerLabels {
|
|
|
42
42
|
emptyMessage?: (query: string) => string;
|
|
43
43
|
/** Builds the accessible label for a selectable row, e.g. `Move to X`. */
|
|
44
44
|
optionLabel?: (label: string) => string;
|
|
45
|
+
/** Builds the label for the create-and-move row, e.g. `Create "Receipts"`. */
|
|
46
|
+
createLabel?: (query: string) => string;
|
|
47
|
+
/** Shown on the create row while the folder is being created. */
|
|
48
|
+
createPending?: string;
|
|
49
|
+
/** Shown when creating the folder fails. */
|
|
50
|
+
createError?: string;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
export interface MoveMailboxPickerProps {
|
|
@@ -51,6 +57,13 @@ export interface MoveMailboxPickerProps {
|
|
|
51
57
|
*/
|
|
52
58
|
mailboxes: readonly MoveMailboxOption[];
|
|
53
59
|
onSelect: (mailboxId: string) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Create a folder named by the current search query. When provided and the
|
|
62
|
+
* query names no existing folder, a create-and-move row is offered at the
|
|
63
|
+
* bottom of the list; resolving it yields the new folder, which is selected
|
|
64
|
+
* (moved into) immediately. Absent means no create affordance renders.
|
|
65
|
+
*/
|
|
66
|
+
onCreateFolder?: (name: string) => Promise<MoveMailboxOption>;
|
|
54
67
|
/**
|
|
55
68
|
* Called when the user dismisses the picker via Escape. Trigger consumers
|
|
56
69
|
* use this to close their popover/drawer; the picker never owns
|
|
@@ -78,6 +91,9 @@ const defaultLabels: Required<MoveMailboxPickerLabels> = {
|
|
|
78
91
|
currentTag: "current",
|
|
79
92
|
emptyMessage: (query) => `No folders match "${query}"`,
|
|
80
93
|
optionLabel: (label) => `Move to ${label}`,
|
|
94
|
+
createLabel: (query) => `Create "${query}"`,
|
|
95
|
+
createPending: "Creating folder…",
|
|
96
|
+
createError: "Couldn't create that folder. Please try again.",
|
|
81
97
|
};
|
|
82
98
|
|
|
83
99
|
const findFirstSelectable = (options: readonly MoveMailboxOption[]): number => {
|
|
@@ -123,12 +139,15 @@ const matchesQuery = (option: MoveMailboxOption, query: string): boolean => {
|
|
|
123
139
|
export const MoveMailboxPicker = ({
|
|
124
140
|
mailboxes,
|
|
125
141
|
onSelect,
|
|
142
|
+
onCreateFolder,
|
|
126
143
|
onCancel,
|
|
127
144
|
autoFocus = false,
|
|
128
145
|
labels,
|
|
129
146
|
}: MoveMailboxPickerProps) => {
|
|
130
147
|
const text = { ...defaultLabels, ...labels };
|
|
131
148
|
const [query, setQuery] = useState("");
|
|
149
|
+
const [creating, setCreating] = useState(false);
|
|
150
|
+
const [createError, setCreateError] = useState<string>();
|
|
132
151
|
const [focusedIndex, setFocusedIndex] = useState<number>(() =>
|
|
133
152
|
findFirstSelectable(mailboxes),
|
|
134
153
|
);
|
|
@@ -176,6 +195,30 @@ export const MoveMailboxPicker = ({
|
|
|
176
195
|
onSelect(target.id);
|
|
177
196
|
}, [focusedIndex, filtered, onSelect]);
|
|
178
197
|
|
|
198
|
+
const showCreate =
|
|
199
|
+
!!onCreateFolder &&
|
|
200
|
+
trimmedQuery.length > 0 &&
|
|
201
|
+
!mailboxes.some((mailbox) => mailbox.label.toLowerCase() === trimmedQuery);
|
|
202
|
+
|
|
203
|
+
const handleCreate = useCallback(() => {
|
|
204
|
+
if (!onCreateFolder) return;
|
|
205
|
+
const name = query.trim();
|
|
206
|
+
if (name === "") return;
|
|
207
|
+
setCreating(true);
|
|
208
|
+
setCreateError(undefined);
|
|
209
|
+
onCreateFolder(name)
|
|
210
|
+
.then((folder) => {
|
|
211
|
+
onSelect(folder.id);
|
|
212
|
+
setCreating(false);
|
|
213
|
+
})
|
|
214
|
+
.catch((error: unknown) => {
|
|
215
|
+
setCreateError(
|
|
216
|
+
error instanceof Error ? error.message : text.createError,
|
|
217
|
+
);
|
|
218
|
+
setCreating(false);
|
|
219
|
+
});
|
|
220
|
+
}, [onCreateFolder, query, onSelect, text.createError]);
|
|
221
|
+
|
|
179
222
|
const handleListKeyDown = useCallback(
|
|
180
223
|
(event: ReactKeyboardEvent<HTMLElement>) => {
|
|
181
224
|
switch (event.key) {
|
|
@@ -307,6 +350,28 @@ export const MoveMailboxPicker = ({
|
|
|
307
350
|
})
|
|
308
351
|
)}
|
|
309
352
|
</div>
|
|
353
|
+
{showCreate && (
|
|
354
|
+
<div className="border-t border-line p-1">
|
|
355
|
+
<button
|
|
356
|
+
type="button"
|
|
357
|
+
onClick={handleCreate}
|
|
358
|
+
disabled={creating}
|
|
359
|
+
className={cn(
|
|
360
|
+
ROW_BASE,
|
|
361
|
+
"font-medium text-accent-2 hover:bg-surface-raised disabled:opacity-60",
|
|
362
|
+
)}
|
|
363
|
+
>
|
|
364
|
+
<span className="truncate">
|
|
365
|
+
{creating ? text.createPending : text.createLabel(query.trim())}
|
|
366
|
+
</span>
|
|
367
|
+
</button>
|
|
368
|
+
{createError && (
|
|
369
|
+
<p className="px-3 py-1 text-xs text-danger" role="alert">
|
|
370
|
+
{createError}
|
|
371
|
+
</p>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
310
375
|
</div>
|
|
311
376
|
);
|
|
312
377
|
};
|
|
@@ -129,3 +129,107 @@ export const Acting: Story = {
|
|
|
129
129
|
);
|
|
130
130
|
},
|
|
131
131
|
};
|
|
132
|
+
|
|
133
|
+
function AnchorRow({
|
|
134
|
+
onLongPress,
|
|
135
|
+
selectionMode,
|
|
136
|
+
checked,
|
|
137
|
+
}: {
|
|
138
|
+
onLongPress?: () => void;
|
|
139
|
+
selectionMode?: boolean;
|
|
140
|
+
checked?: boolean;
|
|
141
|
+
}) {
|
|
142
|
+
return (
|
|
143
|
+
<PhoneFrame>
|
|
144
|
+
<SwipeableRow
|
|
145
|
+
{...baseArgs}
|
|
146
|
+
peek="none"
|
|
147
|
+
selectionMode={selectionMode ?? false}
|
|
148
|
+
checked={checked ?? false}
|
|
149
|
+
onLongPress={onLongPress ?? (() => undefined)}
|
|
150
|
+
linkComponent={({ onOpenClick, children, ...rowProps }) => (
|
|
151
|
+
<a
|
|
152
|
+
{...rowProps}
|
|
153
|
+
href="/mail/inbox?selectedMessageId=thread-1"
|
|
154
|
+
onClick={(e) => {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
onOpenClick(e);
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
{children}
|
|
160
|
+
</a>
|
|
161
|
+
)}
|
|
162
|
+
/>
|
|
163
|
+
</PhoneFrame>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The long press is the way into multi-select on touch. Press and hold the row
|
|
169
|
+
* (mouse hold works too — react-aria fires the long press for both) and it
|
|
170
|
+
* flips into the selection state the `SelectionChecked` story shows: the
|
|
171
|
+
* leading avatar becomes a filled, ticked checkbox and a tap toggles the row
|
|
172
|
+
* instead of opening it.
|
|
173
|
+
*/
|
|
174
|
+
export const LongPressToSelect: Story = {
|
|
175
|
+
name: "Long press to select (interactive)",
|
|
176
|
+
render: () => {
|
|
177
|
+
const [selected, setSelected] = useState(false);
|
|
178
|
+
return (
|
|
179
|
+
<div className="space-y-2">
|
|
180
|
+
<AnchorRow
|
|
181
|
+
selectionMode={selected}
|
|
182
|
+
checked={selected}
|
|
183
|
+
onLongPress={() => setSelected((v) => !v)}
|
|
184
|
+
/>
|
|
185
|
+
<p className="text-xs text-fg-muted">
|
|
186
|
+
{selected
|
|
187
|
+
? "In selection mode — long press again to exit."
|
|
188
|
+
: "Press and hold the row."}
|
|
189
|
+
</p>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A touch long press over a link row normally raises the browser's own link
|
|
197
|
+
* context menu ("Open in new tab / Copy link address"), which collides with the
|
|
198
|
+
* long-press-to-select gesture above. `useLongPress` suppresses that menu when
|
|
199
|
+
* the press came from touch or pen, while leaving a mouse right-click's menu
|
|
200
|
+
* alone. The play step drives a synthetic touch press then a contextmenu and
|
|
201
|
+
* writes the outcome below.
|
|
202
|
+
*/
|
|
203
|
+
export const TouchContextMenuSuppressed: Story = {
|
|
204
|
+
name: "Touch context menu suppressed",
|
|
205
|
+
render: () => (
|
|
206
|
+
<div className="space-y-2">
|
|
207
|
+
<AnchorRow />
|
|
208
|
+
<p data-testid="context-menu-outcome" className="text-xs text-fg-muted">
|
|
209
|
+
Waiting for a touch press…
|
|
210
|
+
</p>
|
|
211
|
+
</div>
|
|
212
|
+
),
|
|
213
|
+
play: async ({ canvasElement }) => {
|
|
214
|
+
const anchor = canvasElement.querySelector<HTMLAnchorElement>("a[href]");
|
|
215
|
+
const outcome = canvasElement.querySelector<HTMLParagraphElement>(
|
|
216
|
+
'[data-testid="context-menu-outcome"]',
|
|
217
|
+
);
|
|
218
|
+
if (!anchor || !outcome) return;
|
|
219
|
+
anchor.dispatchEvent(
|
|
220
|
+
new PointerEvent("pointerdown", {
|
|
221
|
+
bubbles: true,
|
|
222
|
+
pointerType: "touch",
|
|
223
|
+
pointerId: 1,
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
const menu = new MouseEvent("contextmenu", {
|
|
227
|
+
bubbles: true,
|
|
228
|
+
cancelable: true,
|
|
229
|
+
});
|
|
230
|
+
anchor.dispatchEvent(menu);
|
|
231
|
+
outcome.textContent = menu.defaultPrevented
|
|
232
|
+
? "Native context menu suppressed on touch."
|
|
233
|
+
: "Native context menu allowed.";
|
|
234
|
+
},
|
|
235
|
+
};
|
|
@@ -108,8 +108,8 @@ export function SwipeableRow({
|
|
|
108
108
|
} | null>(null);
|
|
109
109
|
const [dragX, setDragX] = useState<number | null>(null);
|
|
110
110
|
|
|
111
|
-
// Long-press timing/threshold and contextmenu
|
|
112
|
-
//
|
|
111
|
+
// Long-press timing/threshold and touch contextmenu suppression are owned
|
|
112
|
+
// by useLongPress; this component only arbitrates the swipe axis.
|
|
113
113
|
const { longPressProps } = useLongPress({
|
|
114
114
|
onLongPress,
|
|
115
115
|
isDisabled: selectionMode,
|
|
@@ -228,9 +228,9 @@ export function SwipeableRow({
|
|
|
228
228
|
"relative touch-pan-y bg-surface",
|
|
229
229
|
// This row's long press enters selection mode; without these, Android
|
|
230
230
|
// Chrome opens the link context menu / starts text selection and iOS
|
|
231
|
-
// Safari fires the callout, racing the app's handler.
|
|
232
|
-
// suppresses
|
|
233
|
-
//
|
|
231
|
+
// Safari fires the callout, racing the app's handler. useLongPress
|
|
232
|
+
// suppresses the touch-fired contextmenu; the callout fires no
|
|
233
|
+
// cancelable event, so CSS is the only lever left for it.
|
|
234
234
|
"select-none [-webkit-touch-callout:none]",
|
|
235
235
|
comfortableRowClass({ active: checked || active }),
|
|
236
236
|
);
|
|
@@ -59,11 +59,11 @@ function mount(props: {
|
|
|
59
59
|
return row;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function pointerDown(row: Element) {
|
|
62
|
+
function pointerDown(row: Element, pointerType = "touch") {
|
|
63
63
|
row.dispatchEvent(
|
|
64
64
|
new dom.window.PointerEvent("pointerdown", {
|
|
65
65
|
bubbles: true,
|
|
66
|
-
pointerType
|
|
66
|
+
pointerType,
|
|
67
67
|
pointerId: 1,
|
|
68
68
|
clientX: 10,
|
|
69
69
|
clientY: 10,
|
|
@@ -71,6 +71,15 @@ function pointerDown(row: Element) {
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
function dispatchContextMenu(row: Element) {
|
|
75
|
+
const event = new dom.window.MouseEvent("contextmenu", {
|
|
76
|
+
bubbles: true,
|
|
77
|
+
cancelable: true,
|
|
78
|
+
});
|
|
79
|
+
row.dispatchEvent(event);
|
|
80
|
+
return event;
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
function pointerUp() {
|
|
75
84
|
dom.window.document.dispatchEvent(
|
|
76
85
|
new dom.window.PointerEvent("pointerup", {
|
|
@@ -83,6 +92,18 @@ function pointerUp() {
|
|
|
83
92
|
);
|
|
84
93
|
}
|
|
85
94
|
|
|
95
|
+
function pointerUpOn(row: Element) {
|
|
96
|
+
row.dispatchEvent(
|
|
97
|
+
new dom.window.PointerEvent("pointerup", {
|
|
98
|
+
bubbles: true,
|
|
99
|
+
pointerType: "touch",
|
|
100
|
+
pointerId: 1,
|
|
101
|
+
clientX: 10,
|
|
102
|
+
clientY: 10,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
function pointerCancel(row: Element) {
|
|
87
108
|
row.dispatchEvent(
|
|
88
109
|
new dom.window.PointerEvent("pointercancel", { bubbles: true }),
|
|
@@ -192,29 +213,72 @@ describe("useLongPress (react-aria wrapper)", () => {
|
|
|
192
213
|
"long press must have fired for this to be meaningful",
|
|
193
214
|
);
|
|
194
215
|
|
|
195
|
-
const contextMenuEvent = new dom.window.MouseEvent("contextmenu", {
|
|
196
|
-
bubbles: true,
|
|
197
|
-
cancelable: true,
|
|
198
|
-
});
|
|
199
|
-
row.dispatchEvent(contextMenuEvent);
|
|
200
|
-
|
|
201
216
|
assert.equal(
|
|
202
|
-
|
|
217
|
+
dispatchContextMenu(row).defaultPrevented,
|
|
203
218
|
true,
|
|
204
|
-
"
|
|
219
|
+
"the link context menu Android/Chrome fires after a touch long press is suppressed",
|
|
205
220
|
);
|
|
206
221
|
});
|
|
207
222
|
|
|
208
|
-
it("
|
|
223
|
+
it("suppresses the touch contextmenu even before the long-press threshold", async () => {
|
|
224
|
+
// Android Chrome can raise the link menu at its own threshold, ahead of
|
|
225
|
+
// the app's long press; keying suppression to the pointer type rather
|
|
226
|
+
// than to a fired long press covers that race.
|
|
227
|
+
const row = mount({ onLongPress: () => undefined });
|
|
228
|
+
|
|
229
|
+
pointerDown(row, "touch");
|
|
230
|
+
assert.equal(dispatchContextMenu(row).defaultPrevented, true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not suppress contextmenu from a mouse right-click", async () => {
|
|
234
|
+
// Desktop right-click must keep its native context menu; a mouse
|
|
235
|
+
// pointerdown precedes the contextmenu, so the pointer type is known.
|
|
236
|
+
const row = mount({ onLongPress: () => undefined });
|
|
237
|
+
|
|
238
|
+
pointerDown(row, "mouse");
|
|
239
|
+
assert.equal(dispatchContextMenu(row).defaultPrevented, false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("does not suppress contextmenu when no pointer interaction preceded it", async () => {
|
|
209
243
|
mount({ onLongPress: () => undefined });
|
|
210
244
|
const row = dom.window.document.getElementById("row") as Element;
|
|
211
245
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
246
|
+
assert.equal(dispatchContextMenu(row).defaultPrevented, false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("does not suppress the keyboard menu that follows a touch long press", async () => {
|
|
250
|
+
// The reported a11y regression: the touch press's pointer type must not
|
|
251
|
+
// linger and suppress the keyboard-invoked menu (Context-Menu key /
|
|
252
|
+
// Shift+F10), which fires with no preceding pointerdown.
|
|
253
|
+
const row = mount({ onLongPress: () => undefined });
|
|
254
|
+
|
|
255
|
+
pointerDown(row, "touch");
|
|
256
|
+
assert.equal(
|
|
257
|
+
dispatchContextMenu(row).defaultPrevented,
|
|
258
|
+
true,
|
|
259
|
+
"the touch long-press menu is still suppressed",
|
|
260
|
+
);
|
|
261
|
+
pointerUpOn(row);
|
|
262
|
+
await wait(THRESHOLD);
|
|
263
|
+
|
|
264
|
+
assert.equal(
|
|
265
|
+
dispatchContextMenu(row).defaultPrevented,
|
|
266
|
+
false,
|
|
267
|
+
"the later keyboard-invoked menu must not inherit the touch press's type",
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("does not suppress the keyboard menu after a touch tap that raised no menu", async () => {
|
|
272
|
+
// A tap that lifts without a menu must still disarm suppression. The wait
|
|
273
|
+
// clears react-aria's own transient post-touch contextmenu listener,
|
|
274
|
+
// which it removes shortly after pointerup — in a browser a keyboard menu
|
|
275
|
+
// arrives long after that window, so only this hook's ref decides.
|
|
276
|
+
const row = mount({ onLongPress: () => undefined });
|
|
277
|
+
|
|
278
|
+
pointerDown(row, "touch");
|
|
279
|
+
pointerUpOn(row);
|
|
280
|
+
await wait(THRESHOLD);
|
|
217
281
|
|
|
218
|
-
assert.equal(
|
|
282
|
+
assert.equal(dispatchContextMenu(row).defaultPrevented, false);
|
|
219
283
|
});
|
|
220
284
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DOMAttributes } from "@react-types/shared";
|
|
2
|
-
import {
|
|
2
|
+
import { type PointerEvent, useCallback, useRef } from "react";
|
|
3
|
+
import { mergeProps, useLongPress as useAriaLongPress } from "react-aria";
|
|
3
4
|
|
|
4
5
|
export interface UseLongPressOptions {
|
|
5
6
|
/** Called once the threshold elapses while the press stays over the target. */
|
|
@@ -31,6 +32,15 @@ export interface UseLongPressResult {
|
|
|
31
32
|
* `-webkit-touch-callout: none` in CSS at the call site, since iOS fires no
|
|
32
33
|
* cancelable event for it.
|
|
33
34
|
*
|
|
35
|
+
* The `contextmenu` suppression is keyed to the active pointer's type, tracked
|
|
36
|
+
* off `pointerdown` on the same element: a touch or pen press suppresses the
|
|
37
|
+
* menu Android Chrome and iOS Safari raise on a long press over a link, while a
|
|
38
|
+
* mouse right-click is left alone so the desktop context menu keeps working. It
|
|
39
|
+
* does not delegate this to react-aria's own suppression — that listener is
|
|
40
|
+
* transient (added on press start, scoped to the touched node, and torn down
|
|
41
|
+
* shortly after pointerup), so a press ended early by the swipe gesture's axis
|
|
42
|
+
* arbitration, or a menu raised over a descendant node, slips past it.
|
|
43
|
+
*
|
|
34
44
|
* Single source of truth for the app's long-press threshold — both mobile
|
|
35
45
|
* row consumers (the plain row and the swipeable row) go through this hook
|
|
36
46
|
* so their timing can't drift apart again.
|
|
@@ -41,10 +51,44 @@ export function useLongPress({
|
|
|
41
51
|
delayMs = 500,
|
|
42
52
|
accessibilityDescription,
|
|
43
53
|
}: UseLongPressOptions): UseLongPressResult {
|
|
44
|
-
|
|
54
|
+
const { longPressProps } = useAriaLongPress({
|
|
45
55
|
isDisabled,
|
|
46
56
|
threshold: delayMs,
|
|
47
57
|
accessibilityDescription,
|
|
48
58
|
onLongPress,
|
|
49
59
|
});
|
|
60
|
+
|
|
61
|
+
const pointerTypeRef = useRef<string>("");
|
|
62
|
+
|
|
63
|
+
const onPointerDown = useCallback((event: PointerEvent) => {
|
|
64
|
+
pointerTypeRef.current = event.pointerType;
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
// A press that lifts without raising a menu disarms suppression, so a later
|
|
68
|
+
// keyboard-invoked menu can't inherit its pointer type. Not cleared on
|
|
69
|
+
// pointercancel: on Android the browser (and react-aria's own long-press
|
|
70
|
+
// timer) can fire pointercancel before the long-press contextmenu, which
|
|
71
|
+
// would race the suppression away.
|
|
72
|
+
const onPointerUp = useCallback(() => {
|
|
73
|
+
pointerTypeRef.current = "";
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const onContextMenu = useCallback((event: { preventDefault: () => void }) => {
|
|
77
|
+
// Consume the armed pointer type. A keyboard-invoked menu (Context-Menu
|
|
78
|
+
// key / Shift+F10) fires no pointerdown, so without spending the type on
|
|
79
|
+
// use it would inherit the last touch press's and be wrongly suppressed.
|
|
80
|
+
const pointerType = pointerTypeRef.current;
|
|
81
|
+
pointerTypeRef.current = "";
|
|
82
|
+
if (pointerType === "touch" || pointerType === "pen") {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
}
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
longPressProps: mergeProps(longPressProps, {
|
|
89
|
+
onPointerDown,
|
|
90
|
+
onPointerUp,
|
|
91
|
+
onContextMenu,
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
50
94
|
}
|