@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.86",
3
+ "version": "0.0.87",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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 < bug && bug < settings && settings < account,
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 the four actions and nothing else", () => {
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,