Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion samples/crm-web/src/design/primitives/Drawer.module.css
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
.scrim {
position: absolute;
position: fixed;
inset: 0;
z-index: 30;
display: flex;
justify-content: flex-end;
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);
Expand Down Expand Up @@ -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;
}
102 changes: 87 additions & 15 deletions samples/crm-web/src/design/primitives/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}

Expand All @@ -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<HTMLDivElement>(null)
const opener = useRef<Element | null>(null)

Expand All @@ -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)
Expand All @@ -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(
<div
className={styles.scrim}
onClick={(event) => {
Expand All @@ -59,29 +97,63 @@ 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
}
>
<header className={styles.head}>
<div className={styles.headTop}>
{eyebrow ? <span className={styles.eyebrow}>{eyebrow}</span> : null}
<Button
iconOnly
size="sm"
aria-label="Close"
className={styles.close}
onClick={onClose}
>
</Button>
<div className={styles.headControls}>
{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.
*/}
<Button
iconOnly
size="sm"
aria-label="Narrow"
disabled={size === 'peek'}
onClick={() => onSizeChange(narrow(size))}
>
</Button>
<Button
iconOnly
size="sm"
aria-label="Widen"
disabled={size === 'full'}
onClick={() => onSizeChange(widen(size))}
>
</Button>
</>
) : null}
<Button
iconOnly
size="sm"
aria-label="Close"
className={styles.close}
onClick={onClose}
>
</Button>
</div>
</div>
<div className={styles.title}>{title}</div>
{subtitle ? <div className={styles.subtitle}>{subtitle}</div> : null}
{actions ? <div className={styles.actions}>{actions}</div> : null}
</header>
<div className={styles.body}>{children}</div>
</div>
</div>
</div>,
document.body,
)
}

Expand Down
83 changes: 83 additions & 0 deletions samples/crm-web/src/design/primitives/__tests__/drawerSize.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
62 changes: 62 additions & 0 deletions samples/crm-web/src/design/primitives/drawerSize.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading