@snowcone-app/ui 0.8.2 → 0.9.0

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": "@snowcone-app/ui",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "React components for merchandise visualization and customization",
5
5
  "keywords": [
6
6
  "react",
@@ -103,7 +103,7 @@
103
103
  "react-instantsearch": "^7.15.5",
104
104
  "react-zoom-pan-pinch": "^3.6.4",
105
105
  "tailwind-merge": "^3.0.0",
106
- "@snowcone-app/sdk": "0.24.1"
106
+ "@snowcone-app/sdk": "0.24.2"
107
107
  },
108
108
  "devDependencies": {
109
109
  "@chromatic-com/storybook": "^4.1.2",
@@ -121,7 +121,7 @@
121
121
  "@vitejs/plugin-react": "^4.7.0",
122
122
  "@vueless/storybook-dark-mode": "^10.0.3",
123
123
  "jsdom": "^24.1.0",
124
- "next": "15.4.6",
124
+ "next": "16.4.3",
125
125
  "react": "^18.3.1",
126
126
  "react-dom": "^18.3.1",
127
127
  "rimraf": "^6.1.2",
@@ -130,7 +130,7 @@
130
130
  "tsup": "^8.0.1",
131
131
  "typescript": "^5.4.5",
132
132
  "vite": "^5.4.21",
133
- "vitest": "^2.0.5",
133
+ "vitest": "^2.1.9",
134
134
  "@snowcone-app/brand": "0.1.0"
135
135
  },
