Dialogs, popovers, menus, pickers and tooltips, on the browser’s top layer.
Built on the native <dialog> element and the popover API: focus is trapped or returned by the browser, Esc closes, and nothing is ever clipped by a scrolling parent.
4 parts
A modal on the native <dialog> element: the page behind goes inert, Esc closes it, and focus goes back to what opened it.
import { useId, useRef, useState, type FormEvent } from 'react'import { Button, Dialog, Field, RadioGroup, TextInput, useToast } from '@rchursin/product/ui'
type Visibility = 'private' | 'team'type Project = { name: string; visibility: Visibility }
/** * A form in a dialog. Focus starts on the first field; Esc, the Close button or a click on the * backdrop closes it, and focus goes back to "New project". The footer's submit button reaches * the form through its id. The draft lives outside the dialog, so closing never loses it. */export default function DialogForm() { const [projects, setProjects] = useState<Project[]>([ { name: 'Nightly evals', visibility: 'team' }, { name: 'Docs agent', visibility: 'private' }, ]) const [open, setOpen] = useState(false) const [name, setName] = useState('') const [visibility, setVisibility] = useState<Visibility>('private') const [error, setError] = useState<string>()A panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
import { useRef, useState, type FormEvent } from 'react'import { Button, Field, Popover, Switch, TextInput, usePopover } from '@rchursin/product/ui'
type Limit = { dollars: number; pause: boolean }
/** * usePopover holds the open state and the anchor; spread pop.props onto <Popover>. Focus moves to * the first field, Enter saves, Esc or a click outside closes, and focus returns to the trigger. * The form is keyed by each opening, so it always starts from the saved values. */export default function PopoverForm() { const pop = usePopover() const [limit, setLimit] = useState<Limit>({ dollars: 25, pause: true }) const [round, setRound] = useState(0)
return ( <div className="flex flex-col items-center gap-2"> <Button ref={pop.anchorRef} variant="secondary"A menu button with actions, checkbox and radio items, group labels, shortcut hints and type-ahead, in a popover beside its trigger.
import { useRef } from 'react'import { useHotkeys } from '@rchursin/product/lib/hotkeys'import { IconButton, Menu, useToast } from '@rchursin/product/ui'
/** * A row's actions behind "more". The shortcut hints are real here: with focus on the row, * P, Shift+D, R and E work without opening the menu (ignoreOverlays leaves keys pressed inside * the open menu to its type-ahead). A disabled item says why in its hint. */export default function MenuActions() { const toast = useToast() const row = useRef<HTMLDivElement>(null)
const pin = () => toast({ title: 'Run pinned to the top', icon: 'pin' }) const duplicate = () => toast({ title: 'Run duplicated · run 1483', icon: 'copy' }) const rerun = () => toast({ title: 'Run 1482 queued again', icon: 'refresh' }) const archive = () => toast({ title: 'Run archived', icon: 'archive', action: { label: 'Undo', onClick: () => toast({ title: 'Run restored', icon: 'undo' }) } })
useHotkeys({ p: pin, 'shift+d': duplicate, r: rerun, e: archive }, { scope: row, ignoreOverlays: true })
A few words on hover (after a short wait) or keyboard focus, in the top layer. Esc hides it. Best for naming icon buttons.
import { useState } from 'react'import { IconButton, Tooltip } from '@rchursin/product/ui'
/** * Hover and wait a moment, or Tab to a button: the tip shows at once on focus. Esc hides it. * Each IconButton passes noTooltip, so the browser's own title tooltip doesn't show as well. */export default function TooltipBasic() { const [zoom, setZoom] = useState(100) const step = (by: number) => setZoom((z) => Math.min(200, Math.max(50, z + by)))
return ( <div className="flex items-center gap-1 rounded-xl p-1 ring-1 ring-line ring-inset"> <Tooltip content="Zoom out"> <IconButton icon="zoom-out" label="Zoom out" noTooltip onClick={() => step(-25)} /> </Tooltip> <output aria-live="polite" className="w-12 text-center font-mono text-xs text-fg-2 tabular-nums"> {zoom}% </output> <Tooltip content="Zoom in"> <IconButton icon="zoom-in" label="Zoom in" noTooltip onClick={() => step(25)} /> </Tooltip> <span aria-hidden="true" className="mx-1 h-5 w-px bg-line" /> <Tooltip content="Reset to 100%"> <IconButton icon="rotate-ccw" label="Reset to 100%" noTooltip onClick={() => setZoom(100)} /> </Tooltip> </div> )}New runs pause at the limit.