react-call v2

Settings drawer

A panel that slides in from the edge. Props carry the initial settings as plain data; the Callable owns its own form state and resolves with the saved values, or null if the user dismisses.

notifications=true · sync=false · theme=system

The Callable

declared once, mounted in the React tree
SettingsDrawer.tsx
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { createCallable } from 'react-call'
export type Theme = 'system' | 'light' | 'dark'
export interface Settings {
notifications: boolean
syncOnLaunch: boolean
theme: Theme
}
interface Props {
initial: Settings
}
export const SettingsDrawer = createCallable<Props, Settings | null>(
({ call, initial }) => {
const drawerRef = useRef<HTMLDivElement>(null)
const [settings, setSettings] = useState<Settings>(initial)
useEffect(() => {
const onPointer = (e: MouseEvent) => {
if (
drawerRef.current &&
!drawerRef.current.contains(e.target as Node)
) {
call.end(null)
}
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') call.end(null)
}
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [call])
return (
<div
role="dialog"
aria-modal="true"
aria-label="Settings"
className="fixed inset-0 z-50 flex justify-end bg-black/50 backdrop-blur-sm"
>
<div
ref={drawerRef}
className="flex h-full w-full max-w-sm flex-col border-l border-[var(--color-border)] bg-[var(--color-bg)] shadow-2xl"
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-4">
<p className="text-base font-medium text-[var(--color-fg)]">
Settings
</p>
<button
type="button"
onClick={() => call.end(null)}
aria-label="Close"
className="-mr-1 inline-flex h-7 w-7 items-center justify-center rounded-md text-base leading-none text-[var(--color-fg-subtle)] transition-colors hover:bg-[var(--color-bg-subtle)] hover:text-[var(--color-fg)]"
>
×
</button>
</div>
<div className="flex-1 space-y-5 p-6 text-sm">
<Row
label="Notifications"
hint="Browser notifications for new mentions."
>
<Toggle
checked={settings.notifications}
onChange={(notifications) =>
setSettings({ ...settings, notifications })
}
/>
</Row>
<Row label="Sync on launch" hint="Fetch latest data on app start.">
<Toggle
checked={settings.syncOnLaunch}
onChange={(syncOnLaunch) =>
setSettings({ ...settings, syncOnLaunch })
}
/>
</Row>
<Row label="Theme" hint="Visual theme used across the app.">
<select
value={settings.theme}
onChange={(e) =>
setSettings({
...settings,
theme: e.target.value as Theme,
})
}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-subtle)] px-2 py-1 text-sm text-[var(--color-fg)] focus:border-[var(--color-accent)] focus:outline-none"
>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</Row>
</div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--color-border)] px-6 py-4">
<button
type="button"
onClick={() => call.end(null)}
className="rounded-md border border-[var(--color-border)] px-4 py-2 text-sm font-medium text-[var(--color-fg-muted)] transition-colors hover:text-[var(--color-fg)]"
>
Cancel
</button>
<button
type="button"
onClick={() => call.end(settings)}
className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)]"
>
Save
</button>
</div>
</div>
</div>
)
},
)
SettingsDrawer.displayName = 'SettingsDrawer'
interface RowProps {
label: string
hint: string
children: ReactNode
}
const Row = ({ label, hint, children }: RowProps) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-medium text-[var(--color-fg)]">{label}</p>
<p className="mt-0.5 text-xs text-[var(--color-fg-subtle)]">{hint}</p>
</div>
{children}
</div>
)
interface ToggleProps {
checked: boolean
onChange: (next: boolean) => void
}
const Toggle = ({ checked, onChange }: ToggleProps) => (
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-1 accent-[var(--color-accent)]"
/>
)

The Root

mounted in your app tree, once
App.tsx
import { SettingsDrawer } from './SettingsDrawer'
import { OpenSettingsButton } from './OpenSettingsButton'
export default function App() {
return (
<>
<SettingsDrawer />
<OpenSettingsButton />
</>
)
}

The caller

anywhere in your app, imperative
OpenSettingsButton.tsx
import { useState } from 'react'
import { type Settings, SettingsDrawer } from './SettingsDrawer'
const INITIAL: Settings = {
notifications: true,
syncOnLaunch: false,
theme: 'system',
}
export const OpenSettingsButton = () => {
const [settings, setSettings] = useState<Settings>(INITIAL)
const handleClick = async () => {
const next = await SettingsDrawer.call({ initial: settings })
// Cancel / dismiss resolves with null — keep current settings.
if (next) setSettings(next)
}
return (
<div className="flex flex-col items-center gap-4">
<button
type="button"
onClick={handleClick}
className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-[var(--color-accent-fg)] transition-colors hover:bg-[var(--color-accent-hover)]"
>
Open settings
</button>
<p className="font-mono text-xs text-[var(--color-fg-subtle)]">
notifications=
<span className="text-[var(--color-fg-muted)]">
{String(settings.notifications)}
</span>
{' · '}sync=
<span className="text-[var(--color-fg-muted)]">
{String(settings.syncOnLaunch)}
</span>
{' · '}theme=
<span className="text-[var(--color-fg-muted)]">{settings.theme}</span>
</p>
</div>
)
}

Related examples