@snowcone-app/ui 0.8.1 → 0.8.3

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.1",
3
+ "version": "0.8.3",
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.0"
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": "15.5.24",
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": {
@@ -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,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
+ );
@@ -157,7 +157,7 @@ export interface RealtimeContextValue {
157
157
  {
158
158
  width: number;
159
159
  height: number;
160
- pieces: Array<{ placement: string; x: number; y: number; width: number; height: number }>;
160
+ pieces: Array<{ placement: string; x: number; y: number; width: number; height: number; part?: { index: number; count: number } }>;
161
161
  }
162
162
  >
163
163
  | undefined
@@ -382,7 +382,7 @@ export function RealtimeProvider({
382
382
  {
383
383
  width: number;
384
384
  height: number;
385
- pieces: Array<{ placement: string; x: number; y: number; width: number; height: number }>;
385
+ pieces: Array<{ placement: string; x: number; y: number; width: number; height: number; part?: { index: number; count: number } }>;
386
386
  }
387
387
  >
388
388
  | undefined
@@ -1173,7 +1173,7 @@ export function RealtimeProvider({
1173
1173
  {
1174
1174
  width: number;
1175
1175
  height: number;
1176
- pieces: Array<{ placement: string; x: number; y: number; width: number; height: number }>;
1176
+ pieces: Array<{ placement: string; x: number; y: number; width: number; height: number; part?: { index: number; count: number } }>;
1177
1177
  }
1178
1178
  >
1179
1179
  | undefined