@remit/ui 0.0.162 → 0.0.164

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.162",
3
+ "version": "0.0.164",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { useState } from "react";
3
+ import { expect, userEvent, within } from "storybook/test";
3
4
  import { CalendarList } from "./calendar-list.js";
4
5
  import type { CalendarDescriptor } from "./calendar-types.js";
5
6
 
@@ -127,4 +128,81 @@ export const AllHidden: Story = {
127
128
  onToggle={() => {}}
128
129
  />
129
130
  ),
131
+ play: async ({ canvasElement }) => {
132
+ const canvas = within(canvasElement);
133
+ for (const box of canvas.getAllByRole("checkbox")) {
134
+ await expect(box).not.toBeChecked();
135
+ }
136
+ await expect(canvas.getByText("Travel")).toBeVisible();
137
+ },
138
+ };
139
+
140
+ /**
141
+ * One account with one calendar in it, which is what a new install looks like.
142
+ * The control is still the legend, so it stays on screen: a grid whose single
143
+ * colour is unexplained is no more readable than one with six.
144
+ */
145
+ export const SingleCalendar: Story = {
146
+ render: () => {
147
+ const only = calendars.slice(0, 1);
148
+ const [visible, setVisible] = useState(new Set([only[0].id]));
149
+ return (
150
+ <CalendarList
151
+ calendars={only}
152
+ visible={visible}
153
+ onToggle={(id) =>
154
+ setVisible((prev) => {
155
+ const next = new Set(prev);
156
+ if (!next.delete(id)) next.add(id);
157
+ return next;
158
+ })
159
+ }
160
+ />
161
+ );
162
+ },
163
+ play: async ({ canvasElement }) => {
164
+ const canvas = within(canvasElement);
165
+ const northwind = canvas.getByRole("checkbox", { name: "Northwind" });
166
+ await expect(northwind).toBeChecked();
167
+ await userEvent.click(canvas.getByText("Northwind"));
168
+ await expect(northwind).not.toBeChecked();
169
+ },
170
+ };
171
+
172
+ /**
173
+ * The same control on a surface with no room for a rail: a scrolling row of
174
+ * chips at thumb size. It is laid out differently and it is not a popover —
175
+ * turning a calendar off stays one press away from the grid.
176
+ */
177
+ export const AsAStrip: Story = {
178
+ render: () => {
179
+ const [visible, setVisible] = useState(new Set(calendars.map((c) => c.id)));
180
+ return (
181
+ <CalendarList
182
+ calendars={calendars}
183
+ layout="strip"
184
+ touch
185
+ visible={visible}
186
+ onToggle={(id) =>
187
+ setVisible((prev) => {
188
+ const next = new Set(prev);
189
+ if (!next.delete(id)) next.add(id);
190
+ return next;
191
+ })
192
+ }
193
+ />
194
+ );
195
+ },
196
+ play: async ({ canvasElement }) => {
197
+ const canvas = within(canvasElement);
198
+ await expect(canvas.getAllByRole("checkbox")).toHaveLength(
199
+ calendars.length,
200
+ );
201
+ const onCall = canvas.getByRole("checkbox", { name: "On-call" });
202
+ await userEvent.click(canvas.getByText("On-call"));
203
+ await expect(onCall).not.toBeChecked();
204
+ await expect(
205
+ canvas.getByRole("checkbox", { name: "Northwind" }),
206
+ ).toBeChecked();
207
+ },
130
208
  };
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { useState } from "react";
3
+ import { expect, userEvent, within } from "storybook/test";
3
4
  import { CalendarDateNav, CalendarViewSwitch } from "./calendar-toolbar.js";
4
5
  import type { CalendarViewId } from "./calendar-types.js";
5
6
 
@@ -52,4 +53,76 @@ export const DateNav: Story = {
52
53
  </div>
53
54
  );
54
55
  },
