diff --git a/samples/crm-web/src/design/primitives/Drawer.module.css b/samples/crm-web/src/design/primitives/Drawer.module.css index 0d238f2f..01fb8415 100644 --- a/samples/crm-web/src/design/primitives/Drawer.module.css +++ b/samples/crm-web/src/design/primitives/Drawer.module.css @@ -1,5 +1,5 @@ .scrim { - position: absolute; + position: fixed; inset: 0; z-index: 30; display: flex; @@ -7,6 +7,11 @@ background: rgba(0, 0, 0, 0.18); } +/* Nothing behind a full-width panel is visible, so there is nothing for the scrim to dim. */ +.scrim:has(.full) { + background: transparent; +} + .panel { position: relative; width: var(--drawer-width, 452px); @@ -102,3 +107,32 @@ line-height: 1.2; white-space: nowrap; } + +/* + * The three sizes. + * + * Widths in `min()` against the viewport so a narrow laptop gets the whole screen rather than a + * panel wider than the window with its close button off the edge. `full` drops the scrim's + * darkening and the panel's shadow: at full width there is nothing behind to dim, and a shadow + * cast by an edge that is the screen edge reads as a rendering fault. + */ +.peek { + width: var(--drawer-width, 452px); +} + +.wide { + width: min(920px, 100%); +} + +.full { + width: 100%; + border-left: 0; + box-shadow: none; +} + +.headControls { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; +} diff --git a/samples/crm-web/src/design/primitives/Drawer.tsx b/samples/crm-web/src/design/primitives/Drawer.tsx index cb84e3a1..f8e8697f 100644 --- a/samples/crm-web/src/design/primitives/Drawer.tsx +++ b/samples/crm-web/src/design/primitives/Drawer.tsx @@ -1,6 +1,9 @@ import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' import type { ReactNode } from 'react' import { Button } from './Button' +import { narrow, widen } from './drawerSize' +import type { DrawerSize } from './drawerSize' import styles from './Drawer.module.css' export interface DrawerProps { @@ -14,6 +17,13 @@ export interface DrawerProps { actions?: ReactNode onClose: () => void width?: number + /** + * How much of the screen it takes. Omit it and the drawer is the strip it always was — the + * forms that open one are the size of their fields and have nothing to widen into. + */ + size?: DrawerSize + /** Supplied with {@link size} to offer the widen and narrow controls. */ + onSizeChange?: (size: DrawerSize) => void children: ReactNode } @@ -24,7 +34,17 @@ export interface DrawerProps { * keyboard user opens and then cannot leave; both are a handful of lines and both are the first * things reported when this pattern ships without them. */ -export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, children }: DrawerProps) { +export function Drawer({ + title, + eyebrow, + subtitle, + actions, + onClose, + width, + size, + onSizeChange, + children, +}: DrawerProps) { const panel = useRef(null) const opener = useRef(null) @@ -33,7 +53,17 @@ export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, chil panel.current?.focus() function onKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') onClose() + if (event.key !== 'Escape') return + + // Escape steps back down through the sizes before it closes. A reader who went full screen + // and pressed Escape meant "give me the page back", not "throw away what I was reading" — + // and the second is not undoable, because the peek does not remember which record it held. + if (size && onSizeChange && size !== 'peek') { + onSizeChange(narrow(size)) + return + } + + onClose() } document.addEventListener('keydown', onKeyDown) @@ -46,7 +76,15 @@ export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, chil } }, [onClose]) - return ( + /* + * PORTALLED TO THE DOCUMENT, so a drawer opened from inside a drawer covers the window rather + * than the panel it was opened from. That became reachable when the list's peek started drawing + * `RecordDetail`, which has an Edit of its own: rendered in place, the edit panel was laid out + * inside the peek and clipped by it, which reads as a broken control rather than a nested one. + * `.shell` is the only positioned ancestor and it fills the window, so `fixed` covers the same + * rectangle `absolute` did — this moves no pixels for the nine drawers that were already fine. + */ + return createPortal(
{ @@ -59,21 +97,54 @@ export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, chil aria-modal="true" aria-label={typeof title === 'string' ? title : undefined} tabIndex={-1} - className={styles.panel} - style={width ? ({ '--drawer-width': `${width}px` } as React.CSSProperties) : undefined} + className={`${styles.panel} ${size ? (styles[size] ?? '') : ''}`} + style={ + width && (size ?? 'peek') === 'peek' + ? ({ '--drawer-width': `${width}px` } as React.CSSProperties) + : undefined + } >
{eyebrow ? {eyebrow} : null} - +
+ {size && onSizeChange ? ( + <> + {/* + Two buttons rather than one toggle. A single "expand" that cycles peek → wide → + full → peek is one click away from the size you wanted and three from the one + you left, and a reader cannot tell which way it will go before pressing it. + */} + + + + ) : null} + +
{title}
{subtitle ?
{subtitle}
: null} @@ -81,7 +152,8 @@ export function Drawer({ title, eyebrow, subtitle, actions, onClose, width, chil
{children}
- + , + document.body, ) } diff --git a/samples/crm-web/src/design/primitives/__tests__/drawerSize.test.ts b/samples/crm-web/src/design/primitives/__tests__/drawerSize.test.ts new file mode 100644 index 00000000..79ecce4b --- /dev/null +++ b/samples/crm-web/src/design/primitives/__tests__/drawerSize.test.ts @@ -0,0 +1,83 @@ +/** + * The peek's size, as a state machine and as a stored preference. + * + * The interesting cases are the two ends and the browser that refuses storage. A wrap at either + * end would take a reader from full screen back to a strip without them asking, and a throw from + * `localStorage` would take the whole record page with it — Safari in private mode throws on + * `setItem`, and a peek is not worth a blank screen. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + DEFAULT_DRAWER_SIZE, + isDrawerSize, + narrow, + rememberDrawerSize, + storedDrawerSize, + widen, +} from '../drawerSize' + +afterEach(() => { + localStorage.clear() + vi.restoreAllMocks() +}) + +describe('widen and narrow', () => { + it('steps up through the three sizes', () => { + expect(widen('peek')).toBe('wide') + expect(widen('wide')).toBe('full') + }) + + it('steps back down', () => { + expect(narrow('full')).toBe('wide') + expect(narrow('wide')).toBe('peek') + }) + + it('saturates rather than wrapping', () => { + expect(widen('full')).toBe('full') + expect(narrow('peek')).toBe('peek') + }) +}) + +describe('the stored preference', () => { + it('round-trips', () => { + rememberDrawerSize('wide') + + expect(storedDrawerSize()).toBe('wide') + }) + + it('falls back to the default when nothing is stored', () => { + expect(storedDrawerSize()).toBe(DEFAULT_DRAWER_SIZE) + }) + + it('falls back when the stored value is not a size', () => { + // A key this application wrote in an earlier shape, or one somebody edited by hand. + localStorage.setItem('crm.drawerSize', 'enormous') + + expect(storedDrawerSize()).toBe(DEFAULT_DRAWER_SIZE) + }) + + it('does not throw when storage is denied on read', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('The operation is insecure.') + }) + + expect(storedDrawerSize()).toBe(DEFAULT_DRAWER_SIZE) + }) + + it('does not throw when storage is denied on write', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('The quota has been exceeded.') + }) + + expect(() => rememberDrawerSize('full')).not.toThrow() + }) +}) + +describe('isDrawerSize', () => { + it('accepts the three and refuses everything else', () => { + expect(isDrawerSize('peek')).toBe(true) + expect(isDrawerSize('full')).toBe(true) + expect(isDrawerSize('wider')).toBe(false) + expect(isDrawerSize(null)).toBe(false) + }) +}) diff --git a/samples/crm-web/src/design/primitives/drawerSize.ts b/samples/crm-web/src/design/primitives/drawerSize.ts new file mode 100644 index 00000000..ca18227c --- /dev/null +++ b/samples/crm-web/src/design/primitives/drawerSize.ts @@ -0,0 +1,62 @@ +/** + * How wide the peek is, and why it is remembered. + * + * THREE SIZES, NOT TWO. A right-hand panel is the right shape for checking one value and the + * wrong shape for reading a record with four sections in it — the fields wrap to one per line and + * the reader scrolls a page that would have fitted on a screen. `wide` is the size that makes the + * peek an answer rather than a preview, and `full` is for the case where the peek has stopped + * being a peek and the reader wants the page. + * + * IT IS REMEMBERED PER BROWSER, because the choice is a working habit rather than a property of a + * record: somebody who reads records wide reads every record wide, and having to say so on each + * one is the kind of friction that ends with the peek being ignored in favour of the full page. + * Same `localStorage` shape as the locale, and the same refusal to throw when storage is denied. + */ +export const DRAWER_SIZES = ['peek', 'wide', 'full'] as const + +export type DrawerSize = (typeof DRAWER_SIZES)[number] + +export const DEFAULT_DRAWER_SIZE: DrawerSize = 'peek' + +const STORAGE_KEY = 'crm.drawerSize' + +export function isDrawerSize(value: string | null): value is DrawerSize { + return value !== null && (DRAWER_SIZES as readonly string[]).includes(value) +} + +/** The size this browser last used, or the default. Never throws: private mode has no storage. */ +export function storedDrawerSize(): DrawerSize { + try { + const saved = localStorage.getItem(STORAGE_KEY) + + return isDrawerSize(saved) ? saved : DEFAULT_DRAWER_SIZE + } catch { + return DEFAULT_DRAWER_SIZE + } +} + +export function rememberDrawerSize(size: DrawerSize): void { + try { + localStorage.setItem(STORAGE_KEY, size) + } catch { + // A browser that refuses storage still gets the size for this session. + } +} + +/** + * The next size up, and the next size down, saturating at each end. + * + * Saturating rather than wrapping: a reader holding the widen key expects to arrive at the widest + * and stop, and a wrap would take them from full screen back to a strip without them asking. + */ +export function widen(size: DrawerSize): DrawerSize { + const next = DRAWER_SIZES[DRAWER_SIZES.indexOf(size) + 1] + + return next ?? size +} + +export function narrow(size: DrawerSize): DrawerSize { + const index = DRAWER_SIZES.indexOf(size) + + return index <= 0 ? size : (DRAWER_SIZES[index - 1] as DrawerSize) +} diff --git a/samples/crm-web/src/features/sales/ListScreen.tsx b/samples/crm-web/src/features/sales/ListScreen.tsx index 60a76247..99ad1122 100644 --- a/samples/crm-web/src/features/sales/ListScreen.tsx +++ b/samples/crm-web/src/features/sales/ListScreen.tsx @@ -6,11 +6,8 @@ import { DataTable, ErrorState, Drawer, - DrawerSection, - FieldRow, Page, PageHeader, - Tag, TextField, } from '@/design/primitives' import type { Column } from '@/design/primitives' @@ -18,14 +15,14 @@ import { useEntityPage, useProcess, useSchema } from '@/api/queries/hooks' import { modelFor } from '@/fixtures/objects' import type { RecordRow } from '@/fixtures/objects' import { isNumeric, renderCell } from './RecordCell' +import { RecordDetail } from './RecordScreen' import { entityOf, toRows } from './liveRecords' -import { EditFieldsDrawer } from './EditFieldsDrawer' +import { rememberDrawerSize, storedDrawerSize } from '@/design/primitives/drawerSize' +import type { DrawerSize } from '@/design/primitives/drawerSize' import { NewLeadDrawer } from './NewLeadDrawer' import { NewTaskDrawer } from './NewTaskDrawer' import styles from './ListScreen.module.css' -/** The kinds a custom field can be declared on, which is what the edit drawer writes. */ -const EDITABLE_KINDS: readonly string[] = ['Lead', 'Account', 'Contact', 'Opportunity'] /** * Why the other objects have no New button. @@ -61,7 +58,15 @@ export function ListScreen({ objectKey }: { objectKey: string }) { const [search, setSearch] = useState('') const [stage, setStage] = useState('all') const [peek, setPeek] = useState(null) - const [editing, setEditing] = useState(null) + + // The size outlives the peek: closing one record and opening the next keeps the width the + // reader chose, which is the whole reason it is remembered rather than reset per record. + const [size, setSizeState] = useState(storedDrawerSize) + + const setSize = (next: DrawerSize) => { + setSizeState(next) + rememberDrawerSize(next) + } const [hidden, setHidden] = useState([]) const [showColumns, setShowColumns] = useState(false) const [creating, setCreating] = useState(false) @@ -319,74 +324,41 @@ export function ListScreen({ objectKey }: { objectKey: string }) { {peek ? ( setPeek(null)} actions={ - <> - - {/* - The same drawer the record page opens, and only for the four kinds a custom field - can be declared on. It did nothing at all before, which is the one outcome a - reader cannot tell from a slow one. - */} - - + } > - - {model.fields.map((field) => ( - - {renderCell(model, peek, field.name)} - - ))} - {model.stageField ? ( - <> - -
- {stages.map((option) => ( - - {option} - - ))} -
- - ) : null} + {/* + THE PEEK IS THE RECORD PAGE, at a density. It used to be a second rendering — the + fields off the model in one flat list, a stage strip made of tags, and its own Edit + button — which is two places to add a field to and one of them gets forgotten. It also + showed the *row* the list had already fetched rather than the record, so a column the + list does not select was simply missing from the peek with nothing saying so. + */} +
) : null} - {editing !== null && entity !== null ? ( - setEditing(null)} - /> - ) : null} ) } diff --git a/samples/crm-web/src/features/sales/RecordScreen.tsx b/samples/crm-web/src/features/sales/RecordScreen.tsx index b7719575..20eaa747 100644 --- a/samples/crm-web/src/features/sales/RecordScreen.tsx +++ b/samples/crm-web/src/features/sales/RecordScreen.tsx @@ -11,7 +11,7 @@ import { Skeleton, Tabs, } from '@/design/primitives' -import { useEntityPage, useEntityRecord, useProcess } from '@/api/queries/hooks' +import { useEntityPage, useEntityRecord, useProcess, useSchema } from '@/api/queries/hooks' import { modelFor } from '@/fixtures/objects' import { renderCell } from './RecordCell' import { entityOf, keyColumnOf, toRows } from './liveRecords' @@ -38,10 +38,61 @@ type RecordTab = 'details' | 'related' | 'activity' | 'files' * appears here, and a record in a stage the model does not have is visibly in none of them rather * than silently drawn as the first. */ -/** The kinds a custom field can be declared on, which is what the edit drawer writes. */ +/** + * The kinds a custom field can be declared *on*, which is not the same as the kinds that have one. + * + * Membership here is necessary for Edit to do anything and nowhere near sufficient: a tenant that + * has declared nothing on contacts has an Edit that opens a drawer listing no fields and offering + * "Save 0 change(s)". Being in this list is checked against the schema below before the button is + * offered. + */ const EDITABLE: readonly string[] = ['Lead', 'Account', 'Contact', 'Opportunity'] +/** + * The record page. + * + * Everything it draws is {@link RecordDetail}, which the list's peek draws too. That is the whole + * point of the split: the page and the peek were about to be two renderings of one record, and + * two renderings drift — the peek grows a field the page does not have, or stops showing one it + * does, and nobody notices because nobody opens both at once. + */ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string }) { + return ( + + + + ) +} + +/** + * How much room the record has. + * + * `full` is the page. `peek` is the drawer at its narrowest, where the highlight strip and the + * stage path are the first things to go — both are horizontal by nature, and a horizontal strip in + * a 452px column is three items wrapping onto four lines, which reads as damage rather than + * density. What stays is the identity, the actions and the sections, because those are what + * somebody opened the record to see. + */ +export type RecordDensity = 'peek' | 'full' + +export function RecordDetail({ + objectKey, + id, + density = 'full', + identity = true, +}: { + objectKey: string + id: string + density?: RecordDensity + /** + * Whether to draw the record's own name and id. + * + * The drawer states them in its header, so a peek that drew them again showed the account name + * twice, eleven pixels apart. The actions stay either way — they are the record's, not the + * chrome's. + */ + identity?: boolean +}) { const navigate = useNavigate() const model = modelFor(objectKey) const { tenantId } = useSession() @@ -60,6 +111,12 @@ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string const accounts = useEntityPage(entity === 'Contact' || entity === 'Opportunity' ? 'Account' : null) const process = useProcess(entity === 'Opportunity' ? 'Opportunity' : null) + // What the tenant has actually declared on this kind. `EDITABLE` says a custom field *may* be + // declared here; this says whether one *is*, and the button needs both. + const schema = useSchema() + const declaredCount = + schema.data?.entities.find((candidate) => candidate.kind === entity)?.fields.length ?? 0 + const accountNames = useMemo(() => { const names = new Map() @@ -95,15 +152,15 @@ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string if (entity !== null && live.isPending) { return ( - + <> - + ) } if (!record) { return ( - + <> } /> - + ) } @@ -137,29 +194,48 @@ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string const related = relatedLinksOf(model.key) return ( - -
+ <> +
- -
-
- {model.label} · {record.id} -
-

{String(record[titleField] ?? record.id)}

-
+ {identity ? ( + <> + +
+
+ {model.label} · {record.id} +
+

{String(record[titleField] ?? record.id)}

+
+ + ) : null}
{/* Only the four kinds a custom field can be declared on, and only for a live record. Everything else has nothing this build can write. */} + {/* + DISABLED WITH THE REASON, rather than opening a drawer that has nothing in it. The + button used to be offered whenever the kind *could* carry a declared field, and on a + tenant that has declared none it opened a panel saying "Nothing has been declared on + contacts" over a Save reading "0 change(s)". A reader who presses Edit and is shown + an empty form does not conclude "this tenant has declared no fields" — they conclude + the editor is broken, and the sentence explaining otherwise arrives after the click + that cost them the trust. + + Two reasons, because they are two different facts and only one of them is fixable by + the person reading it: the kind takes no declared fields at all, or this tenant has + not declared any yet — and the second names where to go. + */}
+ {density === 'full' ? (
{model.listCols.slice(1, 5).map((name) => (
@@ -225,6 +302,7 @@ export function RecordScreen({ objectKey, id }: { objectKey: string; id: string
))}
+ ) : null} {/* THE PATH IS A DISPLAY, AND IT USED TO BE MADE OF BUTTONS. Nine `