136
136
  "scripts": {
@@ -14,14 +14,23 @@ export interface AddToCartProps {
14
14
  text?: string;
15
15
  loadingText?: string;
16
16
  successText?: string;
17
+ /** Button text after a failed add; the error's message is announced in a
18
+ * `role="alert"` below the button and the button stays enabled to retry. */
19
+ errorText?: string;
17
20
  disabled?: boolean;
18
21
  className?: string;
19
22
  quantity?: number;
20
- onClick?: (cartDetail: any) => void;
23
+ /** Performs the add. Return a Promise and the button waits for it: "Added!"
24
+ * only after it resolves; a rejection shows the error state. */
25
+ onClick?: (cartDetail: any) => void | Promise<unknown>;
21
26
  onError?: (error: Error) => void;
27
+ /** Fake delay used only when there is no `onClick` (demos). */
22
28
  simulateDelay?: number;
23
29
  }
24
30
 
31
+ /** `data-add-to-cart` on the button — assert on this, not the label text. */
32
+ export type AddToCartStatus = "idle" | "adding" | "added" | "error";
33
+
25
34
  /**
26
35
  * AddToCart - Add to cart button with validation and loading states
27
36
  *
@@ -31,8 +40,11 @@ export interface AddToCartProps {
31
40
  *
32
41
  * Features:
33
42
  * - Automatic selection validation before allowing add
34
- * - Loading state with customizable text
35
- * - Success state with visual feedback (green background)
43
+ * - Loading state with customizable text; waits for an async `onClick`
44
+ * - Success only after `onClick` resolves; a rejection shows `errorText`
45
+ * plus the error message in a `role="alert"`, and the button stays
46
+ * enabled to retry (never a silent "Added!")
47
+ * - `data-add-to-cart="idle|adding|added|error"` for tests and agents
36
48
  * - Automatic cart detail generation with variant info
37
49
  * - Simulated delay for cart operations
38
50
  * - Screen reader announcements for state changes
@@ -57,9 +69,10 @@ export interface AddToCartProps {
57
69
  * <Product productId="shirt-123">
58
70
  * <ProductOptions />
59
71
  * <AddToCart
60
- * onClick={(cartDetail) => {
61
- * console.log('Added to cart:', cartDetail);
62
- * // Add to your cart state/API
72
+ * onClick={async (cartDetail) => {
73
+ * // Throw (or reject) if the add fails — the button shows the error.
74
+ * const res = await fetch('/api/cart', { method: 'POST', body: JSON.stringify(cartDetail) });
75
+ * if (!res.ok) throw new Error('Could not add to cart');
63
76
  * }}
64
77
  * />
65
78
  * </Product>
@@ -89,27 +102,26 @@ export interface AddToCartProps {
89
102
  *
90
103
  * @example
91
104
  * ```tsx
92
- * // Faster simulation for testing
93
- * <AddToCart
94
- * simulateDelay={500}
95
- * onClick={(detail) => console.log(detail)}
96
- * />
105
+ * // Demo without a real cart: a simulated delay, then "Added!"
106
+ * <AddToCart simulateDelay={500} />
97
107
  * ```
98
108
  *
99
109
  * @param text - Button text in default state (default: "Add to Bag")
100
110
  * @param loadingText - Button text while adding (default: "Adding...")
101
111
  * @param successText - Button text after successful add (default: "Added!")
112
+ * @param errorText - Button text after a failed add (default: "Couldn't add — try again")
102
113
  * @param disabled - Force button to disabled state
103
114
  * @param className - Additional CSS classes for button
104
115
  * @param quantity - Number of items to add (default: 1)
105
- * @param onClick - Callback with cart detail after successful add
116
+ * @param onClick - Performs the add with the cart detail; may return a Promise, which the button awaits
106
117
  * @param onError - Callback when add fails with error
107
- * @param simulateDelay - Delay in ms to simulate cart operation (default: 1000)
118
+ * @param simulateDelay - Delay in ms to simulate a cart operation when there is no `onClick` (default: 1000)
108
119
  */
109
120
  export function AddToCart({
110
121
  text = "Add to Bag",
111
122
  loadingText = "Adding...",
112
123
  successText = "Added!",
124
+ errorText = "Couldn't add — try again",
113
125
  disabled = false,
114
126
  className = "",
115
127
  quantity = 1,
@@ -119,8 +131,10 @@ export function AddToCart({
119
131
  }: AddToCartProps) {
120
132
  const context = useProduct();
121
133
  const shopContext = useShopOptional();
122
- const [loading, setLoading] = useState(false);
123
- const [success, setSuccess] = useState(false);
134
+ const [status, setStatus] = useState<AddToCartStatus>("idle");
135
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
136
+ const loading = status === "adding";
137
+ const success = status === "added";
124
138
 
125
139
  // Dev-mode warning for missing providers
126
140
  useEffect(() => {
@@ -151,7 +165,8 @@ export function AddToCart({
151
165
  if (isDisabled || !product) return;
152
166
 
153
167
  try {
154
- setLoading(true);
168
+ setStatus("adding");
169
+ setErrorMessage(null);
155
170
 
156
171
  // Create cart detail using ui-core
157
172
  const cartDetail = createCartDetail({
@@ -166,28 +181,26 @@ export function AddToCart({
166
181
  throw new Error("Invalid selection - unable to create cart detail");
167
182
  }
168
183
 
169
- // Simulate cart operation delay
170
- await simulateCartOperation(simulateDelay);
171
-
172
- // Call onClick handler with cart detail
173
- onClick?.(cartDetail);
174
-
175
- // Show success state
176
- setSuccess(true);
184
+ if (onClick) {
185
+ // The host performs the add; success means it resolved.
186
+ await onClick(cartDetail);
187
+ } else {
188
+ await simulateCartOperation(simulateDelay);
189
+ }
177
190
 
178
- // Reset success after 2 seconds
191
+ setStatus("added");
179
192
  setTimeout(() => {
180
- setSuccess(false);
193
+ setStatus((s) => (s === "added" ? "idle" : s));
181
194
  }, 2000);
182
195
  } catch (error) {
183
- onError?.(error as Error);
184
- console.error("Add to cart failed:", error);
185
- } finally {
186
- setLoading(false);
196
+ const err = error instanceof Error ? error : new Error(String(error));
197
+ setStatus("error");
198
+ setErrorMessage(err.message);
199
+ onError?.(err);
187
200
  }
188
201
  };
189
202
 
190
- const buttonText = loading ? loadingText : success ? successText : text;
203
+ const buttonText = loading ? loadingText : success ? successText : status === "error" ? errorText : text;
191
204
 
192
205
  // Default styles that work out of the box - LLM-friendly
193
206
  // These are always applied, then className can override/extend them
@@ -204,6 +217,7 @@ export function AddToCart({
204
217
  const getAriaLabel = () => {
205
218
  if (loading) return loadingText;
206
219
  if (success) return successText;
220
+ if (status === "error") return `${errorText} - ${product?.name || "product"}`;
207
221
  if (!product) return text;
208
222
  return `${text} - ${product.name || 'product'}`;
209
223
  };
@@ -221,9 +235,15 @@ export function AddToCart({
221
235
  onClick={handleClick}
222
236
  aria-label={getAriaLabel()}
223
237
  aria-busy={loading}
238
+ data-add-to-cart={status}
224
239
  >
225
240
  {buttonText}
226
241
  </button>
242
+ {status === "error" && errorMessage && (
243
+ <p role="alert" className="mt-2 text-sm text-destructive">
244
+ {errorMessage}
245
+ </p>
246
+ )}
227
247
  </>
228
248
  );
229
249
  }
@@ -43,8 +43,41 @@ export interface PlacementTabsProps {
43
43
  toolbarSize?: number;
44
44
  /** Additional className for the container */
45
45
  className?: string;
46
+ /**
47
+ * Linked placements (ADR-0101): one design on every print area.
48
+ *
49
+ * - `true` — LINKED. Renders one non-interactive pill naming the set
50
+ * ("Both sides" for two placements, "All N areas" otherwise; the labels
51
+ * in its `title`) plus a single **Customize separately** action
52
+ * (`onSplitAll`). No per-placement tabs: there is one design to edit.
53
+ * - `false` — SPLIT. Renders the per-placement tabs plus one
54
+ * **Use one design for all** action (`onLinkAll`).
55
+ * - `undefined` (default) — no link model: plain tabs, exactly as before.
56
+ */
57
+ linked?: boolean;
58
+ /** Linked → split: every placement gets its own copy of the design. */
59
+ onSplitAll?: () => void;
60
+ /** Split → linked: drop the per-placement copies, back to one design. */
61
+ onLinkAll?: () => void;
62
+ /** Override the linked pill's copy (localisation). */
63
+ linkedLabel?: string;
64
+ /** Override the "Customize separately" action copy. */
65
+ splitAllLabel?: string;
66
+ /** Override the "Use one design for all" action copy. */
67
+ linkAllLabel?: string;
46
68
  }
47
69
 
70
+ /** Default pill copy for a linked set of `count` placements. */
71
+ export function linkedPlacementsLabel(count: number): string {
72
+ return count === 2 ? "Both sides" : `All ${count} areas`;
73
+ }
74
+
75
+ const ACTION_CLASS =
76
+ "transition-colors duration-200 cursor-pointer text-muted-foreground hover:text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground";
77
+
78
+ const PILL_CLASS =
79
+ "inline-flex items-center rounded-full bg-foreground/[0.08] px-3 py-1 font-medium text-foreground";
80
+
48
81
  /**
49
82
  * PlacementTabs - Renders placement tabs with optional floating toolbar placeholder.
50
83
  *
@@ -52,6 +85,11 @@ export interface PlacementTabsProps {
52
85
  * to flow around the toolbar placeholder. On desktop, renders simple tabs
53
86
  * without the toolbar placeholder (toolbar is typically in header).
54
87
  *
88
+ * The container carries `data-placement-tabs` and, when the link model is in
89
+ * play, `data-linked="true" | "false"`; the pill is `data-placement-pill`
90
+ * and the actions are `data-action="split-all" | "link-all"` so hosts and
91
+ * agents can assert the state instead of reading pixels.
92
+ *
55
93
  * @example
56
94
  * ```tsx
57
95
  * // Basic usage
@@ -71,6 +109,16 @@ export interface PlacementTabsProps {
71
109
  * onPlacementChange={setSelected}
72
110
  * isDesktop
73
111
  * />
112
+ *
113
+ * // Linked by default (ADR-0101): one design on every area
114
+ * <PlacementTabs
115
+ * placements={placements}
116
+ * selectedPlacement={selected}
117
+ * onPlacementChange={setSelected}
118
+ * linked={linked}
119
+ * onSplitAll={splitAll}
120
+ * onLinkAll={linkAll}
121
+ * />
74
122
  * ```
75
123
  */
76
124
  export function PlacementTabs({
@@ -81,6 +129,12 @@ export function PlacementTabs({
81
129
  toolbarPlaceholderId = "toolbar-placeholder",
82
130
  toolbarSize = 120,
83
131
  className = "",
132
+ linked,
133
+ onSplitAll,
134
+ onLinkAll,
135
+ linkedLabel,
136
+ splitAllLabel = "Customize separately",
137
+ linkAllLabel = "Use one design for all",
84
138
  }: PlacementTabsProps) {
85
139
  const containerRef = useRef<HTMLDivElement>(null);
86
140
  const [lineHeight, setLineHeight] = useState(24); // Default estimate
@@ -103,7 +157,7 @@ export function PlacementTabs({
103
157
  return null;
104
158
  }
105
159
  return (
106
- <div className={`flex justify-end w-full ${className}`}>
160
+ <div className={`flex justify-end w-full ${className}`} data-placement-tabs>
107
161
  <span
108
162
  id={toolbarPlaceholderId}
109
163
  className="block"
@@ -113,15 +167,46 @@ export function PlacementTabs({
113
167
  );
114
168
  }
115
169
 
116
- // Desktop: just render placement tabs without toolbar placeholder
117
- if (isDesktop) {
170
+ const dataLinked = linked === undefined ? undefined : linked ? "true" : "false";
171
+ const labels = placements.map((p) => p.label);
172
+
173
+ // The row content is shared by the desktop and mobile layouts; only the
174
+ // toolbar-placeholder float around it differs.
175
+ const renderItems = (tabClass: string) => {
176
+ if (linked === true) {
177
+ return (
178
+ <>
179
+ <span
180
+ className={`${tabClass} ${PILL_CLASS}`}
181
+ data-placement-pill
182
+ title={labels.join(" · ")}
183
+ aria-label={`One design on ${labels.join(", ")}`}
184
+ >
185
+ {linkedLabel ?? linkedPlacementsLabel(placements.length)}
186
+ </span>
187
+ {onSplitAll && (
188
+ <button
189
+ type="button"
190
+ onClick={onSplitAll}
191
+ className={`${tabClass} ${ACTION_CLASS}`}
192
+ data-action="split-all"
193
+ >
194
+ {splitAllLabel}
195
+ </button>
196
+ )}
197
+ </>
198
+ );
199
+ }
118
200
  return (
119
- <div className={`flex flex-wrap gap-x-3 text-sm leading-relaxed w-full ${className}`}>
201
+ <>
120
202
  {placements.map((p) => (
121
203
  <button
122
204
  key={p.label}
205
+ type="button"
123
206
  onClick={() => onPlacementChange(p.label)}
124
- className={`transition-all duration-200 cursor-pointer border-b-2 ${
207
+ data-placement-tab={p.label}
208
+ aria-pressed={selectedPlacement === p.label}
209
+ className={`${tabClass} transition-all duration-200 cursor-pointer border-b-2 ${
125
210
  selectedPlacement === p.label
126
211
  ? "text-foreground border-primary"
127
212
  : "text-foreground/70 hover:text-foreground border-muted-foreground/20 hover:border-muted-foreground/40"
@@ -130,6 +215,29 @@ export function PlacementTabs({
130
215
  {p.label}
131
216
  </button>
132
217
  ))}
218
+ {linked === false && onLinkAll && (
219
+ <button
220
+ type="button"
221
+ onClick={onLinkAll}
222
+ className={`${tabClass} ${ACTION_CLASS}`}
223
+ data-action="link-all"
224
+ >
225
+ {linkAllLabel}
226
+ </button>
227
+ )}
228
+ </>
229
+ );
230
+ };
231
+
232
+ // Desktop: just render placement tabs without toolbar placeholder
233
+ if (isDesktop) {
234
+ return (
235
+ <div
236
+ className={`flex flex-wrap items-center gap-x-3 gap-y-1 text-sm leading-relaxed w-full ${className}`}
237
+ data-placement-tabs
238
+ data-linked={dataLinked}
239
+ >
240
+ {renderItems("")}
133
241
  </div>
134
242
  );
135
243
  }
@@ -144,6 +252,8 @@ export function PlacementTabs({
144
252
  <div
145
253
  ref={containerRef}
146
254
  className={`flex overflow-hidden text-sm leading-relaxed w-full ${className}`}
255
+ data-placement-tabs
256
+ data-linked={dataLinked}
147
257
  >
148
258
  <p className="m-0 w-full">
149
259
  <span
@@ -159,20 +269,8 @@ export function PlacementTabs({
159
269
  />
160
270
  </span>
161
271
 
162
- {/* Placement tabs */}
163
- {placements.map((p) => (
164
- <button
165
- key={p.label}
166
- onClick={() => onPlacementChange(p.label)}
167
- className={`inline transition-all duration-200 cursor-pointer mr-3 border-b-2 ${
168
- selectedPlacement === p.label
169
- ? "text-foreground border-primary"
170
- : "text-foreground/70 hover:text-foreground border-muted-foreground/20 hover:border-muted-foreground/40"
171
- }`}
172
- >
173
- {p.label}
174
- </button>
175
- ))}
272
+ {/* Placement tabs (or the linked pill + action) */}
273
+ {renderItems("inline mr-3")}
176
274
  </p>
177
275
  </div>
178
276
  );
@@ -0,0 +1,104 @@
1
+ /**
2
+ * AddToCart waits for the host's add and never reports a failure as success.
3
+ *
4
+ * - An async `onClick` is awaited: "Added!" only after it resolves.
5
+ * - A rejection shows `errorText`, puts the error message in a role=alert,
6
+ * calls `onError`, and leaves the button enabled to retry.
7
+ * - `data-add-to-cart` exposes idle | adding | added | error.
8
+ * - With no `onClick`, the demo delay still ends in "Added!".
9
+ */
10
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
+ import React from "react";
12
+ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
13
+
14
+ vi.mock("../../patterns/Product", () => {
15
+ const ctx = {
16
+ product: { id: "P1", name: "Framed Canvas" },
17
+ selection: {},
18
+ combinations: [],
19
+ optionAttributes: {},
20
+ };
21
+ return { useProduct: () => ctx, useProductOptional: () => ctx };
22
+ });
23
+ vi.mock("../../patterns/ShopProvider", () => ({ useShopOptional: () => null }));
24
+ vi.mock("../../utils/devWarnings", () => ({ checkProviders: () => {} }));
25
+ vi.mock("@snowcone-app/sdk", () => ({
26
+ validateProductSelection: () => true,
27
+ createCartDetail: (d: { productId: string; quantity: number }) => ({ productId: d.productId, quantity: d.quantity }),
28
+ simulateCartOperation: () => Promise.resolve(),
29
+ }));
30
+
31
+ import { AddToCart } from "../AddToCart";
32
+
33
+ afterEach(cleanup);
34
+
35
+ function deferred() {
36
+ let resolve!: () => void;
37
+ let reject!: (e: Error) => void;
38
+ const promise = new Promise<void>((res, rej) => {
39
+ resolve = res;
40
+ reject = rej;
41
+ });
42
+ return { promise, resolve, reject };
43
+ }
44
+
45
+ const button = () => screen.getByRole("button");
46
+
47
+ describe("AddToCart with an async onClick", () => {
48
+ it("stays 'adding' until the add resolves, then shows success", async () => {
49
+ const add = deferred();
50
+ render(<AddToCart onClick={() => add.promise} />);
51
+
52
+ fireEvent.click(button());
53
+ await act(async () => {});
54
+ expect(button().getAttribute("data-add-to-cart")).toBe("adding");
55
+ expect(button().textContent).toBe("Adding...");
56
+
57
+ await act(async () => add.resolve());
58
+ expect(button().getAttribute("data-add-to-cart")).toBe("added");
59
+ expect(button().textContent).toBe("Added!");
60
+ });
61
+
62
+ it("shows the error, alerts its message, calls onError, and allows a retry", async () => {
63
+ const onError = vi.fn();
64
+ const onClick = vi.fn(
65
+ (): Promise<void> => Promise.reject(new Error("Couldn't add Framed Canvas to your bag (error 500).")),
66
+ );
67
+ render(<AddToCart onClick={onClick} onError={onError} errorText="Try again" />);
68
+
69
+ await act(async () => fireEvent.click(button()));
70
+ expect(button().getAttribute("data-add-to-cart")).toBe("error");
71
+ expect(button().textContent).toBe("Try again");
72
+ expect(screen.getByRole("alert").textContent).toBe("Couldn't add Framed Canvas to your bag (error 500).");
73
+ expect(onError).toHaveBeenCalledTimes(1);
74
+ expect((button() as HTMLButtonElement).disabled).toBe(false);
75
+
76
+ onClick.mockImplementationOnce(() => Promise.resolve());
77
+ await act(async () => fireEvent.click(button()));
78
+ expect(button().getAttribute("data-add-to-cart")).toBe("added");
79
+ expect(screen.queryByRole("alert")).toBeNull();
80
+ expect(onClick).toHaveBeenCalledTimes(2);
81
+ });
82
+
83
+ it("treats a synchronous throw like a rejection", async () => {
84
+ render(
85
+ <AddToCart
86
+ onClick={() => {
87
+ throw new Error("boom");
88
+ }}
89
+ />,
90
+ );
91
+ await act(async () => fireEvent.click(button()));
92
+ expect(button().getAttribute("data-add-to-cart")).toBe("error");
93
+ expect(screen.getByRole("alert").textContent).toBe("boom");
94
+ });
95
+ });
96
+
97
+ describe("AddToCart without onClick (demo)", () => {
98
+ it("ends in success after the simulated delay", async () => {
99
+ render(<AddToCart />);
100
+ expect(button().getAttribute("data-add-to-cart")).toBe("idle");
101
+ await act(async () => fireEvent.click(button()));
102
+ expect(button().getAttribute("data-add-to-cart")).toBe("added");
103
+ });
104
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * PlacementTabs linked mode (ADR-0101 §4/§6).
3
+ *
4
+ * - `linked` undefined → today's tabs, no link actions (back-compat).
5
+ * - `linked: true` → one non-interactive pill ("Both sides" / "All N areas")
6
+ * plus "Customize separately"; no per-placement tabs.
7
+ * - `linked: false` → the tabs plus "Use one design for all".
8
+ * - The container exposes `data-linked` so the state is assertable.
9
+ */
10
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
+ import React from "react";
12
+ import { cleanup, fireEvent, render } from "@testing-library/react";
13
+ import { PlacementTabs, linkedPlacementsLabel, type PlacementConfig } from "../PlacementTabs";
14
+
15
+ afterEach(cleanup);
16
+
17
+ const two: PlacementConfig[] = [
18
+ { label: "Left", type: "image", width: 1200, height: 1800 },
19
+ { label: "Right", type: "image", width: 1200, height: 1800 },
20
+ ];
21
+ const four: PlacementConfig[] = [
22
+ ...two,
23
+ { label: "Front", type: "image", width: 1200, height: 1800 },
24
+ { label: "Back", type: "image", width: 1200, height: 1800 },
25
+ ];
26
+
27
+ describe("linkedPlacementsLabel", () => {
28
+ it("names the set", () => {
29
+ expect(linkedPlacementsLabel(2)).toBe("Both sides");
30
+ expect(linkedPlacementsLabel(4)).toBe("All 4 areas");
31
+ });
32
+ });
33
+
34
+ describe.each([{ isDesktop: true }, { isDesktop: false }])(
35
+ "PlacementTabs (isDesktop=$isDesktop)",
36
+ ({ isDesktop }) => {
37
+ it("renders plain tabs with no link actions when `linked` is undefined", () => {
38
+ const { container, queryByText, getByText } = render(
39
+ <PlacementTabs
40
+ placements={two}
41
+ selectedPlacement="Left"
42
+ onPlacementChange={() => {}}
43
+ isDesktop={isDesktop}
44
+ onSplitAll={() => {}}
45
+ onLinkAll={() => {}}
46
+ />,
47
+ );
48
+ const root = container.querySelector("[data-placement-tabs]");
49
+ expect(root).not.toBeNull();
50
+ expect(root!.getAttribute("data-linked")).toBeNull();
51
+ expect(getByText("Left")).toBeTruthy();
52
+ expect(getByText("Right")).toBeTruthy();
53
+ expect(queryByText("Customize separately")).toBeNull();
54
+ expect(queryByText("Use one design for all")).toBeNull();
55
+ });
56
+
57
+ it("linked: shows one pill and the split action, no tabs", () => {
58
+ const onSplitAll = vi.fn();
59
+ const onPlacementChange = vi.fn();
60
+ const { container, queryByText, getByText } = render(
61
+ <PlacementTabs
62
+ placements={two}
63
+ selectedPlacement="Left"
64
+ onPlacementChange={onPlacementChange}
65
+ isDesktop={isDesktop}
66
+ linked
67
+ onSplitAll={onSplitAll}
68
+ onLinkAll={() => {}}
69
+ />,
70
+ );
71
+ const root = container.querySelector("[data-placement-tabs]")!;
72
+ expect(root.getAttribute("data-linked")).toBe("true");
73
+ const pill = container.querySelector("[data-placement-pill]")!;
74
+ expect(pill.textContent).toBe("Both sides");
75
+ expect(pill.getAttribute("title")).toBe("Left · Right");
76
+ expect(pill.tagName).not.toBe("BUTTON");
77
+ // No per-placement tabs while linked — there is one design to edit.
78
+ expect(container.querySelector("[data-placement-tab]")).toBeNull();
79
+ expect(queryByText("Use one design for all")).toBeNull();
80
+
81
+ fireEvent.click(getByText("Customize separately"));
82
+ expect(onSplitAll).toHaveBeenCalledTimes(1);
83
+ expect(onPlacementChange).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it("linked with 4 placements says 'All 4 areas'", () => {
87
+ const { container } = render(
88
+ <PlacementTabs
89
+ placements={four}
90
+ selectedPlacement="Left"
91
+ onPlacementChange={() => {}}
92
+ isDesktop={isDesktop}
93
+ linked
94
+ onSplitAll={() => {}}
95
+ />,
96
+ );
97
+ expect(container.querySelector("[data-placement-pill]")!.textContent).toBe(
98
+ "All 4 areas",
99
+ );
100
+ });
101
+
102
+ it("split: shows the tabs plus the link action", () => {
103
+ const onLinkAll = vi.fn();
104
+ const onPlacementChange = vi.fn();
105
+ const { container, getByText, queryByText } = render(
106
+ <PlacementTabs
107
+ placements={two}
108
+ selectedPlacement="Left"
109
+ onPlacementChange={onPlacementChange}
110
+ isDesktop={isDesktop}
111
+ linked={false}
112
+ onSplitAll={() => {}}
113
+ onLinkAll={onLinkAll}
114
+ />,
115
+ );
116
+ const root = container.querySelector("[data-placement-tabs]")!;
117
+ expect(root.getAttribute("data-linked")).toBe("false");
118
+ expect(container.querySelectorAll("[data-placement-tab]")).toHaveLength(2);
119
+ expect(queryByText("Customize separately")).toBeNull();
120
+ expect(container.querySelector("[data-placement-pill]")).toBeNull();
121
+
122
+ fireEvent.click(getByText("Right"));
123
+ expect(onPlacementChange).toHaveBeenCalledWith("Right");
124
+
125
+ fireEvent.click(getByText("Use one design for all"));
126
+ expect(onLinkAll).toHaveBeenCalledTimes(1);
127
+ });
128
+
129
+ it("single placement renders no link model at all", () => {
130
+ const { container, queryByText } = render(
131
+ <PlacementTabs
132
+ placements={[two[0]]}
133
+ selectedPlacement="Left"
134
+ onPlacementChange={() => {}}
135
+ isDesktop={isDesktop}
136
+ linked
137
+ onSplitAll={() => {}}
138
+ />,
139
+ );
140
+ expect(container.querySelector("[data-placement-pill]")).toBeNull();
141
+ expect(queryByText("Customize separately")).toBeNull();
142
+ });
143
+ },
144
+ );