Skip to content

Commit 1eb521a

Browse files
committed
WEB-135 Refactor responsive Puck fields and shared published rendering paths
1 parent 12d2c57 commit 1eb521a

24 files changed

Lines changed: 609 additions & 182 deletions

docs/STYLING.md

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ These helpers are for component logic — see [Rendering](#4-rendering) for how
8282

8383
`defineProps` wires tokens to Puck editor fields (`src/lib/puck/define-props.ts`). It returns `{ fields, defaultProps }` to spread onto a `ComponentConfig`.
8484

85-
There are four field builders:
85+
There are two builder namespaces:
8686

8787
```ts
8888
const props = defineProps({
@@ -94,7 +94,16 @@ const props = defineProps({
9494
layout: field.radio(layout, { label: "Layout" }),
9595

9696
// Responsive — per-breakpoint picker, also token-backed
97-
padding: responsive.token(padding, { label: "Padding" }),
97+
padding: responsive.select(padding, { label: "Padding" }),
98+
99+
// Responsive number — per-breakpoint numeric input
100+
columns: responsive.number({
101+
label: "Columns",
102+
default: { base: 1, md: 2 },
103+
min: 1,
104+
max: 6,
105+
step: 1,
106+
}),
98107

99108
// Raw — any Puck field descriptor, for things tokens don't cover
100109
url: field.raw({ type: "text", label: "URL" }, ""),
@@ -104,8 +113,10 @@ const props = defineProps({
104113
**Defaults.** Every builder falls back to the token's first key if no `default` is given. You can override with a scalar or a full responsive object:
105114

106115
```ts
107-
responsive.token(columnCount, { label: "Columns", default: "3" })
108-
responsive.token(columnCount, { label: "Columns", default: { base: "1", md: "3" } })
116+
responsive.select(columnCount, { label: "Columns", default: "3" })
117+
responsive.select(columnCount, { label: "Columns", default: { base: "1", md: "3" } })
118+
responsive.number({ label: "Rows", default: 2, min: 1, max: 6, step: 1 })
119+
responsive.number({ label: "Rows", default: { base: 1, md: 3 } })
109120
```
110121

111122
**Slots** accept an optional allow/disallow list to restrict which child components can be dropped in:
@@ -115,7 +126,26 @@ field.slot({ allow: ["Card", "Button"] })
115126
field.slot({ disallow: ["Section"] })
116127
```
117128

118-
The same token definition works for both static and responsive use — only the field builder differs.
129+
The same token definition works for both static and responsive use. Numeric responsive fields do not need a token.
130+
131+
### Responsive field architecture
132+
133+
Responsive field descriptors are built on the server and rendered on the client, so the implementation is split intentionally:
134+
135+
- Shared kind definitions live in `src/components/puck/fields/responsive-kinds/*/shared.ts`.
136+
- Client renderers live in `src/components/puck/fields/responsive-kinds/*/client.tsx`.
137+
- `src/components/puck/fields/responsive-field.tsx` is the server-safe wrapper that turns a serializable descriptor into a Puck custom field.
138+
- `src/components/puck/fields/responsive-field-client.tsx` is the client-only registry that maps `descriptor.kind` to the right renderer.
139+
- `src/components/puck/fields/responsive-field-frame.tsx` owns the shared breakpoint UI; kind-specific controls only deal with their own value format.
140+
141+
This keeps the descriptor shape and the renderer associated by kind without pushing client code across the server boundary.
142+
143+
To add a new responsive field kind:
144+
145+
1. Add `shared.ts` and `client.tsx` under `src/components/puck/fields/responsive-kinds/<kind>/`.
146+
2. Export the descriptor factory and type from `src/components/puck/fields/responsive-kinds/index.ts`.
147+
3. Register the client component in `src/components/puck/fields/responsive-kinds/client-registry.tsx`.
148+
4. Optionally add a convenience builder in `src/lib/puck/define-props.ts`.
119149

120150
### 4. Rendering
121151

@@ -143,6 +173,8 @@ const classes = cn(
143173
);
144174
```
145175

176+
Responsive numeric props stay as data. Use `resolveAt(...)` or your own component logic when they need to influence layout or behavior; they do not go through `resolveResponsive(...)`.
177+
146178
## Tailwind source hints
147179

148180
Because `resolveResponsive` builds prefixed class names like `md:p-4` at runtime, Tailwind's scanner never sees them in source. Every responsive token needs a corresponding `@source inline(...)` directive in `src/app/styles.css`:
@@ -187,31 +219,37 @@ import { padding, bgColor, type Spacing, type Color } from "@/lib/puck/tokens";
187219
type CardProps = {
188220
content: Slot;
189221
padding: ResponsiveValue<Spacing>;
222+
columns: ResponsiveValue<number>;
190223
bgColor: Color;
191224
title: string;
192225
};
193226

194227
const props = defineProps({
195228
content: field.slot(),
196-
padding: responsive.token(padding, { label: "Padding", default: "md" }),
229+
padding: responsive.select(padding, { label: "Padding", default: "md" }),
230+
columns: responsive.number({ label: "Columns", default: { base: 1, md: 2 }, min: 1, max: 4, step: 1 }),
197231
bgColor: field.select(bgColor, { label: "Background" }),
198232
title: field.raw({ type: "text", label: "Title" }, ""),
199233
});
200234
```
201235

202236
### Step 3 — Export the component config
203237

204-
Spread `props` onto the config and destructure in `render`. Use `resolveResponsive` for responsive props and direct `token.classes[value]` lookups for static ones:
238+
Spread `props` onto the config and destructure in `render`. Use `resolveResponsive` for responsive token props, `resolveAt` or component logic for responsive numeric props, and direct `token.classes[value]` lookups for static ones:
205239

206240
```ts
241+
import { resolveAt } from "@/lib/puck/responsive";
207242
import { resolveResponsive } from "@/lib/puck/responsive-tailwind";
208243
import { cn } from "@/lib/utils";
209244

210245
export const Card: ComponentConfig<CardProps> = {
211246
label: "Card",
212247
...props,
213-
render: ({ content: Content, padding: pad, bgColor: bg, title }) => (
214-
<div className={cn(bgColor.classes[bg], resolveResponsive(pad, padding.classes))}>
248+
render: ({ content: Content, padding: pad, columns, bgColor: bg, title }) => (
249+
<div
250+
className={cn(bgColor.classes[bg], resolveResponsive(pad, padding.classes))}
251+
style={{ columns: resolveAt(columns, "base") }}
252+
>
215253
{title && <h3>{title}</h3>}
216254
{Content && <Content />}
217255
</div>

src/app/[[...puckPath]]/page.tsx

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,30 @@
1-
import { notFound } from "next/navigation";
21
import { Metadata } from "next";
3-
import { getDocumentByPath } from "../../lib/documents/queries";
4-
import { Render } from "@puckeditor/core";
5-
import { config } from "@/puck.config";
2+
import { notFound } from "next/navigation";
3+
import { PuckRender } from "@/components/puck/render";
4+
import { getPublishedDocument } from "./published-document";
65

76
export async function generateMetadata({
87
params,
98
}: {
10-
params: Promise<{ puckPath: string[] }>;
9+
params: Promise<{ puckPath?: string[] }>;
1110
}): Promise<Metadata> {
12-
const { puckPath = [] } = await params;
13-
const path = `/${puckPath.join("/")}`;
14-
1511
return {
16-
title: (await getDocumentByPath(path))?.root.props?.title,
12+
title: (await getPublishedDocument(params))?.root.props?.title,
1713
};
1814
}
1915

2016
export default async function Page({
2117
params,
2218
}: {
23-
params: Promise<{ puckPath: string[] }>;
19+
params: Promise<{ puckPath?: string[] }>;
2420
}) {
25-
const { puckPath = [] } = await params;
26-
const path = `/${puckPath.join("/")}`;
27-
const data = await getDocumentByPath(path);
21+
const data = await getPublishedDocument(params);
2822

2923
if (!data) {
3024
return notFound();
3125
}
3226

33-
return <Render config={config} data={data} />;
27+
return <PuckRender data={data} />;
3428
}
3529

3630
export const dynamic = "force-static";
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { cache } from "react";
2+
import { getDocumentByPath } from "../../lib/documents/queries";
3+
4+
type PublishedRouteParams = Promise<{ puckPath?: string[] }>;
5+
6+
export async function getPublishedDocument(params: PublishedRouteParams) {
7+
return getPublishedDocumentByPath(getPublishedPath(await params));
8+
}
9+
10+
function getPublishedPath({ puckPath = [] }: { puckPath?: string[] }) {
11+
return `/${puckPath.join("/")}`;
12+
}
13+
14+
const getPublishedDocumentByPath = cache(getDocumentByPath);

src/app/editor/[id]/[slug]/PreviewPage.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { notFound } from "next/navigation";
2+
import { PuckRender } from "@/components/puck/render";
23
import { getVersionContent } from "../../../../lib/documents/queries";
34
import { resolvePreviewVersionId } from "./version-selection";
45
import { loadDocument } from "./params";
5-
import { Render } from "@puckeditor/core";
6-
import { config } from "@/puck.config";
76

87
export default async function PreviewPage({
98
documentId,
@@ -30,5 +29,5 @@ export default async function PreviewPage({
3029
const data = await getVersionContent(targetVersionId);
3130
if (!data) notFound();
3231

33-
return <Render config={config} data={data} />;
32+
return <PuckRender data={data} />;
3433
}

src/components/puck/columns.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ const columnSlotDefaults = Object.fromEntries(
2727
) as Record<SlotKey, Slot>;
2828

2929
const props = defineProps({
30-
columns: responsive.token(columnCount, { label: "Columns", default: { base: "1", md: "2" } }),
31-
gap: responsive.token(gap, { label: "Gap", default: "md" }),
30+
columns: responsive.select(columnCount, { label: "Columns", default: { base: "1", md: "2" } }),
31+
gap: responsive.select(gap, { label: "Gap", default: "md" }),
3232
});
3333

3434
export const Columns: ComponentConfig<ColumnsProps> = {

src/components/puck/container.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ type ContainerProps = ContainerStyle & {
2828
const props = defineProps({
2929
content: field.slot(),
3030
layout: field.radio(layout, { label: "Layout" }),
31-
padding: responsive.token(padding, { label: "Padding", default: "md" }),
32-
gap: responsive.token(gap, { label: "Gap", default: "md" }),
31+
padding: responsive.select(padding, { label: "Padding", default: "md" }),
32+
gap: responsive.select(gap, { label: "Gap", default: "md" }),
3333
align: field.radio(crossAxisAlign, { label: "Align items" }),
3434
justify: field.radio(justify, { label: "Justify" }),
3535
width: field.select(width, { label: "Max width" }),
Lines changed: 8 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,15 @@
11
"use client";
22

3-
import { AutoField, FieldLabel } from "@puckeditor/core";
3+
import type { ComponentType } from "react";
44
import {
5-
responsiveBreakpoints,
6-
setAt,
7-
type ResponsiveBreakpoint,
8-
type ResponsiveValue,
9-
} from "@/lib/puck/responsive";
10-
import type { TokenOption } from "@/lib/puck/tokens";
11-
import type { ResponsiveFieldDescriptor } from "./responsive-field";
5+
responsiveFieldClientRegistry,
6+
type ResponsiveFieldClientProps,
7+
} from "./responsive-kinds/client-registry";
128

13-
const breakpointLabels: Record<ResponsiveBreakpoint, string> = {
14-
base: "Base",
15-
md: "Tablet",
16-
lg: "Desktop",
17-
};
9+
export type { ResponsiveFieldClientProps } from "./responsive-kinds/client-registry";
1810

19-
// Non-base breakpoints can clear their override and inherit from the smaller breakpoint.
20-
const inheritOption: TokenOption = { label: "–", value: "" };
11+
export function ResponsiveFieldClient(props: ResponsiveFieldClientProps) {
12+
const FieldComponent = responsiveFieldClientRegistry[props.descriptor.kind] as ComponentType<any>;
2113

22-
type ResponsiveFieldClientProps<T extends string> = {
23-
label: string;
24-
descriptor: ResponsiveFieldDescriptor<T>;
25-
value: ResponsiveValue<T>;
26-
onChange: (value: ResponsiveValue<T>) => void;
27-
readOnly?: boolean;
28-
};
29-
30-
function renderFieldControl<T extends string>({
31-
descriptor,
32-
isBase,
33-
value,
34-
onChange,
35-
readOnly,
36-
}: {
37-
descriptor: ResponsiveFieldDescriptor<T>;
38-
isBase: boolean;
39-
value: T | undefined;
40-
onChange: (value: T | undefined) => void;
41-
readOnly?: boolean;
42-
}) {
43-
switch (descriptor.kind) {
44-
case "select": {
45-
const fieldOptions = isBase ? descriptor.options : [inheritOption, ...descriptor.options];
46-
const isInheriting = !isBase && value === undefined;
47-
48-
return (
49-
<div className={isInheriting ? "opacity-40" : undefined}>
50-
<AutoField
51-
field={{ type: "select", options: fieldOptions }}
52-
onChange={(selected: string) => {
53-
onChange(selected === "" ? undefined : (selected as T));
54-
}}
55-
value={value ?? ""}
56-
readOnly={readOnly}
57-
/>
58-
</div>
59-
);
60-
}
61-
}
62-
}
63-
64-
export function ResponsiveFieldClient<T extends string>({
65-
label,
66-
descriptor,
67-
value,
68-
onChange,
69-
readOnly,
70-
}: ResponsiveFieldClientProps<T>) {
71-
return (
72-
<FieldLabel label={label} el="div" readOnly={readOnly}>
73-
<div className="grid grid-cols-3 gap-1">
74-
{responsiveBreakpoints.map((bp) => {
75-
const isBase = bp === "base";
76-
const currentValue = value[bp];
77-
78-
return (
79-
<div key={bp} className="flex flex-col gap-1">
80-
<span className="text-xs font-semibold text-muted-foreground">
81-
{breakpointLabels[bp]}
82-
</span>
83-
{renderFieldControl({
84-
descriptor,
85-
isBase,
86-
value: currentValue,
87-
onChange: (nextValue) => {
88-
onChange(setAt(value, bp, nextValue));
89-
},
90-
readOnly,
91-
})}
92-
</div>
93-
);
94-
})}
95-
</div>
96-
</FieldLabel>
97-
);
14+
return <FieldComponent {...props} />;
9815
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
fromResponsiveSelectValue,
4+
toResponsiveSelectValue,
5+
} from "./responsive-select-control";
6+
import {
7+
formatResponsiveNumberValue,
8+
getResponsiveNumberChange,
9+
} from "./responsive-number-control";
10+
11+
describe("responsive select control helpers", () => {
12+
it("translates inherited select values at the UI boundary", () => {
13+
expect(toResponsiveSelectValue(undefined)).toBe("");
14+
expect(fromResponsiveSelectValue("")).toBeUndefined();
15+
expect(fromResponsiveSelectValue("md")).toBe("md");
16+
});
17+
});
18+
19+
describe("responsive number control helpers", () => {
20+
it("formats values for the number input", () => {
21+
expect(formatResponsiveNumberValue(undefined)).toBe("");
22+
expect(formatResponsiveNumberValue(3)).toBe("3");
23+
});
24+
25+
it("clears inherited overrides, preserves base values, and validates numeric input", () => {
26+
expect(getResponsiveNumberChange("", { isBase: false })).toEqual({
27+
kind: "set",
28+
value: undefined,
29+
});
30+
expect(getResponsiveNumberChange("", { isBase: true })).toEqual({
31+
kind: "ignore",
32+
});
33+
expect(
34+
getResponsiveNumberChange("4", { isBase: true, min: 1, max: 6 }),
35+
).toEqual({
36+
kind: "set",
37+
value: 4,
38+
});
39+
expect(
40+
getResponsiveNumberChange("0", { isBase: true, min: 1, max: 6 }),
41+
).toEqual({
42+
kind: "ignore",
43+
});
44+
expect(
45+
getResponsiveNumberChange("9", { isBase: false, min: 1, max: 6 }),
46+
).toEqual({
47+
kind: "ignore",
48+
});
49+
});
50+
});

0 commit comments

Comments
 (0)