Building blocks
Props of StateView, AppWindow, Media and the other building blocks, and the signatures of the hooks and helpers, read from their TypeScript types.
On this page
This page is generated from registry/ballmac. To change it, change the source and run pnpm docs:generate.
The components and helpers the guides refer to, with their props and signatures read from their TypeScript types. The page of every component and block lists its own props too.
Components
State View
Install: @ballmac/state-view. Page: State View.
<StateView>
| Prop | Type | Default | Description |
|---|---|---|---|
state (required) | BlockState | What to show. submitting and success belong to forms and render the children. | |
loading | React.ReactNode | Shown while loading. Make it the same shape as the ready content so nothing jumps. A plain set of lines is used if you leave it out. | |
empty | EmptyConfig | React.ReactNode | Copy and action for the empty state, or your own node. | |
error | ErrorConfig | React.ReactNode | Copy for the error state, or your own node. | |
onRetry | () => void | Called by the retry button. Without it the button is not drawn. | |
label | string | Text announced to assistive technology while loading. | |
bare | boolean | false | Drop the empty and error panels' border and tint, for use inside a frame that already has them. |
children | React.ReactNode | The ready content. |
<State>
| Prop | Type | Default | Description |
|---|---|---|---|
state | BlockState | What the block shows. Defaults to ready. | |
onRetry | () => void | Called by the retry button in the error state. Without it the button is not drawn. | |
emptyState | EmptyConfig | Copy and action for the empty state. | |
errorState | ErrorConfig | Copy for the error state. |
Also accepts the standard attributes of its root element.
App Window
Install: @ballmac/app-window. Page: App Window.
<AppWindow>
| Prop | Type | Default | Description |
|---|---|---|---|
feel | FeelId | Which platform's chrome to draw. Defaults to the page's feel (data-feel), which Feel Auto sets from the device. | |
active | boolean | Whether the window is the focused one. Defaults to following the real window focus. | |
maximized | boolean | false | The window fills the screen: corners square off and the Windows button shows "restore". |
onClose | () => void | Called by the close button. Without a handler the button is drawn but not focusable. | |
onMinimize | () => void | Called by the minimize button. | |
onMaximize | () => void | Called by the maximize (Windows) or zoom (macOS) button, and when the title bar is double-clicked. | |
onFullscreen | () => void | Called by the macOS green button; hold Option to call onMaximize instead. Falls back to onMaximize. |
<AppWindowControls>
| Prop | Type | Default | Description |
|---|---|---|---|
feel | FeelId |
<AppWindowTitleBar>
| Prop | Type | Default | Description |
|---|---|---|---|
title | React.ReactNode | The window title. Centered on macOS and iOS, at the start beside the icon on Windows. | |
icon | React.ReactNode | A small icon before the title (Windows). | |
leading | React.ReactNode | Items after the controls and title, at the start (a back button, a sidebar toggle). | |
controls | boolean | true | Draw the window controls. Turn off when something else draws them. |
Also accepts the standard attributes of its root element.
Media
Install: @ballmac/media. Page: Media.
<Media>
| Prop | Type | Default | Description |
|---|---|---|---|
media | MediaSource | null | The picture, or your own element. Leave it out to show fallback. | |
alt | string | Describes a picture passed as a plain URL. Required for informative images; use "" for decoration. | |
fallback | React.ReactNode | What shows when there is no media or the file fails to load. Usually the block's built-in artwork. | |
aspect | MediaAspect | "auto" | Reserves the box before the image loads, so the page does not jump. auto keeps the image's own proportions. |
fit | "cover" | "contain" | "fill" | "cover" | cover fills and crops, contain shows all of it, fill stretches. |
priority | boolean | false | Above the fold: load now and with high priority instead of lazily. |
fill | boolean | Fill a parent that has its own size (an absolutely positioned panel) instead of reserving space by aspect ratio. | |
frame | boolean | false | A card frame (rounded corners, border, soft shadow) around a real image or element. The built-in artwork keeps its own. |
onImageError | () => void | Called when the file fails to load (the fallback is shown). |
Also accepts the standard attributes of its root element.
Match Highlight
Install: @ballmac/match-highlight. Page: Match Highlight.
<MatchHighlight>
| Prop | Type | Default | Description |
|---|---|---|---|
text (required) | string | The text to show. | |
query | string | string[] | "" | What was searched for: a string (split into words) or a list of words. Every occurrence of any word is marked. |
Also accepts the standard attributes of its root element.
Cover Art
Install: @ballmac/cover-art. Page: Cover Art.
<CoverArt>
| Prop | Type | Default | Description |
|---|---|---|---|
seed (required) | string | number | Anything stable: an id, a name, a slug. The same seed always draws the same picture. | |
variant | CoverArtVariant | "auto" | "auto" | Which picture to draw. Leave it out (or "auto") to let the seed choose among the five. |
grid | boolean | false | Lay a faint grid over the picture. |
Also accepts the standard attributes of its root element.
Helpers
use-resource
Turns a fetch into the state a block takes, and maps TanStack Query or SWR results onto it. Install: @ballmac/use-resource.
isEmptyValue(value: unknown): booleanEmpty arrays, empty objects with no keys, empty strings, null and undefined count as empty.
stateOf<T>(query: QueryLike<T>, isEmpty: (data: T | undefined) => boolean = isEmptyValue): BlockStateMaps the result of TanStack Query, SWR or your own fetch onto a block state:
const query = useQuery(...) <Mail1 state={stateOf(query)} messages={query.data} />
useResource<T>(fetcher: (signal: AbortSignal) => Promise<T>, options: ResourceOptions<T> = {}): Resource<T>A small fetch state machine: it starts in loading, then becomes ready, empty or error, and retry() goes round again.
A request that is replaced or unmounted is aborted, and a slow older answer can never overwrite a newer one.
const mail = useResource((signal) => fetch("/api/mail", { signal }).then((r) => r.json())) <Mail1 state={mail.state} messages={mail.data} onRetry={mail.retry} />
formPhase(state: BlockState | undefined): FormPhase | undefinedisBusy(state: BlockState)Whether something is in flight, so a region can set aria-busy.
type BlockState = "ready" | "loading" | "empty" | "error" | "submitting" | "success"What a block is showing. ready is the normal view. Forms add submitting (the request is in flight) and success
(it went through); data views add loading, empty and error.
type FormStateProps = { /** `submitting` shows the request in flight, `success` the confirmation, `error` the failure. Leave out to let the form decide. */ state?: BlockState /** Copy for the error state. */ errorState?: { title?: string; description?: string } }The props every form block takes. Leave state out and the form runs by itself; pass it and you drive the form.
type FormPhase = "idle" | "sending" | "error" | "done"Where a form is in its life. Forms keep this themselves; formPhase turns the public state prop into it, or returns
undefined when the prop is not given so the form's own value is used.
type Resource = { state: BlockState data: T | undefined error: unknown /** Fetches again, showing the loading state. */ retry: () => void /** Replaces the data you have, for example after an optimistic edit. Keeps `ready` or `empty` in step. */ setData: (next: T) => void }direction
The text direction provider and hook for right-to-left. Install: @ballmac/direction.
useDirection(dir?: TextDirection): TextDirectionThe reading direction for code that has to know it (arrow-key order, pointer maths, which way a chevron faces).
An explicit dir wins, then a surrounding <Direction> or Radix DirectionProvider, then <html dir>.
Server render and the first client render say "ltr", so markup matches, and it updates right after hydration.
DirectionProvider({ dir, children }: { dir: TextDirection; children: React.ReactNode })Sets the direction for a part of the page and tells every Radix-based component inside it, so keyboard arrows,
sliders, menus and tabs follow it. For the whole app, put it in the root layout next to <html dir>.
Dir({ dir, children, ...props }: Omit<React.ComponentProps<"div">, "dir"> & { dir: TextDirection })A box with its own direction (display: contents, so it adds no layout), for previews and mixed-language regions.
type TextDirection = "ltr" | "rtl"i18n
The translation provider and hooks. Install: @ballmac/i18n.
I18nProvider({ messages = {}, locale, children }: { messages?: Messages; locale?: string; children: React.ReactNode })Translates every built-in string (aria-labels, placeholders, hidden text, default button labels) and sets the locale that dates, numbers and sorting use. Put it once near the root. Anything you leave out stays in English.
<I18nProvider locale="ar" messages={{ "pagination.nextPage": "الصفحة التالية" }}>
useLocale(fallback = "en-US"): stringThe locale for Intl formatting: the provider's, else <html lang>, else fallback.
Server render and the first client render use the provider or the fallback, so markup matches.
useMessages(): MsgReturns msg(key, english, values?). The text comes from the provider's messages, else the English default.
{name} placeholders are filled from values. With a numeric count, a plural form is used: the dictionary's
key_one, key_few, key_many or key_other (the CLDR categories of the locale), else the English forms you pass as
{ one: "{count} item", other: "{count} items" }.
defineMessage(key: string, text: string): Messagetype Messages = Record<string, string>A flat dictionary: message key to translated text. Keys and English defaults are listed at /i18n/en.json.
type Fallback = string | ({ [K in Intl.LDMLPluralRule]?: string } & { other: string })English text, or its plural forms keyed by CLDR category (other is required).
type Message = { key: string; text: string }A string declared at module level (a status label in a lookup table); render it with msg.of(message).
type Msg = { (key: string, fallback: Fallback, values?: Values): string /** Translate a message declared with defineMessage. */ of: (message: Message, values?: Values) => string /** Like `msg`, but `{name}` placeholders are filled with React nodes: msg.rich("k", "Press {key} to open", { key: <Kbd>K</Kbd> }). */ rich: (key: string, fallback: string, nodes: Record<string, React.ReactNode>) => React.ReactNode }feel
Read and set the platform feel. Install: @ballmac/feel.
detectFeel(nav: Pick<Navigator, "userAgent" | "platform" | "maxTouchPoints"> = navigator): FeelIdThe feel that matches the device the page is running on. Works in a browser, a Tauri window and an Electron window, because all of them report the platform in the user agent. An iPad that asks for the desktop site still counts as iOS.
readFeel(el: Element | null = typeof document === "undefined" ? null : document.documentElement): FeelIdThe feel set on the page, or "web" when none is.
storedFeel(): FeelId | nullThe choice remembered for this browser, if any.
setFeel(feel: FeelId | "auto")Sets the feel for the whole page. "auto" goes back to the device's own feel and forgets the override. The choice is
remembered in localStorage when that is available.
useFeel(): { feel: FeelId; setFeel: typeof setFeel }The feel the page is set to, live. Server render and the first client render say "web", then the real value is read, so markup always matches. Pass an element to read a scope inside the page.
isFeel(value: unknown): value is FeelIdtype FeelId = "web" | "macos" | "ios" | "windows"return-focus
Puts keyboard focus back where it was when a dialog closes. Install: @ballmac/return-focus.
useReturnFocus(open: boolean)Radix returns focus to a dialog's trigger when it closes, so a dialog opened from state (a confirmation after pressing Delete, a
lightbox opened from a tile) has no trigger and focus falls back to the top of the page. This remembers the element that had focus
when open became true and returns it there. Spread the result onto DialogContent or AlertDialogContent.
shortcut
Formats and records keyboard shortcuts in each platform's notation. Install: @ballmac/shortcut.
formatShortcut(shortcut: string, feel: FeelId)Writes a shortcut the way the platform does: ⇧⌘Space on a Mac (modifiers in Apple's order, no separators), Ctrl+Shift+Space elsewhere.
normalizeShortcut(shortcut: string, feel: FeelId = "web")The same shortcut in one canonical order, so two spellings of one combination compare equal. On Windows and the web Mod and Ctrl are one key.
shortcutFromEvent(e: Pick<KeyboardEvent, "key" | "code" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">, mac: boolean, requireModifier = true): string | nullTurns a key press into a shortcut string, or null while only modifiers are down. With requireModifier (the default) a plain
letter is refused, so a global shortcut cannot take a key from every other app.
usesMacKeys(feel: FeelId)Whether the feel writes shortcuts with Apple's symbols.