Files
fofos-admin/web/sections/SpacingEditor.tsx
T
pastilhasandClaude Opus 5 18c01de6f2 Spacing, on a section and on a component
A Spacing handle on the section bar and in the component tools, opening an editor below the preview —
the same place the text editor goes, and for the same reason: a dialog covers the thing being spaced.

The device toggle picks the breakpoint. Two axes across three widths is six controls, five of them for a
width you are not looking at; this way you switch to Mobile, see the phone, and change the padding you
can watch changing. The site's GUTTER is the exception and shows all three at once, because it is one
axis and one value and it is a progression judged as a whole.

Inherit and 0 stay separate options, and the select names the value it is inheriting rather than leaving
it to be worked out.

Written IMMEDIATELY rather than drafted. A step is one choice from a short list and the effect is on
screen before the menu closes; a Save for it would be a second thing to remember on a panel whose other
verbs already act.

Spacing sits before Delete in the section bar, ordered by how often each is wanted rather than how they
read — the destructive one furthest from where the hand arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 06:29:38 +01:00

120 lines
4.1 KiB
TypeScript

import { Button } from '@/components/ui/button';
import { DEVICES, type DeviceName } from '../ViewMode';
/**
* The padding of one section or one component, at the width being previewed.
*
* ── The device toggle picks the breakpoint ──
*
* Two axes across three widths is six controls, five of them for a width you are not looking at. So the
* bar's Desktop / Tablet / Mobile decides which one these write to: switch to Mobile, see the phone,
* change the padding you can watch changing.
*
* The site's GUTTER is the exception and shows all three at once — one axis, one value, and it is a
* progression judged as a whole. Here it is not.
*
* Mobile base every width
* Tablet md 768 and up
* Desktop lg 1024 and up
*
* ── Inherit and 0 are different instructions ──
*
* Unset takes the smaller breakpoint's value, which is what makes "one padding everywhere" a single
* choice. Zero means none HERE even though the smaller width has some. The select names the value it is
* inheriting rather than leaving it to be worked out.
*/
export type Padding = Partial<Record<'base' | 'md' | 'lg', { x?: number; y?: number }>>;
const BREAKPOINT: Record<DeviceName, 'base' | 'md' | 'lg'> = { mobile: 'base', tablet: 'md', desktop: 'lg' };
const inherited = (padding: Padding, breakpoint: 'base' | 'md' | 'lg', axis: 'x' | 'y'): number | null => {
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
for (const smaller of below[breakpoint]) {
const step = padding[smaller]?.[axis];
if (typeof step === 'number') return step;
}
return null;
};
export const SpacingEditor = ({
label,
padding,
device,
steps,
busy,
onChange,
onClose,
}: {
/** What is being spaced — the band's name, or the kind of component. */
label: string;
padding: Padding;
device: DeviceName;
steps: number[];
busy: boolean;
onChange: (padding: Padding) => void;
onClose: () => void;
}) => {
const breakpoint = BREAKPOINT[device];
const set = (axis: 'x' | 'y', value: string) => {
const at = { ...(padding[breakpoint] ?? {}) };
if (value === '') delete at[axis];
else at[axis] = Number(value);
const next = { ...padding };
// An empty breakpoint is removed rather than kept as `{}`, so "inherit everything" stays an absence
// instead of becoming a thing that has to be told apart from one later.
if (Object.keys(at).length) next[breakpoint] = at;
else delete next[breakpoint];
onChange(next);
};
const axis = (name: 'x' | 'y', title: string) => {
const own = padding[breakpoint]?.[name];
const from = inherited(padding, breakpoint, name);
return (
<div className="flex flex-col gap-1">
<label className="text-xs">{title}</label>
<select
value={own === undefined ? '' : String(own)}
onChange={(event) => set(name, event.currentTarget.value)}
disabled={busy}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
{/* Mobile is the smallest, so there is nothing below it and the empty option means none. */}
<option value="">
{breakpoint === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}
</option>
{steps.map((step) => (
<option key={step} value={String(step)}>
{step}
</option>
))}
</select>
</div>
);
};
return (
<div className="flex shrink-0 flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-baseline gap-2">
<h4 className="text-xs font-medium">Spacing — {label}</h4>
<span className="text-[10px] text-muted-foreground">
editing {DEVICES[device].label.toLowerCase()} — {breakpoint}
</span>
<Button size="sm" variant="ghost" className="ml-auto h-7 px-2 text-xs" onClick={onClose}>
Done
</Button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{axis('x', 'Horizontal')}
{axis('y', 'Vertical')}
</div>
</div>
);
};