Choose a band — Header, Page, Footer — and set its horizontal and vertical padding. Which WIDTH you are
setting is decided by the Desktop / Tablet / Mobile buttons already in the bar:
Mobile 390 base every width
Tablet 834 md 768 and up
Desktop 1440 lg 1024 and up
That mapping is Tailwind's own and it lines up with the three preview widths exactly. Editing all three
at once would be six controls per section, 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 see changing.
INHERIT AND ZERO ARE DIFFERENT OPTIONS, because they are different instructions. Unset takes the
smaller breakpoint's value — which is what makes "one padding everywhere" a single choice rather than
three — and zero means no padding HERE even though the smaller width has some. The select says which
value is being inherited rather than leaving it to be worked out, and at the smallest width it says
"None", since there is nothing below to inherit from.
An emptied breakpoint is REMOVED rather than stored as `{}`, so "inherit everything" stays an absence
instead of becoming a thing that has to be told apart from one later.
One Save for both endpoints. The alternative was a second Save for spacing, which means somebody
changing a colour and a padding presses two buttons and is told about unsaved work by one of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
import { Button } from '@/components/ui/button';
|
|
import { DEVICES, type DeviceName } from '../ViewMode';
|
|
|
|
/**
|
|
* Padding, per section, per breakpoint.
|
|
*
|
|
* ── The device toggle IS the breakpoint selector ──
|
|
*
|
|
* Editing all three widths at once would be six controls per section, and five of them would be for a
|
|
* width you are not looking at. Instead the bar's Desktop / Tablet / Mobile decides which breakpoint
|
|
* these two controls write to — so you switch to Mobile, see the phone, and change the padding you can
|
|
* see changing.
|
|
*
|
|
* The mapping is Tailwind's, and it lines up with the three preview widths exactly:
|
|
*
|
|
* Mobile 390 base every width
|
|
* Tablet 834 md 768 and up
|
|
* Desktop 1440 lg 1024 and up
|
|
*
|
|
* ── Inherit is a real value, and not the same as zero ──
|
|
*
|
|
* They cascade, so an unset breakpoint takes the smaller one's value. That is what makes the common
|
|
* case — one padding everywhere — a single choice rather than three. Zero is the opposite instruction:
|
|
* no padding HERE even though the smaller width has some. Both have to be expressible, so "Inherit"
|
|
* and "0" are separate options rather than one meaning both.
|
|
*/
|
|
|
|
export type SectionPadding = Partial<Record<'base' | 'md' | 'lg', { x?: number; y?: number }>>;
|
|
export type LayoutBand = { id: number; name: string; padding: SectionPadding };
|
|
|
|
/** Which breakpoint a device is looking at. See the table above. */
|
|
export const BREAKPOINT: Record<DeviceName, 'base' | 'md' | 'lg'> = {
|
|
mobile: 'base',
|
|
tablet: 'md',
|
|
desktop: 'lg',
|
|
};
|
|
|
|
/** What a breakpoint inherits when an axis is unset: the nearest smaller one that has a value. */
|
|
const inherited = (padding: SectionPadding, 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 PaddingTab = ({
|
|
bands,
|
|
device,
|
|
steps,
|
|
selected,
|
|
onSelect,
|
|
onChange,
|
|
}: {
|
|
bands: LayoutBand[];
|
|
device: DeviceName;
|
|
/** The scale, sent by the API so this file holds no copy of it. */
|
|
steps: number[];
|
|
selected: number | null;
|
|
onSelect: (id: number) => void;
|
|
onChange: (id: number, padding: SectionPadding) => void;
|
|
}) => {
|
|
const breakpoint = BREAKPOINT[device];
|
|
const band = bands.find((one) => one.id === selected) ?? bands[0];
|
|
|
|
if (!band) {
|
|
// Before the layout has been seeded. Said plainly rather than shown as an empty form somebody
|
|
// would try to use.
|
|
return <p className="text-xs text-muted-foreground">The layout has no sections yet.</p>;
|
|
}
|
|
|
|
const set = (axis: 'x' | 'y', value: string) => {
|
|
const at = { ...(band.padding[breakpoint] ?? {}) };
|
|
if (value === '') delete at[axis];
|
|
else at[axis] = Number(value);
|
|
|
|
const next = { ...band.padding };
|
|
// An empty breakpoint is REMOVED rather than left as `{}` — otherwise "inherit everything" would
|
|
// be stored as a thing rather than as an absence, and the two would have to be told apart later.
|
|
if (Object.keys(at).length) next[breakpoint] = at;
|
|
else delete next[breakpoint];
|
|
|
|
onChange(band.id, next);
|
|
};
|
|
|
|
const axis = (name: 'x' | 'y', label: string) => {
|
|
const own = band.padding[breakpoint]?.[name];
|
|
const from = inherited(band.padding, breakpoint, name);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-xs">{label}</label>
|
|
<select
|
|
value={own === undefined ? '' : String(own)}
|
|
onChange={(event) => set(name, event.currentTarget.value)}
|
|
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
|
|
>
|
|
{/* At the smallest width there is nothing below to inherit from, so the empty option means
|
|
what it actually does there: no padding. */}
|
|
<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 flex-col gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{bands.map((one) => (
|
|
<Button
|
|
key={one.id}
|
|
size="sm"
|
|
variant={one.id === band.id ? 'secondary' : 'ghost'}
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => onSelect(one.id)}
|
|
>
|
|
{one.name}
|
|
</Button>
|
|
))}
|
|
|
|
{/* Which width is being edited, said out loud. The device buttons are at the top of the panel
|
|
and the effect of pressing one is not obvious from down here. */}
|
|
<span className="ml-auto text-[10px] text-muted-foreground">
|
|
editing {DEVICES[device].label.toLowerCase()} — {breakpoint}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
{axis('x', 'Horizontal')}
|
|
{axis('y', 'Vertical')}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|