# App shell
Source: https://ui.tale.dev/docs/components/app-shell
`AppShell` provides the shared theme, translation, and tooltip context. It does not draw your page header or navigation. Compose those separately with `PageLayout`, the adaptive header components, and `ContentArea`.
## Mount the providers
[Installation](/docs/getting-started/installation) includes a complete entry point. A router-based application uses this composition:
```tsx
import { AppShell } from '@tale/ui/app-shell';
import { RouterProvider } from '@tanstack/react-router';
// i18n and router are initialized by the host application.
;
```
| Option | What it adds |
| --- | --- |
| `i18n` | Required service instance, supplied to `I18nextProvider`. |
| `locale={{ mode: 'client' }}` | Preference/browser locale detection and synchronization. `onChange` can load additional locale data; `defaultLocale` supplies a fallback. |
| `theme` | The shared theme provider with the normal system-preference behavior. |
| `children` | Your router or application content. |
The full optional stack is ThemeProvider, TooltipProvider, LocaleProvider, I18nextProvider, then locale synchronization and content. Do not add a TooltipProvider around each button; the outer provider shares tooltip timing across controls.
For a URL-driven locale, omit `locale` and synchronize the route's language through `LocaleSync`. Query clients, authentication, authorization, branding, and a toast viewport remain host responsibilities. `AppShell` imports the shared Inter fonts.
## Compose the page
This is a labelled layout illustration. Its nested application header and controls are inert; inspect **Code** to see how the pieces compose without adding a second accessible application to this page.
| Piece | Responsibility |
| --- | --- |
| `PageLayout` | Flex page and scroll container; wraps an optional `header` in `StickyHeader`. |
| `AdaptiveHeaderRoot` | The title/action row, with optional border and responsive treatment. |
| `AdaptiveHeaderTitle` | The page title, rendered as `h1`. |
| `ContentArea` | Content spacing and width: `page`, `narrow`, or `panel`. |
Give flex ancestors a usable height and `min-h-0` when the page should scroll inside them. `PageLayout` reserves scrollbar space to reduce sideways movement as row counts change. `ContentArea` includes clearance for mobile floating actions.
Choose one divider between header and content. Use `showBorder` for a plain header; avoid adding another border when a tab strip already supplies the divider.
## Plan the mobile header
Adaptive headers coordinate through `AdaptiveHeaderProvider`. The desktop root alone is not a complete mobile header: the host must render the receiving `AdaptiveHeaderSlot` in its mobile chrome. The mobile treatment removes the desktop title from the accessibility tree and presents the active title through that slot.
Check a real phone-width page after composing the providers and slots. It should have one accessible `h1`, visible actions, and enough bottom clearance for any floating action bar. A desktop layout illustration cannot prove this integration for your service.
## Add breadcrumbs
`HeaderBreadcrumbs` renders a labelled navigation list whose leaf is the current page's `h1`. Supply ancestor links or buttons through the crumb content and use `HEADER_CRUMB_LINK_CLASS` for their shared treatment. The component does not resolve application routes for you.
Below `md`, the trail collapses toward an immediate-parent back control. `showImmediateParentOnMobile` can preserve the parent's visible name where there is room. Long titles still need testing in the full header, including the action buttons beside them.
## Add a section rail
`SubPanel` provides the bordered panel a section's navigation lives in, full height beside the page. Choose its width by the rows it holds: the default 224px for single-line rows, `wide` (256px) for longer labels, and `list` (280px) when each row carries a second line of context. It is hidden below `md`, so the host needs a mobile navigation path, such as a tab strip or a list screen of its own.
```tsx
import { SubPanel, SubPanelHeader } from '@tale/ui/sub-panel';
import { SubPanelRowLink, SubPanelSectionHeader } from '@tale/ui/sub-panel-list';
```
Head the panel with `SubPanelHeader`: the section's name and at most one or two actions, in the same `h-13` row as the page header beside it, so the two bottom rules meet as one line. The caller supplies rows and scrolling. Use `SubPanelRowLink` for destinations and `SubPanelSectionHeader` for groups. For a custom row, reuse `SUB_PANEL_ROW_CLASS` and `useSubPanelRowTreatment` rather than reproducing selection styles.
When the section's navigation is a fixed set of pages, `SectionNavPanel` assembles the whole panel: the `list`-width frame, the header, and one highlight that glides to the open page instead of blinking between rows.
```tsx
import { SectionNavPanel, SectionNavRow } from '@tale/ui/section-nav';
```
`activeKey` names the `href` of the row the highlight rests on; each `SectionNavRow` marks itself with that key. Pass `layoutVersion` when something moves rows without changing the open page, such as a disclosure opening above it. The highlight comes from `useSlidingIndicator` (`@tale/ui/use-sliding-indicator`), which you can use directly for any control whose selection should glide: the item carries `data-indicator-key`, the hook measures it against its positioned container, and the indicator moves with a CSS transform that respects reduced motion.
For a panel list of your own, draw that highlight with `SlidingHighlight` (from `@tale/ui/section-nav`) inside the list's positioned scroller, and let the rows keep only their text treatment above it (`relative z-10`, no fill of their own):
```tsx
import { SlidingHighlight } from '@tale/ui/section-nav';
import { useSlidingIndicator } from '@tale/ui/use-sliding-indicator';
const { containerRef, ...indicator } = useSlidingIndicator(openKey, rowOrder);
{rows}
```
The hook applies one motion to every highlight: with nothing active it fades out where it stood, it lands on the next active item with a fade instead of sliding in from a corner, and it glides only between two places the reader can see. Take its `transitionClassName` rather than a transition of your own, so the rail, the segmented controls and the panels all move alike.
## Head a conversation page
`ThreadHeader` (`@tale/ui/thread-header`) is the title row of every conversation-shaped page — a chat, a task's discussion, a customer conversation — so each reads the same way: controls before the identity (a panel toggle, a phone's back button), a 32px identity mark, the title, one line of context, and the page's actions.
```tsx
import { ThreadHeader, ThreadHeaderSeparator } from '@tale/ui/thread-header';
}
title={{subject}
}
meta={
<>
{contact}
{age}
>
}
actions={}
/>
```
It keeps the page header's `h-13` height and bottom rule; pass `floating` to draw it over scrolling content without the rule, as the chat does. The title slot takes a heading or an in-place editor for the name — the page supplies its single `h1`.
The row is a size container named `thread-header`, so what you put in it answers to the header's width rather than the window's: beside the rail and a section panel the header is only ~435px wide in a 768px window. Show an action's label from `@xl/thread-header:` and leave it icon-only below. Order the context line by importance, and when its items don't all fit, drop the last ones whole rather than truncating every item to a letter: a wrapping row clipped to one line (`flex-wrap` with a one-line height and `overflow-hidden`) keeps what fits and hides the rest, with each separator grouped with the item it introduces.
When the page opens another item in the same place — the next chat, task or conversation — let the swap read as new content rather than a flicker with `useSwapFade` (`@tale/ui/use-swap-fade`). Give it the open item's key and put its ref on the view that shows the item:
```tsx
import { useSwapFade } from '@tale/ui/use-swap-fade';
const viewRef = useSwapFade(itemId);
{item}
```
It fades opacity only, through the Web Animations API, so the view stays mounted and nothing it measures moves: a transcript restoring its scroll, a composer that keeps a draft. The first render never fades, nor does a change to nothing open, and reduced motion skips it. Pass `fromEmpty: false` when an item is born in place — a chat created by its first message is the same conversation continuing.
Start the application with a `SkipLink` targeting ``. Name each navigation landmark. Scrolling a rail to its active row should not steal the reader's initial keyboard position. Use the [list-page](/docs/patterns/list-page) or [settings-page](/docs/patterns/settings-page) pattern for the content inside this shell.
# Button
Source: https://ui.tale.dev/docs/components/button
Use `Button` for an action on the current screen. Give it a specific verb, such as **Save changes** or **Export**, and choose its appearance according to the action's importance and consequence. For navigation, use a link with button styling.
```tsx
import { Button, LinkButton } from '@tale/ui/button';
```
## Variants
| Variant | Appropriate use |
| --- | --- |
| `primary` (default) | The main action in the current task. |
| `secondary` | A supporting action such as Cancel. |
| `ghost` | A quiet action in a toolbar or row. |
| `destructive` | A consequential removal, such as deleting or revoking. |
| `warning` | A consequential action that requires caution. |
| `success` | A positive action or completion treatment where the meaning is clear. |
| `link` | A text-style action; this remains a button unless you change its element. |
Use one primary action per decision area. A destructive color does not ask for confirmation or enforce permissions; the host owns those behaviors. Use a [confirmation dialog](/docs/components/dialog) when the decision needs explanation.
## Sizes and responsive labels
`default` is 36px high (`h-9`), and `sm` is 32px (`h-8`). `icon` and `icon-sm` are square controls at those heights. There is no `lg` size. The text-style `link` variant uses an automatic height instead of the fixed control box.
Pass a Lucide component through `icon`; Button supplies a 16px decorative icon and spacing. `collapseLabel` visually hides the text below `sm` while preserving the accessible name. Pair it with an icon so the mobile control still has visible content.
## Show work in progress
Choose **Save changes** to see a short simulated operation. `isLoading` displays a spinner, sets `aria-busy`, and disables activation. The label remains in place. The example resets after 1.5 seconds; a real screen should clear loading when its request settles.
Set `type="submit"` for form submission and `type="button"` for other form actions. The component does not generally override the browser's default button type. Disable duplicate submissions in your handler as well, and keep a failed save visible near the form.
## Explain an unavailable action
Focus **Publish** with the keyboard to read why it is unavailable. When `disabled` and a nonempty `disabledReason` are present, the component uses `aria-disabled` instead of native `disabled`, preserves keyboard focus, and blocks clicks, Enter, and Space. A plain disabled button leaves the tab order.
The reason only applies while `disabled` is true. Describe what would make the action available; use visible nearby text when the explanation is essential to completing the task. A disabled UI control is not an authorization check.
## Navigate with a link
`asChild` merges styling onto one child element, such as an anchor. Keep link behavior on that child. Tooltips and `disabledReason` are suppressed in this mode, and an anchor does not acquire native button disabling. Do not use `disabled` as a way to prevent a link from navigating.
For TanStack Router destinations, `LinkButton` accepts `href`, `params`, `search`, and `prefetch`. It requires router context. For an external URL, an anchor inside `Button asChild` keeps normal browser link behavior.
## Props
Native button attributes pass through. These are the component-specific choices:
| Prop | Values or default | Behavior |
| --- | --- | --- |
| `variant` | `primary`, `secondary`, `ghost`, `destructive`, `warning`, `success`, `link`; default `primary` | Visual emphasis. |
| `size` | `default`, `sm`, `icon`, `icon-sm`; default `default` | Control dimensions. |
| `icon`, `iconClassName` | Lucide component; optional extra classes | Leading decorative icon. |
| `isLoading` | `false` | Spinner, busy state, and activation blocking. |
| `disabledReason` | Optional React content | Focusable explanation while disabled; unavailable with `asChild`. |
| `fullWidth` | `false` | Fills the available width. |
| `collapseLabel` | `false` | Hides text visually below `sm`. |
| `asChild` | `false` | Styles one child instead of rendering a button. |
| `title` | Optional string | Tooltip; also names an icon-sized Button unless `aria-label` overrides it. |
| `tooltip` | Optional React content | Overrides the visible tooltip text. |
| `tooltipSide` | `top`, `right`, `bottom`, `left`; default `top` | Tooltip placement. |
| `tooltipOpen`, `onTooltipOpenChange` | Optional controlled state | Lets a caller control a state-announcing tooltip. |
## Accessibility and alternatives
Icon-sized Buttons require `aria-label` or `title` at the type level. Text-sized buttons still need meaningful children; the type system cannot judge the label's quality. A tooltip alone is a description, not the control's name. `title` on a text Button adds a tooltip without replacing its visible accessible name.
Use `IconButton` from `@tale/ui/icon-button` for a toolbar glyph: it requires `aria-label`, defaults to `ghost`, and supplies a tooltip. For a table's create action, prefer `DataTable.addAction`; for a row menu, use the table's column builders. Check keyboard focus, the disabled explanation, and the loading state in both themes.
# Data table
Source: https://ui.tale.dev/docs/components/data-table
`DataTable` renders a table and its surrounding search, filters, create action, and paging controls. You provide the data and state transitions. The component does not fetch rows, authorize actions, or filter a backend query for you.
```tsx
import { DataTable } from '@tale/ui/data-table/data-table';
import type { ColumnDef } from '@tanstack/react-table';
```
## Define the visible columns
Each TanStack `ColumnDef` supplies an accessor or cell renderer and a header. Use a stable domain ID through `getRowId` when rows can be selected, expanded, reordered, or refreshed. Otherwise row-index identity can attach state to the wrong item after a data change.
Pass a descriptive `caption`, such as **Agents in this workspace**. It becomes a screen-reader table caption. It is optional in the TypeScript interface, but a data table still needs an accessible name in the page.
The builders exported from `@tale/ui/data-table/column-builders` include text, date, creation-time, selection, and action columns. Reuse them for those common shapes; use custom cells when the content requires them.
## Lead the row with a name and a glyph
`TableIconCell` from `@tale/ui/data-table/table-icon-cell` is a list's icon-and-name cell: a 20px mark, 8px, then the name. The slot and the gap are the contract — a list that sets its own leaves its labels a few pixels off every other list's.
Pass the icon bare. The default `tile` variant frames a monochrome glyph — a Lucide icon, an Iconify ``, a `ConfigIcon` — in a muted square and sets its size and colour for you. Use `variant="plain"` for a mark that carries its own shape and colour: a file-type icon, a vendor logo, an avatar. It keeps the same slot, so the labels still line up.
`badges` places chips beside the name; they hold their width while the label truncates. `caption` adds a second line for an address the reader needs next to the name, such as an automation's slug. Leave it off for a single-line row.
A string `label` gets the shared label style and truncation. Pass a node instead when the name has to be a link or a button — the cell renders it untouched, and that label owns its own truncation and `title`.
Pair the column with `tableIconCellSkeleton()` — `{ lines: 2 }` when the cell renders a caption. It reserves the tile's footprint rather than the skeleton's bare-icon default, so rows do not jump when the data arrives.
Reach for it in any column that leads with a glyph, not only the first one. A secondary column that frames its own mark — an audience, an owner, a state — sits its text a few pixels off the row's name; the same cell keeps the whole row on one offset. Pass `label` a node when the content is chips rather than a name.
## Wire search to the rows
Activate **Search automations**, then type `Weekly`: only **Weekly digest** remains. Type a value that matches nothing to see the shared **No results found** state. Clear the query to restore all four rows. The example filters names in memory; it does not search the trigger column.
`search={{ value, onChange, placeholder }}` renders and controls the search field. Your callback updates the query and the rows. For a backend search, send the new query to the backend, reset the paging cursor, and pass the resulting rows back to the table. Preserve meaningful query/filter state in the URL when the screen needs shareable results.
`emptyState` describes an initially empty collection. An active query or filter with zero rows uses the table's shared no-results copy instead. If cursor pages remain, the table treats zero visible matches as still loading rather than declaring the entire source empty.
## Choose toolbar controls
| Prop | Host responsibility |
| --- | --- |
| `search` | Own the query and apply it to the data source. |
| `filters`, `dateRange` | Supply available choices, selected values, and handlers. |
| `onClearFilters` | Reset the relevant filters consistently. |
| `filtersContent` | Place an additional filter-side control inside the toolbar. |
| `addAction` | Supply a label and a click handler, destination, or create-menu items. |
| `actionMenu` | Supply bespoke primary-side toolbar content; it takes precedence over `addAction`. |
When an initially empty table has no search/filter toolbar, `addAction` moves into the empty state. With toolbar controls present, it stays in the header. Pass the permission-dependent disabled state from your service; the table does not decide access.
Over an empty collection that no query or filter narrows, the table disables its search box and **Filter**, because there is nothing to narrow. A query or filter that narrowed the rows to none keeps both usable so the reader can undo it, and a filter marked `widensResultSet` keeps **Filter** usable because it can reveal rows the default view hides. Neither control is disabled under the reader's focus. A search box whose last character was erased stays editable until focus leaves it. When **Clear all** or **Escape** leaves nothing to narrow, **Filter** takes the focus back and reads as unavailable (`aria-disabled`); it leaves the tab order once focus moves on. For a toolbar built outside a table, derive its `disabled` flags from `isFilterAffordanceDisabled` in `@tale/ui/filters/filter-panel`, passing the read's loading and error states so an unknown set never reads as empty.
**Filter** opens with the focus on its first facet's header, so the keyboard reaches every option from there: Enter or Space expands a facet, and Tab moves between the facets and into the one that is open. A single-choice facet is a radio group (one per heading when its options are grouped) with one Tab stop, on its chosen option or else its first. The arrow keys, Home and End move the focus and the choice together. Space chooses the focused option, and on the chosen option clears it; a facet with `defaultValues` goes back to them instead. A multi-choice facet lists one checkbox per option. Escape closes the panel and gives the focus back to **Filter**.
The toolbar wraps rather than overflows. When its column cannot hold the controls and the primary action on one line, the action moves to a line of its own on the right, and the search box gives up width before anything is pushed past the edge; on a phone the action takes a full-width row. A list that builds its own toolbar outside a table uses the same `DataTableToolbar` from `@tale/ui/data-table/data-table-filters`.
When an `addAction` opens a dialog, pass a button ref as `addAction.triggerRef` and the same ref as the dialog's `restoreFocusRef`. The ref follows the button when creating the first row moves it from the empty state into the toolbar, so closing the dialog returns focus to the new button.
When a `DataTableActionMenu` or `EntityRowActions` item opens a dialog, closing the dialog returns focus to the menu's button. The menu item disappears when the dialog opens, so the dialog returns to the button the menu names as its label. Pass a button ref as `triggerRef` and the same ref as the dialog's `restoreFocusRef` only when that button can itself unmount or move while the dialog is open.
## Decide what scrolls
`stickyLayout` turns the table into a fixed frame: the toolbar, the header row and the footer hold their place while the rows scroll in the table's own scrollport. It measures itself against its parent, so it needs a bounded one — `ContentArea variant="list"` on a collection screen. Without that bound the frame collapses. On a short viewport (`short-viewport:`, under 30rem tall) the list variant lifts the bound, the frame grows with its rows and the page scrolls; the table's infinite loading then watches the page scroll instead of its own.
Leave it off for a table embedded in a page that scrolls as a whole, such as a section of a settings page. Every collection screen takes it; see the [list-page pattern](/docs/patterns/list-page).
A sticky frame is still only as tall as its rows, so a short list ends high on the page. Add `fillHeight` when the table is the whole screen and nothing follows it: the frame then takes the full bounded height, the count footer stays on the bottom edge, and the rows scroll inside it at any count. The empty, no-results and error states opt out of the stretch on their own — a line of copy centred in an empty frame reads worse than a frame that hugs it. Leave `fillHeight` off wherever the page continues below the table.
## Loading and errors
Set `isLoading` while fetching the initial data. `approxRowCount` helps reserve space: an unknown count gives the default skeleton; a positive estimate gives skeleton rows up to the component's cap; zero allows the supplied initial empty state. Do not pass zero merely because a request has not returned yet.
Pass `error` and `onRetry` for a failed query. A load failure should explain recovery rather than masquerade as an empty collection. Keep filter state when retrying so the request still matches what the reader sees. A refresh the reader did not start, such as the tab regaining focus or another session's change, replaces the error state with the loading state while it runs. Pass `onErrorFocusLost` a stable, named target around the table, such as the list's region, so that the focus **Try again** held lands there instead of on the page. Focus the reader moved elsewhere stays where it is.
Rows already on screen stay through a failed refetch, and the host names that failure above the table with the same retry. When a cursor source fails while more rows may exist, set `infiniteScroll.loadFailed` until the retry: the table stops asking for more on scroll, a search that matches none of the loaded rows says it searched only those instead of showing a skeleton, and the count footer says the rest could not be loaded, never "all", even when nothing more is paged. [`useListPage`](/docs/patterns/list-page) sets the flag from its data source. While the body is a skeleton the footer counts nothing, so rows a host lists beside a first read still in flight are never called all of them.
## Pick one paging model
On a collection screen, let [`useListPage`](/docs/patterns/list-page) choose: it drives the cursor model below for an in-memory set and for backend pages alike, so every list loads more on scroll and ends on the same count footer. The table's own models remain for a table outside that pattern.
| Model | Configuration |
| --- | --- |
| All rows already loaded | `pagination.clientSide: true`; the table slices the in-memory data. |
| Server pages | Supply `pagination` callbacks/counts and the one-based `currentPage`; replace rows after each request. |
| Cursor loading | Supply `infiniteScroll.hasMore`, `onLoadMore`, and loading state; append returned rows in the host. |
Cursor loading is automatic by default and also provides a load-more control. Supply `entityLabel: { one, other }` for count-aware footer text. `totalCount` is the unfiltered total; `displayedCount` is useful when one visible row represents multiple entities. Do not report an estimate as an exact total.
## Selection, sorting, and row actions
`enableRowSelection` accepts a boolean or a per-row predicate. Pair controlled `rowSelection` with `onRowSelectionChange`, a stable `getRowId`, and a selection column. A disabled UI row is not a server-side permission boundary.
The `sorting` configuration enables sorting and carries `initialSorting` with `onSortingChange`. Verify whether your host is sorting the complete local set or requesting a sorted backend set; sorting only the currently loaded page is not a global ordering.
`onRowClick` receives a TanStack `Row`, so domain data is in `row.original`. `isRowClickable` can exclude aggregate or restricted rows. Keep a named keyboard-accessible link or action in the row; a pointer click handler alone is not equivalent to a navigation link. Use `onRowMouseEnter` for optional route preloading.
`enableExpanding` and `renderExpandedRow` reveal inline detail. Keep the expansion control distinct from row navigation and test both with the keyboard. For the surrounding screen, use the [list-page pattern](/docs/patterns/list-page); for a short static table without this chrome, use `Table` from `@tale/ui/table`.
# Dialog
Source: https://ui.tale.dev/docs/components/dialog
Use a dialog when someone needs to complete a focused task or make a decision before returning to the current page. `Dialog` supplies the modal structure; your application supplies its state, content, and callbacks.
```tsx
import { Dialog } from '@tale/ui/dialog/dialog';
import { ConfirmDialog } from '@tale/ui/dialog/confirm-dialog';
```
## Open and close a dialog
Choose **Invite a member**, enter a sample email, and press Escape or **Cancel**. Focus returns to the opener. **Send invitation** only closes this local example; it sends no email and performs no validation or persistence.
`open`, `onOpenChange`, and `title` are required by `Dialog`. Pass a `trigger` when the opener is available in the same composition. Otherwise update `open` from your own button or menu and let the dialog capture the active opener.
The title is the accessible name. `description` gives context under it. Place required instructions where they remain visible, and label every form control independently. The footer is caller-owned: dismissing action first, confirming action second.
## Explain a consequential decision
Choose **Delete project**, then cancel or confirm. Confirmation updates only the example's local message. The sample consequence text demonstrates where an application would explain its own deletion rules; it is not a specification of Tale project deletion.
`ConfirmDialog` renders Cancel and Confirm actions for you. `confirmText` and `cancelText` override shared translated defaults. `variant` selects `default`, `destructive`, or `warning` styling; it does not implement the underlying operation.
Your `onConfirm` handler owns the request and closing behavior. Set `isLoading` while it runs: confirmation, cancellation, and close requests are blocked until it settles. On failure, keep the decision context and explain the problem. `disableConfirm` disables confirmation without disabling cancellation.
For a type-to-confirm decision, set `requireConfirmPhrase`. The trimmed input must match the phrase exactly, including case. It resets when the dialog opens again. This deliberate UI step is additional confirmation, not a substitute for authorization.
## Choose the right wrapper
| Component subpath | Use |
| --- | --- |
| `dialog/dialog` | Custom content and footer. |
| `dialog/confirm-dialog` | A decision with paired cancel/confirm actions. |
| `dialog/delete-dialog` | Entity-specific deletion wording. |
| `dialog/form-dialog` | A form with submission state and actions. |
| `dialog/view-dialog` | Read-only detail. |
| `entity/entity-view-dialog` | One record's details in the shared record layout, with an edit handoff. |
| `overlays/responsive-dialog` | The responsive wrapper's dialog/drawer composition. |
The base Dialog itself uses a bottom-sheet layout below `md` and a centered modal above it. Its header and footer remain outside the scrollable body. Test long content on a phone; choosing a large desktop size does not remove the need for that check.
## Keep record details scannable
`EntityViewDialog` uses the `lg` reading width and sizes its height to the content.
The record name, status and actions form one header; the generic `title` remains
available to assistive technology. Use `name` for the main heading, a short
`summary` only when it adds information,
and `content` for the primary reading area. Omit generic descriptions that repeat
the dialog title.
`facts` renders aligned label/value rows; items with `colSpan: 2` use the full width
for longer content. Put pages or version history in `EntityViewSection`, and pass
`identifier={{ label, value, hint? }}` for a compact copyable ID after all sections;
`hint` is a caption saying what the value does and does not identify. Give a section
`focusRef` when the host moves focus into it — for instance when a retry replaces the
control that held focus — so focus lands on the named section rather than the dialog.
Keep descriptions in one place rather than repeating them in the summary and body.
## Base dialog options
| Prop | Purpose |
| --- | --- |
| `size` | `sm`, `default`, `md`, `lg`, `xl`, `3xl`, `entity`, or `wide`; default `default`. `entity` gives record forms a shared width and content-driven height. `EntityViewDialog` uses the wider `lg` reading measure. |
| `children`, `footer` | Body and action content; either may be omitted. |
| `icon`, `headerActions` | Additional header content. |
| `onBack`, `backLabel` | A labelled back control for an in-dialog subview. |
| `customHeader` | Replaces the visible header; the required title remains available to assistive technology. |
| `hideClose` | Hides the close control; provide an accessible dismiss path unless the current operation deliberately blocks it. |
| `className`, `headerClassName`, `bodyClassName`, `footerClassName` | Targeted layout adjustments. |
| `restoreFocusRef` | Stable fallback when the captured opener unmounts or moves, for example a toolbar button the first row replaces. A dialog opened from a menu item needs none: it returns to that menu's button. |
| `preventCloseAutoFocus` | Opt out of automatic restoration only when the caller explicitly manages the next focus target. |
## Handle lifecycle and focus deliberately
The modal traps focus while open. Escape and the close control request dismissal; controlled state determines whether the request is accepted. Restore focus to a useful surviving control after close, especially when a successful action removes the original row.
Content can remain mounted through a closing animation. Do not assume `open=false` immediately stops its subscriptions or requests. If hook-heavy content has a closing-lifecycle problem, move it into a separate component and conditionally mount that component; do not call hooks conditionally inside one component.
Use an inline error for repairable form problems and a [toast](/docs/components/toast) for an optional completion notice. Use a persistent page or side panel when the task needs more room or the reader needs to refer to the surrounding content continuously.
# Input
Source: https://ui.tale.dev/docs/components/input
`Input` combines an input element with a label, help text, and validation feedback. Supply a visible `label` for ordinary forms; if the surrounding UI already labels the control, provide the corresponding accessible name yourself.
```tsx
import { Input } from '@tale/ui/input';
```
## Give the field enough context
Use `label` for the field's name, `description` for context before entry, and `hint` for a short format or usage rule below the control. A placeholder is an example value, not a replacement for a label: it disappears when the person types.
The component generates an ID unless you pass one. It associates the label, description, hint, and error with the input, and preserves additional IDs you supply through `aria-describedby`.
## Validate and explain the fix
`errorMessage` displays an inline alert and sets the invalid state automatically; `Select` takes the same `errorMessage` and renders it the same way, under the control. `isInvalid` can also set the state without an error string, for example when a separately rendered error summary explains the problem. The component displays validation supplied by the host; it does not decide whether an email, URL, or identifier is valid for your application.
For a controlled field, pass `value` and update it from `event.target.value` in `onChange`. For an uncontrolled example, use `defaultValue`. Keep the user's draft after a failed submission and explain how to repair the value.
## Choose read-only or disabled behavior
| Need | Use |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Show a value that can be focused and copied but not edited | Native `readOnly`; it automatically selects the borderless read-only appearance unless you specify a variant. |
| Keep the outlined appearance while preventing edits | `readOnly` with `variant="default"`. |
| Make the control unavailable | `disabled`. |
| Explain why it is unavailable on hover and focus | `disabled` with `disabledReason`. |
`variant="readOnly"` changes styling only. Pass `readOnly` as well to prevent editing. A disabled field with a reason stays focusable and read-only with `aria-disabled`; native disabled fields leave the tab order and are excluded from normal form submission. Account for that difference when reading form values.
## Distinguish account passwords from secrets
For an account sign-in field, use `type="password"` with `autoComplete="current-password"`. For a new account password, use `autoComplete="new-password"`. Explicit autocomplete lets these fields retain normal password-manager behavior.
For an API key or token, use `sensitive`. A password field with no explicit `autoComplete` is also treated as sensitive. This branch uses a text input masked with CSS, `autocomplete="off"`, and password-manager opt-out hints. These reduce unwanted autofill; they do not encrypt the value or guarantee that every browser extension ignores it. Never use a real secret in a demonstration.
The reveal toggle is enabled by default for password or sensitive fields. Set `passwordToggle={false}` to hide it. In the example, enter a sample value and use **Show password** and **Hide password** to inspect the two states.
## Props
| Prop | Type or default | Purpose |
| --------------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
| `label` | Optional string | Visible field name; supply another accessible name if omitted. |
| `description`, `hint` | Optional React content | Context above and below the control. |
| `errorMessage` | Optional string | Error text and invalid state. |
| `isInvalid` | Optional boolean | Additional way to mark invalid. |
| `variant` | `default`, `unstyled`, `readOnly` | Appearance; native `readOnly` automatically selects the last when no variant is set. |
| `passwordToggle` | `true` | Reveal control for a password or sensitive value. |
| `sensitive` | Optional boolean | Secret-entry behavior described above. |
| `disabledReason` | Optional React content | Explanation while `disabled`. |
| `prefix`, `suffix` | Optional React content | Fixed text inside the outlined field; do not combine with the password toggle. |
| `labelInfo` | Optional React content | Additional label tooltip. |
| `wideControl` | `false` | Lets the control fill a layout-owned frame instead of the settings control column. |
| `wrapperClassName` | Optional string | Classes on `FieldShell`. |
Other native input attributes pass through, except native `size`; `prefix` is reserved for the component's fixed addon. A prefix or suffix is visual context, not part of the submitted input value. Your host must construct and validate any combined value.
## Layout and related controls
`ContentArea variant="narrow"` selects the shared settings field layout: stacked until the surface is 36rem wide, label beside control from there. See [Settings page](/docs/patterns/settings-page) before adding per-field widths.
Use `Textarea` for multiple lines, `Select` for a fixed set, `SearchableSelect` for a searchable set, `JsonInput` for structured JSON, and `CopyableField` for a value primarily meant to be copied. A table search belongs in [`DataTable.search`](/docs/components/data-table), where it can stay associated with the filtered results.
## Related controls
`NumberStepper` from `@tale/ui/number-stepper` edits a small whole number, often inside a sentence. It is a text field with `role="spinbutton"`: the arrow keys step by `step` (1), Page Up and Page Down by `pageStep` (10), and Home and End jump to `min` and `max`. It accepts digits only. A number in range is committed as you type; anything else is clamped when the field loses focus or on Enter, which also calls `onEnter`, and an emptied field returns to the last value. The − and + buttons stay out of the tab order and are disabled at the bounds. Name the field with `aria-label`, or with `aria-labelledby` listing the words around it and the field's own `id`, as the example does, so it reads "Keep 2 backup copies".
`ToggleChipGroup` from `@tale/ui/toggle-chip-group` picks several of a few short options. It is one tab stop; the arrow keys move between chips and Space or Enter toggles one. `minSelected` ignores only turning a chip off when that would leave fewer chips on than the minimum; turning one on always counts. The example therefore always keeps one day. Give abbreviated chips an `aria-label` that contains the visible text, such as **Monday** for **Mo**, and name the group with `aria-label` or `aria-labelledby`.
Both controls also make up the Custom view of the [recurrence picker](/docs/components/recurrence-picker).
# Recurrence picker
Source: https://ui.tale.dev/docs/components/recurrence-picker
`RecurrencePicker` edits a repeat rule, such as "every 2 weeks on Tuesday and Thursday". It fits a property row: a compact trigger shows the rule, and its popover offers one-click presets plus a **Custom** view for any other rule. The component stores nothing and does no calendar arithmetic. Your host saves the rule, and supplies the day the presets are read from and, if you want them listed, the dates a rule produces.
```tsx
import type { RecurrenceReference, RecurrenceRule } from '@tale/ui/recurrence';
import { RecurrencePicker } from '@tale/ui/recurrence-picker';
```
## Pick a repeat
Open **Repeat** and choose a row. The presets are **Never**, **Daily**, **Every weekday** (Monday to Friday), and weekly, monthly and yearly rules read from `reference`: with a due date of Tuesday, September 29, they read **Weekly on Tuesday**, **Monthly on day 29** and **Yearly on Sep 29**. A preset saves at once: the popover closes, `onChange` is called once with the new rule, and focus returns to the trigger. Choosing the preset that is already saved calls nothing, and **Never** calls `onChange(null)`.
`reference` is usually the item's due date, or today when there is none. Pass it as a `RecurrenceReference` — `year`, `month` (1 is January), `day`, and `weekday` as a `Date#getDay` number, where 0 is Sunday. The host works out the weekday in its own time zone; the package never reads the clock.
The trigger shows the rule compactly, such as **Weekly · Tue, Thu** or **Monthly · day 30**. Its tooltip holds the full sentence. `presets` limits or reorders the one-click rows; **Never** always comes first.
## Build a custom rule
**Custom** replaces the preset list with an editor in the same popover. Choose **Day**, **Week**, **Month** or **Year**, set the interval ("Every 2 weeks"), and then the weekdays, the day of the month, or the month and day. The number fields clamp to their range and one weekday always stays on, so the editor cannot hold an invalid rule and **Save** is never blocked.
Custom edits are a draft for the popover session. **Save**, Enter in a number field, or Ctrl+Enter (Cmd+Enter on a Mac) calls `onChange` once. **Cancel**, Escape, or a click outside throws the draft away. **Back to presets** keeps the draft, and the list then checks whichever preset equals it — the footer stays until you save or cancel. In the example, only saving increments the counter.
A monthly rule on day 29, 30 or 31 explains that shorter months use their last day, and a yearly rule on February 29 names the date it falls on in other years. The picker only says so: what a short month does with the 31st is the host's decision. `frequencies` limits the units the editor offers, and `maxInterval` (99 by default) caps the interval.
## Show the dates a rule produces
Pass `nextDates` to list the next dates under the presets or the editor. It receives the draft rule and returns `CalendarDay` values (`year`, `month`, `day`); the picker shows the first three with their weekday, and adds the year when it differs from the reference year. Label the list with `nextDatesLabel`, such as **Next due dates**. Without `nextDates`, no list is shown.
The picker does not step a rule itself. Time zones, daylight-saving changes and clamping the 31st to a short month depend on your storage and business rules, so the dates come from the same code that will create the next item. The examples on this page use a small UTC stepper that is good enough for a demonstration.
## Add a host option
When a host option belongs with the rule, pass its saved value as `extra` and render its control with `renderExtra`. The control appears below the dates in both views and receives `{ rule, extra, setExtra }`, where `rule` is the draft rule (`null` while **Never** is chosen). Its changes are drafted like a custom rule, so `onChange(rule, extra)` saves both in one call. A preset saves with the drafted option too. Return `null` to render nothing, as the example does while no rule is chosen.
The example also passes a matching `icon` and a `description`. The description is the second line of the tooltip and part of the trigger's accessible description while a rule is set.
## Explain unavailable and read-only states
| Need | Use |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The rule cannot change now, and the person should learn why | `disabled` with `disabledReason`. The trigger stays focusable with `aria-disabled`, opens nothing, keeps the rule at full contrast, and adds the reason to its tooltip and description. |
| The control is unavailable and needs no explanation | `disabled` alone. The trigger is natively disabled and leaves the tab order. |
| The person may see the rule but never edit it | `readOnly`. The picker renders plain text with no button; screen readers hear the full sentence, and pointer users see it on hover. |
A reason should say what would make the rule editable, such as reopening the item. A disabled control is not a permission check; enforce the rule where you save it.
## Fit a narrow column
The trigger always stays on one line. When the compact rule does not fit, its tail — the weekdays or the day — drops out whole rather than being cut mid-word, and the head is truncated only if it cannot fit alone. In the states example, the narrow column shows **Every 2 weeks** without **Tue, Thu**. The accessible name, the tooltip, and the description still carry the full rule. Use `variant="default"` for a 36px outlined field in a form; the default `ghost` fits a 28px property row.
## Format a rule elsewhere
Use the same words outside the picker, such as on a card or in an activity line:
```tsx
import { useRecurrenceFormat } from '@tale/ui/use-recurrence-format';
const format = useRecurrenceFormat();
format.sentence(rule); // "Every 2 weeks on Tuesday and Thursday"
format.compact(rule); // { head: 'Every 2 weeks', tail: 'Tue, Thu' }
format.day({ year: 2026, month: 10, day: 6 }, 2026); // "Tue, Oct 6"
format.never; // "Never"
```
The hook formats in the language the i18n instance renders. Outside React, `formatRecurrence(rule, t, locale)` and `formatRecurrenceCompact` from `@tale/ui/recurrence-format` take a `t` bound to the `recurrence` namespace. `@tale/ui/recurrence` holds the rule helpers: `normalizeRecurrence` returns a rule with only its own keys and sorted weekdays, `sameRecurrence` compares two rules and ignores extra keys, and `matchRecurrencePreset` names the preset a rule equals.
A host may keep keys of its own on a rule, such as a time zone. The picker accepts them in `value`, ignores them when it compares, and always emits a normalized rule, so add them back in `onChange`.
## Embed the editor in a form
`RecurrenceEditor` is the Custom view's form body without the popover. Use it when the rule is one field of a larger form:
```tsx
import { recurrenceDraft, recurrenceFromDraft } from '@tale/ui/recurrence';
import { RecurrenceEditor } from '@tale/ui/recurrence-editor';
const [draft, setDraft] = useState(() => recurrenceDraft(savedRule, reference));
;
// On submit: save recurrenceFromDraft(draft).
```
The editor keeps each unit's own setting in the draft, so switching from **Month** to **Week** and back keeps the day of the month. It renders no `