No label, full width, three across. It scopes the fields below it, so it reads as their header.
81 lines
3.0 KiB
TypeScript
81 lines
3.0 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
|
|
// In a section it spans the width and spreads its three: it scopes everything below it, so it
|
|
// reads as a header for the group rather than a control sitting in a corner of one.
|
|
className={`flex items-center rounded-md border border-border p-0.5 ${
|
|
compact ? 'gap-0.5' : 'w-full justify-around'
|
|
}`}
|
|
>
|
|
{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-7 flex-1 text-xs'}
|
|
onClick={() => setDevice(value)}
|
|
aria-label={value}
|
|
title={value}
|
|
>
|
|
<Icon className="size-3" />
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|