Files
fofos-admin/web/DeviceToggle.tsx
T
pastilhasandClaude Opus 5 c0c88a434d the toggle drops its labels when the panel is narrow
A container query, not a screen one: this sits in a workspace panel whose width
is a divider somebody drags, so the window says nothing about the room. Below
240px the icons stand alone and the word is the tooltip.

Every editor section starts open. It opened on one, from ?tab=, which is what
tabs want — these are not tabs, and a collapsed column asks somebody to go
looking for the setting they came for. ?tab= is gone with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:44:15 +01:00

96 lines
3.8 KiB
TypeScript

import { Monitor, Smartphone, Tablet } from 'lucide-react';
import { useCallback } from 'react';
import { useSearchParams } from 'react-router';
import { Button } from '@/components/ui/button';
import { DEVICES, type DeviceName } from './ViewMode';
/**
* Which width everything is showing, and the control for changing it.
*
* ── Why this reads the URL and not the context ──
*
* `useViewMode` is a context, and the editor is a SIBLING panel: an element built inside the detail
* panel resolves its context where it is RENDERED, not where it was created, so a toggle handed to the
* editor would look up a tree that has no provider in it and quietly show the default forever.
*
* The device already lives in `?device=`, which every panel can read. So this is the shared controller
* the whole screen wants and there is nothing to share: the URL is the state, three components read it,
* and they cannot disagree because there is only one of it.
*
* That is also why the preview follows along for free. It is not subscribed to this control; it reads
* the same parameter.
*/
export const useDevice = (): [DeviceName, (next: DeviceName) => void] => {
const [params, setParams] = useSearchParams();
const raw = params.get('device');
const device: DeviceName = raw === 'tablet' || raw === 'mobile' ? raw : 'desktop';
const setDevice = useCallback(
(next: DeviceName) => {
const search = new URLSearchParams(params);
search.set('device', next);
setParams(search, { replace: true });
},
[params, setParams],
);
return [device, setDevice];
};
const OPTIONS: { value: DeviceName; Icon: typeof Monitor }[] = [
{ value: 'desktop', Icon: Monitor },
{ value: 'tablet', Icon: Tablet },
{ value: 'mobile', Icon: Smartphone },
];
/**
* The control itself: three tabs across the width of whatever holds it.
*
* ── One shape, not two ──
*
* There was a `size` prop. `bar` was a small icon-only cluster for the panel bar across the top of the
* detail panel, and that bar is gone — so what is left is the one shape, and a prop with a single
* value is a decision nobody makes.
*
* ── Labelled, and spread ──
*
* The icons alone were a rebus: a rectangle, a slightly narrower rectangle, and a narrower one still,
* telling three things apart by proportion at 12 pixels. They read as tabs now because they behave
* like tabs — one of three, the whole width, the chosen one filled — and the word is what makes the
* choice legible without hovering for a tooltip.
*
* The label comes from DEVICES, which is also what the preview names in its own caption. One list.
*
* ── Narrow, it goes back to icons ──
*
* A CONTAINER query, not a screen one: this sits in a workspace panel whose width is a divider the
* user drags, so the window's width says nothing about the room there is. `@container` on the row
* makes the row itself the thing measured.
*
* The word is then the `title`, so the answer is still one hover away rather than gone.
*/
export const DeviceToggle = () => {
const [device, setDevice] = useDevice();
return (
<div className="@container flex w-full items-center rounded-md border border-border p-0.5">
{OPTIONS.map(({ value, Icon }) => (
<Button
key={value}
size="sm"
variant={device === value ? 'secondary' : 'ghost'}
className="h-7 flex-1 gap-1.5 text-xs"
onClick={() => setDevice(value)}
aria-label={DEVICES[value].label}
title={DEVICES[value].label}
>
<Icon className="size-3.5" />
{/* Three labels need about 240px between them. Below that the icons stand alone and the
word is the tooltip. */}
<span className="hidden @[15rem]:inline">{DEVICES[value].label}</span>
</Button>
))}
</div>
);
};