56
+ play: async ({ canvasElement }) => {
57
+ const canvas = within(canvasElement);
58
+ for (const name of ["Previous", "Next", "Today"]) {
59
+ await expect(canvas.getByRole("button", { name })).toBeVisible();
60
+ }
61
+ await userEvent.click(canvas.getByRole("radio", { name: "Day" }));
62
+ await expect(canvas.getByRole("radio", { name: "Day" })).toBeChecked();
63
+ },
64
+ };
65
+
66
+ /**
67
+ * The bar at a phone's width, where the whole ladder does not fit and a year
68
+ * grid would be unreadable anyway. Back, forward and Today stay: they are how
69
+ * the calendar is moved, and a toolbar that dropped them to make room would
70
+ * leave the reader stuck on whatever week they opened on.
71
+ */
72
+ export const OnAPhone: Story = {
73
+ render: () => {
74
+ const [view, setView] = useState<CalendarViewId>("day");
75
+ return (
76
+ <div className="w-[390px] rounded-lg border border-line bg-surface p-2">
77
+ <CalendarDateNav
78
+ title="Wed 10 June 2026"
79
+ onPrev={() => {}}
80
+ onNext={() => {}}
81
+ onToday={() => {}}
82
+ touch
83
+ >
84
+ <CalendarViewSwitch
85
+ value={view}
86
+ onChange={setView}
87
+ views={["day", "agenda"]}
88
+ touch
89
+ />
90
+ </CalendarDateNav>
91
+ </div>
92
+ );
93
+ },
94
+ play: async ({ canvasElement }) => {
95
+ const canvas = within(canvasElement);
96
+ for (const name of ["Previous", "Next", "Today"]) {
97
+ await expect(canvas.getByRole("button", { name })).toBeVisible();
98
+ }
99
+ await expect(canvas.queryByRole("radio", { name: "Y" })).toBeNull();
100
+ await userEvent.click(canvas.getByRole("radio", { name: "List" }));
101
+ await expect(canvas.getByRole("radio", { name: "List" })).toBeChecked();
102
+ },
103
+ };
104
+
105
+ /**
106
+ * A range whose name is longer than the room it has. The title gives way, not
107
+ * the controls: it is the one thing on the bar that can be read off the grid
108
+ * underneath it.
109
+ */
110
+ export const LongRangeTitle: Story = {
111
+ render: () => (
112
+ <div className="w-[420px] rounded-lg border border-line bg-surface p-2">
113
+ <CalendarDateNav
114
+ title="29 December 2025 – 4 January 2026, week 1"
115
+ onPrev={() => {}}
116
+ onNext={() => {}}
117
+ onToday={() => {}}
118
+ >
119
+ <CalendarViewSwitch value="week" onChange={() => {}} />
120
+ </CalendarDateNav>
121
+ </div>
122
+ ),
123
+ play: async ({ canvasElement }) => {
124
+ const canvas = within(canvasElement);
125
+ await expect(canvas.getByRole("radio", { name: "Week" })).toBeChecked();
126
+ await expect(canvas.getByRole("button", { name: "Today" })).toBeVisible();
127
+ },
55
128
  };
@@ -1,8 +1,9 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { AtSign, Inbox, Server } from "lucide-react";
3
3
  import { useState } from "react";
4
+ import { Button } from "./button.js";
4
5
  import type { ServerSecurity } from "./security-select.js";
5
- import { ConnectorTile, ServerFields } from "./wizard.js";
6
+ import { ConnectorTile, ServerFields, WizardShell } from "./wizard.js";
6
7
 
