@bearmenu/ui 0.8.32 → 0.8.34
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/dist/{chunk-5HHRVJOT.js → chunk-5SGKGFMN.js} +1 -1
- package/dist/{chunk-GESB7GCF.js → chunk-6W25S5CF.js} +1 -1
- package/dist/chunk-LY67SOFC.js +142 -0
- package/dist/{chunk-ZU4NAYN7.js → chunk-NIAL4ZU2.js} +13 -1
- package/dist/{chunk-JWTN7MNK.js → chunk-TVEIUTBQ.js} +2 -2
- package/dist/{chunk-ZJMCU7MI.js → chunk-VCRXL7H5.js} +1 -1
- package/dist/chunk-XLHMF6F5.js +127 -0
- package/dist/{chunk-VSRGEB36.js → chunk-XNBDACL7.js} +1 -1
- package/dist/components/action-bar.d.ts +67 -0
- package/dist/components/action-bar.js +43 -0
- package/dist/components/activity-row.d.ts +38 -0
- package/dist/components/activity-row.js +43 -0
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/avatar.d.ts +4 -1
- package/dist/components/avatar.js +39 -5
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/booking-row.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/carousel.js +1 -1
- package/dist/components/coach-marks.js +1 -1
- package/dist/components/combobox.js +3 -3
- package/dist/components/command.d.ts +15 -15
- package/dist/components/command.js +2 -2
- package/dist/components/concept-convergence.js +2 -2
- package/dist/components/currency-input.js +4 -142
- package/dist/components/dialog.d.ts +6 -0
- package/dist/components/dialog.js +1 -1
- package/dist/components/drawer.d.ts +6 -0
- package/dist/components/drawer.js +3 -127
- package/dist/components/empty-state.d.ts +2 -2
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/hold-countdown.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.js +1 -1
- package/dist/components/menu-index-list.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/menu-pick-row.d.ts +47 -0
- package/dist/components/menu-pick-row.js +49 -0
- package/dist/components/multi-select-combobox.js +4 -4
- package/dist/components/pagination.js +1 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/qr-panel.d.ts +31 -0
- package/dist/components/qr-panel.js +33 -0
- package/dist/components/reconcile-bar.d.ts +47 -0
- package/dist/components/reconcile-bar.js +60 -0
- package/dist/components/responsive-overlay.d.ts +59 -0
- package/dist/components/responsive-overlay.js +132 -0
- package/dist/components/searchable-select.js +3 -3
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/split-share-row.d.ts +77 -0
- package/dist/components/split-share-row.js +101 -0
- package/dist/components/table-ledger-card.d.ts +80 -0
- package/dist/components/table-ledger-card.js +84 -0
- package/dist/components/table-trigger-band.d.ts +56 -0
- package/dist/components/table-trigger-band.js +43 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/dist/components/venue-events-block.js +1 -1
- package/package.json +10 -1
- package/src/components/action-bar.stories.tsx +78 -0
- package/src/components/action-bar.tsx +80 -0
- package/src/components/activity-row.stories.tsx +78 -0
- package/src/components/activity-row.tsx +65 -0
- package/src/components/avatar.stories.tsx +55 -0
- package/src/components/avatar.tsx +63 -19
- package/src/components/badge.stories.tsx +41 -0
- package/src/components/badge.tsx +13 -0
- package/src/components/button.tsx +1 -1
- package/src/components/dialog.tsx +8 -2
- package/src/components/drawer.tsx +8 -2
- package/src/components/hold-countdown.tsx +1 -1
- package/src/components/menu-pick-row.stories.tsx +71 -0
- package/src/components/menu-pick-row.tsx +76 -0
- package/src/components/qr-panel.stories.tsx +39 -0
- package/src/components/qr-panel.tsx +52 -0
- package/src/components/reconcile-bar.stories.tsx +57 -0
- package/src/components/reconcile-bar.tsx +80 -0
- package/src/components/responsive-overlay.stories.tsx +91 -0
- package/src/components/responsive-overlay.test.tsx +162 -0
- package/src/components/responsive-overlay.tsx +232 -0
- package/src/components/shared-table-primitives.test.tsx +482 -0
- package/src/components/split-share-row.stories.tsx +114 -0
- package/src/components/split-share-row.tsx +150 -0
- package/src/components/table-ledger-card.stories.tsx +120 -0
- package/src/components/table-ledger-card.tsx +138 -0
- package/src/components/table-trigger-band.stories.tsx +76 -0
- package/src/components/table-trigger-band.tsx +75 -0
- package/src/globals.css +62 -0
- package/src/test/setup.ts +11 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen, within } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { Users } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { ActionBar } from "./action-bar";
|
|
7
|
+
import { ActivityList, ActivityRow } from "./activity-row";
|
|
8
|
+
import { Avatar, AvatarFallback, avatarVariants } from "./avatar";
|
|
9
|
+
import { badgeVariants } from "./badge";
|
|
10
|
+
import { MenuPickRow } from "./menu-pick-row";
|
|
11
|
+
import { QrPanel } from "./qr-panel";
|
|
12
|
+
import { ReconcileBar } from "./reconcile-bar";
|
|
13
|
+
import { SplitShareRow } from "./split-share-row";
|
|
14
|
+
import { TableLedgerCard } from "./table-ledger-card";
|
|
15
|
+
import { TableTriggerBand } from "./table-trigger-band";
|
|
16
|
+
|
|
17
|
+
describe("avatar tone", () => {
|
|
18
|
+
it("paints the tone on the root and lets the fallback carry white ink", () => {
|
|
19
|
+
render(
|
|
20
|
+
<Avatar tone="teal" data-testid="a">
|
|
21
|
+
<AvatarFallback>B</AvatarFallback>
|
|
22
|
+
</Avatar>
|
|
23
|
+
);
|
|
24
|
+
const root = screen.getByTestId("a");
|
|
25
|
+
expect(root.className).toContain("bg-avatar-teal");
|
|
26
|
+
expect(root).toHaveAttribute("data-tone", "teal");
|
|
27
|
+
|
|
28
|
+
const fallback = screen.getByText("B");
|
|
29
|
+
expect(fallback.className).toContain("text-white");
|
|
30
|
+
// The fallback's own ground would cover the tone.
|
|
31
|
+
expect(fallback.className).not.toContain("bg-muted");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("keeps the muted ink for the non-person disc", () => {
|
|
35
|
+
render(
|
|
36
|
+
<Avatar tone="muted">
|
|
37
|
+
<AvatarFallback>S</AvatarFallback>
|
|
38
|
+
</Avatar>
|
|
39
|
+
);
|
|
40
|
+
const fallback = screen.getByText("S");
|
|
41
|
+
expect(fallback.className).toContain("text-muted-foreground");
|
|
42
|
+
expect(fallback.className).not.toContain("text-white");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("leaves an untoned avatar exactly as it was", () => {
|
|
46
|
+
render(
|
|
47
|
+
<Avatar data-testid="plain">
|
|
48
|
+
<AvatarFallback>P</AvatarFallback>
|
|
49
|
+
</Avatar>
|
|
50
|
+
);
|
|
51
|
+
expect(screen.getByTestId("plain")).not.toHaveAttribute("data-tone");
|
|
52
|
+
const fallback = screen.getByText("P");
|
|
53
|
+
expect(fallback.className).toContain("bg-muted");
|
|
54
|
+
expect(fallback.className).toContain("text-muted-foreground");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("offers six person hues plus the non-person disc", () => {
|
|
58
|
+
for (const tone of ["brown", "teal", "green", "blue", "violet", "rose", "muted"] as const) {
|
|
59
|
+
expect(cn(avatarVariants({ tone }))).toContain(
|
|
60
|
+
tone === "muted" ? "bg-muted" : `bg-avatar-${tone}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("badge assignee", () => {
|
|
67
|
+
it("is a soft pill with room for its avatar and chevron", () => {
|
|
68
|
+
const classes = cn(badgeVariants({ variant: "assignee" })).split(" ");
|
|
69
|
+
expect(classes).toContain("bg-surface-2");
|
|
70
|
+
expect(classes).toContain("rounded-full");
|
|
71
|
+
expect(classes).toContain("gap-1.5");
|
|
72
|
+
// Not a status pill: it must not inherit the 10.5px bold sizing.
|
|
73
|
+
expect(classes).not.toContain("text-[10.5px]");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("ActionBar", () => {
|
|
78
|
+
it("puts the note first and keeps the actions together on the trailing edge", () => {
|
|
79
|
+
render(
|
|
80
|
+
<ActionBar
|
|
81
|
+
note="Added items can be assigned afterwards."
|
|
82
|
+
primary={<button type="button">Add 1 item</button>}
|
|
83
|
+
data-testid="bar"
|
|
84
|
+
/>
|
|
85
|
+
);
|
|
86
|
+
const bar = screen.getByTestId("bar");
|
|
87
|
+
expect(bar.firstElementChild).toHaveAttribute("data-slot", "action-bar-note");
|
|
88
|
+
const actions = bar.querySelector('[data-slot="action-bar-actions"]');
|
|
89
|
+
expect(actions?.className).toContain("shrink-0");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps the actions at their own width by default, note or no note", () => {
|
|
93
|
+
// T5 and T6 draw a lone Save on the trailing edge; only the table
|
|
94
|
+
// overview divides the bar, and that is `layout="split"`.
|
|
95
|
+
render(
|
|
96
|
+
<ActionBar
|
|
97
|
+
secondary={<button type="button">Cancel</button>}
|
|
98
|
+
primary={<button type="button">Review</button>}
|
|
99
|
+
data-testid="bar"
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
const actions = screen.getByTestId("bar").querySelector('[data-slot="action-bar-actions"]');
|
|
103
|
+
expect(actions?.className).toContain("ml-auto");
|
|
104
|
+
expect(actions?.className).toContain("shrink-0");
|
|
105
|
+
expect(actions?.className).not.toContain("flex-1");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("divides the bar between two equal buttons on layout=split", () => {
|
|
109
|
+
render(
|
|
110
|
+
<ActionBar
|
|
111
|
+
layout="split"
|
|
112
|
+
secondary={<button type="button">Add item</button>}
|
|
113
|
+
primary={<button type="button">Organise</button>}
|
|
114
|
+
data-testid="bar"
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
const actions = screen.getByTestId("bar").querySelector('[data-slot="action-bar-actions"]');
|
|
118
|
+
expect(actions?.className).toContain("flex-1");
|
|
119
|
+
expect(actions?.className).not.toContain("ml-auto");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("renders the primary last, whatever else is in the bar", () => {
|
|
123
|
+
render(
|
|
124
|
+
<ActionBar
|
|
125
|
+
secondary={<button type="button">Cancel</button>}
|
|
126
|
+
primary={<button type="button">Review</button>}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
const buttons = screen.getAllByRole("button");
|
|
130
|
+
expect(buttons.map((b) => b.textContent)).toEqual(["Cancel", "Review"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("owns the home-indicator inset so no caller has to remember it", () => {
|
|
134
|
+
render(<ActionBar primary={<button type="button">Save</button>} data-testid="bar" />);
|
|
135
|
+
expect(screen.getByTestId("bar").className).toContain(
|
|
136
|
+
"pb-[max(env(safe-area-inset-bottom),12px)]"
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("ReconcileBar", () => {
|
|
142
|
+
it("states the running figure and the target, in tabular digits", () => {
|
|
143
|
+
render(<ReconcileBar tone="ok" message="The amounts add up" current="120" target="120" />);
|
|
144
|
+
const figures = screen.getByText("/").parentElement;
|
|
145
|
+
expect(figures?.className).toContain("tabular-nums");
|
|
146
|
+
expect(figures?.textContent).toBe("120/120");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("takes the warning ground from the tokens, with no dark branch of its own", () => {
|
|
150
|
+
const { container } = render(
|
|
151
|
+
<ReconcileBar tone="short" message="10 lei short" current="110" target="120" />
|
|
152
|
+
);
|
|
153
|
+
const bar = container.querySelector('[data-slot="reconcile-bar"]');
|
|
154
|
+
expect(bar).toHaveAttribute("data-tone", "short");
|
|
155
|
+
// `--warning-deep` and `--warning-ink` both have dark twins already.
|
|
156
|
+
expect(bar?.className).toContain("bg-warning-deep/15");
|
|
157
|
+
expect(bar?.className).toContain("text-warning-ink");
|
|
158
|
+
expect(bar?.className).not.toContain("dark:");
|
|
159
|
+
expect(bar?.className).not.toContain("oklch(");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("SplitShareRow", () => {
|
|
164
|
+
it("includes and excludes a person through the checkbox", async () => {
|
|
165
|
+
const onCheckedChange = vi.fn();
|
|
166
|
+
render(
|
|
167
|
+
<SplitShareRow
|
|
168
|
+
person="Ana"
|
|
169
|
+
amount="40 lei"
|
|
170
|
+
checked
|
|
171
|
+
onCheckedChange={onCheckedChange}
|
|
172
|
+
includeLabel="Include Ana in the split"
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
await userEvent.click(screen.getByRole("checkbox", { name: "Include Ana in the split" }));
|
|
176
|
+
expect(onCheckedChange).toHaveBeenCalledWith(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("mutes only the amount when someone is out of the split", () => {
|
|
180
|
+
const { container } = render(
|
|
181
|
+
<SplitShareRow
|
|
182
|
+
person="Bruno"
|
|
183
|
+
amount="0 lei"
|
|
184
|
+
checked={false}
|
|
185
|
+
onCheckedChange={() => {}}
|
|
186
|
+
includeLabel="Include Bruno"
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
189
|
+
// Dimming the whole row would take the name under 4.5:1 with it.
|
|
190
|
+
expect(container.querySelector('[data-slot="split-share-row"]')?.className).not.toContain(
|
|
191
|
+
"opacity-60"
|
|
192
|
+
);
|
|
193
|
+
expect(container.querySelector('[data-slot="split-share-amount"]')?.className).toContain(
|
|
194
|
+
"text-muted-foreground"
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("renders no checkbox for a row that cannot be excluded", () => {
|
|
199
|
+
render(<SplitShareRow person="Ana" amount="40 lei" />);
|
|
200
|
+
expect(screen.queryByRole("checkbox")).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("names the editable amount, since CurrencyInput takes no aria-label", () => {
|
|
204
|
+
render(
|
|
205
|
+
<SplitShareRow
|
|
206
|
+
person="Ana"
|
|
207
|
+
amount="40 lei"
|
|
208
|
+
editable
|
|
209
|
+
amountValue={40}
|
|
210
|
+
onAmountChange={() => {}}
|
|
211
|
+
amountLabel="Ana's share"
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
expect(screen.getByLabelText("Ana's share")).toBeInTheDocument();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("flips the check from trailing and round to leading and square at md", () => {
|
|
218
|
+
const { container } = render(
|
|
219
|
+
<SplitShareRow
|
|
220
|
+
person="Ana"
|
|
221
|
+
amount="40 lei"
|
|
222
|
+
checked
|
|
223
|
+
onCheckedChange={() => {}}
|
|
224
|
+
includeLabel="Include Ana"
|
|
225
|
+
/>
|
|
226
|
+
);
|
|
227
|
+
const box = screen.getByRole("checkbox");
|
|
228
|
+
expect(box.className).toContain("rounded-full");
|
|
229
|
+
expect(box.className).toContain("md:order-1");
|
|
230
|
+
expect(box.className).toContain("md:rounded-md");
|
|
231
|
+
// Last in the DOM, where the sheet paints it, so tab order follows the eye.
|
|
232
|
+
const row = container.querySelector('[data-slot="split-share-row"]');
|
|
233
|
+
expect(row?.lastElementChild).toBe(box);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("keeps a 44px target on the checkbox at both widths", () => {
|
|
237
|
+
render(
|
|
238
|
+
<SplitShareRow
|
|
239
|
+
person="Ana"
|
|
240
|
+
amount="40 lei"
|
|
241
|
+
checked
|
|
242
|
+
onCheckedChange={() => {}}
|
|
243
|
+
includeLabel="Include Ana"
|
|
244
|
+
/>
|
|
245
|
+
);
|
|
246
|
+
// 24px box + 2×10px on mobile; 20px box + 2×12px from md.
|
|
247
|
+
const box = screen.getByRole("checkbox");
|
|
248
|
+
expect(box.className).toContain("before:-inset-2.5");
|
|
249
|
+
expect(box.className).toContain("md:before:-inset-3");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("gives the editable amount a hairline and a focus ring so it reads as a field", () => {
|
|
253
|
+
render(
|
|
254
|
+
<SplitShareRow
|
|
255
|
+
person="Ana"
|
|
256
|
+
amount="40 lei"
|
|
257
|
+
editable
|
|
258
|
+
amountValue={40}
|
|
259
|
+
onAmountChange={() => {}}
|
|
260
|
+
amountLabel="Ana's share"
|
|
261
|
+
/>
|
|
262
|
+
);
|
|
263
|
+
const input = screen.getByLabelText("Ana's share");
|
|
264
|
+
expect(input.className).toContain("border-border");
|
|
265
|
+
expect(input.className).toContain("focus-within:ring-2");
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("money type", () => {
|
|
270
|
+
it("sets the numeral in the bold sans, never in mono", () => {
|
|
271
|
+
// The board draws the numeral in the bold sans and only the currency
|
|
272
|
+
// suffix in mono; `font-mono` on the slot would change the numeral's face.
|
|
273
|
+
render(
|
|
274
|
+
<TableLedgerCard
|
|
275
|
+
person="Adrian"
|
|
276
|
+
total="96"
|
|
277
|
+
rows={[{ id: "1", label: "Ciorbă de burtă", amount: "32" }]}
|
|
278
|
+
/>
|
|
279
|
+
);
|
|
280
|
+
expect(screen.getByText("96").className).not.toContain("font-mono");
|
|
281
|
+
expect(screen.getByText("96").className).toContain("tabular-nums");
|
|
282
|
+
expect(screen.getByText("32").className).not.toContain("font-mono");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("does the same in the pick row and the reconcile bar", () => {
|
|
286
|
+
render(<MenuPickRow label="Papanași" price="28" addLabel="Add it" />);
|
|
287
|
+
expect(screen.getByText("28").className).not.toContain("font-mono");
|
|
288
|
+
|
|
289
|
+
render(<ReconcileBar tone="ok" message="ok" current="120" target="120" />);
|
|
290
|
+
const figures = screen.getByText("/").parentElement;
|
|
291
|
+
expect(figures?.className).not.toContain("font-mono");
|
|
292
|
+
expect(figures?.className).toContain("tabular-nums");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("TableLedgerCard", () => {
|
|
297
|
+
it("draws the header, the total and the items", () => {
|
|
298
|
+
render(
|
|
299
|
+
<TableLedgerCard
|
|
300
|
+
person="Adrian"
|
|
301
|
+
meta="2 items + share"
|
|
302
|
+
total="96 lei"
|
|
303
|
+
rows={[
|
|
304
|
+
{ id: "1", label: "Ciorbă de burtă", amount: "32 lei" },
|
|
305
|
+
{ id: "2", label: "Zacuscă de casă", amount: "24 lei" },
|
|
306
|
+
]}
|
|
307
|
+
labels={{ rows: "Adrian's items" }}
|
|
308
|
+
/>
|
|
309
|
+
);
|
|
310
|
+
const list = screen.getByRole("list", { name: "Adrian's items" });
|
|
311
|
+
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
|
312
|
+
expect(screen.getByText("96 lei").className).toContain("tabular-nums");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("marks a remote change in place instead of re-sorting", () => {
|
|
316
|
+
const { container } = render(<TableLedgerCard state="updated" person="Bruno" total="150 lei" />);
|
|
317
|
+
const card = container.querySelector('[data-slot="table-ledger-card"]');
|
|
318
|
+
expect(card).toHaveAttribute("data-state", "updated");
|
|
319
|
+
expect(card?.className).toContain("border-primary");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("takes the dashed accent ground while a drag is over it", () => {
|
|
323
|
+
const { container } = render(<TableLedgerCard state="drop-active" person="Ana" />);
|
|
324
|
+
const card = container.querySelector('[data-slot="table-ledger-card"]');
|
|
325
|
+
expect(card?.className).toContain("border-dashed");
|
|
326
|
+
expect(card?.className).toContain("bg-accent-subtle");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("renders the header alone when there are no rows — the T4 drop target", () => {
|
|
330
|
+
const { container } = render(<TableLedgerCard state="drop-idle" person="Ana" total="28 lei" />);
|
|
331
|
+
expect(container.querySelector('[data-slot="table-ledger-rows"]')).toBeNull();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("lets a row show a per-head line instead of a price", () => {
|
|
335
|
+
render(
|
|
336
|
+
<TableLedgerCard
|
|
337
|
+
person="Shared"
|
|
338
|
+
rows={[{ id: "w", label: "Fetească Neagră", amount: "120 lei", trailing: "40 lei each" }]}
|
|
339
|
+
/>
|
|
340
|
+
);
|
|
341
|
+
expect(screen.getByText("40 lei each")).toBeInTheDocument();
|
|
342
|
+
expect(screen.queryByText("120 lei")).toBeNull();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("TableTriggerBand", () => {
|
|
347
|
+
it("is one link, and the wide screen's action is decorative inside it", () => {
|
|
348
|
+
render(
|
|
349
|
+
<TableTriggerBand
|
|
350
|
+
href="/t/8f3c1a"
|
|
351
|
+
title="You're at Table 18"
|
|
352
|
+
meta="3 people · 367 lei so far"
|
|
353
|
+
action={<span>Open the table</span>}
|
|
354
|
+
/>
|
|
355
|
+
);
|
|
356
|
+
const link = screen.getByRole("link", { name: /You're at Table 18/ });
|
|
357
|
+
expect(link).toHaveAttribute("href", "/t/8f3c1a");
|
|
358
|
+
// One control, not a button nested in a link.
|
|
359
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
360
|
+
expect(link.querySelector('[data-slot="table-trigger-action"]')).toHaveAttribute("aria-hidden");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("takes the app's link component when one is given", () => {
|
|
364
|
+
const Link = ({ href, ...rest }: { href: string }) => <a data-router="" href={href} {...rest} />;
|
|
365
|
+
render(<TableTriggerBand href="/t/1" title="Table 18" linkComponent={Link} />);
|
|
366
|
+
expect(screen.getByRole("link")).toHaveAttribute("data-router");
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe("MenuPickRow", () => {
|
|
371
|
+
it("adds a unit on tap and reports its state to assistive tech", async () => {
|
|
372
|
+
const onToggle = vi.fn();
|
|
373
|
+
render(
|
|
374
|
+
<MenuPickRow
|
|
375
|
+
label="Papanași"
|
|
376
|
+
price="28 lei"
|
|
377
|
+
onToggle={onToggle}
|
|
378
|
+
addLabel="Add Papanași"
|
|
379
|
+
removeLabel="Remove Papanași"
|
|
380
|
+
/>
|
|
381
|
+
);
|
|
382
|
+
const control = screen.getByRole("button", { name: "Add Papanași" });
|
|
383
|
+
expect(control).toHaveAttribute("aria-pressed", "false");
|
|
384
|
+
await userEvent.click(control);
|
|
385
|
+
expect(onToggle).toHaveBeenCalledOnce();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("names the control for removal once the dish is on the table", () => {
|
|
389
|
+
render(
|
|
390
|
+
<MenuPickRow
|
|
391
|
+
label="Papanași"
|
|
392
|
+
price="28 lei"
|
|
393
|
+
selected
|
|
394
|
+
addLabel="Add Papanași"
|
|
395
|
+
removeLabel="Remove Papanași"
|
|
396
|
+
/>
|
|
397
|
+
);
|
|
398
|
+
expect(screen.getByRole("button", { name: "Remove Papanași" })).toHaveAttribute(
|
|
399
|
+
"aria-pressed",
|
|
400
|
+
"true"
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("keeps a 44px target around the drawn disc", () => {
|
|
405
|
+
render(<MenuPickRow label="Papanași" price="28 lei" addLabel="Add it" />);
|
|
406
|
+
expect(screen.getByRole("button").className).toContain("size-11");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("does not make the whole row a target", () => {
|
|
410
|
+
const { container } = render(<MenuPickRow label="Papanași" price="28 lei" addLabel="Add it" />);
|
|
411
|
+
const row = container.querySelector('[data-slot="menu-pick-row"]');
|
|
412
|
+
expect(row?.tagName).toBe("DIV");
|
|
413
|
+
expect(row).not.toHaveAttribute("onclick");
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("ActivityRow", () => {
|
|
418
|
+
it("leads with the actor's avatar when an event has one", () => {
|
|
419
|
+
render(
|
|
420
|
+
<ActivityList>
|
|
421
|
+
<ActivityRow
|
|
422
|
+
avatar={
|
|
423
|
+
<Avatar size="sm" tone="teal">
|
|
424
|
+
<AvatarFallback>B</AvatarFallback>
|
|
425
|
+
</Avatar>
|
|
426
|
+
}
|
|
427
|
+
body="Bruno added Tochitură ardelenească"
|
|
428
|
+
time="9 min ago"
|
|
429
|
+
/>
|
|
430
|
+
</ActivityList>
|
|
431
|
+
);
|
|
432
|
+
expect(screen.getByText("B")).toBeInTheDocument();
|
|
433
|
+
expect(screen.getByText("9 min ago")).toBeInTheDocument();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("falls back to a quiet glyph disc for an event with no actor", () => {
|
|
437
|
+
const { container } = render(
|
|
438
|
+
<ActivityList>
|
|
439
|
+
<ActivityRow icon={<Users aria-hidden />} body="Fetească Neagră split between 3" time="18 min ago" />
|
|
440
|
+
</ActivityList>
|
|
441
|
+
);
|
|
442
|
+
expect(container.querySelector("svg")).toBeInTheDocument();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("is a real list, so a screen reader counts the events", () => {
|
|
446
|
+
render(
|
|
447
|
+
<ActivityList aria-label="Table activity">
|
|
448
|
+
<ActivityRow body="one" time="now" />
|
|
449
|
+
<ActivityRow body="two" time="now" />
|
|
450
|
+
</ActivityList>
|
|
451
|
+
);
|
|
452
|
+
const list = screen.getByRole("list", { name: "Table activity" });
|
|
453
|
+
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe("QrPanel", () => {
|
|
458
|
+
it("keeps the light plate in dark mode, because a reader needs it to decode", () => {
|
|
459
|
+
const { container } = render(
|
|
460
|
+
<QrPanel>
|
|
461
|
+
<svg />
|
|
462
|
+
</QrPanel>
|
|
463
|
+
);
|
|
464
|
+
const plate = container.querySelector('[data-slot="qr-panel-plate"]');
|
|
465
|
+
expect(plate?.className).toContain("bg-white");
|
|
466
|
+
expect(plate?.className).not.toContain("dark:");
|
|
467
|
+
// A themed border would be a dark ring on an always-white plate.
|
|
468
|
+
expect(plate?.className).toContain("border-black/10");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("puts the caption outside the plate, on the sheet's own ground", () => {
|
|
472
|
+
const { container } = render(
|
|
473
|
+
<QrPanel caption="Scan with a phone camera to join">
|
|
474
|
+
<svg />
|
|
475
|
+
</QrPanel>
|
|
476
|
+
);
|
|
477
|
+
const caption = screen.getByText("Scan with a phone camera to join");
|
|
478
|
+
expect(container.querySelector('[data-slot="qr-panel-plate"]')).not.toContainElement(caption);
|
|
479
|
+
// Outside the white plate it can use the theme's own muted ink.
|
|
480
|
+
expect(caption.className).toContain("text-muted-foreground");
|
|
481
|
+
});
|
|
482
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { Avatar, AvatarFallback } from "./avatar";
|
|
4
|
+
import { ReconcileBar } from "./reconcile-bar";
|
|
5
|
+
import { SplitShareRow } from "./split-share-row";
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof SplitShareRow> = {
|
|
8
|
+
title: "Shared table/SplitShareRow",
|
|
9
|
+
component: SplitShareRow,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
parameters: {
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
"One person's share of a split item. A full-width row with the check on the trailing edge under md; a tile in a three-up grid with a square checkbox leading from md. Same DOM, CSS only.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof SplitShareRow>;
|
|
23
|
+
|
|
24
|
+
const people = [
|
|
25
|
+
{ name: "Adrian", initial: "A", tone: "brown" as const },
|
|
26
|
+
{ name: "Bruno", initial: "B", tone: "teal" as const },
|
|
27
|
+
{ name: "Ana", initial: "A", tone: "green" as const },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function person(p: (typeof people)[number]) {
|
|
31
|
+
return (
|
|
32
|
+
<span className="flex items-center gap-2.5">
|
|
33
|
+
<Avatar size="sm" tone={p.tone}>
|
|
34
|
+
<AvatarFallback>{p.initial}</AvatarFallback>
|
|
35
|
+
</Avatar>
|
|
36
|
+
<span className="truncate text-[14px] font-bold">{p.name}</span>
|
|
37
|
+
</span>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** T6 — Equally: everyone checked, the remainder handed out in join order. */
|
|
42
|
+
export const Equally: Story = {
|
|
43
|
+
render: () => {
|
|
44
|
+
const [checked, setChecked] = React.useState([true, true, true]);
|
|
45
|
+
return (
|
|
46
|
+
<div className="grid gap-2 md:grid-cols-3">
|
|
47
|
+
{people.map((p, i) => (
|
|
48
|
+
<SplitShareRow
|
|
49
|
+
key={p.name}
|
|
50
|
+
person={person(p)}
|
|
51
|
+
amount="40 lei"
|
|
52
|
+
checked={checked[i]}
|
|
53
|
+
onCheckedChange={(next) =>
|
|
54
|
+
setChecked((prev) => prev.map((c, j) => (j === i ? next : c)))
|
|
55
|
+
}
|
|
56
|
+
includeLabel={`Include ${p.name} in the split`}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** T6 — Custom: the amounts become editable and must still reconcile. */
|
|
65
|
+
export const Custom: Story = {
|
|
66
|
+
render: () => {
|
|
67
|
+
const [amounts, setAmounts] = React.useState<(number | null)[]>([50, 40, 30]);
|
|
68
|
+
const sum = amounts.reduce<number>((total, value) => total + (value ?? 0), 0);
|
|
69
|
+
return (
|
|
70
|
+
<div className="grid gap-2">
|
|
71
|
+
<div className="grid gap-2 md:grid-cols-3">
|
|
72
|
+
{people.map((p, i) => (
|
|
73
|
+
<SplitShareRow
|
|
74
|
+
key={p.name}
|
|
75
|
+
person={person(p)}
|
|
76
|
+
amount={`${amounts[i] ?? 0} lei`}
|
|
77
|
+
editable
|
|
78
|
+
amountValue={amounts[i]}
|
|
79
|
+
onAmountChange={(next) =>
|
|
80
|
+
setAmounts((prev) => prev.map((a, j) => (j === i ? next : a)))
|
|
81
|
+
}
|
|
82
|
+
amountLabel={`${p.name}'s share`}
|
|
83
|
+
/>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
<ReconcileBar
|
|
87
|
+
tone={sum === 120 ? "ok" : "short"}
|
|
88
|
+
message={
|
|
89
|
+
sum === 120 ? (
|
|
90
|
+
"The amounts add up"
|
|
91
|
+
) : (
|
|
92
|
+
<>
|
|
93
|
+
<b>{Math.abs(120 - sum)} lei {sum < 120 ? "short" : "over"}</b> of the item total
|
|
94
|
+
</>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
current={String(sum)}
|
|
98
|
+
target="120"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Someone left out of the split: the row dims, and the amount goes with it. */
|
|
106
|
+
export const Excluded: Story = {
|
|
107
|
+
args: {
|
|
108
|
+
person: person(people[1]),
|
|
109
|
+
amount: "0 lei",
|
|
110
|
+
checked: false,
|
|
111
|
+
onCheckedChange: () => {},
|
|
112
|
+
includeLabel: "Include Bruno in the split",
|
|
113
|
+
},
|
|
114
|
+
};
|