the tab panels share one grid cell, so nothing jumps

Making Typefaces one row equalised the two tabs by luck, not by construction — the labels and the hints
under the selects still differed, and any tab that grows a row would put the jump straight back.

So every panel now sits in the SAME grid cell. The block is as tall as the tallest of them and stops
moving entirely, and it stays right when a tab is added or one of them grows.

Measuring instead would have meant a ResizeObserver, a re-measure after every edit, and a wrong answer
for one frame after each. `col-start-1 row-start-1` gets the same number out of the layout engine for
nothing.

`invisible`, not `hidden`. An invisible element still takes its space, which IS the mechanism — and it
is out of the tab order, so the controls behind the front panel cannot be reached by keyboard. The Tabs
root is controlled now, because visibility is decided here rather than by Radix unmounting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 05:20:53 +01:00
co-authored by Claude Opus 5
parent 7e29953c07
commit 495320f555
+31 -8
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
@@ -114,6 +114,8 @@ export const LayoutPanel = () => {
const [draft, setDraft] = useState<Layout | null>(null);
const [busy, setBusy] = useState(false);
const devPort = useDevPort();
/** Controlled, because the grid stack below decides visibility rather than Radix unmounting. */
const [tab, setTab] = useState('colours');
const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash();
@@ -233,7 +235,7 @@ export const LayoutPanel = () => {
enough that it does not need capping, which is the other thing this bought.
*/}
{mode === 'edit' && (
<Tabs defaultValue="colours" className="shrink-0 gap-2">
<Tabs value={tab} onValueChange={setTab} className="shrink-0 gap-2">
<TabsList className="h-7">
<TabsTrigger value="colours" className="text-xs">
Colours
@@ -243,8 +245,25 @@ export const LayoutPanel = () => {
</TabsTrigger>
</TabsList>
<TabsContent value="colours" className="rounded-lg border border-border p-4">
<>
{/*
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">
@@ -274,10 +293,13 @@ export const LayoutPanel = () => {
))}
</div>
</>
</TabsContent>
</div>
<TabsContent value="typefaces" className="rounded-lg border border-border p-4">
<>
<div
className={`col-start-1 row-start-1 ${tab === 'typefaces' ? '' : 'invisible'}`}
aria-hidden={tab !== 'typefaces'}
>
<>
{/*
Four across, one row.
@@ -317,7 +339,8 @@ export const LayoutPanel = () => {
))}
</div>
</>
</TabsContent>
</div>
</div>
</Tabs>
)}