@remit/web-client 0.0.90 → 0.0.92
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/mail/DailyBrief.selection.test.ts +19 -15
- package/src/components/mail/DailyBrief.tsx +70 -89
- package/src/components/mail/FlaggedList.tsx +58 -50
- package/src/components/mail/LabelApplyTrigger.tsx +11 -5
- package/src/components/mail/MailListHeader.tsx +165 -85
- package/src/components/mail/MailViewChrome.tsx +7 -6
- package/src/components/mail/MessageList.selection.test.ts +42 -37
- package/src/components/mail/MessageList.tsx +70 -98
- package/src/components/mail/MoveToTrigger.tsx +1 -1
- package/src/components/mail/SelectionWizardHost.tsx +191 -0
- package/src/components/mail/ThreadListInteraction.test.ts +8 -2
- package/src/components/mail/ThreadListInteraction.tsx +56 -24
- package/src/hooks/useInitialSyncProgress.render.test.ts +11 -5
- package/src/lib/list-header-chrome.ts +50 -0
- package/src/lib/mail-context.ts +9 -0
- package/src/lib/selection-mode.test.ts +20 -6
- package/src/lib/selection-mode.ts +8 -1
- package/src/lib/wizard-history.test.ts +176 -0
- package/src/lib/wizard-history.ts +116 -0
- package/src/routes/mail.tsx +11 -0
- package/src/components/mail/SelectionToolbar.render.test.ts +0 -324
- package/src/components/mail/SelectionToolbar.stories.tsx +0 -199
- package/src/components/mail/SelectionToolbar.tsx +0 -274
- package/src/lib/selection-sheet-mode.test.ts +0 -105
- package/src/lib/selection-sheet-mode.ts +0 -49
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The list header's own chrome, as rendered nodes.
|
|
5
|
+
*
|
|
6
|
+
* The list header and the selection bar are one surface (#480), so the nav
|
|
7
|
+
* button, the unread count and the search affordance belong on the same row as
|
|
8
|
+
* the count and the verbs. That row is rendered by the body — the list owns the
|
|
9
|
+
* selection — while the search state it needs belongs to `MailListHeader`.
|
|
10
|
+
* Handing the built nodes down keeps one definition of the chrome and keeps the
|
|
11
|
+
* bar's two states in the same commit: nothing here is derived from a state
|
|
12
|
+
* update that lands after paint.
|
|
13
|
+
*/
|
|
14
|
+
export interface ListHeaderChrome {
|
|
15
|
+
/** The view's name, carried while nothing is ticked. */
|
|
16
|
+
title: string;
|
|
17
|
+
/** Hamburger for the nav slide-over; absent where the nav is a pane. */
|
|
18
|
+
navSlot: ReactNode;
|
|
19
|
+
/** Unread count, beside the title. */
|
|
20
|
+
titleMeta: ReactNode;
|
|
21
|
+
/** Magnifier that opens search, on the tiers whose header owns a field. */
|
|
22
|
+
searchSlot: ReactNode;
|
|
23
|
+
/** The expanded field, which takes the title's place for as long as it is up. */
|
|
24
|
+
searchField: ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* The read-only two-engine results panel, when a query is being typed and
|
|
27
|
+
* this view answers it with the panel rather than its own rows. The body
|
|
28
|
+
* renders it in place of its list; `MailListHeader` cannot swap the body
|
|
29
|
+
* out from above, because the header is inside it.
|
|
30
|
+
*/
|
|
31
|
+
searchResults: ReactNode;
|
|
32
|
+
/** The search's make-this-a-filter row, up only while nothing is ticked. */
|
|
33
|
+
makeFilterSlot: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NO_CHROME: ListHeaderChrome = {
|
|
37
|
+
title: "",
|
|
38
|
+
navSlot: null,
|
|
39
|
+
titleMeta: null,
|
|
40
|
+
searchSlot: null,
|
|
41
|
+
searchField: null,
|
|
42
|
+
searchResults: null,
|
|
43
|
+
makeFilterSlot: null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const ListHeaderChromeContext =
|
|
47
|
+
createContext<ListHeaderChrome>(NO_CHROME);
|
|
48
|
+
|
|
49
|
+
export const useListHeaderChrome = (): ListHeaderChrome =>
|
|
50
|
+
useContext(ListHeaderChromeContext);
|
package/src/lib/mail-context.ts
CHANGED
|
@@ -54,6 +54,13 @@ export interface MailContextValue {
|
|
|
54
54
|
onToggleIntelligence: () => void;
|
|
55
55
|
/** Set the pane open/closed and persist the choice (desktop default-open). */
|
|
56
56
|
onSetIntelligenceOpen: (open: boolean) => void;
|
|
57
|
+
/**
|
|
58
|
+
* How many rows the list has ticked. The selection is owned by the list, and
|
|
59
|
+
* surfaces mounted beside it — the selection wizard (#477) — need the same
|
|
60
|
+
* number the bar is counting rather than one of their own.
|
|
61
|
+
*/
|
|
62
|
+
selectedCount: number;
|
|
63
|
+
onSelectedCountChange: (count: number) => void;
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
export const MailContext = createContext<MailContextValue | null>(null);
|
|
@@ -78,6 +85,8 @@ export const useMailContext = (): MailContextValue => {
|
|
|
78
85
|
intelligenceOpen: false,
|
|
79
86
|
onToggleIntelligence: () => {},
|
|
80
87
|
onSetIntelligenceOpen: () => {},
|
|
88
|
+
selectedCount: 0,
|
|
89
|
+
onSelectedCountChange: () => {},
|
|
81
90
|
}
|
|
82
91
|
);
|
|
83
92
|
};
|
|
@@ -28,17 +28,31 @@ describe("deriveIsMultiSelectMode", () => {
|
|
|
28
28
|
|
|
29
29
|
describe("shouldExitSelectionOnNavigate", () => {
|
|
30
30
|
test("back while selecting exits selection instead of navigating", () => {
|
|
31
|
-
assert.equal(shouldExitSelectionOnNavigate("BACK", true), true);
|
|
31
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", true, undefined), true);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
test("back with nothing selected is left alone", () => {
|
|
35
|
-
assert.equal(
|
|
35
|
+
assert.equal(
|
|
36
|
+
shouldExitSelectionOnNavigate("BACK", false, undefined),
|
|
37
|
+
false,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("back inside the wizard pops a step instead of the selection", () => {
|
|
42
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", true, "match"), false);
|
|
43
|
+
assert.equal(shouldExitSelectionOnNavigate("BACK", true, "review"), false);
|
|
36
44
|
});
|
|
37
45
|
|
|
38
46
|
test("forward, push, replace and go are never blocked", () => {
|
|
39
|
-
assert.equal(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
assert.equal(
|
|
48
|
+
shouldExitSelectionOnNavigate("FORWARD", true, undefined),
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
assert.equal(shouldExitSelectionOnNavigate("PUSH", true, undefined), false);
|
|
52
|
+
assert.equal(
|
|
53
|
+
shouldExitSelectionOnNavigate("REPLACE", true, undefined),
|
|
54
|
+
false,
|
|
55
|
+
);
|
|
56
|
+
assert.equal(shouldExitSelectionOnNavigate("GO", true, undefined), false);
|
|
43
57
|
});
|
|
44
58
|
});
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* and 10). Pure so "one source of truth, one exit" is testable without a DOM.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { StepId } from "@remit/ui";
|
|
7
|
+
|
|
6
8
|
/** The subset of `@tanstack/history`'s `HistoryAction` a blocker can see. */
|
|
7
9
|
export type NavigationAction = "PUSH" | "REPLACE" | "FORWARD" | "BACK" | "GO";
|
|
8
10
|
|
|
@@ -22,8 +24,13 @@ export const deriveIsMultiSelectMode = (
|
|
|
22
24
|
* Whether a history navigation should exit selection mode instead of leaving
|
|
23
25
|
* the route. Only the back gesture is intercepted, so a navigation the app
|
|
24
26
|
* itself starts (opening a message, switching mailboxes) is never blocked.
|
|
27
|
+
*
|
|
28
|
+
* While the selection wizard is open its own steps own the back gesture, and
|
|
29
|
+
* the selection is what the wizard is acting on — swallowing back there would
|
|
30
|
+
* clear the selection out from under the flow instead of popping a step.
|
|
25
31
|
*/
|
|
26
32
|
export const shouldExitSelectionOnNavigate = (
|
|
27
33
|
action: NavigationAction,
|
|
28
34
|
hasSelection: boolean,
|
|
29
|
-
|
|
35
|
+
wizardStep: StepId | undefined,
|
|
36
|
+
): boolean => action === "BACK" && hasSelection && wizardStep === undefined;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
backExits,
|
|
8
|
+
type MatchMode,
|
|
9
|
+
type RuleScope,
|
|
10
|
+
type StepId,
|
|
11
|
+
stepIndex,
|
|
12
|
+
stepsFor,
|
|
13
|
+
type Verb,
|
|
14
|
+
type WizardAnswers,
|
|
15
|
+
} from "@remit/ui";
|
|
16
|
+
import {
|
|
17
|
+
ownedHistoryEntries,
|
|
18
|
+
wizardStepFromParam,
|
|
19
|
+
wizardStepValue,
|
|
20
|
+
} from "./wizard-history.js";
|
|
21
|
+
|
|
22
|
+
const VERBS: readonly Verb[] = [
|
|
23
|
+
"delete",
|
|
24
|
+
"move",
|
|
25
|
+
"junk",
|
|
26
|
+
"markRead",
|
|
27
|
+
"organize",
|
|
28
|
+
];
|
|
29
|
+
const MODES: readonly MatchMode[] = ["selected", "similar", "properties"];
|
|
30
|
+
const SCOPES: readonly (RuleScope | undefined)[] = [
|
|
31
|
+
undefined,
|
|
32
|
+
"once",
|
|
33
|
+
"standing",
|
|
34
|
+
"until",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const answerSets = (): WizardAnswers[] => {
|
|
38
|
+
const sets: WizardAnswers[] = [];
|
|
39
|
+
for (const verb of VERBS) {
|
|
40
|
+
for (const mode of MODES) {
|
|
41
|
+
for (const scope of SCOPES) {
|
|
42
|
+
for (const fromSearch of [false, true]) {
|
|
43
|
+
sets.push({ verb, mode, scope, fromSearch });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return sets;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const source = readFileSync(resolve(here, "wizard-history.ts"), "utf8");
|
|
53
|
+
|
|
54
|
+
/** The steps up to and including the one an answer is given on. */
|
|
55
|
+
const prefixThrough = (steps: readonly StepId[], step: StepId): StepId[] =>
|
|
56
|
+
steps.slice(0, steps.indexOf(step) + 1);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The re-root runs inside a router, so the rule is enforced by reading the
|
|
60
|
+
* source — as `../components/mail/MessageList.selection.test.ts` does for its
|
|
61
|
+
* own component-level rules. What it pins is where the decision comes from: a
|
|
62
|
+
* step the app itself pushed must never be re-rooted, or the push that opens
|
|
63
|
+
* the wizard from a verb (#483) duplicates the entry underneath it and the
|
|
64
|
+
* first back after Cancel appears to do nothing.
|
|
65
|
+
*/
|
|
66
|
+
describe("re-rooting a wizard that was loaded into", () => {
|
|
67
|
+
it("decides from what the first render held, not from a flag", () => {
|
|
68
|
+
assert.match(source, /useRef\(step !== undefined\)/);
|
|
69
|
+
assert.match(
|
|
70
|
+
source,
|
|
71
|
+
/if \(!loadedHoldingStep\.current\) return;\s*\n\s*loadedHoldingStep\.current = false;/,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is never armed by a step the app pushed", () => {
|
|
76
|
+
const goToStep = source.slice(source.indexOf("const goToStep"));
|
|
77
|
+
assert.doesNotMatch(goToStep, /loadedHoldingStep/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not re-run when the step changes", () => {
|
|
81
|
+
assert.doesNotMatch(source, /\}, \[step, openingStep, navigate\]\)/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("the wizard step in the URL", () => {
|
|
86
|
+
it("round-trips every step the wizard can reach", () => {
|
|
87
|
+
for (const answers of answerSets()) {
|
|
88
|
+
for (const step of stepsFor(answers)) {
|
|
89
|
+
assert.equal(wizardStepFromParam(step), step);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("reads a value the wizard cannot be on as no step", () => {
|
|
95
|
+
for (const value of [undefined, "", "Match", "step-1", 2, null, {}]) {
|
|
96
|
+
assert.equal(wizardStepFromParam(value), undefined);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("never fails validation, so a mistyped link still lands on the mail", () => {
|
|
101
|
+
for (const value of ["nope", "MATCH", "run ", 7, [], null, undefined]) {
|
|
102
|
+
const parsed = wizardStepValue.safeParse(value);
|
|
103
|
+
assert.ok(parsed.success);
|
|
104
|
+
assert.equal(parsed.data, undefined);
|
|
105
|
+
}
|
|
106
|
+
assert.equal(wizardStepValue.parse("review"), "review");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("the history entries the wizard owns", () => {
|
|
111
|
+
it("is one per step reached, on every shape of the list", () => {
|
|
112
|
+
for (const answers of answerSets()) {
|
|
113
|
+
const steps = stepsFor(answers);
|
|
114
|
+
steps.forEach((step, position) => {
|
|
115
|
+
assert.equal(ownedHistoryEntries(steps, step), position + 1);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("cannot be moved by the match door, which is answered before it", () => {
|
|
121
|
+
for (const verb of VERBS) {
|
|
122
|
+
for (const scope of SCOPES) {
|
|
123
|
+
const prefixes = MODES.map((mode) =>
|
|
124
|
+
prefixThrough(stepsFor({ verb, mode, scope }), "match"),
|
|
125
|
+
);
|
|
126
|
+
for (const prefix of prefixes) {
|
|
127
|
+
assert.deepEqual(prefix, prefixes[0]);
|
|
128
|
+
for (const step of prefix) {
|
|
129
|
+
const counts = MODES.map((mode) =>
|
|
130
|
+
ownedHistoryEntries(stepsFor({ verb, mode, scope }), step),
|
|
131
|
+
);
|
|
132
|
+
assert.equal(new Set(counts).size, 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("cannot be moved by the scope, which is answered before it", () => {
|
|
140
|
+
for (const mode of MODES) {
|
|
141
|
+
for (const fromSearch of [false, true]) {
|
|
142
|
+
const listFor = (scope: RuleScope | undefined) =>
|
|
143
|
+
stepsFor({ verb: "organize", mode, scope, fromSearch });
|
|
144
|
+
const prefixes = SCOPES.map((scope) =>
|
|
145
|
+
prefixThrough(listFor(scope), "rule"),
|
|
146
|
+
);
|
|
147
|
+
for (const prefix of prefixes) {
|
|
148
|
+
assert.deepEqual(prefix, prefixes[0]);
|
|
149
|
+
for (const step of prefix) {
|
|
150
|
+
const counts = SCOPES.map((scope) =>
|
|
151
|
+
ownedHistoryEntries(listFor(scope), step),
|
|
152
|
+
);
|
|
153
|
+
assert.equal(new Set(counts).size, 1);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rewinds the whole flow from the steps Back leaves on", () => {
|
|
161
|
+
for (const answers of answerSets()) {
|
|
162
|
+
const steps = stepsFor(answers);
|
|
163
|
+
assert.ok(backExits(steps, steps[0]));
|
|
164
|
+
assert.equal(ownedHistoryEntries(steps, steps[0]), 1);
|
|
165
|
+
const run = steps[steps.length - 1];
|
|
166
|
+
assert.ok(backExits(steps, run));
|
|
167
|
+
assert.equal(ownedHistoryEntries(steps, run), steps.length);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("holds a step the answers dropped to the opening entry", () => {
|
|
172
|
+
const steps = stepsFor({ verb: "delete", mode: "selected" });
|
|
173
|
+
assert.equal(stepIndex(steps, "properties"), 0);
|
|
174
|
+
assert.equal(ownedHistoryEntries(steps, "properties"), 1);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selection wizard's step in the URL, and the history entries it owns
|
|
3
|
+
* (#477 clause 1.6). TanStack Router owns history here: the step is a validated
|
|
4
|
+
* `wizard` search param on /mail, pushed with `navigate({ search })`, so
|
|
5
|
+
* `router.state.location` and `window.location` never disagree.
|
|
6
|
+
*
|
|
7
|
+
* The wizard is rooted on an entry that carries no step, so the entries it owns
|
|
8
|
+
* are the pushes that reached the current step: one for the opening step, one
|
|
9
|
+
* per advance. A document that loads straight into a step has no such root, and
|
|
10
|
+
* no answers behind the step either, so it is re-rooted at the opening step
|
|
11
|
+
* before anything else — otherwise the number of entries to rewind is a guess
|
|
12
|
+
* about a tab this code never saw.
|
|
13
|
+
*
|
|
14
|
+
* Which steps Back leaves the wizard from is `backExits` in the step model, and
|
|
15
|
+
* the shell reads it to route Back to exit rather than to a step. These are the
|
|
16
|
+
* two movements it routes between: `goBack` pops one entry, `closeWizard`
|
|
17
|
+
* rewinds every entry the wizard owns.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { type StepId, stepIndex } from "@remit/ui";
|
|
21
|
+
import { useNavigate, useRouter, useSearch } from "@tanstack/react-router";
|
|
22
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
|
|
25
|
+
const stepId = z.enum([
|
|
26
|
+
"match",
|
|
27
|
+
"properties",
|
|
28
|
+
"folder",
|
|
29
|
+
"rule",
|
|
30
|
+
"name",
|
|
31
|
+
"review",
|
|
32
|
+
"run",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export const wizardStepFromParam = (value: unknown): StepId | undefined => {
|
|
36
|
+
const parsed = stepId.safeParse(value);
|
|
37
|
+
return parsed.success ? parsed.data : undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The route's `wizard` field. A value the wizard cannot be on reads as no step
|
|
42
|
+
* rather than as a validation failure, so a truncated or hand-typed link lands
|
|
43
|
+
* on the mail list instead of the router's error screen.
|
|
44
|
+
*/
|
|
45
|
+
export const wizardStepValue = z.unknown().transform(wizardStepFromParam);
|
|
46
|
+
|
|
47
|
+
export const ownedHistoryEntries = (
|
|
48
|
+
steps: readonly StepId[],
|
|
49
|
+
step: StepId,
|
|
50
|
+
): number => stepIndex(steps, step) + 1;
|
|
51
|
+
|
|
52
|
+
export const useWizardStepValue = (): StepId | undefined =>
|
|
53
|
+
useSearch({ from: "/mail", select: (search) => search.wizard });
|
|
54
|
+
|
|
55
|
+
export interface WizardStepNavigation {
|
|
56
|
+
step: StepId | undefined;
|
|
57
|
+
goToStep: (step: StepId) => void;
|
|
58
|
+
goBack: () => void;
|
|
59
|
+
closeWizard: (steps: readonly StepId[], step: StepId) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const useWizardStep = (openingStep: StepId): WizardStepNavigation => {
|
|
63
|
+
const router = useRouter();
|
|
64
|
+
const navigate = useNavigate();
|
|
65
|
+
const step = useWizardStepValue();
|
|
66
|
+
// Whether this document loaded already holding a step, which is the one
|
|
67
|
+
// entrance that leaves the wizard unrooted. A step the app itself pushed
|
|
68
|
+
// arrives rooted, so re-rooting it would duplicate the entry underneath it.
|
|
69
|
+
const loadedHoldingStep = useRef(step !== undefined);
|
|
70
|
+
const pushedTo = useRef<StepId | undefined>(undefined);
|
|
71
|
+
|
|
72
|
+
// Two taps on Continue land before the URL settles, and both would push the
|
|
73
|
+
// same step — leaving a back that appears to do nothing.
|
|
74
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the guard is cleared because the step changed, which is only knowable from the dependency.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
pushedTo.current = undefined;
|
|
77
|
+
}, [step]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!loadedHoldingStep.current) return;
|
|
81
|
+
loadedHoldingStep.current = false;
|
|
82
|
+
void (async () => {
|
|
83
|
+
await navigate({
|
|
84
|
+
to: ".",
|
|
85
|
+
search: (prev) => ({ ...prev, wizard: undefined }),
|
|
86
|
+
replace: true,
|
|
87
|
+
});
|
|
88
|
+
await navigate({
|
|
89
|
+
to: ".",
|
|
90
|
+
search: (prev) => ({ ...prev, wizard: openingStep }),
|
|
91
|
+
});
|
|
92
|
+
})();
|
|
93
|
+
}, [openingStep, navigate]);
|
|
94
|
+
|
|
95
|
+
const goToStep = useCallback(
|
|
96
|
+
(next: StepId) => {
|
|
97
|
+
if (next === step || pushedTo.current === next) return;
|
|
98
|
+
pushedTo.current = next;
|
|
99
|
+
navigate({ to: ".", search: (prev) => ({ ...prev, wizard: next }) });
|
|
100
|
+
},
|
|
101
|
+
[navigate, step],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const goBack = useCallback(() => {
|
|
105
|
+
router.history.back();
|
|
106
|
+
}, [router]);
|
|
107
|
+
|
|
108
|
+
const closeWizard = useCallback(
|
|
109
|
+
(steps: readonly StepId[], current: StepId) => {
|
|
110
|
+
router.history.go(-ownedHistoryEntries(steps, current));
|
|
111
|
+
},
|
|
112
|
+
[router],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return { step, goToStep, goBack, closeWizard };
|
|
116
|
+
};
|
package/src/routes/mail.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
|
21
21
|
import { MailboxPane } from "@/components/mail/MailboxPane";
|
|
22
22
|
import { MailNav } from "@/components/mail/MailNav";
|
|
23
23
|
import { OutboxPane } from "@/components/mail/OutboxPane";
|
|
24
|
+
import { SelectionWizardHost } from "@/components/mail/SelectionWizardHost";
|
|
24
25
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
25
26
|
import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
|
|
26
27
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
searchInputForView,
|
|
44
45
|
shouldMirrorQuery,
|
|
45
46
|
} from "@/lib/search-view";
|
|
47
|
+
import { wizardStepValue } from "@/lib/wizard-history";
|
|
46
48
|
import "@/lib/client";
|
|
47
49
|
|
|
48
50
|
// `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
|
|
@@ -53,6 +55,9 @@ export { useMailContext } from "@/lib/mail-context";
|
|
|
53
55
|
|
|
54
56
|
const mailSearchSchema = z.object({
|
|
55
57
|
q: z.string().optional(),
|
|
58
|
+
// The selection wizard's step (#477 clause 1.6). The router owns history, so
|
|
59
|
+
// the step is a validated search param rather than a raw pushState entry.
|
|
60
|
+
wizard: wizardStepValue,
|
|
56
61
|
});
|
|
57
62
|
|
|
58
63
|
export const Route = createFileRoute("/mail")({
|
|
@@ -92,6 +97,9 @@ function MailLayout() {
|
|
|
92
97
|
// collapse preference there (#782). DKIM-mismatch auto-open still fires on
|
|
93
98
|
// every tier. Explicit toggles persist the user's choice.
|
|
94
99
|
const [intelligenceOpen, setIntelligenceOpen] = useState(false);
|
|
100
|
+
// The list owns the selection; the wizard is mounted here, beside it, and
|
|
101
|
+
// counts the same rows the selection bar is counting.
|
|
102
|
+
const [selectedCount, setSelectedCount] = useState(0);
|
|
95
103
|
const handleSetIntelligenceOpen = useCallback((open: boolean) => {
|
|
96
104
|
setIntelligenceOpen(open);
|
|
97
105
|
writeIntelligencePref(open);
|
|
@@ -304,6 +312,8 @@ function MailLayout() {
|
|
|
304
312
|
intelligenceOpen,
|
|
305
313
|
onToggleIntelligence: handleToggleIntelligence,
|
|
306
314
|
onSetIntelligenceOpen: handleSetIntelligenceOpen,
|
|
315
|
+
selectedCount,
|
|
316
|
+
onSelectedCountChange: setSelectedCount,
|
|
307
317
|
};
|
|
308
318
|
|
|
309
319
|
// Single nav node: the kit renders it as a pane (≥1024px) or inside its
|
|
@@ -468,6 +478,7 @@ function MailLayout() {
|
|
|
468
478
|
isOpen={showShortcuts}
|
|
469
479
|
onClose={() => setShowShortcuts(false)}
|
|
470
480
|
/>
|
|
481
|
+
<SelectionWizardHost verb="organize" selectedCount={selectedCount} />
|
|
471
482
|
{/* Outlet is required for TanStack Router to activate child routes.
|
|
472
483
|
Routes that manage their own rendering (brief, mailbox, outbox) return
|
|
473
484
|
null from their component — the parent shell owns the layout. */}
|