Layout's controls move to the editor panel too
Colours, Typefaces and Padding sat below the preview and took their height from it — the same trade the
page builder's editors made, and undone for the same reason: this screen exists to be looked at, and
the controls were eating the thing being judged.
Announced through the same slot, so the panel still holds nothing and still knows nothing about what a
layout is.
One thing that does not survive the move: a JSX comment cannot be an argument. The block carried its
own `{/* … */}` above the tabs and it had to become an ordinary comment on the effect, which is where
the reasoning belongs anyway now that the element is announced rather than rendered in place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+161
-144
@@ -8,6 +8,7 @@ import { useSaveTarget } from '../SaveTarget';
|
||||
import { usePreviewable, useViewMode } from '../ViewMode';
|
||||
import { applyGutter, paintMeasure } from '../canvas';
|
||||
import { SitePreview, useDevPort } from '../SitePreview';
|
||||
import { useEditorSlot } from '../useEditorSlot';
|
||||
import { type LayoutBand } from './PaddingTab';
|
||||
import { GutterTab, type Gutter } from './GutterTab';
|
||||
|
||||
@@ -252,6 +253,166 @@ export const LayoutPanel = () => {
|
||||
[gutter],
|
||||
);
|
||||
|
||||
const [, setEditor] = useEditorSlot();
|
||||
const setEditorRef = useRef(setEditor);
|
||||
setEditorRef.current = setEditor;
|
||||
|
||||
/*
|
||||
The layout's controls, handed to the editor panel.
|
||||
|
||||
They were below the preview and took their height from it — the same trade the page builder's
|
||||
editors made, and the same reason for undoing it: this screen exists to be looked at, and the
|
||||
controls were eating the thing being judged.
|
||||
|
||||
Announced rather than rendered, because the panel is a SIBLING — see useEditorSlot. The dependency
|
||||
list is everything the tabs DISPLAY; an element captures the props it was built with, so one left
|
||||
out here reads as a control that has stopped responding rather than as an error.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditorRef.current(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditorRef.current(
|
||||
<Tabs value={tab} onValueChange={setTab} className="shrink-0 gap-2">
|
||||
<TabsList className="h-7">
|
||||
<TabsTrigger value="colours" className="text-xs">
|
||||
Colours
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="typefaces" className="text-xs">
|
||||
Typefaces
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="padding" className="text-xs">
|
||||
Padding
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/*
|
||||
EVERY panel in the same grid cell, so the block is as tall as the TALLEST of them and stops
|
||||
moving when the tab changes.
|
||||
|
||||
Measuring the panels and setting a height would need a ResizeObserver, a re-measure on every
|
||||
edit, and a wrong answer for one frame after each. `grid-area: 1/1` gets it from the layout
|
||||
engine for nothing, and it stays right when a tab is added or one of them grows a row —
|
||||
which is the case that made the first fix insufficient.
|
||||
|
||||
`invisible` and not `hidden`: an invisible element still takes its space, which IS the
|
||||
mechanism. It is also removed from the tab order, so the controls behind the front panel
|
||||
cannot be reached by keyboard.
|
||||
*/}
|
||||
<div className="grid rounded-lg border border-border p-4">
|
||||
<div
|
||||
className={`col-start-1 row-start-1 ${tab === 'colours' ? '' : 'invisible'}`}
|
||||
aria-hidden={tab !== 'colours'}
|
||||
>
|
||||
<>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{COLOURS.map(({ key, label, fallback }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
{/*
|
||||
The browser's own picker. It sits on the LEFT of a wide row here, which is the whole
|
||||
reason it works: the same control at the right-hand edge of this panel opened its popup
|
||||
off the side of the window. See OverlayEditor for the version that had to move.
|
||||
*/}
|
||||
<input
|
||||
type="color"
|
||||
value={draft[key] ?? fallback}
|
||||
onChange={(event) => patch({ [key]: event.currentTarget.value } as Partial<Layout>)}
|
||||
className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
|
||||
title={label}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left text-[10px] text-muted-foreground hover:underline"
|
||||
onClick={() => patch({ [key]: null } as Partial<Layout>)}
|
||||
>
|
||||
{draft[key] ?? 'default'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`col-start-1 row-start-1 ${tab === 'typefaces' ? '' : 'invisible'}`}
|
||||
aria-hidden={tab !== 'typefaces'}
|
||||
>
|
||||
<>
|
||||
{/*
|
||||
Four across, one row.
|
||||
|
||||
At `sm:` and not `lg:`. The breakpoint here is the BROWSER's, not the panel's — and this panel
|
||||
is wide in every window anybody runs the admin in — but lg is 1024 and there was no reason to
|
||||
make four short selects wait for it.
|
||||
|
||||
Two columns meant Typefaces was twice the height of Colours, so switching tabs moved
|
||||
everything below it — and the preview above it changed size to match. A tab strip whose
|
||||
contents jump is a tab strip that has to be re-read every time it is used.
|
||||
|
||||
The hint moves into the option list's own space rather than a line under each select, which
|
||||
is where the second row of height was really going.
|
||||
*/}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{ROLES.map(({ key, label, hint }) => (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
<label className="text-xs">{label}</label>
|
||||
<select
|
||||
value={draft[key] ?? ''}
|
||||
onChange={(event) => patch({ [key]: event.currentTarget.value || null } as Partial<Layout>)}
|
||||
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
|
||||
>
|
||||
{/* Not "Default" — the face the stylesheet actually uses, named. Choosing it puts
|
||||
things back rather than setting something new, and now you can see what back is. */}
|
||||
<option value="">
|
||||
{draft.font_defaults[key] ? `${draft.font_defaults[key]} (site default)` : 'Default'}
|
||||
</option>
|
||||
{draft.fonts.map((font) => (
|
||||
<option key={font} value={font}>
|
||||
{font}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="truncate text-[10px] text-muted-foreground">{hint}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`col-start-1 row-start-1 ${tab === 'padding' ? '' : 'invisible'}`}
|
||||
aria-hidden={tab !== 'padding'}
|
||||
>
|
||||
{/*
|
||||
The site's gutter, not a per-band padding.
|
||||
|
||||
PaddingTab is the per-band control and it stays — for the pages, where a band really
|
||||
does want its own spacing. Here the three wrappers share one measure and should share
|
||||
one number.
|
||||
|
||||
All three widths at once, and deliberately NOT tied to the device toggle: a gutter is a
|
||||
progression, 4 then 8 then 12, and it is judged as one. Reading it off three separate
|
||||
visits to the same control is how a site ends up tighter on a tablet than on a phone.
|
||||
*/}
|
||||
<GutterTab
|
||||
gutter={draft.padding_x}
|
||||
steps={draft.padding_steps}
|
||||
onChange={(padding_x) => patch({ padding_x })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}, [draft, tab, device]);
|
||||
|
||||
// Cleared on unmount, or the panel goes on offering Layout's controls over somebody else's page.
|
||||
useEffect(() => () => setEditorRef.current(null), []);
|
||||
|
||||
if (!draft) return <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
|
||||
|
||||
/*
|
||||
@@ -280,150 +441,6 @@ export const LayoutPanel = () => {
|
||||
would have gone on being wrong differently every time either side changed.
|
||||
*/}
|
||||
<SitePreview port={devPort} path="layout" theme={themeProperties(draft)} onDocument={onDocument} />
|
||||
|
||||
{/*
|
||||
The controls as TABS, and only in Edit.
|
||||
|
||||
They were two stacked cards, which took as much height as both of them together — and every
|
||||
pixel of it came off the preview, which is the thing being judged. One at a time costs a click
|
||||
and gives the page back most of the panel.
|
||||
|
||||
`shrink-0` so they never take height from the preview, and no `max-h` any more: one tab is short
|
||||
enough that it does not need capping, which is the other thing this bought.
|
||||
*/}
|
||||
<Tabs value={tab} onValueChange={setTab} className="shrink-0 gap-2">
|
||||
<TabsList className="h-7">
|
||||
<TabsTrigger value="colours" className="text-xs">
|
||||
Colours
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="typefaces" className="text-xs">
|
||||
Typefaces
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="padding" className="text-xs">
|
||||
Padding
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/*
|
||||
EVERY panel in the same grid cell, so the block is as tall as the TALLEST of them and stops
|
||||
moving when the tab changes.
|
||||
|
||||
Measuring the panels and setting a height would need a ResizeObserver, a re-measure on every
|
||||
edit, and a wrong answer for one frame after each. `grid-area: 1/1` gets it from the layout
|
||||
engine for nothing, and it stays right when a tab is added or one of them grows a row —
|
||||
which is the case that made the first fix insufficient.
|
||||
|
||||
`invisible` and not `hidden`: an invisible element still takes its space, which IS the
|
||||
mechanism. It is also removed from the tab order, so the controls behind the front panel
|
||||
cannot be reached by keyboard.
|
||||
*/}
|
||||
<div className="grid rounded-lg border border-border p-4">
|
||||
<div
|
||||
className={`col-start-1 row-start-1 ${tab === 'colours' ? '' : 'invisible'}`}
|
||||
aria-hidden={tab !== 'colours'}
|
||||
>
|
||||
<>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{COLOURS.map(({ key, label, fallback }) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
{/*
|
||||
The browser's own picker. It sits on the LEFT of a wide row here, which is the whole
|
||||
reason it works: the same control at the right-hand edge of this panel opened its popup
|
||||
off the side of the window. See OverlayEditor for the version that had to move.
|
||||
*/}
|
||||
<input
|
||||
type="color"
|
||||
value={draft[key] ?? fallback}
|
||||
onChange={(event) => patch({ [key]: event.currentTarget.value } as Partial<Layout>)}
|
||||
className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
|
||||
title={label}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-left text-[10px] text-muted-foreground hover:underline"
|
||||
onClick={() => patch({ [key]: null } as Partial<Layout>)}
|
||||
>
|
||||
{draft[key] ?? 'default'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`col-start-1 row-start-1 ${tab === 'typefaces' ? '' : 'invisible'}`}
|
||||
aria-hidden={tab !== 'typefaces'}
|
||||
>
|
||||
<>
|
||||
{/*
|
||||
Four across, one row.
|
||||
|
||||
At `sm:` and not `lg:`. The breakpoint here is the BROWSER's, not the panel's — and this panel
|
||||
is wide in every window anybody runs the admin in — but lg is 1024 and there was no reason to
|
||||
make four short selects wait for it.
|
||||
|
||||
Two columns meant Typefaces was twice the height of Colours, so switching tabs moved
|
||||
everything below it — and the preview above it changed size to match. A tab strip whose
|
||||
contents jump is a tab strip that has to be re-read every time it is used.
|
||||
|
||||
The hint moves into the option list's own space rather than a line under each select, which
|
||||
is where the second row of height was really going.
|
||||
*/}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{ROLES.map(({ key, label, hint }) => (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
<label className="text-xs">{label}</label>
|
||||
<select
|
||||
value={draft[key] ?? ''}
|
||||
onChange={(event) => patch({ [key]: event.currentTarget.value || null } as Partial<Layout>)}
|
||||
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
|
||||
>
|
||||
{/* Not "Default" — the face the stylesheet actually uses, named. Choosing it puts
|
||||
things back rather than setting something new, and now you can see what back is. */}
|
||||
<option value="">
|
||||
{draft.font_defaults[key] ? `${draft.font_defaults[key]} (site default)` : 'Default'}
|
||||
</option>
|
||||
{draft.fonts.map((font) => (
|
||||
<option key={font} value={font}>
|
||||
{font}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="truncate text-[10px] text-muted-foreground">{hint}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`col-start-1 row-start-1 ${tab === 'padding' ? '' : 'invisible'}`}
|
||||
aria-hidden={tab !== 'padding'}
|
||||
>
|
||||
{/*
|
||||
The site's gutter, not a per-band padding.
|
||||
|
||||
PaddingTab is the per-band control and it stays — for the pages, where a band really
|
||||
does want its own spacing. Here the three wrappers share one measure and should share
|
||||
one number.
|
||||
|
||||
All three widths at once, and deliberately NOT tied to the device toggle: a gutter is a
|
||||
progression, 4 then 8 then 12, and it is judged as one. Reading it off three separate
|
||||
visits to the same control is how a site ends up tighter on a tablet than on a phone.
|
||||
*/}
|
||||
<GutterTab
|
||||
gutter={draft.padding_x}
|
||||
steps={draft.padding_steps}
|
||||
onChange={(padding_x) => patch({ padding_x })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user