@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/CHANGELOG.md +16 -0
- package/dist/index.cjs +78 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +46 -3
- package/dist/index.d.ts +46 -3
- package/dist/index.js +103 -45
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/composed/PlacementTabs.tsx +117 -19
- package/src/composed/__tests__/PlacementTabs.linked.test.tsx +144 -0
- package/src/patterns/RealtimeProvider.tsx +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowcone-app/ui",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
201
|
+
<>
|
|
120
202
|
{placements.map((p) => (
|
|
121
203
|
<button
|
|
122
204
|
key={p.label}
|
|
205
|
+
type="button"
|
|
123
206
|
onClick={() => onPlacementChange(p.label)}
|
|
124
|
-
|
|
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
|
-
{
|
|
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
|