The room between a band's columns: gap-8 in a two-column section, gap-x-24 gap-y-10 in the footer. Both were written into the template that drew them. The canvas now SKIPS a utility nothing has chosen instead of stripping it. The old rule lied twice — a section's own padding vanished while the panel was open, and a band with no chosen gap lost the site's default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
134 lines
4.9 KiB
TypeScript
134 lines
4.9 KiB
TypeScript
import { useDevice } from '../DeviceToggle';
|
|
import type { SpacingDraft, Steps } from '../canvas';
|
|
|
|
/**
|
|
* Where the space goes: three bands, each with its own numbers.
|
|
*
|
|
* It was one gutter for the whole site, on the argument that the header, the bands and the footer hold
|
|
* the same measure and should not drift. They do hold the same measure — and they still want different
|
|
* edges, because the menu is not the page. So the guarantee moved: three values, chosen in one place,
|
|
* side by side.
|
|
*
|
|
* ── One value per field ──
|
|
*
|
|
* The device toggle above says which width is being edited. Showing all three would be eighteen
|
|
* selects, seventeen of them for a screen you are not looking at.
|
|
*
|
|
* Unset inherits the smaller width, so "the same everywhere" is one choice. Zero is the other
|
|
* instruction — none HERE even though the phone has some — which is why they are separate options.
|
|
*/
|
|
|
|
const BREAKPOINT = { desktop: 'lg', tablet: 'md', mobile: 'base' } as const;
|
|
|
|
/** The nearest smaller width that has a value, or null. */
|
|
const inherited = (steps: Steps | undefined, key: 'base' | 'md' | 'lg'): number | null => {
|
|
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
|
|
for (const smaller of below[key]) {
|
|
const step = steps?.[smaller];
|
|
if (typeof step === 'number') return step;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/** The vocabulary. A knob is a line here, the class it emits on the site, and a field in the API. */
|
|
const BANDS = [
|
|
{
|
|
key: 'header' as const,
|
|
label: 'Top Nav',
|
|
fields: [
|
|
{ key: 'paddingX' as const, label: 'Horizontal padding' },
|
|
{ key: 'marginBottom' as const, label: 'Margin bottom' },
|
|
],
|
|
},
|
|
{
|
|
key: 'sections' as const,
|
|
label: 'Sections',
|
|
fields: [
|
|
{ key: 'paddingX' as const, label: 'Horizontal padding' },
|
|
{ key: 'paddingY' as const, label: 'Vertical padding' },
|
|
{ key: 'gap' as const, label: 'Gap' },
|
|
],
|
|
},
|
|
{
|
|
key: 'footer' as const,
|
|
label: 'Footer',
|
|
fields: [
|
|
{ key: 'paddingX' as const, label: 'Horizontal padding' },
|
|
{ key: 'marginTop' as const, label: 'Margin top' },
|
|
{ key: 'gap' as const, label: 'Gap' },
|
|
],
|
|
},
|
|
];
|
|
|
|
export const SpacingTab = ({
|
|
spacing,
|
|
steps,
|
|
onChange,
|
|
}: {
|
|
spacing: SpacingDraft;
|
|
/** The scale, sent by the API so this file holds no copy of it. */
|
|
steps: number[];
|
|
onChange: (spacing: SpacingDraft) => void;
|
|
}) => {
|
|
const [device] = useDevice();
|
|
const key = BREAKPOINT[device];
|
|
|
|
const set = (band: string, field: string, value: string) => {
|
|
const bands = spacing as Record<string, Record<string, Steps> | undefined>;
|
|
const at: Steps = { ...(bands[band]?.[field] ?? {}) };
|
|
// Removed rather than stored as null: an absence is what "inherit" means, all the way up.
|
|
if (value === '') delete at[key];
|
|
else at[key] = Number(value);
|
|
|
|
const nextBand = { ...(bands[band] ?? {}) };
|
|
if (Object.keys(at).length) nextBand[field] = at;
|
|
else delete nextBand[field];
|
|
|
|
const next = { ...bands };
|
|
if (Object.keys(nextBand).length) next[band] = nextBand;
|
|
else delete next[band];
|
|
|
|
onChange(next as SpacingDraft);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{BANDS.map((band) => (
|
|
<div key={band.key} className="flex flex-col gap-2">
|
|
<h4 className="text-xs font-medium">{band.label}</h4>
|
|
{/* Three across, and the same grid for every band — Top Nav has two fields and leaves the
|
|
third empty, so the columns line up down the section rather than staggering. */}
|
|
<div className="grid grid-cols-1 gap-3 @[26rem]:grid-cols-3">
|
|
{band.fields.map((field) => {
|
|
const current = (spacing as Record<string, Record<string, Steps> | undefined>)[band.key]?.[field.key];
|
|
const own = current?.[key];
|
|
const from = inherited(current, key);
|
|
|
|
return (
|
|
<div key={field.key} className="flex flex-col gap-1">
|
|
<label className="text-xs text-muted-foreground">{field.label}</label>
|
|
<select
|
|
value={own === undefined ? '' : String(own)}
|
|
onChange={(event) => set(band.key, field.key, event.currentTarget.value)}
|
|
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
|
|
>
|
|
{/* Mobile is the smallest, so there is nothing below it to inherit from. */}
|
|
<option value="">
|
|
{key === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}
|
|
</option>
|
|
{steps.map((step) => (
|
|
<option key={step} value={String(step)}>
|
|
{step}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|