Files
fofos-admin/web/DeviceToggle.tsx
T
pastilhas 89380a69d6 the device control spans the section
No label, full width, three across. It scopes the fields below it, so it reads as their header.
2026-09-01 03:55:12 +01:00

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>
);
};