@remit/ui 0.0.112 → 0.0.114
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/app-shell-slotted.render.test.ts +83 -0
- package/src/components/app-shell-slotted.tsx +31 -3
- package/src/components/app-shell-types.ts +10 -0
- package/src/components/attendee-row.render.test.ts +73 -0
- package/src/components/attendee-row.stories.tsx +68 -0
- package/src/components/attendee-row.tsx +96 -0
- package/src/components/calendar-event-chip.render.test.ts +67 -0
- package/src/components/calendar-event-chip.stories.tsx +128 -0
- package/src/components/calendar-event-chip.tsx +116 -0
- package/src/components/calendar-list.render.test.ts +77 -0
- package/src/components/calendar-list.stories.tsx +130 -0
- package/src/components/calendar-list.tsx +225 -0
- package/src/components/calendar-toolbar.render.test.ts +69 -0
- package/src/components/calendar-toolbar.stories.tsx +55 -0
- package/src/components/calendar-toolbar.tsx +178 -0
- package/src/components/calendar-types.ts +135 -0
- package/src/components/custom-recurrence.stories.tsx +106 -0
- package/src/components/custom-recurrence.tsx +411 -0
- package/src/components/event-detail.render.test.ts +104 -0
- package/src/components/event-detail.stories.tsx +178 -0
- package/src/components/event-detail.tsx +188 -0
- package/src/components/event-editor-pane.tsx +54 -0
- package/src/components/event-editor.render.test.ts +83 -0
- package/src/components/event-editor.stories.tsx +99 -0
- package/src/components/event-editor.tsx +480 -0
- package/src/components/event-quick-entry.render.test.ts +40 -0
- package/src/components/event-quick-entry.stories.tsx +56 -0
- package/src/components/event-quick-entry.tsx +159 -0
- package/src/components/event-suggestion-card.render.test.ts +65 -0
- package/src/components/event-suggestion-card.stories.tsx +93 -0
- package/src/components/event-suggestion-card.tsx +116 -0
- package/src/components/flow-screen.tsx +169 -0
- package/src/components/intelligence-panel.render.test.ts +94 -0
- package/src/components/intelligence-panel.stories.tsx +10 -6
- package/src/components/intelligence-panel.tsx +7 -5
- package/src/components/message-list-pane.render.test.ts +2 -2
- package/src/components/message-list-pane.stories.tsx +1 -1
- package/src/components/mobile-reading-pane.render.test.ts +1 -1
- package/src/components/nav-sidebar.tsx +20 -0
- package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
- package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
- package/src/components/recurrence-scope-prompt.tsx +84 -0
- package/src/components/selection-wizard.tsx +19 -69
- package/src/index.ts +112 -0
- package/src/lib/calendar-color.ts +71 -0
- package/src/lib/event-phrase.test.ts +89 -0
- package/src/lib/event-phrase.ts +228 -0
- package/src/lib/recurrence.test.ts +141 -0
- package/src/lib/recurrence.ts +266 -0
- package/src/tokens.css +63 -0
|
@@ -15,7 +15,7 @@ const base: IntelligenceData = {
|
|
|
15
15
|
dkimDomain: "example.com",
|
|
16
16
|
summary: "This message was signed by example.com.",
|
|
17
17
|
},
|
|
18
|
-
category: { value: "
|
|
18
|
+
category: { value: "personal" },
|
|
19
19
|
similar: [],
|
|
20
20
|
};
|
|
21
21
|
|
|
@@ -62,7 +62,7 @@ export const SignedButUnrecognised: Story = {
|
|
|
62
62
|
trust: "unknown",
|
|
63
63
|
firstSeenLabel: "today",
|
|
64
64
|
},
|
|
65
|
-
category: { value: "
|
|
65
|
+
category: { value: "automated" },
|
|
66
66
|
authenticity: {
|
|
67
67
|
verdict: "caution",
|
|
68
68
|
fromDomain: "serviceupdatebank.atlassian.net",
|
|
@@ -88,7 +88,7 @@ export const SignedButLookalikeName: Story = {
|
|
|
88
88
|
trust: "unknown",
|
|
89
89
|
firstSeenLabel: "today",
|
|
90
90
|
},
|
|
91
|
-
category: { value: "
|
|
91
|
+
category: { value: "transactional" },
|
|
92
92
|
authenticity: {
|
|
93
93
|
verdict: "caution",
|
|
94
94
|
fromDomain: "1nfomedics.nl",
|
|
@@ -111,7 +111,7 @@ export const Impersonation: Story = {
|
|
|
111
111
|
trust: "unknown",
|
|
112
112
|
firstSeenLabel: "today",
|
|
113
113
|
},
|
|
114
|
-
category: { value: "
|
|
114
|
+
category: { value: "automated" },
|
|
115
115
|
authenticity: {
|
|
116
116
|
verdict: "mismatch",
|
|
117
117
|
fromDomain: "your-bank.example",
|
|
@@ -136,7 +136,7 @@ export const UnreadableSender: Story = {
|
|
|
136
136
|
firstSeenLabel: "today",
|
|
137
137
|
addressUnverified: true,
|
|
138
138
|
},
|
|
139
|
-
category: { value: "
|
|
139
|
+
category: { value: "uncategorized" },
|
|
140
140
|
authenticity: {
|
|
141
141
|
verdict: "mismatch",
|
|
142
142
|
fromDomain: "",
|
|
@@ -152,13 +152,14 @@ export const WithSimilarMessages: Story = {
|
|
|
152
152
|
args: {
|
|
153
153
|
similarLinkComponent: ({
|
|
154
154
|
mailboxId,
|
|
155
|
+
threadId,
|
|
155
156
|
messageId,
|
|
156
157
|
className,
|
|
157
158
|
ariaLabel,
|
|
158
159
|
children,
|
|
159
160
|
}) => (
|
|
160
161
|
<a
|
|
161
|
-
href={`/mail/${mailboxId}
|
|
162
|
+
href={`/mail/${mailboxId}/${threadId}/${messageId}`}
|
|
162
163
|
className={className}
|
|
163
164
|
aria-label={ariaLabel}
|
|
164
165
|
>
|
|
@@ -171,6 +172,7 @@ export const WithSimilarMessages: Story = {
|
|
|
171
172
|
{
|
|
172
173
|
id: "msg-1",
|
|
173
174
|
mailboxId: "mbx-1",
|
|
175
|
+
threadId: "thr-msg-1",
|
|
174
176
|
fromName: "Alex Rivera",
|
|
175
177
|
subject: "Re: Q3 planning notes",
|
|
176
178
|
timeLabel: "Jan 17",
|
|
@@ -179,6 +181,7 @@ export const WithSimilarMessages: Story = {
|
|
|
179
181
|
{
|
|
180
182
|
id: "msg-2",
|
|
181
183
|
mailboxId: "mbx-1",
|
|
184
|
+
threadId: "thr-msg-2",
|
|
182
185
|
fromName: "Billing",
|
|
183
186
|
subject: "Your invoice is ready",
|
|
184
187
|
timeLabel: "Yesterday",
|
|
@@ -187,6 +190,7 @@ export const WithSimilarMessages: Story = {
|
|
|
187
190
|
{
|
|
188
191
|
id: "msg-3",
|
|
189
192
|
mailboxId: "mbx-2",
|
|
193
|
+
threadId: "thr-msg-3",
|
|
190
194
|
fromName: "",
|
|
191
195
|
subject: "(No subject)",
|
|
192
196
|
timeLabel: "Dec 4, 2024",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "lucide-react";
|
|
13
13
|
import type { ReactElement, ReactNode } from "react";
|
|
14
14
|
import { cn } from "../lib/cn.js";
|
|
15
|
+
import { categoryTone, type ThreadCategory } from "./app-shell-types.js";
|
|
15
16
|
import { Avatar } from "./avatar.js";
|
|
16
17
|
import { Badge } from "./badge.js";
|
|
17
18
|
import { Button } from "./button.js";
|
|
@@ -84,6 +85,8 @@ export interface SimilarMessageIntel {
|
|
|
84
85
|
id: string;
|
|
85
86
|
/** Mailbox the message lives in — the route param for opening it. */
|
|
86
87
|
mailboxId: string;
|
|
88
|
+
/** Conversation the message belongs to, which is what the reading pane opens. */
|
|
89
|
+
threadId: string;
|
|
87
90
|
fromName: string;
|
|
88
91
|
subject: string;
|
|
89
92
|
timeLabel: string;
|
|
@@ -98,6 +101,7 @@ export interface SimilarMessageIntel {
|
|
|
98
101
|
*/
|
|
99
102
|
export interface SimilarMessageLinkProps {
|
|
100
103
|
mailboxId: string;
|
|
104
|
+
threadId: string;
|
|
101
105
|
messageId: string;
|
|
102
106
|
className: string;
|
|
103
107
|
ariaLabel?: string;
|
|
@@ -128,7 +132,7 @@ export interface SenderFlagsIntel {
|
|
|
128
132
|
export interface IntelligenceData {
|
|
129
133
|
sender: SenderIntel;
|
|
130
134
|
authenticity: AuthenticityIntel;
|
|
131
|
-
category: { value:
|
|
135
|
+
category: { value: ThreadCategory; overridden?: boolean };
|
|
132
136
|
flags?: SenderFlagsIntel;
|
|
133
137
|
similar: SimilarMessageIntel[];
|
|
134
138
|
}
|
|
@@ -395,7 +399,6 @@ export function IntelligencePanel({
|
|
|
395
399
|
notSpamPending = false,
|
|
396
400
|
}: IntelligencePanelProps) {
|
|
397
401
|
const { sender, authenticity, category, flags = {}, similar } = data;
|
|
398
|
-
const suspicious = authenticity.verdict === "mismatch";
|
|
399
402
|
|
|
400
403
|
return (
|
|
401
404
|
<aside
|
|
@@ -430,9 +433,7 @@ export function IntelligencePanel({
|
|
|
430
433
|
|
|
431
434
|
<Section label="Category">
|
|
432
435
|
<div className="flex items-center gap-2">
|
|
433
|
-
<Badge tone={
|
|
434
|
-
{category.value}
|
|
435
|
-
</Badge>
|
|
436
|
+
<Badge tone={categoryTone[category.value]}>{category.value}</Badge>
|
|
436
437
|
{category.overridden && (
|
|
437
438
|
<span className="text-2xs text-fg-subtle">your override</span>
|
|
438
439
|
)}
|
|
@@ -542,6 +543,7 @@ export function IntelligencePanel({
|
|
|
542
543
|
{similarLinkComponent ? (
|
|
543
544
|
similarLinkComponent({
|
|
544
545
|
mailboxId: s.mailboxId,
|
|
546
|
+
threadId: s.threadId,
|
|
545
547
|
messageId: s.id,
|
|
546
548
|
className: rowClass,
|
|
547
549
|
ariaLabel,
|
|
@@ -93,13 +93,13 @@ describe("MessageListPane", () => {
|
|
|
93
93
|
listState: "ready",
|
|
94
94
|
listBody: createElement(
|
|
95
95
|
"a",
|
|
96
|
-
{ href: "/mail/inbox
|
|
96
|
+
{ href: "/mail/inbox/thr-t1/t1" },
|
|
97
97
|
"Q3 planning notes",
|
|
98
98
|
),
|
|
99
99
|
}),
|
|
100
100
|
);
|
|
101
101
|
// The consumer's anchor row is rendered on the mobile/touch path…
|
|
102
|
-
assert.match(html, /href="\/mail\/inbox
|
|
102
|
+
assert.match(html, /href="\/mail\/inbox\/thr-t1\/t1"/);
|
|
103
103
|
// …and the built-in TouchListBody mock fallback is NOT substituted.
|
|
104
104
|
assert.doesNotMatch(html, /Pull to refresh/);
|
|
105
105
|
});
|
|
@@ -229,7 +229,7 @@ export const CustomListBody: Story = {
|
|
|
229
229
|
s.threads.map((t) => (
|
|
230
230
|
<a
|
|
231
231
|
key={t.id}
|
|
232
|
-
href={
|
|
232
|
+
href={`/mail/mbx-1/thr-${t.id}/${t.id}`}
|
|
233
233
|
className="flex items-center gap-3 px-4 py-3 hover:bg-surface-sunken"
|
|
234
234
|
>
|
|
235
235
|
<span className="font-medium text-sm">{t.fromName}</span>
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Archive,
|
|
5
5
|
BellOff,
|
|
6
6
|
BookmarkPlus,
|
|
7
|
+
CalendarDays,
|
|
7
8
|
ChevronDown,
|
|
8
9
|
ChevronRight,
|
|
9
10
|
FileText,
|
|
@@ -558,6 +559,12 @@ export interface NavSidebarProps
|
|
|
558
559
|
* fall back to programmatic buttons (static stories / AppShell preview).
|
|
559
560
|
*/
|
|
560
561
|
linkComponent?: NavLinkComponent;
|
|
562
|
+
/**
|
|
563
|
+
* Whether the calendar destination sits under the daily brief. It is
|
|
564
|
+
* "hidden" until the app has a route behind it — the prototype turns it on,
|
|
565
|
+
* the shipping nav does not offer an entry that leads nowhere.
|
|
566
|
+
*/
|
|
567
|
+
calendarNav?: "hidden" | "shown";
|
|
561
568
|
/** Every saved query, most recently saved first. See `SavedSearchesGroup`. */
|
|
562
569
|
savedSearches?: string[];
|
|
563
570
|
/**
|
|
@@ -577,6 +584,7 @@ export function NavSidebar({
|
|
|
577
584
|
onSelectNav,
|
|
578
585
|
variant = "desktop",
|
|
579
586
|
linkComponent,
|
|
587
|
+
calendarNav = "hidden",
|
|
580
588
|
savedSearches = [],
|
|
581
589
|
saveableQuery,
|
|
582
590
|
onSelectSavedSearch,
|
|
@@ -604,6 +612,18 @@ export function NavSidebar({
|
|
|
604
612
|
onClick={() => onSelectNav?.("brief")}
|
|
605
613
|
/>
|
|
606
614
|
|
|
615
|
+
{calendarNav === "shown" && (
|
|
616
|
+
<NavItem
|
|
617
|
+
navId="calendar"
|
|
618
|
+
linkComponent={linkComponent}
|
|
619
|
+
icon={<CalendarDays className="size-4" />}
|
|
620
|
+
label="Calendar"
|
|
621
|
+
ariaLabel="Calendar"
|
|
622
|
+
active={selectedNavId === "calendar"}
|
|
623
|
+
onClick={() => onSelectNav?.("calendar")}
|
|
624
|
+
/>
|
|
625
|
+
)}
|
|
626
|
+
|
|
607
627
|
<NavItem
|
|
608
628
|
navId="flagged"
|
|
609
629
|
linkComponent={linkComponent}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { RecurrenceScopePrompt } from "./recurrence-scope-prompt.js";
|
|
6
|
+
|
|
7
|
+
const render = (touch = false) =>
|
|
8
|
+
renderToString(
|
|
9
|
+
createElement(RecurrenceScopePrompt, {
|
|
10
|
+
title: "Standup",
|
|
11
|
+
ruleText: "Every weekday, 09:15",
|
|
12
|
+
instanceText: "Wednesday 10 June",
|
|
13
|
+
onChoose: () => undefined,
|
|
14
|
+
onCancel: () => undefined,
|
|
15
|
+
touch,
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe("RecurrenceScopePrompt", () => {
|
|
20
|
+
it("offers all three scopes", () => {
|
|
21
|
+
const html = render();
|
|
22
|
+
assert.match(html, /This event/);
|
|
23
|
+
assert.match(html, /This and everything after/);
|
|
24
|
+
assert.match(html, /The whole series/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("states the rule and the instance that was clicked", () => {
|
|
28
|
+
const html = render();
|
|
29
|
+
assert.match(html, /Every weekday, 09:15/);
|
|
30
|
+
assert.match(html, /Wednesday 10 June/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("grows the choices for a thumb", () => {
|
|
34
|
+
assert.match(render(true), /min-h-11/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { RecurrenceScopePrompt } from "./recurrence-scope-prompt.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The scope question, asked before the form opens. Editing one instance and
|
|
6
|
+
* editing the rule are different acts, so the choice is made while the change
|
|
7
|
+
* is still an intention.
|
|
8
|
+
*/
|
|
9
|
+
const meta: Meta<typeof RecurrenceScopePrompt> = {
|
|
10
|
+
title: "Calendar/Recurrence scope",
|
|
11
|
+
component: RecurrenceScopePrompt,
|
|
12
|
+
parameters: { layout: "padded" },
|
|
13
|
+
};
|
|
14
|
+
export default meta;
|
|
15
|
+
|
|
16
|
+
type Story = StoryObj<typeof RecurrenceScopePrompt>;
|
|
17
|
+
|
|
18
|
+
export const Desktop: Story = {
|
|
19
|
+
render: () => (
|
|
20
|
+
<div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
|
|
21
|
+
<RecurrenceScopePrompt
|
|
22
|
+
title="Standup"
|
|
23
|
+
ruleText="Every weekday, 09:15"
|
|
24
|
+
instanceText="Wednesday 10 June"
|
|
25
|
+
onChoose={() => {}}
|
|
26
|
+
onCancel={() => {}}
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The same question with thumb-sized targets. */
|
|
33
|
+
export const Touch: Story = {
|
|
34
|
+
render: () => (
|
|
35
|
+
<div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
|
|
36
|
+
<RecurrenceScopePrompt
|
|
37
|
+
title="Standup"
|
|
38
|
+
ruleText="Every weekday, 09:15"
|
|
39
|
+
instanceText="Wednesday 10 June"
|
|
40
|
+
onChoose={() => {}}
|
|
41
|
+
onCancel={() => {}}
|
|
42
|
+
touch
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
),
|
|
46
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { Button } from "./button.js";
|
|
3
|
+
import type { RecurrenceScope } from "./calendar-types.js";
|
|
4
|
+
|
|
5
|
+
export interface RecurrenceScopePromptProps {
|
|
6
|
+
/** The series being edited, e.g. "Standup". */
|
|
7
|
+
title: string;
|
|
8
|
+
/** The rule in words, e.g. "Every weekday, 09:15". */
|
|
9
|
+
ruleText: string;
|
|
10
|
+
/** The instance that was clicked, already formatted. */
|
|
11
|
+
instanceText: string;
|
|
12
|
+
onChoose: (scope: RecurrenceScope) => void;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
touch?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const choices: { scope: RecurrenceScope; label: string; note: string }[] = [
|
|
19
|
+
{
|
|
20
|
+
scope: "this",
|
|
21
|
+
label: "This event",
|
|
22
|
+
note: "Leaves the rest of the series alone",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
scope: "following",
|
|
26
|
+
label: "This and everything after",
|
|
27
|
+
note: "Splits the series here",
|
|
28
|
+
},
|
|
29
|
+
{ scope: "all", label: "The whole series", note: "Rewrites the rule itself" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The scope question, asked first. A series is one object with one rule, and
|
|
34
|
+
* which instances an edit reaches changes what the edit means — so it is
|
|
35
|
+
* settled before the form opens, not confirmed on the way out when the change
|
|
36
|
+
* has already been typed.
|
|
37
|
+
*/
|
|
38
|
+
export function RecurrenceScopePrompt({
|
|
39
|
+
title,
|
|
40
|
+
ruleText,
|
|
41
|
+
instanceText,
|
|
42
|
+
onChoose,
|
|
43
|
+
onCancel,
|
|
44
|
+
touch,
|
|
45
|
+
className,
|
|
46
|
+
}: RecurrenceScopePromptProps) {
|
|
47
|
+
return (
|
|
48
|
+
<div className={cn("flex flex-col gap-3", className)}>
|
|
49
|
+
<div>
|
|
50
|
+
<h2 className="text-md font-semibold text-fg">{title} repeats</h2>
|
|
51
|
+
<p className="text-xs text-fg-muted">{ruleText}</p>
|
|
52
|
+
<p className="text-xs text-fg-subtle">You clicked {instanceText}.</p>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<p className="text-sm text-fg">What should the change apply to?</p>
|
|
56
|
+
|
|
57
|
+
<div className="flex flex-col gap-1.5">
|
|
58
|
+
{choices.map((choice) => (
|
|
59
|
+
<button
|
|
60
|
+
key={choice.scope}
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={() => onChoose(choice.scope)}
|
|
63
|
+
className={cn(
|
|
64
|
+
"flex flex-col rounded-md border border-line bg-surface px-3 py-2 text-left outline-none transition-colors hover:border-line-strong hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring",
|
|
65
|
+
touch && "min-h-11 justify-center",
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
<span className="text-sm font-medium text-fg">{choice.label}</span>
|
|
69
|
+
<span className="text-2xs text-fg-subtle">{choice.note}</span>
|
|
70
|
+
</button>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<Button
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size={touch ? "md" : "sm"}
|
|
77
|
+
onClick={onCancel}
|
|
78
|
+
className={cn("self-start", touch && "min-h-11")}
|
|
79
|
+
>
|
|
80
|
+
Cancel
|
|
81
|
+
</Button>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
scopeLabel,
|
|
70
70
|
} from "./filter-rule.js";
|
|
71
71
|
import type { ClauseEditState } from "./filter-rule-editor.js";
|
|
72
|
+
import { FlowScreen, FlowStepRail } from "./flow-screen.js";
|
|
72
73
|
import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
|
|
73
74
|
import { Input } from "./input.js";
|
|
74
75
|
import { ProgressBar } from "./progress-bar.js";
|
|
@@ -91,21 +92,8 @@ export interface StepRailProps {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
export function StepRail({ steps, step }: StepRailProps) {
|
|
94
|
-
const active = stepIndex(steps, step);
|
|
95
95
|
return (
|
|
96
|
-
<
|
|
97
|
-
{steps.map((id, i) => (
|
|
98
|
-
<li key={id} className="flex flex-1 items-center gap-1.5">
|
|
99
|
-
<span
|
|
100
|
-
className={cn(
|
|
101
|
-
"h-1 flex-1 rounded-full transition-colors",
|
|
102
|
-
i <= active ? "bg-accent" : "bg-surface-sunken",
|
|
103
|
-
)}
|
|
104
|
-
aria-current={i === active ? "step" : undefined}
|
|
105
|
-
/>
|
|
106
|
-
</li>
|
|
107
|
-
))}
|
|
108
|
-
</ol>
|
|
96
|
+
<FlowStepRail count={steps.length} activeStep={stepIndex(steps, step)} />
|
|
109
97
|
);
|
|
110
98
|
}
|
|
111
99
|
|
|
@@ -135,61 +123,22 @@ export function WizardScreen({
|
|
|
135
123
|
footer,
|
|
136
124
|
children,
|
|
137
125
|
}: WizardScreenProps) {
|
|
138
|
-
|
|
126
|
+
// A modal at every width: full-bleed below 768px it covers the list rather
|
|
127
|
+
// than sitting beside it, so the list behind must leave the accessibility
|
|
128
|
+
// tree with it — otherwise the verbs that opened the wizard are still
|
|
129
|
+
// reachable underneath the screen that replaced them.
|
|
139
130
|
return (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
className="fixed inset-0 z-50 flex flex-col font-sans text-fg md:items-center md:justify-center md:bg-black/40 md:p-6"
|
|
131
|
+
<FlowScreen
|
|
132
|
+
title={title}
|
|
133
|
+
subtitle={subtitle}
|
|
134
|
+
steps={steps.map(stepLabel)}
|
|
135
|
+
activeStep={stepIndex(steps, step)}
|
|
136
|
+
onBack={onBack}
|
|
137
|
+
onExit={onExit}
|
|
138
|
+
footer={footer}
|
|
149
139
|
>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<div className="flex items-center gap-1">
|
|
153
|
-
<button
|
|
154
|
-
type="button"
|
|
155
|
-
onClick={onBack}
|
|
156
|
-
aria-label="Back"
|
|
157
|
-
className="flex size-11 items-center justify-center rounded-md text-fg-muted"
|
|
158
|
-
>
|
|
159
|
-
<ArrowLeft className="size-5" />
|
|
160
|
-
</button>
|
|
161
|
-
<div className="min-w-0 flex-1 text-center">
|
|
162
|
-
<h1 className="truncate text-sm font-semibold">{title}</h1>
|
|
163
|
-
{subtitle && (
|
|
164
|
-
<p className="truncate text-2xs text-fg-muted">{subtitle}</p>
|
|
165
|
-
)}
|
|
166
|
-
</div>
|
|
167
|
-
<button
|
|
168
|
-
type="button"
|
|
169
|
-
onClick={onExit}
|
|
170
|
-
aria-label="Cancel"
|
|
171
|
-
className="flex size-11 items-center justify-center rounded-md text-fg-muted"
|
|
172
|
-
>
|
|
173
|
-
<X className="size-5" />
|
|
174
|
-
</button>
|
|
175
|
-
</div>
|
|
176
|
-
<div className="space-y-1 px-1 pt-2">
|
|
177
|
-
<StepRail steps={steps} step={step} />
|
|
178
|
-
<p className="text-2xs text-fg-subtle">
|
|
179
|
-
Step {active + 1} of {steps.length} · {stepLabel(steps[active])}
|
|
180
|
-
</p>
|
|
181
|
-
</div>
|
|
182
|
-
</header>
|
|
183
|
-
|
|
184
|
-
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
|
185
|
-
{children}
|
|
186
|
-
</div>
|
|
187
|
-
|
|
188
|
-
<footer className="shrink-0 border-t border-line px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] md:pb-3">
|
|
189
|
-
{footer}
|
|
190
|
-
</footer>
|
|
191
|
-
</div>
|
|
192
|
-
</div>
|
|
140
|
+
{children}
|
|
141
|
+
</FlowScreen>
|
|
193
142
|
);
|
|
194
143
|
}
|
|
195
144
|
|
|
@@ -354,7 +303,8 @@ export interface FooterNavProps {
|
|
|
354
303
|
onBack: () => void;
|
|
355
304
|
nextLabel: string;
|
|
356
305
|
onNext: () => void;
|
|
357
|
-
|
|
306
|
+
/** `commit` reads as primary without the arrow: it ends the flow. */
|
|
307
|
+
nextVariant?: "primary" | "commit" | "danger";
|
|
358
308
|
/** What the step is still missing. Dims the control; never disables it. */
|
|
359
309
|
blockedReason?: string;
|
|
360
310
|
/** Continue was pressed while blocked, so the reason is on screen. */
|
|
@@ -397,7 +347,7 @@ export function FooterNav({
|
|
|
397
347
|
Back
|
|
398
348
|
</Button>
|
|
399
349
|
<Button
|
|
400
|
-
variant={nextVariant}
|
|
350
|
+
variant={nextVariant === "danger" ? "danger" : "primary"}
|
|
401
351
|
size="touch"
|
|
402
352
|
onClick={onNext}
|
|
403
353
|
aria-describedby={blockedReason ? reasonId : undefined}
|