?mode=edit&device=mobile I had argued this one should stay in React state — a way of LOOKING rather than a thing being looked at — and the distinction did not survive contact with the obvious use. "Here is what it does on a phone" is exactly the link somebody wants to send, and a URL that drops the width sends them the desktop. docs/navigation-audit.md already said addressable state goes in the URL. Both of these are addressable. The rule was right; my exception was not. Absent means desktop, as absent means view: an ordinary link carries neither parameter, and each appears once somebody has chosen it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
155 lines
6.5 KiB
TypeScript
155 lines
6.5 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
|
|
/**
|
|
* How the open section is being looked at: editing or just viewing, and at what width.
|
|
*
|
|
* ── Why a context, and the same shape as SaveTarget ──
|
|
*
|
|
* The controls live in DetailPanel's bar and the thing they control lives in the section. DetailPanel
|
|
* deliberately knows nothing about the sections beyond a map of keys to components, so the section
|
|
* announces itself rather than being handed props — exactly as it does for Save.
|
|
*
|
|
* ── Why a section has to OPT IN ──
|
|
*
|
|
* Most sections have no preview. Media is a file browser, Administration is a connection form, Messages
|
|
* is an inbox — offering to view any of them at tablet width is offering something that means nothing,
|
|
* and a control that does nothing teaches people that controls do nothing.
|
|
*
|
|
* So the bar shows these only when the open section says it has a preview, the same way Save renders
|
|
* nothing when there is nothing to save.
|
|
*
|
|
* ── Both live in the URL ──
|
|
*
|
|
* `?mode=edit&device=mobile`. I had argued the device should stay in React state — a way of LOOKING
|
|
* rather than a thing being looked at — and that distinction did not survive contact with the obvious
|
|
* use: "here is what it does on a phone" is exactly the link somebody wants to send, and a URL that
|
|
* drops the width sends them the desktop.
|
|
*
|
|
* docs/navigation-audit.md says addressable state goes in the URL, and both of these are addressable.
|
|
* The rule was already right; the exception was not.
|
|
*/
|
|
|
|
export type Mode = 'edit' | 'view';
|
|
export type DeviceName = 'desktop' | 'tablet' | 'mobile';
|
|
|
|
/**
|
|
* The widths a preview is rendered at, and why these numbers.
|
|
*
|
|
* Widths ONLY. There were heights too, and they were wrong: media queries answer width, so a frame's
|
|
* height changes how much page you see and never which layout you get. The preview takes whatever
|
|
* height the panel has.
|
|
*
|
|
* They are REAL viewport sizes, and the preview is scaled down to fit rather than being given whatever
|
|
* width the panel happens to have. That distinction is the whole point: a "Desktop" preview squeezed
|
|
* into a 900px panel would render the site's tablet layout and call it desktop, which is precisely the
|
|
* lie a preview exists to prevent.
|
|
*
|
|
* mobile 390 — the iPhone width the site's own slot table already assumes (see media/slots.ts,
|
|
* where MOBILE is 342: this minus the layout's 48px of padding)
|
|
* tablet 834 — iPad portrait, and deliberately ABOVE the site's `md:` breakpoint at 768, so it
|
|
* shows the desktop navigation rather than the drawer. A tablet at 768 exactly sits
|
|
* on the boundary and is the least informative width available.
|
|
* desktop 1440 — a common laptop, comfortably past the 1280 the content column caps at, so the
|
|
* margins either side are visible rather than implied.
|
|
*/
|
|
export const DEVICES: Record<DeviceName, { label: string; width: number }> = {
|
|
desktop: { label: 'Desktop', width: 1440 },
|
|
tablet: { label: 'Tablet', width: 834 },
|
|
mobile: { label: 'Mobile', width: 390 },
|
|
};
|
|
|
|
type ViewModeValue = {
|
|
mode: Mode;
|
|
device: DeviceName;
|
|
setMode: (mode: Mode) => void;
|
|
setDevice: (device: DeviceName) => void;
|
|
/** True while a section with a preview is open. The bar shows its controls only then. */
|
|
previewable: boolean;
|
|
announcePreviewable: (has: boolean) => void;
|
|
};
|
|
|
|
const ViewModeContext = createContext<ViewModeValue>({
|
|
mode: 'edit',
|
|
device: 'desktop',
|
|
setMode: () => {},
|
|
setDevice: () => {},
|
|
previewable: false,
|
|
announcePreviewable: () => {},
|
|
});
|
|
|
|
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
|
const [params, setParams] = useSearchParams();
|
|
|
|
/*
|
|
The mode lives in the URL — `?mode=edit`.
|
|
|
|
docs/navigation-audit.md draws the line: anything ADDRESSABLE goes in the URL. Whether you are
|
|
editing a page or looking at it is exactly that. It survives a reload, it can be sent to somebody,
|
|
and a link to a page being edited is a different thing from a link to the page.
|
|
|
|
`mode`, not `view`. The parameter names the AXIS and the value names the state, so the pair reads
|
|
as `mode=edit` and `mode=view` rather than the tautological `view=view`.
|
|
|
|
Absent means View, which is the default and the common case — so an ordinary link carries nothing,
|
|
and the parameter only appears once somebody has chosen.
|
|
*/
|
|
const mode: Mode = params.get('mode') === 'edit' ? 'edit' : 'view';
|
|
|
|
const setMode = useCallback(
|
|
(next: Mode) => {
|
|
const search = new URLSearchParams(params);
|
|
// Written even for the default, once somebody has touched it: a URL that says `mode=view` is
|
|
// unambiguous, and a toggle that sometimes leaves no trace reads as a toggle that failed.
|
|
search.set('mode', next);
|
|
setParams(search, { replace: true });
|
|
},
|
|
[params, setParams],
|
|
);
|
|
|
|
/*
|
|
The device, alongside the mode, for the same reason.
|
|
|
|
Absent means desktop — the default and the common case — so an ordinary link carries neither
|
|
parameter and each appears only once somebody has chosen it.
|
|
*/
|
|
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],
|
|
);
|
|
const [previewable, setPreviewable] = useState(false);
|
|
|
|
// Stable, so the effect in `usePreviewable` is not re-run by this provider re-rendering.
|
|
const announcePreviewable = useCallback((has: boolean) => setPreviewable(has), []);
|
|
|
|
return (
|
|
<ViewModeContext.Provider value={{ mode, device, setMode, setDevice, previewable, announcePreviewable }}>
|
|
{children}
|
|
</ViewModeContext.Provider>
|
|
);
|
|
};
|
|
|
|
/** Read by a section: how it is being looked at. */
|
|
export const useViewMode = (): ViewModeValue => useContext(ViewModeContext);
|
|
|
|
/**
|
|
* Called by a section that HAS a preview, so the bar offers the controls.
|
|
*
|
|
* The cleanup clears it, so a section that unmounts leaves no controls behind for a preview that is no
|
|
* longer on screen.
|
|
*/
|
|
export const usePreviewable = (): void => {
|
|
const { announcePreviewable } = useContext(ViewModeContext);
|
|
useEffect(() => {
|
|
announcePreviewable(true);
|
|
return () => announcePreviewable(false);
|
|
}, [announcePreviewable]);
|
|
};
|