Ballmac UI home

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>

PropTypeDefaultDescription
state (required)BlockStateWhat to show. submitting and success belong to forms and render the children.
loadingReact.ReactNodeShown 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.
emptyEmptyConfig | React.ReactNodeCopy and action for the empty state, or your own node.
errorErrorConfig | React.ReactNodeCopy for the error state, or your own node.
onRetry() => voidCalled by the retry button. Without it the button is not drawn.
labelstringText announced to assistive technology while loading.
barebooleanfalseDrop the empty and error panels' border and tint, for use inside a frame that already has them.
childrenReact.ReactNodeThe ready content.

<State>

PropTypeDefaultDescription
stateBlockStateWhat the block shows. Defaults to ready.
onRetry() => voidCalled by the retry button in the error state. Without it the button is not drawn.
emptyStateEmptyConfigCopy and action for the empty state.
errorStateErrorConfigCopy for the error state.

Also accepts the standard attributes of its root element.

App Window

Install: @ballmac/app-window. Page: App Window.

<AppWindow>

PropTypeDefaultDescription
feelFeelIdWhich platform's chrome to draw. Defaults to the page's feel (data-feel), which Feel Auto sets from the device.
activebooleanWhether the window is the focused one. Defaults to following the real window focus.
maximizedbooleanfalseThe window fills the screen: corners square off and the Windows button shows "restore".
onClose() => voidCalled by the close button. Without a handler the button is drawn but not focusable.
onMinimize() => voidCalled by the minimize button.
onMaximize() => voidCalled by the maximize (Windows) or zoom (macOS) button, and when the title bar is double-clicked.
onFullscreen() => voidCalled by the macOS green button; hold Option to call onMaximize instead. Falls back to onMaximize.

<AppWindowControls>

PropTypeDefaultDescription
feelFeelId

<AppWindowTitleBar>

PropTypeDefaultDescription
titleReact.ReactNodeThe window title. Centered on macOS and iOS, at the start beside the icon on Windows.
iconReact.ReactNodeA small icon before the title (Windows).
leadingReact.ReactNodeItems after the controls and title, at the start (a back button, a sidebar toggle).
controlsbooleantrueDraw 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>

PropTypeDefaultDescription
mediaMediaSource | nullThe picture, or your own element. Leave it out to show fallback.
altstringDescribes a picture passed as a plain URL. Required for informative images; use "" for decoration.
fallbackReact.ReactNodeWhat shows when there is no media or the file fails to load. Usually the block's built-in artwork.
aspectMediaAspect"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.
prioritybooleanfalseAbove the fold: load now and with high priority instead of lazily.
fillbooleanFill a parent that has its own size (an absolutely positioned panel) instead of reserving space by aspect ratio.
framebooleanfalseA card frame (rounded corners, border, soft shadow) around a real image or element. The built-in artwork keeps its own.
onImageError() => voidCalled 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>

PropTypeDefaultDescription
text (required)stringThe text to show.
querystring | 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>

PropTypeDefaultDescription
seed (required)string | numberAnything stable: an id, a name, a slug. The same seed always draws the same picture.
variantCoverArtVariant | "auto""auto"Which picture to draw. Leave it out (or "auto") to let the seed choose among the five.
gridbooleanfalseLay 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): boolean

Empty 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): BlockState

Maps 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 | undefined
isBusy(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): TextDirection

The 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"): string

The 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(): Msg

Returns 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): Message
type 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): FeelId

The 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): FeelId

The feel set on the page, or "web" when none is.

storedFeel(): FeelId | null

The 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 FeelId
type 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 | null

Turns 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.