@remit/ui 0.0.86 → 0.0.87
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/refresh-button.render.test.ts +77 -0
- package/src/components/refresh-button.stories.tsx +77 -0
- package/src/components/refresh-button.tsx +93 -0
- package/src/components/shell-top-bar.render.test.ts +15 -4
- package/src/components/shell-top-bar.stories.tsx +32 -0
- package/src/components/shell-top-bar.tsx +8 -0
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
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 {
|
|
6
|
+
RefreshButton,
|
|
7
|
+
type RefreshButtonProps,
|
|
8
|
+
type RefreshControlState,
|
|
9
|
+
} from "./refresh-button.js";
|
|
10
|
+
|
|
11
|
+
const render = (overrides: Partial<RefreshButtonProps> = {}): string =>
|
|
12
|
+
renderToString(
|
|
13
|
+
createElement(RefreshButton, {
|
|
14
|
+
state: "idle",
|
|
15
|
+
label: "Refresh inbox",
|
|
16
|
+
onRefresh: () => undefined,
|
|
17
|
+
...overrides,
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const stateOf = (html: string): RefreshControlState =>
|
|
22
|
+
html.includes("animate-spin")
|
|
23
|
+
? "refreshing"
|
|
24
|
+
: /text-danger/.test(html)
|
|
25
|
+
? "error"
|
|
26
|
+
: /text-positive/.test(html)
|
|
27
|
+
? "success"
|
|
28
|
+
: "idle";
|
|
29
|
+
|
|
30
|
+
describe("RefreshButton", () => {
|
|
31
|
+
it("names the plain action when idle", () => {
|
|
32
|
+
const html = render();
|
|
33
|
+
assert.match(html, /aria-label="Refresh inbox"/);
|
|
34
|
+
assert.doesNotMatch(html, /aria-busy/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("spins and disables while refreshing", () => {
|
|
38
|
+
const html = render({ state: "refreshing" });
|
|
39
|
+
assert.equal(stateOf(html), "refreshing");
|
|
40
|
+
assert.match(html, /disabled=""/);
|
|
41
|
+
assert.match(html, /aria-busy="true"/);
|
|
42
|
+
assert.match(html, /aria-label="Refresh inbox — refreshing"/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("shows a quiet dot for new mail only at rest", () => {
|
|
46
|
+
const idle = render({ hasUpdate: true });
|
|
47
|
+
assert.match(idle, /aria-label="Refresh inbox — new mail"/);
|
|
48
|
+
assert.match(idle, /bg-accent-2/);
|
|
49
|
+
|
|
50
|
+
const refreshing = render({ state: "refreshing", hasUpdate: true });
|
|
51
|
+
assert.doesNotMatch(refreshing, /bg-accent-2/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("confirms success without claiming a failure", () => {
|
|
55
|
+
const html = render({ state: "success" });
|
|
56
|
+
assert.equal(stateOf(html), "success");
|
|
57
|
+
assert.match(html, /aria-label="Refresh inbox — up to date"/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("names what failed in the tooltip and the accessible name, never silently", () => {
|
|
61
|
+
const html = render({
|
|
62
|
+
state: "error",
|
|
63
|
+
errorMessage: "Could not reach the server",
|
|
64
|
+
});
|
|
65
|
+
assert.equal(stateOf(html), "error");
|
|
66
|
+
assert.match(
|
|
67
|
+
html,
|
|
68
|
+
/aria-label="Refresh inbox — failed: Could not reach the server"/,
|
|
69
|
+
);
|
|
70
|
+
assert.match(html, /title="Could not reach the server"/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("stays clickable on error so the same action can retry", () => {
|
|
74
|
+
const html = render({ state: "error", errorMessage: "Offline" });
|
|
75
|
+
assert.doesNotMatch(html, /disabled=""/);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { RefreshButton } from "./refresh-button.js";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof RefreshButton> = {
|
|
5
|
+
title: "Mail/RefreshButton",
|
|
6
|
+
component: RefreshButton,
|
|
7
|
+
args: {
|
|
8
|
+
label: "Refresh inbox",
|
|
9
|
+
onRefresh: () => undefined,
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
export default meta;
|
|
13
|
+
|
|
14
|
+
type Story = StoryObj<typeof RefreshButton>;
|
|
15
|
+
|
|
16
|
+
/** At rest, nothing to report. */
|
|
17
|
+
export const Idle: Story = {
|
|
18
|
+
args: { state: "idle" },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The background poll found new mail; the dot is the whole message — nothing
|
|
22
|
+
* reloads until this is clicked. */
|
|
23
|
+
export const NewMailAvailable: Story = {
|
|
24
|
+
args: { state: "idle", hasUpdate: true },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A sync round is in flight — the glyph spins and the button won't stack a
|
|
28
|
+
* second click on top of it. */
|
|
29
|
+
export const Refreshing: Story = {
|
|
30
|
+
args: { state: "refreshing" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Confirms the refresh landed with nothing left unresolved. */
|
|
34
|
+
export const Success: Story = {
|
|
35
|
+
args: { state: "success" },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The refresh could not be confirmed — a real reason in the tooltip and the
|
|
39
|
+
* accessible name, and clicking retries the same action. */
|
|
40
|
+
export const Failed: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
state: "error",
|
|
43
|
+
errorMessage: "Couldn't reach the server — check your connection",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** All four states side by side. */
|
|
48
|
+
export const AllStates: Story = {
|
|
49
|
+
render: (args) => (
|
|
50
|
+
<div className="flex items-center gap-4 p-4">
|
|
51
|
+
<div className="flex flex-col items-center gap-1 text-2xs text-fg-muted">
|
|
52
|
+
<RefreshButton {...args} state="idle" />
|
|
53
|
+
Idle
|
|
54
|
+
</div>
|
|
55
|
+
<div className="flex flex-col items-center gap-1 text-2xs text-fg-muted">
|
|
56
|
+
<RefreshButton {...args} state="idle" hasUpdate />
|
|
57
|
+
New mail
|
|
58
|
+
</div>
|
|
59
|
+
<div className="flex flex-col items-center gap-1 text-2xs text-fg-muted">
|
|
60
|
+
<RefreshButton {...args} state="refreshing" />
|
|
61
|
+
Refreshing
|
|
62
|
+
</div>
|
|
63
|
+
<div className="flex flex-col items-center gap-1 text-2xs text-fg-muted">
|
|
64
|
+
<RefreshButton {...args} state="success" />
|
|
65
|
+
Success
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex flex-col items-center gap-1 text-2xs text-fg-muted">
|
|
68
|
+
<RefreshButton
|
|
69
|
+
{...args}
|
|
70
|
+
state="error"
|
|
71
|
+
errorMessage="Couldn't reach the server"
|
|
72
|
+
/>
|
|
73
|
+
Failed
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { AlertCircle, CheckCircle, RefreshCw } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { Button, type ButtonProps } from "./button.js";
|
|
4
|
+
|
|
5
|
+
export type RefreshControlState = "idle" | "refreshing" | "success" | "error";
|
|
6
|
+
|
|
7
|
+
export interface RefreshButtonProps {
|
|
8
|
+
state: RefreshControlState;
|
|
9
|
+
onRefresh: () => void;
|
|
10
|
+
/** What this refreshes, e.g. "Refresh inbox", "Refresh daily brief". Also the
|
|
11
|
+
* retry action's name while `state` is "error". */
|
|
12
|
+
label: string;
|
|
13
|
+
/** What failed and the likely fix, shown as the tooltip and appended to the
|
|
14
|
+
* accessible name. Required once `state` is "error" — a failure with
|
|
15
|
+
* nothing said is a dead button. */
|
|
16
|
+
errorMessage?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Mail arrived since this was last acted on — the once-a-minute background
|
|
19
|
+
* poll's own signal, surfaced as a quiet dot rather than reloading anything.
|
|
20
|
+
* Only shown at rest; a refresh already in flight or just finished has
|
|
21
|
+
* nothing left to point at.
|
|
22
|
+
*/
|
|
23
|
+
hasUpdate?: boolean;
|
|
24
|
+
size?: ButtonProps["size"];
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The one refresh control, shared by the inbox header, the daily brief header
|
|
30
|
+
* and the shell's global refresh — three call sites, one affordance. States:
|
|
31
|
+
*
|
|
32
|
+
* - idle: a plain refresh glyph, optionally carrying the "new mail" dot.
|
|
33
|
+
* - refreshing: the same glyph spinning, disabled — never a second click
|
|
34
|
+
* stacked on the one in flight.
|
|
35
|
+
* - success: a brief checkmark, the caller's cue that nothing was left
|
|
36
|
+
* unresolved (reverts to idle on its own after a beat).
|
|
37
|
+
* - error: an alert glyph naming what failed, in the tooltip and the
|
|
38
|
+
* accessible name; clicking it retries the same action. A refresh that
|
|
39
|
+
* never answers must land here, not spin forever.
|
|
40
|
+
*/
|
|
41
|
+
export function RefreshButton({
|
|
42
|
+
state,
|
|
43
|
+
onRefresh,
|
|
44
|
+
label,
|
|
45
|
+
errorMessage,
|
|
46
|
+
hasUpdate,
|
|
47
|
+
size = "sm",
|
|
48
|
+
className,
|
|
49
|
+
}: RefreshButtonProps) {
|
|
50
|
+
const busy = state === "refreshing";
|
|
51
|
+
const failed = state === "error";
|
|
52
|
+
const succeeded = state === "success";
|
|
53
|
+
|
|
54
|
+
const icon = failed ? (
|
|
55
|
+
<AlertCircle className="size-4 text-danger" />
|
|
56
|
+
) : succeeded ? (
|
|
57
|
+
<CheckCircle className="size-4 text-positive" />
|
|
58
|
+
) : (
|
|
59
|
+
<RefreshCw className={cn("size-4", busy && "animate-spin")} />
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const accessibleLabel = failed
|
|
63
|
+
? `${label} — failed: ${errorMessage ?? "try again"}`
|
|
64
|
+
: busy
|
|
65
|
+
? `${label} — refreshing`
|
|
66
|
+
: succeeded
|
|
67
|
+
? `${label} — up to date`
|
|
68
|
+
: hasUpdate
|
|
69
|
+
? `${label} — new mail`
|
|
70
|
+
: label;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="relative inline-flex">
|
|
74
|
+
<Button
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size={size}
|
|
77
|
+
icon={icon}
|
|
78
|
+
onClick={onRefresh}
|
|
79
|
+
disabled={busy}
|
|
80
|
+
aria-label={accessibleLabel}
|
|
81
|
+
aria-busy={busy || undefined}
|
|
82
|
+
title={failed ? errorMessage : label}
|
|
83
|
+
className={className}
|
|
84
|
+
/>
|
|
85
|
+
{state === "idle" && hasUpdate && (
|
|
86
|
+
<span
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
className="absolute right-1 top-1 size-1.5 rounded-full bg-accent-2"
|
|
89
|
+
/>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -26,26 +26,37 @@ const render = (overrides: Partial<ShellTopBarProps> = {}): string =>
|
|
|
26
26
|
onCompose: () => undefined,
|
|
27
27
|
onReportBug: () => undefined,
|
|
28
28
|
onOpenSettings: () => undefined,
|
|
29
|
+
refreshControl: createElement(
|
|
30
|
+
"div",
|
|
31
|
+
{ "data-testid": "refresh" },
|
|
32
|
+
"refresh",
|
|
33
|
+
),
|
|
29
34
|
account: createElement("div", { "data-testid": "account" }, "account"),
|
|
30
35
|
...overrides,
|
|
31
36
|
}),
|
|
32
37
|
);
|
|
33
38
|
|
|
34
39
|
describe("ShellTopBar", () => {
|
|
35
|
-
it("carries compose, bug report, settings and the account, in that order", () => {
|
|
40
|
+
it("carries compose, bug report, settings, refresh and the account, in that order", () => {
|
|
36
41
|
const html = render();
|
|
37
42
|
const compose = html.indexOf('aria-label="Compose"');
|
|
38
43
|
const bug = html.indexOf('aria-label="Report a bug"');
|
|
39
44
|
const settings = html.indexOf('aria-label="Settings"');
|
|
45
|
+
const refresh = html.indexOf('data-testid="refresh"');
|
|
40
46
|
const account = html.indexOf('data-testid="account"');
|
|
41
|
-
assert.ok(compose > -1 && bug > -1 && settings > -1 && account > -1);
|
|
42
47
|
assert.ok(
|
|
43
|
-
compose
|
|
48
|
+
compose > -1 && bug > -1 && settings > -1 && refresh > -1 && account > -1,
|
|
49
|
+
);
|
|
50
|
+
assert.ok(
|
|
51
|
+
compose < bug &&
|
|
52
|
+
bug < settings &&
|
|
53
|
+
settings < refresh &&
|
|
54
|
+
refresh < account,
|
|
44
55
|
"actions render in reading order",
|
|
45
56
|
);
|
|
46
57
|
});
|
|
47
58
|
|
|
48
|
-
it("carries exactly
|
|
59
|
+
it("carries exactly these aria-labelled elements and nothing else — the refresh control and account slot are opaque children, named by whatever the caller passes in", () => {
|
|
49
60
|
const labels = [...render().matchAll(/aria-label="([^"]+)"/g)].map(
|
|
50
61
|
(match) => match[1],
|
|
51
62
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { Avatar } from "./avatar.js";
|
|
4
|
+
import { RefreshButton, type RefreshControlState } from "./refresh-button.js";
|
|
4
5
|
import type { SearchChip } from "./search-chip-input.js";
|
|
5
6
|
import { type ShellSearchScope, ShellTopBar } from "./shell-top-bar.js";
|
|
6
7
|
|
|
@@ -21,12 +22,25 @@ const Account = () => (
|
|
|
21
22
|
</button>
|
|
22
23
|
);
|
|
23
24
|
|
|
25
|
+
const GlobalRefresh = ({ state = "idle" }: { state?: RefreshControlState }) => (
|
|
26
|
+
<RefreshButton
|
|
27
|
+
state={state}
|
|
28
|
+
label="Refresh all accounts"
|
|
29
|
+
errorMessage={
|
|
30
|
+
state === "error" ? "2 of 3 accounts couldn't be reached" : undefined
|
|
31
|
+
}
|
|
32
|
+
onRefresh={() => undefined}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
|
|
24
36
|
const Bar = ({
|
|
25
37
|
initialChips,
|
|
26
38
|
scope = "global",
|
|
39
|
+
refreshState = "idle",
|
|
27
40
|
}: {
|
|
28
41
|
initialChips?: SearchChip[];
|
|
29
42
|
scope?: ShellSearchScope;
|
|
43
|
+
refreshState?: RefreshControlState;
|
|
30
44
|
}) => {
|
|
31
45
|
const [chips, setChips] = useState<SearchChip[] | undefined>(initialChips);
|
|
32
46
|
const [value, setValue] = useState("");
|
|
@@ -48,6 +62,7 @@ const Bar = ({
|
|
|
48
62
|
onReportBug={() => undefined}
|
|
49
63
|
onOpenSettings={() => undefined}
|
|
50
64
|
composeShortcut="c"
|
|
65
|
+
refreshControl={<GlobalRefresh state={refreshState} />}
|
|
51
66
|
account={<Account />}
|
|
52
67
|
/>
|
|
53
68
|
);
|
|
@@ -92,6 +107,7 @@ export const ScopePending: Story = {
|
|
|
92
107
|
onReportBug={() => undefined}
|
|
93
108
|
onOpenSettings={() => undefined}
|
|
94
109
|
composeShortcut="c"
|
|
110
|
+
refreshControl={<GlobalRefresh />}
|
|
95
111
|
account={<Account />}
|
|
96
112
|
/>
|
|
97
113
|
),
|
|
@@ -111,6 +127,22 @@ export const ScopedToFlagged: Story = {
|
|
|
111
127
|
),
|
|
112
128
|
};
|
|
113
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The global refresh mid-flight — every connected account syncing at once,
|
|
132
|
+
* next to the avatar it sits beside.
|
|
133
|
+
*/
|
|
134
|
+
export const RefreshingAllAccounts: Story = {
|
|
135
|
+
render: () => <Bar refreshState="refreshing" />,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* At least one account couldn't be reached. The tooltip and accessible name
|
|
140
|
+
* say so; the button stays clickable to retry.
|
|
141
|
+
*/
|
|
142
|
+
export const RefreshFailed: Story = {
|
|
143
|
+
render: () => <Bar refreshState="error" />,
|
|
144
|
+
};
|
|
145
|
+
|
|
114
146
|
/** The arrangement: one bar across the top of the shell, over every pane. */
|
|
115
147
|
export const OverTheLayout: Story = {
|
|
116
148
|
render: () => (
|
|
@@ -64,6 +64,12 @@ export interface ShellTopBarProps {
|
|
|
64
64
|
* `g then b`. The tooltip's wording around it is this component's.
|
|
65
65
|
*/
|
|
66
66
|
composeShortcut?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The global refresh — every connected account, one control, next to the
|
|
69
|
+
* avatar it sits beside. An element, like `account`: the app owns which
|
|
70
|
+
* accounts it reaches and what refreshing them means.
|
|
71
|
+
*/
|
|
72
|
+
refreshControl: ReactNode;
|
|
67
73
|
/**
|
|
68
74
|
* The account control at the bar's trailing edge. An element rather than
|
|
69
75
|
* data: the app hangs a signed-in session and its sign-out off it.
|
|
@@ -77,6 +83,7 @@ export function ShellTopBar({
|
|
|
77
83
|
onReportBug,
|
|
78
84
|
onOpenSettings,
|
|
79
85
|
composeShortcut,
|
|
86
|
+
refreshControl,
|
|
80
87
|
account,
|
|
81
88
|
}: ShellTopBarProps) {
|
|
82
89
|
return (
|
|
@@ -119,6 +126,7 @@ export function ShellTopBar({
|
|
|
119
126
|
aria-label="Settings"
|
|
120
127
|
onClick={onOpenSettings}
|
|
121
128
|
/>
|
|
129
|
+
{refreshControl}
|
|
122
130
|
{account}
|
|
123
131
|
</>
|
|
124
132
|
}
|
package/src/index.ts
CHANGED
|
@@ -407,6 +407,11 @@ export {
|
|
|
407
407
|
ReadingPaneEmpty,
|
|
408
408
|
type ReadingPaneEmptyProps,
|
|
409
409
|
} from "./components/reading-pane-empty.js";
|
|
410
|
+
export {
|
|
411
|
+
RefreshButton,
|
|
412
|
+
type RefreshButtonProps,
|
|
413
|
+
type RefreshControlState,
|
|
414
|
+
} from "./components/refresh-button.js";
|
|
410
415
|
export {
|
|
411
416
|
RescueBanner,
|
|
412
417
|
type RescueBannerProps,
|