7
8
  const meta: Meta = {
8
9
  title: "Components/Wizard",
@@ -101,3 +102,53 @@ export const ServerFieldsPhone: Story = {
101
102
  globals: { viewport: { value: "mobile" } },
102
103
  render: () => <ServerFieldsDemo />,
103
104
  };
105
+
106
+ const longStepRows = Array.from(
107
+ { length: 60 },
108
+ (_, index) => `Row ${index + 1} of a report nobody sized the box for`,
109
+ );
110
+
111
+ function LongStep() {
112
+ return (
113
+ <WizardShell
114
+ steps={["File", "Review", "Credentials", "Folders"]}
115
+ activeStep={1}
116
+ title="What this file will change"
117
+ subtitle="A step whose content runs well past the bottom of the screen."
118
+ footer={
119
+ <>
120
+ <Button variant="ghost">Back</Button>
121
+ <Button variant="primary">Continue</Button>
122
+ </>
123
+ }
124
+ >
125
+ <ul className="divide-y divide-line">
126
+ {longStepRows.map((row) => (
127
+ <li key={row} className="py-2 text-sm text-fg">
128
+ {row}
129
+ </li>
130
+ ))}
131
+ </ul>
132
+ </WizardShell>
133
+ );
134
+ }
135
+
136
+ /**
137
+ * A step taller than the viewport, on the 1512×827 laptop the config import
138
+ * wizard was found unusable on (#1021). The rows scroll inside the card; the
139
+ * title and the Continue button stay on screen.
140
+ */
141
+ export const ShellLongStepShortViewport: Story = {
142
+ name: "WizardShell — long step, short viewport",
143
+ parameters: { layout: "fullscreen" },
144
+ globals: { viewport: { value: "laptopShort" } },
145
+ render: () => <LongStep />,
146
+ };
147
+
148
+ /** The same step on a phone with the address bar and system nav showing. */
149
+ export const ShellLongStepPhoneShort: Story = {
150
+ name: "WizardShell — long step, short phone",
151
+ parameters: { layout: "fullscreen" },
152
+ globals: { viewport: { value: "mobileShort" } },
153
+ render: () => <LongStep />,
154
+ };
@@ -76,25 +76,40 @@ export function WizardShell({
76
76
  hideSteps,
77
77
  }: WizardShellProps) {
78
78
  return (
79
- // First-run is a focal moment: the wizard column is horizontally
80
- // centered with its top edge anchored ~30% down the viewport
81
- // (optical center) on larger screens. On phone the top padding is
82
- // minimal so taller steps (connector picker, servers) don't push the
83
- // CTA bar below the fold — the shell scrolls if content overflows.
84
- <div className="flex min-h-dvh w-full flex-col items-center overflow-y-auto bg-canvas pb-[max(2rem,env(safe-area-inset-bottom,0px))] pl-[max(2rem,env(safe-area-inset-left,0px))] pr-[max(2rem,env(safe-area-inset-right,0px))] pt-[max(2rem,env(safe-area-inset-top,0px))] font-sans text-fg sm:pt-[30vh]">
79
+ // The shell is exactly the viewport, never more: the step's own content
80
+ // is what scrolls, and the title and the footer actions are pinned
81
+ // either side of it. A dry-run report or a list of accounts can run to
82
+ // any length and the primary action stays where the reader can press it
83
+ // (#1021).
84
+ //
85
+ // First-run is still a focal moment, so on larger screens the column
86
+ // sits with its top edge about 30% down the viewport (optical centre).
87
+ // That offset is a flex spacer rather than top padding because padding
88
+ // cannot yield: the spacer collapses first and the card only gives up
89
+ // height once the offset is gone.
90
+ <div className="flex h-dvh w-full flex-col items-center overflow-hidden bg-canvas pb-[max(2rem,env(safe-area-inset-bottom,0px))] pl-[max(2rem,env(safe-area-inset-left,0px))] pr-[max(2rem,env(safe-area-inset-right,0px))] pt-[max(2rem,env(safe-area-inset-top,0px))] font-sans text-fg">
91
+ <div className="hidden shrink-[9999] basis-[30vh] sm:block" />
85
92
  {!hideSteps && (
86
- <div className="mb-5 w-full max-w-xl">
93
+ <div className="mb-5 w-full max-w-xl shrink-0">
87
94
  <StepRail steps={steps} activeStep={activeStep} />
88
95
  </div>
89
96
  )}
90
- <Card raised className="w-full max-w-xl">
91
- <div className="px-5 pt-5">
97
+ <Card raised className="flex w-full min-h-0 max-w-xl flex-col">
98
+ <div className="shrink-0 px-5 pt-5">
92
99
  <h1 className="text-xl font-semibold text-fg">{title}</h1>
93
100
  {subtitle && <p className="mt-1 text-sm text-fg-muted">{subtitle}</p>}
94
101
  </div>
95
- <div className="px-5 py-5">{children}</div>
102
+ <div
103
+ data-testid="wizard-body"
104
+ className="min-h-0 overflow-y-auto px-5 py-5"
105
+ >
106
+ {children}
107
+ </div>
96
108
  {footer && (
97
- <div className="flex items-center justify-between gap-3 border-t border-line px-5 py-3">
109
+ <div
110
+ data-testid="wizard-footer"
111
+ className="flex shrink-0 items-center justify-between gap-3 border-t border-line px-5 py-3"
112
+ >
98
113
  {footer}
99
114
  </div>
100
115
  )}