Spacing's settings are per width, so the section has to say which width it is editing — and the answer must be the one the preview is already showing, not a second selector able to disagree with it. It reads `?device=` rather than the ViewMode context, and that is not a preference. The editor is a SIBLING panel: an element built inside the detail panel resolves context where it is RENDERED, so a toggle handed over would look up a tree with no provider in it and quietly show the default forever. The URL is the shared state, which means there is nothing to share. Three components read one parameter, so they cannot disagree, and the preview follows without being subscribed to anything — it reads the same value. More editor sections can take the control by rendering it. The bar's own toggle is now the same component. It had its own copy of the options and the handler, which is exactly how two controls for one thing start to differ. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
75 lines
2.7 KiB
TypeScript
75 lines
2.7 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 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, for an editor section that has per-view settings.
|
|
*
|
|
* `size` because the one in the panel's bar is chrome and these are part of a form: the bar's is as
|
|
* small as a bar allows, and a section's sits above its own fields and should read as belonging to
|
|
* them.
|
|
*/
|
|
export const DeviceToggle = ({ size = 'bar' }: { size?: 'bar' | 'section' }) => {
|
|
const [device, setDevice] = useDevice();
|
|
const compact = size === 'bar';
|
|
|
|
return (
|
|
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
|
|
{OPTIONS.map(({ value, Icon }) => (
|
|
<Button
|
|
key={value}
|
|
size="sm"
|
|
variant={device === value ? 'secondary' : 'ghost'}
|
|
className={compact ? 'h-5 gap-1 px-2 text-xs' : 'h-6 gap-1 px-2 text-xs'}
|
|
onClick={() => setDevice(value)}
|
|
aria-label={value}
|
|
title={value}
|
|
>
|
|
<Icon className="size-3" />
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|