Icons alone told three things apart by proportion at 12 pixels. Full width, one of three filled, and the word from DEVICES. The `size` prop is gone with the bar that needed the small one, and so is the second copy inside Spacing — the panel header scopes the whole editor now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
85 lines
3.2 KiB
TypeScript
85 lines
3.2 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.
|
|
*/
|
|
export const DeviceToggle = () => {
|
|
const [device, setDevice] = useDevice();
|
|
|
|
return (
|
|
<div className="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}
|
|
>
|
|
<Icon className="size-3.5" />
|
|
{DEVICES[value].label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|