@remit/web-client 0.0.321 → 0.0.323
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 +4 -4
- package/src/auth/SignInAgain.tsx +18 -0
- package/src/auth/amplify-config.test.ts +1 -107
- package/src/auth/auth-interceptor.test.ts +0 -6
- package/src/auth/cognito/cognito-token.test.ts +2 -4
- package/src/components/calendar/AgendaStrip.tsx +1 -1
- package/src/components/calendar/CalendarWorkspace.tsx +1 -1
- package/src/components/calendar/ChangeCalendarSuggestion.tsx +1 -1
- package/src/components/calendar/OpenCalendarEvent.tsx +1 -18
- package/src/components/compose/ComposeForm.tsx +11 -6
- package/src/components/compose/FullCompose.tsx +1 -1
- package/src/components/compose/MobileComposeSheet.tsx +1 -2
- package/src/components/compose/compose.stories.tsx +18 -0
- package/src/components/compose/draft-discard-failure.render.test.ts +138 -0
- package/src/components/config-import/config-import.fixtures.ts +8 -9
- package/src/components/config-import/config-import.stories.tsx +2 -2
- package/src/components/layout/MailTopBar.tsx +3 -0
- package/src/components/mail/BriefPane.tsx +0 -3
- package/src/components/mail/ConversationView.tsx +1 -3
- package/src/components/mail/DailyBrief.tsx +5 -28
- package/src/components/mail/FlaggedList.tsx +3 -4
- package/src/components/mail/FlaggedPane.tsx +0 -3
- package/src/components/mail/IntelligenceDrawer.tsx +0 -3
- package/src/components/mail/IntelligencePane.render.test.ts +0 -1
- package/src/components/mail/IntelligencePane.test.ts +1 -95
- package/src/components/mail/IntelligencePane.tsx +3 -93
- package/src/components/mail/MailListHeader.tsx +12 -43
- package/src/components/mail/MailNav.tsx +6 -2
- package/src/components/mail/MailViewChrome.tsx +1 -11
- package/src/components/mail/MailboxPane.tsx +4 -34
- package/src/components/mail/MessageActionMenu.tsx +6 -6
- package/src/components/mail/MessageAttachments.tsx +35 -0
- package/src/components/mail/MessageBody.tsx +1 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +0 -30
- package/src/components/mail/MessageCard.tsx +16 -4
- package/src/components/mail/MessageList.run-reporting.test.ts +2 -46
- package/src/components/mail/MessageList.tsx +23 -11
- package/src/components/mail/MessageRow.render.test.ts +0 -7
- package/src/components/mail/MoveToTrigger.tsx +1 -1
- package/src/components/mail/RawMessageView.tsx +1 -1
- package/src/components/mail/SelectionWizardHost.search-entry.test.ts +0 -1
- package/src/components/mail/SelectionWizardHost.tsx +16 -102
- package/src/components/mail/authenticity-warning.stories.tsx +0 -21
- package/src/components/mail/daily-brief.stories.tsx +31 -17
- package/src/components/mail/enter-cursor-late-open.render.test.ts +197 -0
- package/src/components/mail/intelligence-drawer.stories.tsx +0 -2
- package/src/components/mail/message-gone.stories.tsx +44 -0
- package/src/components/mail/offer-times-reply.render.test.ts +25 -1
- package/src/components/mail/reclassify-remove-override.render.test.ts +2 -6
- package/src/components/overlay-escape.render.test.ts +7 -3
- package/src/components/settings/AccountFormPanel.render.test.ts +26 -3
- package/src/components/settings/AccountFormPanel.test.ts +1 -36
- package/src/components/settings/AccountFormPanel.tsx +5 -25
- package/src/components/settings/AccountServices.tsx +1 -1
- package/src/components/settings/AdvancedNavIcon.tsx +3 -3
- package/src/components/settings/CalendarFeedCard.tsx +2 -1
- package/src/components/settings/CalendarSubscribeCard.tsx +1 -1
- package/src/components/settings/CalendarSubscriptionStatus.tsx +1 -2
- package/src/components/settings/FilterActionError.tsx +1 -1
- package/src/components/settings/FilterEditor.render.test.ts +3 -27
- package/src/components/settings/FilterEditor.tsx +4 -24
- package/src/components/settings/FilterEditorSurface.tsx +0 -3
- package/src/components/settings/FiltersList.render.test.ts +5 -24
- package/src/components/settings/FiltersList.stories.tsx +0 -1
- package/src/components/settings/FiltersList.tsx +10 -35
- package/src/components/settings/WarningsNavIcon.tsx +12 -0
- package/src/components/settings/{QuarantinePanel.delimiter.test.ts → WarningsPanel.dom.test.ts} +64 -5
- package/src/components/settings/WarningsPanel.render.test.ts +56 -0
- package/src/components/settings/WarningsPanel.tsx +244 -0
- package/src/components/settings/accounts.stories.tsx +18 -0
- package/src/components/settings/advanced.stories.tsx +3 -125
- package/src/components/settings/settings-filter.stories.tsx +2 -42
- package/src/components/settings/warnings.stories.tsx +290 -0
- package/src/components/ui/ErrorBannerProvider.tsx +5 -3
- package/src/components/ui/error-banners.test.ts +1 -22
- package/src/components/ui/error-banners.ts +9 -20
- package/src/hooks/calendar/useCalendarCollectionWrites.ts +2 -1
- package/src/hooks/calendar/useCalendarSuggestions.ts +2 -1
- package/src/hooks/calendar/useCalendarWrites.ts +2 -1
- package/src/hooks/intelligence-calendar.render.test.ts +0 -1
- package/src/hooks/useDeleteMessages.test.ts +1 -42
- package/src/hooks/useFilters.test.ts +0 -8
- package/src/hooks/useFollowFocusOpen.render.test.ts +1 -11
- package/src/hooks/useIntelligenceData.test.ts +9 -47
- package/src/hooks/useIntelligenceData.ts +7 -100
- package/src/hooks/useLayoutTier.test.ts +0 -48
- package/src/hooks/useReportSpam.ts +1 -1
- package/src/hooks/useRescueCandidates.test.ts +0 -23
- package/src/hooks/useRulePreview.ts +8 -7
- package/src/hooks/useSearchTokenContext.ts +2 -3
- package/src/hooks/useStaleAccountSync.test.ts +0 -17
- package/src/hooks/useStarredThreads.test.ts +0 -10
- package/src/hooks/useTriggerSync.test.ts +0 -6
- package/src/hooks/useVisualViewport.render.test.ts +63 -0
- package/src/hooks/useWarningsAttention.ts +37 -0
- package/src/lib/account-order.test.ts +0 -10
- package/src/lib/adjacent-message.test.ts +0 -9
- package/src/lib/app-info.ts +0 -3
- package/src/lib/autodiscovery.test.ts +0 -47
- package/src/lib/bug-report.test.ts +38 -0
- package/src/lib/bug-report.ts +10 -7
- package/src/lib/bulk-action-copy.ts +1 -1
- package/src/lib/calendar-route.test.ts +0 -5
- package/src/lib/config-import.test.ts +3 -39
- package/src/lib/dedupe-thread-messages.test.ts +0 -4
- package/src/lib/desktop-breakpoint.test.ts +0 -8
- package/src/lib/drafts.test.ts +0 -23
- package/src/lib/fatal-error.test.ts +0 -19
- package/src/lib/folder-roles.test.ts +0 -4
- package/src/lib/hot-sync-window.test.ts +0 -5
- package/src/lib/list-header-chrome.ts +1 -1
- package/src/lib/mail-context.ts +1 -1
- package/src/lib/message-attachments.test.ts +77 -0
- package/src/lib/message-attachments.ts +31 -15
- package/src/lib/message-body-source.test.ts +21 -4
- package/src/lib/message-body-source.ts +5 -2
- package/src/lib/onboarding-completion.test.ts +0 -16
- package/src/lib/organize/filter-edit-model.test.ts +2 -32
- package/src/lib/organize/filter-edit-model.ts +6 -16
- package/src/lib/organize/organize-model.test.ts +7 -40
- package/src/lib/organize/organize-model.ts +16 -18
- package/src/lib/organize/rule-model.test.ts +24 -202
- package/src/lib/organize/rule-model.ts +19 -166
- package/src/lib/organize/search-to-rule.test.ts +2 -36
- package/src/lib/organize/search-to-rule.ts +1 -16
- package/src/lib/provider-presets.test.ts +1 -77
- package/src/lib/query-error-handler.test.ts +0 -61
- package/src/lib/query-escalation.integration.test.ts +4 -4
- package/src/lib/result-count.ts +1 -1
- package/src/lib/result-folder.ts +1 -1
- package/src/lib/rum-adapter.test.ts +0 -104
- package/src/lib/search-query.test.ts +0 -11
- package/src/lib/search-result.test.ts +2 -142
- package/src/lib/search-result.ts +3 -73
- package/src/lib/search-scope.test.ts +1 -92
- package/src/lib/search-scope.ts +0 -29
- package/src/lib/search-surface.ts +2 -2
- package/src/lib/search-token-index.test.ts +0 -8
- package/src/lib/search-tokens.ts +15 -16
- package/src/lib/starred-rows.test.ts +0 -4
- package/src/lib/sync-warning-entries.ts +33 -0
- package/src/lib/wizard-selection.ts +1 -1
- package/src/mocks/story-frame/mail-handlers.ts +12 -8
- package/src/mocks/story-frame/mail-world.ts +5 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/calendar/$view.$date/index.tsx +1 -1
- package/src/routes/calendar.tsx +2 -1
- package/src/routes/mail/brief.tsx +1 -1
- package/src/routes/mail/flagged.tsx +1 -1
- package/src/routes/mail.tsx +5 -3
- package/src/routes/settings/-accounts.test.ts +0 -5
- package/src/routes/settings/-filters.test.ts +0 -1
- package/src/routes/settings/-shared/help-copy.tsx +15 -0
- package/src/routes/settings/accounts.tsx +1 -1
- package/src/routes/settings/advanced.tsx +1 -4
- package/src/routes/settings/filters.tsx +1 -2
- package/src/routes/settings/folders.tsx +1 -1
- package/src/routes/settings/labels.tsx +1 -1
- package/src/routes/settings/senders.tsx +1 -1
- package/src/routes/settings/warnings.tsx +36 -0
- package/src/routes/settings.tsx +7 -0
- package/src/routing/wizard-history.test.ts +1 -45
- package/src/runtime-config.ts +5 -0
- package/src/test-support/fixtures.ts +1 -1
- package/src/components/compose/draft-discard-banner.test.ts +0 -76
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +0 -42
- package/src/components/mail/SpamRescue.render.test.ts +0 -35
- package/src/components/mail/intelligence-semantic-off.render.test.ts +0 -111
- package/src/components/mail/selection-surface-rollcall.test.ts +0 -270
- package/src/components/settings/QuarantinePanel.render.test.ts +0 -71
- package/src/components/settings/QuarantinePanel.tsx +0 -178
- package/src/components/ui/DropdownMenu.tsx +0 -99
- package/src/components/ui/EmptyState.tsx +0 -11
- package/src/components/ui/ErrorBanner.render.test.ts +0 -104
- package/src/components/ui/ErrorBanner.stories.tsx +0 -132
- package/src/components/ui/ErrorBanner.tsx +0 -106
- package/src/components/ui/ErrorBannerStack.tsx +0 -35
- package/src/components/ui/ErrorState.render.test.ts +0 -82
- package/src/components/ui/ErrorState.tsx +0 -80
- package/src/components/ui/KeyboardShortcutsModal.tsx +0 -113
- package/src/hooks/useMoveMessages.test.ts +0 -63
- package/src/hooks/useOrganizeWiden.test.ts +0 -217
- package/src/hooks/useOrganizeWiden.ts +0 -112
- package/src/hooks/useSemanticSearch.ts +0 -132
- package/src/hooks/useSemanticSearchEnabled.ts +0 -23
- package/src/hooks/useVisualViewport.test.ts +0 -126
- package/src/lib/organize/sender-fallback.test.ts +0 -52
- package/src/lib/organize/sender-fallback.ts +0 -38
- package/src/lib/rescue-telemetry.test.ts +0 -75
- package/src/router.test.ts +0 -81
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Banner rendering (#55): a banner sits on top of the toolbar and the message
|
|
3
|
-
* list, so it is opaque, it says which severity it is out loud, and only an
|
|
4
|
-
* error interrupts a screen reader.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import assert from "node:assert/strict";
|
|
8
|
-
import { describe, it } from "node:test";
|
|
9
|
-
import React, { createElement } from "react";
|
|
10
|
-
import { renderToString } from "react-dom/server";
|
|
11
|
-
import { ErrorBanner } from "./ErrorBanner";
|
|
12
|
-
import { ErrorBannerStack } from "./ErrorBannerStack";
|
|
13
|
-
import type { ErrorBannerEntry, ErrorBannerSeverity } from "./error-banners.js";
|
|
14
|
-
|
|
15
|
-
(globalThis as { React?: typeof React }).React = React;
|
|
16
|
-
|
|
17
|
-
const renderBanner = (severity: ErrorBannerSeverity, detail?: string): string =>
|
|
18
|
-
renderToString(
|
|
19
|
-
createElement(ErrorBanner, {
|
|
20
|
-
id: "b1",
|
|
21
|
-
severity,
|
|
22
|
-
title: "Couldn't move message",
|
|
23
|
-
detail,
|
|
24
|
-
onDismiss: () => undefined,
|
|
25
|
-
}) as never,
|
|
26
|
-
);
|
|
27
|
-
|
|
28
|
-
describe("ErrorBanner", () => {
|
|
29
|
-
it("interrupts assertively for an error and politely for the rest", () => {
|
|
30
|
-
assert.match(renderBanner("error"), /role="alert"/);
|
|
31
|
-
assert.match(renderBanner("error"), /aria-live="assertive"/);
|
|
32
|
-
for (const severity of ["warning", "info"] as const) {
|
|
33
|
-
assert.match(renderBanner(severity), /role="status"/);
|
|
34
|
-
assert.match(renderBanner(severity), /aria-live="polite"/);
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("names the severity in the dismiss label so it is not just an X", () => {
|
|
39
|
-
assert.match(renderBanner("error"), /aria-label="Dismiss error"/);
|
|
40
|
-
assert.match(renderBanner("warning"), /aria-label="Dismiss warning"/);
|
|
41
|
-
assert.match(renderBanner("info"), /aria-label="Dismiss information"/);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("stays opaque — no translucent surface over the message list (#55)", () => {
|
|
45
|
-
const backgrounds: Record<ErrorBannerSeverity, RegExp> = {
|
|
46
|
-
error: /bg-danger-soft/,
|
|
47
|
-
warning: /bg-warning-soft/,
|
|
48
|
-
info: /bg-accent-2-soft/,
|
|
49
|
-
};
|
|
50
|
-
for (const [severity, background] of Object.entries(backgrounds)) {
|
|
51
|
-
const html = renderBanner(severity as ErrorBannerSeverity);
|
|
52
|
-
assert.match(html, background);
|
|
53
|
-
// An alpha-suffixed background (`bg-x/40`) is see-through; text over
|
|
54
|
-
// the message list behind it stops being readable.
|
|
55
|
-
assert.doesNotMatch(html, /class="[^"]*\sbg-[\w-]+\/\d/);
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("shows the detail only when there is one", () => {
|
|
60
|
-
assert.match(renderBanner("error", "Connection reset"), /Connection reset/);
|
|
61
|
-
assert.doesNotMatch(renderBanner("error"), /Connection reset/);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
const entry = (
|
|
66
|
-
id: string,
|
|
67
|
-
severity: ErrorBannerSeverity,
|
|
68
|
-
title: string,
|
|
69
|
-
): ErrorBannerEntry => ({
|
|
70
|
-
id,
|
|
71
|
-
severity,
|
|
72
|
-
title,
|
|
73
|
-
createdAt: 0,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const renderStack = (errors: ErrorBannerEntry[]): string =>
|
|
77
|
-
renderToString(
|
|
78
|
-
createElement(ErrorBannerStack, {
|
|
79
|
-
errors,
|
|
80
|
-
onDismiss: () => undefined,
|
|
81
|
-
}) as never,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
describe("ErrorBannerStack", () => {
|
|
85
|
-
it("renders nothing at all when there is nothing to say", () => {
|
|
86
|
-
assert.equal(renderStack([]), "");
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("stacks every banner it is given under one labelled region", () => {
|
|
90
|
-
const html = renderStack([
|
|
91
|
-
entry("a", "error", "Couldn't delete"),
|
|
92
|
-
entry("b", "info", "Sync finished"),
|
|
93
|
-
]);
|
|
94
|
-
assert.match(html, /aria-label="Notifications"/);
|
|
95
|
-
assert.match(html, /Couldn't delete/);
|
|
96
|
-
assert.match(html, /Sync finished/);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("lets clicks through the region so it never blocks the mail behind it", () => {
|
|
100
|
-
const html = renderStack([entry("a", "error", "Couldn't delete")]);
|
|
101
|
-
assert.match(html, /pointer-events-none/);
|
|
102
|
-
assert.match(html, /pointer-events-auto/);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import { ErrorBanner } from "./ErrorBanner";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The soft, dismissible notification (#55). It sits over the toolbar and the
|
|
6
|
-
* message list, so it is opaque and it names its own severity out loud rather
|
|
7
|
-
* than leaving colour to carry the meaning.
|
|
8
|
-
*
|
|
9
|
-
* Every error banner carries a prefilled report link, so reporting a failure
|
|
10
|
-
* the user cannot act on is one click instead of a form they have to
|
|
11
|
-
* assemble. The hrefs below stand in for the real report URL, which the app
|
|
12
|
-
* builds from build-time constants Storybook has no `define` for.
|
|
13
|
-
*/
|
|
14
|
-
const meta: Meta<typeof ErrorBanner> = {
|
|
15
|
-
title: "Playground/Shipped/Mail/ErrorBanner",
|
|
16
|
-
component: ErrorBanner,
|
|
17
|
-
parameters: { layout: "padded" },
|
|
18
|
-
args: {
|
|
19
|
-
id: "banner-1",
|
|
20
|
-
onDismiss: () => undefined,
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
export default meta;
|
|
24
|
-
|
|
25
|
-
type Story = StoryObj<typeof ErrorBanner>;
|
|
26
|
-
|
|
27
|
-
const REPORT_URL =
|
|
28
|
-
"https://github.com/remit-mail/reader/issues/new?title=Spellcheck+stopped";
|
|
29
|
-
|
|
30
|
-
/** A mutation that failed, with the reason underneath and a way out. */
|
|
31
|
-
export const Failed: Story = {
|
|
32
|
-
name: "Error",
|
|
33
|
-
args: {
|
|
34
|
-
severity: "error",
|
|
35
|
-
title: "Couldn't move message",
|
|
36
|
-
detail: "Connection reset by peer",
|
|
37
|
-
action: { label: "Report an issue", href: REPORT_URL },
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/** Nothing more to say than the title — the report link still stands. */
|
|
42
|
-
export const NoDetail: Story = {
|
|
43
|
-
args: {
|
|
44
|
-
severity: "error",
|
|
45
|
-
title: "Couldn't move message",
|
|
46
|
-
action: { label: "Report an issue", href: REPORT_URL },
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Report spam failing because the account has nowhere to file it. The reason
|
|
52
|
-
* names the folder and the fix, so this one is actionable without the report
|
|
53
|
-
* link — which is offered anyway, because the user should not have to decide.
|
|
54
|
-
*/
|
|
55
|
-
export const NoJunkFolder: Story = {
|
|
56
|
-
args: {
|
|
57
|
-
severity: "error",
|
|
58
|
-
title: "Couldn't report this message as spam",
|
|
59
|
-
detail:
|
|
60
|
-
"This account has no Junk folder. Create one named Junk or Spam in your mail provider, then report this message again.",
|
|
61
|
-
action: { label: "Report an issue", href: REPORT_URL },
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
/** Something degraded rather than broke. */
|
|
66
|
-
export const Warning: Story = {
|
|
67
|
-
args: {
|
|
68
|
-
severity: "warning",
|
|
69
|
-
title: "Draft saved locally",
|
|
70
|
-
detail: "The server did not answer, so this draft has not been uploaded.",
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
/** A statement of fact, not a problem. */
|
|
75
|
-
export const Info: Story = {
|
|
76
|
-
args: {
|
|
77
|
-
severity: "info",
|
|
78
|
-
title: "Sync finished",
|
|
79
|
-
detail: "1,204 messages are up to date.",
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The spellchecker stopping (#707): the writer cannot fix this one, so the
|
|
85
|
-
* banner says what stopped, what is happening instead, and offers the report
|
|
86
|
-
* already filled in.
|
|
87
|
-
*/
|
|
88
|
-
export const WithActionLink: Story = {
|
|
89
|
-
args: {
|
|
90
|
-
severity: "warning",
|
|
91
|
-
title: "Spellcheck stopped",
|
|
92
|
-
detail:
|
|
93
|
-
"Spellcheck for en is off: the checker could not start. Your browser is checking this message instead. Failed to fetch dynamically imported module.",
|
|
94
|
-
action: { label: "Report this", href: REPORT_URL },
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* A delete refused because the message is still being moved on the mail server
|
|
100
|
-
* (#845). Transient: the same press works once the move confirms, so the copy
|
|
101
|
-
* says so rather than sending the user off to report a bug.
|
|
102
|
-
*/
|
|
103
|
-
export const PlacementInFlight: Story = {
|
|
104
|
-
args: {
|
|
105
|
-
severity: "warning",
|
|
106
|
-
title: "Couldn't delete this message yet",
|
|
107
|
-
detail:
|
|
108
|
-
"It is still being moved on the mail server. Try again in a moment.",
|
|
109
|
-
action: { label: "Report an issue", href: REPORT_URL },
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* The same refusal after the move gave up without confirming. Waiting will
|
|
115
|
-
* never clear this one, so the remedy is a resync instead.
|
|
116
|
-
*/
|
|
117
|
-
export const PlacementUnverified: Story = {
|
|
118
|
-
args: {
|
|
119
|
-
severity: "warning",
|
|
120
|
-
title: "Couldn't delete 4 messages yet",
|
|
121
|
-
detail:
|
|
122
|
-
"An earlier move never finished, so where this message sits is unknown. Sync the folder, then delete it again.",
|
|
123
|
-
action: { label: "Report an issue", href: REPORT_URL },
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
/** The action link on the dark theme. */
|
|
128
|
-
export const WithActionLinkDark: Story = {
|
|
129
|
-
name: "With Action Link (dark)",
|
|
130
|
-
parameters: { theme: "dark" },
|
|
131
|
-
args: WithActionLink.args,
|
|
132
|
-
};
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { cn } from "@remit/ui";
|
|
2
|
-
import { AlertCircle, AlertTriangle, Info, X } from "lucide-react";
|
|
3
|
-
import type {
|
|
4
|
-
ErrorBannerAction,
|
|
5
|
-
ErrorBannerSeverity,
|
|
6
|
-
} from "./error-banners.js";
|
|
7
|
-
|
|
8
|
-
interface ErrorBannerProps {
|
|
9
|
-
id: string;
|
|
10
|
-
severity: ErrorBannerSeverity;
|
|
11
|
-
title: string;
|
|
12
|
-
detail?: string;
|
|
13
|
-
action?: ErrorBannerAction;
|
|
14
|
-
onDismiss: (id: string) => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const SEVERITY_STYLES: Record<
|
|
18
|
-
ErrorBannerSeverity,
|
|
19
|
-
{ container: string; icon: string; title: string }
|
|
20
|
-
> = {
|
|
21
|
-
error: {
|
|
22
|
-
container: "border-danger/50 bg-danger-soft",
|
|
23
|
-
icon: "text-danger",
|
|
24
|
-
title: "text-danger",
|
|
25
|
-
},
|
|
26
|
-
warning: {
|
|
27
|
-
container: "border-warning/50 bg-warning-soft",
|
|
28
|
-
icon: "text-warning",
|
|
29
|
-
title: "text-warning",
|
|
30
|
-
},
|
|
31
|
-
info: {
|
|
32
|
-
container: "border-accent-2/50 bg-accent-2-soft",
|
|
33
|
-
icon: "text-accent-2",
|
|
34
|
-
title: "text-accent-2",
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const SEVERITY_ICONS: Record<
|
|
39
|
-
ErrorBannerSeverity,
|
|
40
|
-
typeof AlertCircle | typeof AlertTriangle | typeof Info
|
|
41
|
-
> = {
|
|
42
|
-
error: AlertCircle,
|
|
43
|
-
warning: AlertTriangle,
|
|
44
|
-
info: Info,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const SEVERITY_LABEL: Record<ErrorBannerSeverity, string> = {
|
|
48
|
-
error: "Error",
|
|
49
|
-
warning: "Warning",
|
|
50
|
-
info: "Information",
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export const ErrorBanner = ({
|
|
54
|
-
id,
|
|
55
|
-
severity,
|
|
56
|
-
title,
|
|
57
|
-
detail,
|
|
58
|
-
action,
|
|
59
|
-
onDismiss,
|
|
60
|
-
}: ErrorBannerProps) => {
|
|
61
|
-
const styles = SEVERITY_STYLES[severity];
|
|
62
|
-
const Icon = SEVERITY_ICONS[severity];
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
<div
|
|
66
|
-
role={severity === "error" ? "alert" : "status"}
|
|
67
|
-
aria-live={severity === "error" ? "assertive" : "polite"}
|
|
68
|
-
className={cn(
|
|
69
|
-
// Opaque, not translucent: a banner overlaps the toolbar and the
|
|
70
|
-
// message list, and see-through text on top of see-through text is
|
|
71
|
-
// unreadable (issue #55).
|
|
72
|
-
"pointer-events-auto flex items-start gap-3 rounded-md border bg-canvas px-3 py-2 shadow-lg",
|
|
73
|
-
styles.container,
|
|
74
|
-
)}
|
|
75
|
-
>
|
|
76
|
-
<Icon
|
|
77
|
-
className={cn("size-5 shrink-0 mt-0.5", styles.icon)}
|
|
78
|
-
aria-hidden="true"
|
|
79
|
-
/>
|
|
80
|
-
<div className="flex-1 min-w-0">
|
|
81
|
-
<p className={cn("text-sm font-medium", styles.title)}>{title}</p>
|
|
82
|
-
{detail && (
|
|
83
|
-
<p className="mt-0.5 text-xs text-fg-muted break-words">{detail}</p>
|
|
84
|
-
)}
|
|
85
|
-
{action && (
|
|
86
|
-
<a
|
|
87
|
-
href={action.href}
|
|
88
|
-
target="_blank"
|
|
89
|
-
rel="noopener noreferrer"
|
|
90
|
-
className="mt-1 inline-block text-xs font-medium text-accent-2 underline underline-offset-2"
|
|
91
|
-
>
|
|
92
|
-
{action.label}
|
|
93
|
-
</a>
|
|
94
|
-
)}
|
|
95
|
-
</div>
|
|
96
|
-
<button
|
|
97
|
-
type="button"
|
|
98
|
-
onClick={() => onDismiss(id)}
|
|
99
|
-
className="shrink-0 rounded-md p-1 text-fg-muted hover:bg-surface-raised hover:text-fg transition-colors"
|
|
100
|
-
aria-label={`Dismiss ${SEVERITY_LABEL[severity].toLowerCase()}`}
|
|
101
|
-
>
|
|
102
|
-
<X className="size-4" aria-hidden="true" />
|
|
103
|
-
</button>
|
|
104
|
-
</div>
|
|
105
|
-
);
|
|
106
|
-
};
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { ErrorBanner } from "./ErrorBanner.js";
|
|
2
|
-
import type { ErrorBannerEntry } from "./error-banners.js";
|
|
3
|
-
|
|
4
|
-
interface ErrorBannerStackProps {
|
|
5
|
-
errors: ErrorBannerEntry[];
|
|
6
|
-
onDismiss: (id: string) => void;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export const ErrorBannerStack = ({
|
|
10
|
-
errors,
|
|
11
|
-
onDismiss,
|
|
12
|
-
}: ErrorBannerStackProps) => {
|
|
13
|
-
if (errors.length === 0) return null;
|
|
14
|
-
|
|
15
|
-
return (
|
|
16
|
-
<section
|
|
17
|
-
aria-label="Notifications"
|
|
18
|
-
className="pointer-events-none fixed inset-x-0 top-0 z-[60] flex flex-col items-center gap-2 px-4 pt-[calc(1rem+env(safe-area-inset-top,0px))] sm:items-end sm:pr-6"
|
|
19
|
-
>
|
|
20
|
-
<div className="flex w-full max-w-md flex-col gap-2">
|
|
21
|
-
{errors.map((entry) => (
|
|
22
|
-
<ErrorBanner
|
|
23
|
-
key={entry.id}
|
|
24
|
-
id={entry.id}
|
|
25
|
-
severity={entry.severity}
|
|
26
|
-
title={entry.title}
|
|
27
|
-
detail={entry.detail}
|
|
28
|
-
action={entry.action}
|
|
29
|
-
onDismiss={onDismiss}
|
|
30
|
-
/>
|
|
31
|
-
))}
|
|
32
|
-
</div>
|
|
33
|
-
</section>
|
|
34
|
-
);
|
|
35
|
-
};
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ErrorState is the shared "this didn't load" surface. Two things matter about
|
|
3
|
-
* it: the message it shows comes from whatever the caller caught (which is
|
|
4
|
-
* rarely an `Error`), and Retry only exists when there is something to retry.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import assert from "node:assert/strict";
|
|
8
|
-
import { describe, it } from "node:test";
|
|
9
|
-
import React, { createElement } from "react";
|
|
10
|
-
import { renderToString } from "react-dom/server";
|
|
11
|
-
import { ErrorState, formatErrorMessage } from "./ErrorState";
|
|
12
|
-
|
|
13
|
-
(globalThis as { React?: typeof React }).React = React;
|
|
14
|
-
|
|
15
|
-
const render = (props: Parameters<typeof ErrorState>[0]): string =>
|
|
16
|
-
renderToString(createElement(ErrorState, props) as never);
|
|
17
|
-
|
|
18
|
-
describe("formatErrorMessage", () => {
|
|
19
|
-
it("uses an Error's message", () => {
|
|
20
|
-
assert.equal(
|
|
21
|
-
formatErrorMessage(new Error("imap timed out")),
|
|
22
|
-
"imap timed out",
|
|
23
|
-
);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("passes a bare string through", () => {
|
|
27
|
-
assert.equal(formatErrorMessage("nope"), "nope");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("reads `message` off a plain object — what a rejected fetch body looks like", () => {
|
|
31
|
-
assert.equal(formatErrorMessage({ message: "Bad Gateway" }), "Bad Gateway");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("falls back for a shape it cannot read", () => {
|
|
35
|
-
assert.equal(
|
|
36
|
-
formatErrorMessage({ status: 500 }),
|
|
37
|
-
"An unexpected error occurred",
|
|
38
|
-
);
|
|
39
|
-
assert.equal(formatErrorMessage(null), "An unexpected error occurred");
|
|
40
|
-
assert.equal(
|
|
41
|
-
formatErrorMessage({ message: 42 }),
|
|
42
|
-
"An unexpected error occurred",
|
|
43
|
-
);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe("ErrorState", () => {
|
|
48
|
-
it("announces itself as an alert and shows the caught message", () => {
|
|
49
|
-
const html = render({ error: new Error("mailbox unreachable") });
|
|
50
|
-
assert.match(html, /role="alert"/);
|
|
51
|
-
assert.match(html, /mailbox unreachable/);
|
|
52
|
-
assert.match(html, /Couldn't load content/);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("takes a caller-supplied title over the default", () => {
|
|
56
|
-
const html = render({ error: "x", title: "Couldn't load folders" });
|
|
57
|
-
assert.match(html, /Couldn't load folders/);
|
|
58
|
-
assert.doesNotMatch(html, /load content/);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("offers no Retry when the caller has no retry to give", () => {
|
|
62
|
-
assert.doesNotMatch(render({ error: "x" }), /Retry/);
|
|
63
|
-
assert.doesNotMatch(render({ error: "x", variant: "inline" }), /Retry/);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("offers Retry in both variants when it can retry", () => {
|
|
67
|
-
const retry = () => undefined;
|
|
68
|
-
assert.match(render({ error: "x", onRetry: retry }), /Retry/);
|
|
69
|
-
assert.match(
|
|
70
|
-
render({ error: "x", onRetry: retry, variant: "inline" }),
|
|
71
|
-
/Retry/,
|
|
72
|
-
);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("renders the inline variant as a single row, not the centred block", () => {
|
|
76
|
-
const inline = render({ error: "x", variant: "inline" });
|
|
77
|
-
const block = render({ error: "x" });
|
|
78
|
-
assert.match(inline, /items-start/);
|
|
79
|
-
assert.match(block, /justify-center/);
|
|
80
|
-
assert.doesNotMatch(inline, /justify-center/);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { AlertCircle } from "lucide-react";
|
|
2
|
-
|
|
3
|
-
interface ErrorStateProps {
|
|
4
|
-
title?: string;
|
|
5
|
-
error: unknown;
|
|
6
|
-
onRetry?: () => void;
|
|
7
|
-
variant?: "block" | "inline";
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export const formatErrorMessage = (error: unknown): string => {
|
|
11
|
-
if (error instanceof Error) return error.message;
|
|
12
|
-
if (typeof error === "string") return error;
|
|
13
|
-
if (
|
|
14
|
-
typeof error === "object" &&
|
|
15
|
-
error !== null &&
|
|
16
|
-
"message" in error &&
|
|
17
|
-
typeof (error as { message: unknown }).message === "string"
|
|
18
|
-
) {
|
|
19
|
-
return (error as { message: string }).message;
|
|
20
|
-
}
|
|
21
|
-
return "An unexpected error occurred";
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export const ErrorState = ({
|
|
25
|
-
title = "Couldn't load content",
|
|
26
|
-
error,
|
|
27
|
-
onRetry,
|
|
28
|
-
variant = "block",
|
|
29
|
-
}: ErrorStateProps) => {
|
|
30
|
-
const message = formatErrorMessage(error);
|
|
31
|
-
|
|
32
|
-
if (variant === "inline") {
|
|
33
|
-
return (
|
|
34
|
-
<div
|
|
35
|
-
role="alert"
|
|
36
|
-
className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm"
|
|
37
|
-
>
|
|
38
|
-
<AlertCircle
|
|
39
|
-
className="size-4 shrink-0 text-danger mt-0.5"
|
|
40
|
-
aria-hidden="true"
|
|
41
|
-
/>
|
|
42
|
-
<div className="flex-1 min-w-0">
|
|
43
|
-
<p className="font-medium text-danger">{title}</p>
|
|
44
|
-
<p className="text-fg-muted mt-0.5 break-words">{message}</p>
|
|
45
|
-
</div>
|
|
46
|
-
{onRetry && (
|
|
47
|
-
<button
|
|
48
|
-
type="button"
|
|
49
|
-
onClick={onRetry}
|
|
50
|
-
className="shrink-0 text-sm font-medium text-accent hover:underline"
|
|
51
|
-
>
|
|
52
|
-
Retry
|
|
53
|
-
</button>
|
|
54
|
-
)}
|
|
55
|
-
</div>
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<div
|
|
61
|
-
role="alert"
|
|
62
|
-
className="flex flex-col items-center justify-center gap-3 p-8 text-center"
|
|
63
|
-
>
|
|
64
|
-
<AlertCircle className="size-8 text-danger" aria-hidden="true" />
|
|
65
|
-
<div>
|
|
66
|
-
<p className="font-medium text-danger">{title}</p>
|
|
67
|
-
<p className="text-sm text-fg-muted mt-1 break-words">{message}</p>
|
|
68
|
-
</div>
|
|
69
|
-
{onRetry && (
|
|
70
|
-
<button
|
|
71
|
-
type="button"
|
|
72
|
-
onClick={onRetry}
|
|
73
|
-
className="inline-flex items-center rounded-md border border-line bg-canvas px-4 py-2 text-sm font-medium hover:bg-surface-raised transition-colors"
|
|
74
|
-
>
|
|
75
|
-
Retry
|
|
76
|
-
</button>
|
|
77
|
-
)}
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
};
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
cn,
|
|
3
|
-
DialogBackdrop,
|
|
4
|
-
Kbd,
|
|
5
|
-
KEY_HINT_GROUPS,
|
|
6
|
-
useModalFocus,
|
|
7
|
-
useOverlayScope,
|
|
8
|
-
} from "@remit/ui";
|
|
9
|
-
import { X } from "lucide-react";
|
|
10
|
-
import { Fragment, useRef } from "react";
|
|
11
|
-
|
|
12
|
-
interface KeyboardShortcutsModalProps {
|
|
13
|
-
isOpen: boolean;
|
|
14
|
-
onClose: () => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The `?` help overlay (#429). Renders the full triage key map from the single
|
|
19
|
-
* source of truth (`@remit/ui`'s keymap) using the `Kbd` component, so the
|
|
20
|
-
* displayed bindings can never drift from what the dispatcher actually routes.
|
|
21
|
-
*/
|
|
22
|
-
export const KeyboardShortcutsModal = ({
|
|
23
|
-
isOpen,
|
|
24
|
-
onClose,
|
|
25
|
-
}: KeyboardShortcutsModalProps) => {
|
|
26
|
-
const modalRef = useRef<HTMLDivElement>(null);
|
|
27
|
-
|
|
28
|
-
useOverlayScope({
|
|
29
|
-
id: "shortcuts",
|
|
30
|
-
open: isOpen,
|
|
31
|
-
answers: { back: onClose },
|
|
32
|
-
});
|
|
33
|
-
useModalFocus(modalRef, isOpen);
|
|
34
|
-
|
|
35
|
-
if (!isOpen) return null;
|
|
36
|
-
|
|
37
|
-
return (
|
|
38
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
39
|
-
<DialogBackdrop
|
|
40
|
-
label="Dismiss keyboard shortcuts"
|
|
41
|
-
onDismiss={onClose}
|
|
42
|
-
className="backdrop-blur-sm"
|
|
43
|
-
/>
|
|
44
|
-
|
|
45
|
-
{/* Modal */}
|
|
46
|
-
<div
|
|
47
|
-
ref={modalRef}
|
|
48
|
-
role="dialog"
|
|
49
|
-
aria-modal="true"
|
|
50
|
-
aria-label="Keyboard shortcuts"
|
|
51
|
-
tabIndex={-1}
|
|
52
|
-
className={cn(
|
|
53
|
-
"relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto",
|
|
54
|
-
"rounded-lg border border-line bg-surface shadow-lg",
|
|
55
|
-
"p-6",
|
|
56
|
-
)}
|
|
57
|
-
>
|
|
58
|
-
{/* Header */}
|
|
59
|
-
<div className="mb-6 flex items-center justify-between">
|
|
60
|
-
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
|
|
61
|
-
<button
|
|
62
|
-
type="button"
|
|
63
|
-
onClick={onClose}
|
|
64
|
-
className="p-1 text-fg-muted transition-colors hover:text-fg"
|
|
65
|
-
aria-label="Close"
|
|
66
|
-
>
|
|
67
|
-
<X className="size-5" />
|
|
68
|
-
</button>
|
|
69
|
-
</div>
|
|
70
|
-
|
|
71
|
-
{/* Sections — two columns on wider modals */}
|
|
72
|
-
<div className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
|
73
|
-
{KEY_HINT_GROUPS.map((group) => (
|
|
74
|
-
<div key={group.title}>
|
|
75
|
-
<h3 className="mb-3 text-sm font-medium text-fg-muted">
|
|
76
|
-
{group.title}
|
|
77
|
-
</h3>
|
|
78
|
-
<div className="space-y-2">
|
|
79
|
-
{group.hints.map((hint) => (
|
|
80
|
-
<div
|
|
81
|
-
key={`${hint.action}-${hint.keys.join("+")}`}
|
|
82
|
-
className="flex items-center justify-between gap-4"
|
|
83
|
-
>
|
|
84
|
-
<span className="text-sm">{hint.description}</span>
|
|
85
|
-
<span className="flex shrink-0 items-center gap-1">
|
|
86
|
-
{hint.keys.map((key, index) => (
|
|
87
|
-
<Fragment key={key}>
|
|
88
|
-
{index > 0 && hint.keys[0] === "g" && (
|
|
89
|
-
<span className="text-2xs text-fg-subtle">
|
|
90
|
-
then
|
|
91
|
-
</span>
|
|
92
|
-
)}
|
|
93
|
-
<Kbd>{key}</Kbd>
|
|
94
|
-
</Fragment>
|
|
95
|
-
))}
|
|
96
|
-
</span>
|
|
97
|
-
</div>
|
|
98
|
-
))}
|
|
99
|
-
</div>
|
|
100
|
-
</div>
|
|
101
|
-
))}
|
|
102
|
-
</div>
|
|
103
|
-
|
|
104
|
-
{/* Footer */}
|
|
105
|
-
<div className="mt-6 border-t border-line pt-4">
|
|
106
|
-
<p className="text-center text-xs text-fg-muted">
|
|
107
|
-
Press <Kbd>Esc</Kbd> to close
|
|
108
|
-
</p>
|
|
109
|
-
</div>
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
);
|
|
113
|
-
};
|