Layout's controls are stacked sections, not tabs

Three tabs became three collapsible sections in a column, because there are going to be more than
three. A tab strip works while the names fit on one line and degrades the moment they do not — it wraps,
or it scrolls, and either way the set stops being visible at a glance.

`type="multiple"`, so more than one can be open. Tabs enforced exactly one, which is right when the
panels are alternatives and wrong when they are parts of the same thing: the colours and the padding of
a layout are read together as often as separately.

The grid overlay went with them. Every panel shared one cell so the block stayed as tall as the tallest
and did not jump when the tab changed — a real problem that stacked sections do not have, because a
section that is closed occupies nothing and one that is open is as tall as it is.

`?tab=` still seeds which section starts open, so an old link keeps working and lands on the thing it
named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 03:28:15 +01:00
co-authored by Claude Opus 5
parent ba2f98c7fb
commit dd10a4df45
+18 -47
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { readableError } from '../errors'; import { readableError } from '../errors';
@@ -275,37 +275,10 @@ export const LayoutPanel = () => {
} }
setEditorRef.current( setEditorRef.current(
<Tabs value={tab} onValueChange={setTab} className="shrink-0 gap-2"> <Accordion type="multiple" defaultValue={[tab]} className="flex flex-col gap-2">
<TabsList className="h-7"> <AccordionItem value="colours" className="rounded-lg border border-border px-4">
<TabsTrigger value="colours" className="text-xs"> <AccordionTrigger className="text-xs">Colours</AccordionTrigger>
Colours <AccordionContent>
</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"> <div className="flex flex-wrap gap-4">
{COLOURS.map(({ key, label, fallback }) => ( {COLOURS.map(({ key, label, fallback }) => (
@@ -336,12 +309,11 @@ export const LayoutPanel = () => {
))} ))}
</div> </div>
</> </>
</div> </AccordionContent>
</AccordionItem>
<div <AccordionItem value="typefaces" className="rounded-lg border border-border px-4">
className={`col-start-1 row-start-1 ${tab === 'typefaces' ? '' : 'invisible'}`} <AccordionTrigger className="text-xs">Typefaces</AccordionTrigger>
aria-hidden={tab !== 'typefaces'} <AccordionContent>
>
<> <>
{/* {/*
Four across, one row. Four across, one row.
@@ -382,12 +354,11 @@ export const LayoutPanel = () => {
))} ))}
</div> </div>
</> </>
</div> </AccordionContent>
</AccordionItem>
<div <AccordionItem value="padding" className="rounded-lg border border-border px-4">
className={`col-start-1 row-start-1 ${tab === 'padding' ? '' : 'invisible'}`} <AccordionTrigger className="text-xs">Padding</AccordionTrigger>
aria-hidden={tab !== 'padding'} <AccordionContent>
>
{/* {/*
The site's gutter, not a per-band padding. The site's gutter, not a per-band padding.
@@ -404,9 +375,9 @@ export const LayoutPanel = () => {
steps={draft.padding_steps} steps={draft.padding_steps}
onChange={(padding_x) => patch({ padding_x })} onChange={(padding_x) => patch({ padding_x })}
/> />
</div> </AccordionContent>
</div> </AccordionItem>
</Tabs> </Accordion>
); );
}, [draft, tab, device]); }, [draft, tab, device]);