# grani > Interaction patterns for admin apps, and the Vue 3 components that keep them: focus, late answers, loading, errors. Most proved by a test. Tailwind CSS 4, typed, SSR-ready. Version 0.1.0. ## How to use this reference 1. Check the installed grani version (`npm ls grani`); this text is 0.1.0. 2. Choose components from the catalog, or start from the task below. 3. Read a component's page before writing code: its whole example, its API and what it leaves to the app. 4. Use only the documented props, events and slots. 5. When the components make a screen together, read the patterns for what the person does there: focus, errors, waiting, closing. ## Setup Vue 3 components as a plain package: `npm i grani`, then `app.use(createGrani())`. Tailwind 4.1.0 or later: `@import 'tailwindcss'; @import 'grani/style.css';` in the app's stylesheet (the theme and the sources come with it; it styles grani's elements only, and `@import 'grani/base.css';` after it is an optional base for the app's own). Components are imported from `grani` (`import { UiButton } from 'grani'`) or resolved in templates as by `GraniResolver` from `grani/resolver` through unplugin-vue-components. Brand: override the eight `--color-brand*` variables in your own stylesheet, below the imports, in `:root` for the light theme and again in `.dark` for the dark one; never edit the package's files. Shape: the `--radius-*` variables. Gutters: `--space-overlay`, `--space-surface`, `--space-surface-md`, `--space-drawer`. The dark theme is the `dark` class on ``. Guides: https://grani.arseni.work/guide/theming.md and https://grani.arseni.work/guide/dark-mode.md Colors in the app's own markup: the theme's classes, which follow the brand and the dark theme. The page `bg-bg`; a panel `bg-surface`, a quieter one `bg-surface-muted`; words `text-fg`, quieter words `text-fg-muted` (`text-fg-subtle` is for a mark beside them, an icon or a dot, never for words to read: 2.5:1 on white); a line `border-border`; a state `text-danger`, `bg-success-subtle` and their kin for `danger`, `warning`, `success` and `info`; never Tailwind's raw palette (`bg-neutral-50`, `text-gray-500`) or a hex, which stay the same in the dark theme and under any brand. Language: `createGrani({ locale, translate })`. `locale` is a BCP 47 tag, or a ref or getter of one, and drives dates, numbers, money and plural forms; `translate(key, params)` returns the app's text for grani's keys, or nothing (or the key itself) to keep grani's English. `grani/i18n` exports `en` and `ru`, grani's keys with their text, to feed into the app's own i18n, or `translate: (key) => ru[key]` without one. Size: `createGrani({ size: 'xs' | 'sm' | 'md' })` sets the size of every control without a size of its own; `grani.setSize()` changes it later. Zone and money: `createGrani({ timeZone, currency })`, `grani.setTimeZone()`, `grani.setCurrency()`. Links and server values: `createGrani({ link, shared })`. `link` is the component that draws a link to the app's own paths (unset, `RouterLink` when the app has a router, else a plain anchor); `shared` is the values a server wrote into the page with `sharedSnapshot(app)`, so the first render reads the same. Helpers the entry exports: `rangeFor(preset, today)` gives the days of a named period (`{ from, to }`, whole months), `periodBounds(period, { timeZone, locale, min, max })` the moments it covers for a query (`at >= start && at < end`, the last day whole, by the app's clock, a named period counted from today with the filter's window), and `RANGE_PRESETS` the periods in the order a UiDateRange panel lists them, for the app's own query; `formatChartValue(useUiT(), value, format)` writes a value and `parseChartDate(value)` reads a point of a time axis the way the charts do, for a headline or a tooltip of the app's own. Outside a component (a router guard, a timer): `grani.toast` and `grani.confirm`, the instance's own `useToast()` and `useConfirm()`; inside one, `useGrani()` returns the instance. ## Start from the task - A list of records with search, filters, selection and pages: `UiDataTable`. - A record edited in a side panel, with Save: `UiCrudDrawer`. - A field with its label, hint and error: `UiField` around the control (`UiInput`, `UiSelect`, `UiDatePicker`...). - A question before an action that cannot come back: `UiConfirm`, or `useConfirm()`. - The outcome of an action: `useToast()`, with `UiToaster` mounted once. - Settings as rows: `UiList` with `UiListItem`, in a `UiCard`. - A figure over time in a card: `UiChartCard` around `UiAreaChart`, `UiBarChart` or `UiSparkline`. ## Common mistakes - Import components from `grani`; use only the documented entry points (`grani`, `grani/resolver`, `grani/i18n`, `grani/test`, `grani/style.css`, `grani/base.css`, `grani/theme.css` with `grani/classes/.css` per component). - Set up auto-imports (`GraniResolver`) before using components without an import. - With `grani/theme.css`, import `grani/classes/.css` for every component the app uses: a component without its file renders without its styles. Never point `@source` into `node_modules/grani`: Tailwind then scans the whole folder, every component with it. - Mount `UiToaster` once when using `useToast()`, and `UiConfirm` once when using `useConfirm()`. - Under Yarn Plug'n'Play keep grani unpacked, as its package asks (`preferUnplugged`): kept in a zip, its files reach the development server but not the production build, and the components ship unstyled with no warning. - Import Tailwind without `prefix()`: with it the build stays green and the components show unstyled (with `grani/style.css`, `createGrani()` warns in the browser's console in development). - Keep an empty string and `null` apart where a component documents the difference. - Check each component's props in its API; do not borrow prop names from another UI library. - Components do no requests: the app fetches, saves, checks permissions and passes the data and the states in. - Leave no empty list at a dead end: give the step only the app knows, such as adding the first record or creating what was searched for, in UiDataTable's `#empty-actions` (UiTable's `#empty`), beside the table's own Clear search. ## Components ### App shell (`UiAppShell`) > Organize an app around a sidebar for navigation and a main area for the page you are working on. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/app-shell Also called app layout, sidebar layout, navigation shell. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Keep nearby content in place while loading. - Reach frequent actions from the keyboard. - Continue from where you opened the panel. - Keep keyboard focus where you are working. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass `loading` with an empty `nav` until the items arrive. - List the shortcut wherever your app lists its keys. #### Why it works this way The frame of an app, a sidebar of its places with the page beside it. It is the root of the app's layout, with UiToaster and UiConfirm mounted beside it. **Sidebar.** The sidebar holds the logo (a link home), the places in groups (`nav`, each a link of the panel list's rows, `utils/panelList`, with a Lucide icon, a label cut to one line, and a count), `#banner` above the foot (a goal, a notice), and `#footer` at the bottom. **Current place.** One place is lit, its label semibold: the one whose `to` is `current` itself (a section of one page, `#billing`), else the deepest whose `to` contains its path, a trailing slash no part of it; `home` and the root `/` are lit only on themselves, and of two places with one address the first. The app passes `current`, since the shell knows no router. Under forced colors the current place takes the highlight. **Collapsing.** On desktop the sidebar collapses to its icons, by the button beside the logo (under `#mark`, or alone when collapsed) or by Ctrl or ⌘ B; each icon is named by a UiTooltip. Collapsed without `#mark` there is no link home, so the app lists home among its places; dev warns when it does not, and when `current` is not given. `v-model:collapsed` keeps the state where the app wants it; keeping it across visits is the app's: write it on `update:collapsed` (a cookie) and read it in setup, so the first paint is already right, on the server too; read after mount, the sidebar opens out and slides back. When it is bound, the shell follows it and only asks for a change. **Narrow.** Narrow means the shell's own width is under 768px. There a bar holds the menu button and the logo, and the sidebar slides in from the start as a sheet (by the menu button, or Ctrl or ⌘ B). The focus moves in and stays; Escape or the backdrop closes the sheet, and so does a new `current`. **Keys.** The keys do nothing under a window or a panel. With two shells, they reach the one the focus is in, else the first. In Firefox, Ctrl or ⌘ B is the bookmarks sidebar's too: where the shell acts on the key it takes it, held as well. **Loading.** While the app's places are on their way (`loading`, `nav` still empty) the sidebar draws five rows in their shape, a glyph's square and a line; the logo, the collapse button and `#footer` are there at once, and `#banner` waits. Collapsed, the rows are their squares alone. When the places come nothing moves: the rows are the places' height, the sidebar its width; for places in one group without a heading, since a second group or a heading moves the rows under the first. With places already in view `loading` changes nothing: a sidebar is not dimmed for a refresh. **Height.** The page scrolls inside the shell, which is as tall as the window unless `--ui-shell-height` says otherwise. A new path opens the page at its top; a new hash, a section of the same page, keeps the place. ##### Keyboard The first stop is a skip link, shown only while focused. Then come the link home, the collapse button and the places, each a link. - `Enter`: on the skip link, puts the focus on the page. - `Ctrl+B` `Meta+B`: Ctrl or ⌘ B collapses the sidebar, or opens and closes the sheet while the shell is narrow; not while typing in a text field or an editor, not under a window or a panel, and a held key toggles once. In the sheet, where the focus moves in: - `Tab`: stays inside. - `Escape`: closes it, as the backdrop does, and the focus returns to the menu button. Collapsing from the link home or from `#banner` hands the focus to the collapse button, so it is never dropped. ##### Screen reader - Skip link: comes first. - Sidebar: a navigation landmark, and the sheet a modal dialog of the same name. - Group: its heading names its list, open and collapsed. - Current place: `aria-current="page"`. - Collapsed row: named by its label and its count; the count on the icon's corner is a mark the reader skips. - Buttons: the collapse and menu buttons say what they open (`aria-expanded`, `aria-controls`). - Logo: a link named by the brand and "home". - Loading: a status region, always there, says "Loading…" while the places come; the navigation is busy (`aria-busy`), so the sheet opened meanwhile says it too; the rows in their shape are hidden. ##### States Desktop open; desktop collapsed to icons, each named by a tooltip, with `#banner` gone; narrow, with the sheet closed or open, showing the full sidebar even when the desktop one is collapsed; loading, open and collapsed, with rows in the places' shape. On a row: hover, focus, and the current place, which takes the highlight under forced colors. In dev, a missing `current` and a collapsed head with no way home warn. ##### When to use another component - A set of panes on one screen: UiTabs. - A window or a panel over the page: UiModal or UiDrawer. - The name of the page inside the main area: UiHeader `page`. - Places inside a section of a page: UiListItem links in a UiList. #### Examples ##### Collapsed ```vue ``` ##### Loading ```vue ``` ##### Narrow ```vue ``` #### API Props: - `nav`: `NavGroup[]` (required) — The places of the app, in groups; an empty `label` draws none. - `brand`: `string` (default `""`) — The app's name: the logo when `#logo` is not given, and the name of the link home; unset, a translated "App". - `home`: `string` (default `"/"`) — Where the logo leads; the item on it is the current page only on it, not under it. A `javascript:` or `data:` address is refused, as for the places. - `current`: `string` (default `""`) — The path of the page shown (`useRoute().path`): it lights its item and closes the sheet. - `collapsed`: `boolean` — Desktop: only the icons show. Bound to a value, the shell follows it and only asks through `update:collapsed`; unbound or `undefined`, it keeps its own. - `loading`: `boolean` (default `false`) — The places are on their way: with `nav` still empty, the sidebar draws rows in their shape. Anything else lands on the root element: native attributes, aria-*, data-testid. Events: - `update:collapsed` (`value: boolean`) — The sidebar was collapsed or opened out: its button or Ctrl or ⌘ B. Slots: - `logo` with `Record` — The logo in the sidebar's head and in the narrow bar: a mark and the name. - `mark` with `Record` — The mark alone, for the collapsed sidebar: a 24px glyph or image, over the button. Without it the collapsed head holds the button alone. - `banner` with `Record` — A band under a line at the foot of the sidebar: a goal with its bar, a notice. Its words stand on the places' icons. The collapsed sidebar has no room for it. - `footer` with `{ collapsed: boolean; }` — The foot of the sidebar: the person signed in, a switch of theme. - `default` with `Record` — The page. #### How it behaves ##### Stable dimensions - When: the navigation items arrive from the server after the shell is drawn. - Props: `loading`. - Guarantee: While the navigation loads, the sidebar draws five placeholder rows in the shape of its places, with the logo and the footer already in place. When the places arrive they take the rows’ place. - Without it: The sidebar is an empty column with nothing to show that places are coming, and the rows appear all at once when the items arrive. - Code: `…` - Your part: Pass `loading` with an empty `nav` until the items arrive. - Not covered: Vertical positions match the placeholders only for a single navigation group without a heading. Additional groups or headings can move rows when they arrive. - Proof, in the repository's tests (UiAppShell.spec.ts, not in the package): "places on their way draw five rows in their shape, keep the footer, and say so once" - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt ##### Shortcut keys - When: you collapse the sidebar, or open it as a panel on a narrow screen, from the keyboard. - Props: none: every UiAppShell. - Guarantee: Ctrl or ⌘ B collapses and expands the sidebar, or opens and closes it as a panel on a narrow screen. It does nothing while you type in a field or an editor, or while a window or a panel is open above the shell, and a held key toggles once. On a Latin layout it is the key that types B; on another alphabet, the key in B's place. - Without it: The sidebar can be folded only by aiming at its small button. - Code: `` - Your part: List the shortcut wherever your app lists its keys. - Not covered: With two shells on one page, the shortcut goes to the one that holds keyboard focus, or else to the first one. In Firefox, Ctrl or ⌘ B also opens the bookmarks sidebar: where the shell acts on the key, held as well, it takes the key from the browser; in a text field, under a window or a panel, the browser keeps it. - Proof, in the repository's tests (UiAppShell.spec.ts, not in the package): "Ctrl or ⌘ B toggles the collapse, but not while typing in a field", "Ctrl or ⌘ B is refused in any editable text, not only contenteditable="true"", "Ctrl or ⌘ B does nothing while a window is open above the shell", "a held Ctrl or ⌘ B toggles once", "a held Ctrl or ⌘ B is taken from the browser on every repeat, and toggles once", "Ctrl or ⌘ B works on a non-Latin layout", "Ctrl or ⌘ B follows the typed letter on a Latin layout", "narrow: Ctrl or ⌘ B opens and closes the sheet", "two shells: Ctrl or ⌘ B reaches the one the focus is in, else only the first" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#keyboard-actions ##### Focus comes back - When: the navigation sheet of a narrow layout closes. - Props: none: every UiAppShell under 768px wide. - Guarantee: Escape or a press on the backdrop closes the sheet, and keyboard focus returns to the menu button that opened it. - Without it: The sheet closes with keyboard focus inside it: focus falls to the page, and a screen reader loses its place. - Code: `…` - Your part: Nothing. - Proof, in the repository's tests (UiAppShell.spec.ts, not in the package): "Escape closes the sheet and the focus returns to the menu button that opened it.", "A press on the backdrop closes the sheet, and the focus returns to the menu button too." - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-back ##### Focus stays near - When: The sidebar collapses, for example with Ctrl or ⌘ B, while keyboard focus is on the logo link or inside `#banner`. - Props: none: every UiAppShell. - Guarantee: When the logo link or a banner disappears while it has keyboard focus, focus moves to the collapse button, which remains available in the narrow sidebar, so you can continue from there. - Without it: The banner closes with keyboard focus inside it, focus is lost, and you have to find the sidebar again. - Code: ` ` - Your part: Nothing. - Proof, in the repository's tests (UiAppShell.spec.ts, not in the package): "Ctrl or ⌘ B with the focus on the logo hands the focus to the button", "collapsing with the focus inside #banner hands it to the toggle" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-stays The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Area chart (`UiAreaChart`) > Follow values over time as lines and filled areas. Explore each point from the keyboard, with gaps shown as missing data rather than zero. A component of grani 0.1.0. Charts, stable, since 0.1.0. Page: https://grani.arseni.work/components/area-chart Also called line chart, time series chart, trend chart. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Show missing data as a gap. - Show when a reporting period is still incomplete. - Read every chart value from the keyboard. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass `null` where there is no data, and `0` only for a real zero. - Set `partial` while the period runs, and remove it once the period closes. - Name the chart with `ariaLabel`: what it shows and over which period. #### Why it works this way Values over time as areas and lines. Take it for a flow over time, usually in the body of a UiChartCard. **Data.** `x` is the time axis: dates that the library labels by `grain`, with the year on the first label shown and where it changes. Names that are not dates (regions, products) are categories: UiBarChart labels each. Each series is its `values` in that order. A `null` is a gap where the line breaks, never a zero. **Series.** A series is an area, or a `line`; `dashed` marks a figure of another kind (a profit over revenue). A `muted` series is drawn in gray behind the rest, as a period to compare with, and takes no color. **Value axis.** The value axis covers every value shown, losses included, and draws the zero line when the values cross it. **Legend.** The legend names each series with its total. Pressing one shows it alone and fits the axis to it; pressing it again shows all. `tooltipTotal` adds a sum under the series in the tooltip, hidden while one series stands alone. Partial and highlight. `partial` says the last point is a period still going: its segment is dashed, and its label and tooltip say so. `highlight` puts a dot on each series at one point, the month the card's headline talks about. **Drawing.** The SVG stretches in one viewBox and keeps its stroke widths. Every word is HTML over it, and the width changes only how many time-axis labels show, so what the server renders is what the browser keeps. A label that would still run into the next one or out of the plot is left out; the newest always stays. It takes the whole width of its host, even in a centering row. **Points.** Each point is a row of the hidden table and a word of the axis. Measured in Chromium (production build, two series): 500 points draw about 2,600 elements and a pointer move reaches the screen within a frame; 1,000 take about 20 ms, 2,000 about 50 ms and 10,000 over half a second. Past 500, group the days into weeks or months; in development the chart warns once. In a card. In a UiChartCard it draws its own shape pale on the card's first load, and holds that shape still when there is nothing to draw. Alone, with no point or no value that is a number, it says "No data for this period" in its own place. When the card bleeds it to the sides, it keeps its words on the gutter, drops the value axis and draws the focus ring inside; on the card's floor it keeps the gutter under its legend. **Placement.** In a UiChartCard's body, under the card's headline, with a UiDelta in the card's actions. Alone it fills its host, a page section or a panel. ##### Keyboard The plot is one tab stop; an empty chart is no tab stop. On the plot: - `Tab`: reaches it; focus shows the newest point, or the one last visited. - `ArrowLeft` `ArrowRight`: step from point to point; each point is read out. - `Home` `End`: jump to the first and the last point; each point is read out. - `Alt` `Ctrl` `Meta`: a chord with them stays the browser's. On the legend, whose chips are buttons when there are two series or more: - `Tab`: reaches each chip. - `Enter` `Space`: shows that series alone; a second press shows all. A finger scrolls the page over the plot, and a tap keeps its point until a tap elsewhere. ##### Screen reader - Plot: an image named by `ariaLabel`. - Point: only a point reached by the keyboard is read, in a polite live region, as "September 2026: Spending 3,640, Income 5,100", followed by the sum of `tooltipTotal`. - Table: a hidden table, captioned with the chart's name, holds every point, every series and the sum, including a series shown alone; `partial` says "in progress" with the last point. - Legend: a group named "Series; press one to show it alone", each chip `aria-pressed`. - Hidden: the tooltip, the dots, the labels and the axis. - Empty: alone, a group named by `ariaLabel` that says there is no data. ##### States Rest, a point under the pointer or the keys (the line, the dots and the tooltip), one series alone, `partial` with its dashed tail, `highlight` dots until the cursor takes over, a `muted` series behind the rest, a gap and a lone point with its dot, a loss with the zero line, nothing to draw alone. In a UiChartCard: its first load as a pale line, nothing to draw held still, a refresh dimmed by the card, bled to the sides without a value axis. ##### When to use another component - Amounts per period: UiBarChart. - The parts of a whole: UiDonutChart. - A trend with no axes beside a number: UiSparkline. - The card with its title, headline and data states: UiChartCard around it. #### Examples ##### Gaps ```vue ``` ##### Partial ```vue ``` ##### Compare ```vue ``` ##### One ```vue ``` ##### Shape ```vue ``` ##### In a card ```vue ``` ##### Percent ```vue ``` #### API Props: - `x`: `DateValue[]` (required) — The time axis: one date per point, `2026-09` a month, `2026-09-24` a day; text that is not a date stays as it is. - `series`: `AreaSeries[]` (required) — The series, in the order they take the chart colors, five at most. Each is `key`, `label` and `values`; `variant: 'line'` draws it unfilled, `dashed` for a figure of another kind, `total: false` keeps its sum out of the legend. - `ariaLabel`: `string` (required) — What the chart shows, for a screen reader: "Spending and income by month". - `grain`: `ChartGrain` = `month | day | weekday | week` (default `"month"`) — What one point stands for: the labels and the tooltip read the dates by it; `weekday` names a day of one week, "Tue". - `format`: `ChartFormat` (default `"number"`) — How values read in the legend, the tooltip and the table; `money` writes the currency code after the digits, `1,234.00 USD`. The axis prints compact figures (or percents) without the unit. - `curve`: `ChartCurve` = `smooth | linear | step` (default `"smooth"`) — `smooth` bends through the points without passing them, `linear` joins them, `step` holds each value. - `height`: `number` (default `240`) — The height in pixels, the time axis included; the legend is below it. - `yAxis`: `boolean` (default `true`) — The value axis with its grid; off for a chart that only shows a shape. - `legend`: `boolean` (default `true`) — The legend with each series' total, a press shows one series alone. - `tooltipTotal`: `{ label: string; of: string[]; }` — A sum of some series under them in the tooltip and the table: `{ label: 'Revenue', of: ['orders', 'events'] }`. - `partial`: `boolean` (default `false`) — The last point is a period still going: its segment is dashed, its label and tooltip say so. - `highlight`: `number` — The index of the point in light, the one the card's headline speaks of: a dot on each series there. Anything else lands on the root element: native attributes, aria-*, data-testid. #### How it behaves ##### A gap, not a zero - When: a series has a point with no data. - Props: `series[].values` with `null` where there is no data. - Guarantee: The line and its area break at a `null` and go on after it, and a point alone between two gaps keeps a dot. The tooltip and the data table available to screen readers show a dash there, never 0. - Without it: A month with no data is drawn as a fall to zero and counted as zero in a sum. - Code: `` - Your part: Pass `null` where there is no data, and `0` only for a real zero. - Proof, in the repository's tests (UiAreaChart.spec.ts, not in the package): "a missing value breaks the line instead of dropping to zero", "a point with gaps around it keeps a dot" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#gap-not-zero ##### In progress, not a drop - When: the last point is not over. - Props: `partial`. - Guarantee: The last segment of the line is dashed, and the axis label, the tooltip and the data table available to screen readers say the period is in progress, for example “Sep · in progress”. - Without it: The line dives at its end for a month that still has a week to go. - Code: `` - Your part: Set `partial` while the period runs, and remove it once the period closes. - Proof, in the repository's tests (UiAreaChart.spec.ts, not in the package): "partial dashes the last segment and says the period is going" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#partial-not-drop ##### Values by keys - When: you need the values, not only the shape. - Props: `ariaLabel`. - Guarantee: Tab reaches the plot and shows the newest point. The arrow keys, Home and End move between points, and a screen reader announces each one, for example “September 2026: Spending 3,640, Income 5,100”. A data table available to screen readers holds every point of every series. - Without it: A tooltip only a mouse can open: Tab skips the chart, and a screen reader announces only an image. - Code: `` - Your part: Name the chart with `ariaLabel`: what it shows and over which period. - Proof, in the repository's tests (UiAreaChart.spec.ts, not in the package): "the plot is one tab stop: focus says the newest point, the arrows walk", "a hidden table holds every value for a screen reader" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#values-by-keys The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Aspect ratio (`UiAspectRatio`) > Reserve space for a photo or video before it loads, so nearby content stays in place. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/aspect-ratio Also called media box, image placeholder. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Keep nearby content in place while loading. Implemented by the app: - Data fetching, saving, permissions and business rules. - Give the box a width (its column or a class), and set the background shown while it waits. #### Why it works this way A box of fixed proportions for a photo, a video or an iframe. Take it wherever media must not move the page as it loads. **Fit.** `fit` crops the media to fill the box (`cover`) or shows it whole (`contain`). **Your part.** You write the `alt`, paint the background shown while loading and in the bands of `contain`, and place any overlay with `absolute`; a badge, a link or an image placed that way keeps its own size. **Placement.** It stands alone in a grid of media, or in the `#image` of a framed UiCard. The card squares the image's corners, clips it, and draws the ring itself for a focused linked image. Beside the card's body it fills the column, and `ratio` does not apply. Anywhere else the box rounds its own corners, and the link's focus halo shows round it. ##### Keyboard Not interactive: the box takes no focus. - `Tab`: reaches a link in the slot, which keeps its own tab stop. While that link is focused, the box lets the halo show outside it, and the link clips the image instead. ##### Screen reader - Media: nothing of its own; a screen reader hears the media, so the `alt` you write on the image is all there is. ##### States `cover` crops the media; `contain` shows it whole between bands of your background, which also shows while the media loads. A linked image dims a little on hover and rings on focus. A ratio that is not a finite number above zero falls back to 16 / 9 and warns in dev. ##### When to use another component - An avatar: UiAvatar. - A block holding the page's shape while loading: UiSkeleton. - A chart: UiChartCard. #### Examples ##### Ratio ```vue ``` ##### Overlay ```vue ``` ##### Link ```vue ``` #### API Props: - `ratio`: `number` (default `16 / 9`) — Width divided by height, finite and above zero. Examples: `16 / 9`, `1`, `4 / 3`. - `fit`: `"cover" | "contain"` = `cover | contain` (default `"cover"`) — How media meets the box: `cover` crops it to fill, `contain` shows it whole. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `default` with `Record` — The media. A lone child fills the box; beside an overlay, an image, a picture, a video, an iframe, a canvas or a link still fills it. #### How it behaves ##### Stable dimensions - When: a photo, a video or an embedded frame is on its way. - Props: `ratio`, the media in the slot. - Guarantee: The box takes its height from its width and ratio before the media arrives, so the text below stays where it is when the media loads or fails. - Without it: An image has no size until it arrives: the space is empty, then everything below jumps down. - Code: ` … ` - Your part: Give the box a width (its column or a class), and set the background shown while it waits. - Proof, in the repository's tests (UiAspectRatio.spec.ts, not in the package): "The box holds its place before the media loads: its proportions are its own." - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt The patterns: https://grani.arseni.work/patterns ### Avatar (`UiAvatar`) > Recognize a person by their photo, with initials shown while the photo loads or if it is unavailable. A component of grani 0.1.0. Data, stable, since 0.1.0. Page: https://grani.arseni.work/components/avatar Also called user avatar, profile picture, initials. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Keep nearby content in place while loading. Implemented by the app: - Data fetching, saving, permissions and business rules. #### Why it works this way A round mark for a person or a thing, with a photo or initials. Take it beside a name: in a card header, a table cell or a UiListItem's `#media`. **Look.** The photo from `src` lies over the initials of `name`, on a pale plate in the tone. While the photo loads, and when it fails, the initials show at the same size. With no name and no photo it shows a person glyph. **Placement.** In the `#media` of a UiListItem, beside a name in a card header, in a table cell, and as the `xs` actor node of a UiLog beside the log line. **Mark.** A UiIndicator with `round` marks it: `sm` on the `xs` and `sm` sizes, `md` on `md`. ##### Keyboard Not interactive: no tab stop, no keys. ##### Screen reader - Beside a name: hidden (`aria-hidden`); the name beside it is what is read. - Alone: with `alt` or a caller's `aria-label`, an image (`role="img"`) with that name. - Photo and glyph: the photo has an empty `alt` and the person glyph is hidden, so nothing is read twice. ##### States Initials on the plate, a photo over them, a photo loading or failed (the initials show at the same size), no name and no photo (a person glyph); three sizes and the tones, with a UiIndicator mark. No hover or focus state of its own. ##### When to use another component - A state mark on it: UiIndicator `round`. - A state word: UiBadge. - A count: UiCount. - A glyph that acts: UiIconButton. #### Examples ##### Sizes ```vue ``` ##### Photo ```vue ``` ##### With a mark ```vue ``` ##### No name ```vue ``` ##### In a row ```vue ``` ##### In a table ```vue ``` #### API Props: - `name`: `string` (required) — Whose it is; the initials come from its first two words ("Anna Petrova" is "AP"). - `src`: `string` (default `""`) — A photo; when it is missing or fails to load, the initials stand in. - `alt`: `string` (default `""`) — Alone, what it shows ("Anna Petrova"); beside the name leave it empty: it is decoration. - `size`: `"sm" | "xs" | "md"` = `xs | sm | md` (default `"sm"`) — `xs` 24, `sm` 32, `md` 40: beside a dense row, a row, a page row. - `tone`: `Tone` = `brand | neutral | success | warning | danger | info` (default `"brand"`) — The plate behind the initials. Anything else lands on the root element: native attributes, aria-*, data-testid. #### How it behaves ##### Stable dimensions - When: it has a photo. - Props: `src` is set. - Guarantee: The initials hold the place while the photo loads or if it fails, and the avatar never changes size. - Without it: An empty spot while the photo loads, a broken-image icon if it fails, and the row shifts when it arrives. - Code: `` - Your part: Nothing. Do not put a spinner or a placeholder of your own over the avatar. - Not covered: A failed photo is not retried; a new `src` tries again. - Proof, in the repository's tests (UiAvatar.spec.ts, not in the package): "The avatar container and initials remain rendered while the photo loads.", "The avatar keeps its size while the photo loads, once it arrives and if it fails.", "Initials are shown on mount if the photo has already failed." - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt The patterns: https://grani.arseni.work/patterns ### Badge (`UiBadge`) > Add a short status or tag beside an item, such as “Draft” or “Paid”. A component of grani 0.1.0. Status, stable, since 0.1.0. Page: https://grani.arseni.work/components/badge Also called chip, tag, pill, status label. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Ignore extra presses while a request runs. - See where the app is working. - Name states in words, not color alone. - Read the full text when a label is shortened. - Remove selected tags with the keyboard. Implemented by the app: - Data fetching, saving, permissions and business rules. - Remove the badge on `remove`, and move keyboard focus elsewhere, since the badge disappears. - Set `loading` while the work runs and give words for it. When it must be announced, add a live region of your own. - Give every badge its word, such as Paid or Overdue, and use the same word for the same state everywhere. - Remove the item in `@remove`. When the label alone does not say what is removed, name it with `removeLabel`. #### Why it works this way A short word for a state or a tag. Take it for a status in a row, a tag you picked or a state in a card's head, in one of three forms; a number is UiCount. **Forms.** `tone` is the meaning and `variant` the form: `soft` is a pale pill, `inline` a dot with text for a table row, `outline` a pale plate with a border. `size` `sm` makes every form compact. **Loading.** `loading` puts a spinner where the dot or the icon stands and sets `aria-busy` on the root, and the text says what is happening ("Syncing"). The spinner is 14px with a heavier stroke, since at 12px the ring breaks into pixels. It is seen, not heard: a host that must announce it puts the words in its own live region. **Remove.** `removable` adds a close button in the plate's own ink and corner, the one interactive part, with a 24px touch target. A double click fires it only once, and it works again after 500 ms (for example, after a canceled confirm), but not while a `loading` the host set from the click is running. The badge unmounts on remove, so the host moves the focus. **Long labels.** A long label is cut the way UiTruncate cuts it, with the whole text in a tooltip; a slot handles its own cut. The text is its name: a badge without text is a bare color. **Placement.** It sits in a table's status column, in UiLog's changed value, as UiSelect's picked tags, under UiDelta, and in the `#actions` of UiCard, the `#actions` of UiListItem and a drawer's `#status`. The root is a ``. ##### Keyboard Not interactive: the badge takes no focus. `removable` adds the one tab stop, the cross, a native button: - `Tab`: reaches the cross. - `Enter` `Space`: press the cross. It fires `remove` once per double click, and the host moves the focus, since the badge unmounts. ##### Screen reader - Name: the text; the dot, the icon and the spinner are hidden. - Loading: says busy (`aria-busy`), and the text says what is happening. - Cross: named "Remove" with the label: the words on screen when the slot gives them, or `removeLabel`. - Cut label: keeps the whole text in a tooltip. - No text: a badge without text is a bare color and warns in dev. ##### States Six tones in three forms, `soft`, `inline` and `outline`, at `sm` and `md`; `dot` before the text; `loading`, the spinner where the dot or the icon stands; `removable`, the cross, with its hover plate only when enabled; a long label cut with an ellipsis. ##### When to use another component - A number: UiCount. - A change against the value before: UiDelta. - A mark on the corner of an icon or an avatar: UiIndicator. - A notice with words and an action: UiListItem in a `tone`. #### Examples ##### Dot ```vue ``` ##### Inline ```vue ``` ##### Outline ```vue ``` ##### Long label ```vue ``` ##### Sizes ```vue ``` ##### Loading ```vue ``` ##### Removable ```vue ``` ##### Icon ```vue ``` #### API Props: - `label`: `string` (default `""`) — Text of the label; the default slot overrides it. - `tone`: `Tone` = `brand | neutral | success | warning | danger | info` (default `"neutral"`) — Meaning: a verdict, `brand` for the product's own marks, or `neutral` for a category. - `variant`: `BadgeVariant` = `soft | inline | outline` (default `"soft"`) — Form: `soft` pale pill, `inline` dot with text, `outline` pale plate with a border. - `dot`: `boolean` (default `false`) — Indicator dot before the text; `inline` always has one. The text still names it: a dot alone is color. - `loading`: `boolean` (default `false`) — Work in progress: a spinner stands where the dot or the icon would. - `size`: `"sm" | "md"` = `sm | md` (default `"md"`) — Scale: `sm` for dense tables and long lists, `md` beside a title. - `removable`: `boolean` (default `false`) — Close button: an applied filter, an added tag. - `removeLabel`: `string` (default `""`) — Accessible name of the close button; the default names the label. Anything else lands on the root element: native attributes, aria-*, data-testid. Events: - `remove` (`event: MouseEvent`) — The close button of a `removable` badge was pressed: once per double click; the host removes the badge and moves focus. Carries the click (`detail` tells a double click's second from a key's). Slots: - `default` with `Record` — Label text instead of the `label` prop. - `icon` with `Record` — Glyph before the text. #### How it behaves ##### One press, one request - When: it can be removed (`removable`). - Props: `removable`, the host removing it on `remove`. - Guarantee: A double click on the remove button emits one removal. If removing takes time, set `loading` after the first click to block further presses until it finishes. - Without it: A double click sends the removal twice. - Code: `` - Your part: Remove the badge on `remove`, and move keyboard focus elsewhere, since the badge disappears. - Proof, in the repository's tests (UiBadge.spec.ts, not in the package): "A double click on the cross removes once, even when the host re-renders between the clicks.", "While a loading set from the click runs, a second press past the double-click window waits." - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Visible work - When: the status it shows is being worked on (`loading`). - Props: `loading`, with words that say what runs ("Syncing"). - Guarantee: The badge shows a spinner in place of its dot and text such as “Syncing”, and it is marked busy for assistive technology. - Without it: The badge keeps its old word while the work runs, as if nothing were happening. - Code: `` - Your part: Set `loading` while the work runs and give words for it. When it must be announced, add a live region of your own. - Proof, in the repository's tests (UiBadge.spec.ts, not in the package): "While it works a spinner stands where the dot was, and the words say what runs." - Pattern for all components: https://grani.arseni.work/patterns/waiting#visible-work ##### Words, not only color - When: it shows a status or a tag. - Props: `label` or the default slot, with a `tone`. - Guarantee: Its words name the state in every variant, and its tone only adds to them; a badge with no words warns in development that it is a bare color. - Without it: Colored dots alone: in grayscale, or for someone who does not tell red from green, Paid and Overdue look the same. - Code: `` - Your part: Give every badge its word, such as Paid or Overdue, and use the same word for the same state everywhere. - Not covered: It does not choose the word for you; a tone alone is not a name. - Proof, in the repository's tests (UiBadge.spec.ts, not in the package): "The words name the state in every form, from the label or the slot.", "A badge with no words is a bare color and warns in development." - Pattern for all components: https://grani.arseni.work/patterns/filling-in#level-not-by-colour ##### Whole text - When: it has a long label. - Props: `label`. - Guarantee: A label longer than its place is cut the same way, with its full text in a tooltip, so the badge does not grow past its column. - Without it: A badge that grows past its column, or a label cut with no way to read the rest. - Code: `` - Your part: Nothing. Content you put in the slot instead of `label` handles its own cut. - Proof, in the repository's tests (UiBadge.spec.ts, not in the package): "A long label is cut at the width of its place.", "A cut label keeps its whole text in a tooltip; text from the slot is cut without one." - Pattern for all components: https://grani.arseni.work/patterns/finding#whole-text ##### Tag taken back - When: A badge shows a selected tag and has `removable`. - Props: `removable`. - Guarantee: Its × is a real button with a name such as “Remove Desserts”: Tab reaches it, and Enter or Space presses it. - Without it: A × that only the mouse can press. - Code: `` - Your part: Remove the item in `@remove`. When the label alone does not say what is removed, name it with `removeLabel`. - Proof, in the repository's tests (UiBadge.spec.ts, not in the package): "The cross is a named button that removes the badge." - Pattern for all components: https://grani.arseni.work/patterns/filling-in#tag-taken-back The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Bar chart (`UiBarChart`) > Compare amounts across periods with grouped or stacked bars and a plan line, with a marker for a period still in progress. A component of grani 0.1.0. Charts, stable, since 0.1.0. Page: https://grani.arseni.work/components/bar-chart Also called column chart, histogram. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Show missing data as a gap. - Show when a reporting period is still incomplete. - Read the full text when a label is shortened. - Read every chart value from the keyboard. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass `null` where there is no data, and `0` only for a real zero. - Set `partial` while the period runs, and remove it once the period closes. - Name the chart with `ariaLabel`. #### Why it works this way Values by period as bars. Take it for amounts per period rather than a flow over time, usually in the body of a UiChartCard. **Data.** `x` is the periods: dates that the library labels by `grain`, with the year on the first label shown and where it changes. Names that are not dates are categories: each keeps its label, cut to its band, and the tooltip says it whole. Each series is its `values` in that order. One series draws one bar per period; several stand side by side, or on top of each other with `stack` (the parts of a whole). **Values.** A `null` is no data yet: its place keeps a thin gray stub, never a zero bar. A zero keeps a sliver of its color on the zero line. A loss stands below the zero line. In a stack only values at zero or above are drawn and counted, so the stack's label, the legend total and the tooltip sum agree with the bars; a period of zeros is a sliver whose sum is 0. **Emphasis.** `highlight` lights one period or several and quiets the rest to a pale bar with a line at its height. `marks` draws a reference line (a plan), named under the chart. `labels="value"` prints the value over each period, thinned the same way as the axis labels. **Legend.** The legend names each series with its total; a press shows one series alone. `tooltipTotal` adds a sum under the series in the tooltip. **In progress.** `partial` says the newest period with data is still going: its bar is hatched and read as in progress, and the empty periods after it are dashed outlines, meaning still to come, not missing. **Drawing.** Bars are HTML boxes in bands, so their corners stay round at any width; the width changes only how many time-axis labels show. A label that would still run into the next one or out of the plot is left out; the newest always stays. **Periods.** Each period is a band, a bar per series and a row of the hidden table. Measured in Chromium (production build, two series): 500 periods draw about 7,000 elements and a pointer move reaches the screen within a frame or two; 1,000 take about 45 ms, 2,000 over 60 ms and 10,000 over half a second. Past 500, group the days into weeks or months; in development the chart warns once. In a card. In a UiChartCard's body, under the headline, with a UiDelta in the card's actions; or alone in its host. In the card it draws pale bars on the first load and holds them still when there is nothing to draw. Alone, with no period or no value that is a number, it says "No data for this period" in its own place. When the card bleeds it, it keeps its words on the gutter, drops the value axis and draws the focus ring inside. ##### Keyboard The plot is one tab stop; an empty chart is no tab stop. On the plot: - `Tab`: reaches it; focus shows the newest period, or the one last visited. - `ArrowLeft` `ArrowRight`: step from period to period; each period is read out. - `Home` `End`: jump to the first and the last period; each period is read out. - `Alt` `Ctrl` `Meta`: a chord with them stays the browser's. On the legend, whose chips are buttons when there are two series or more: - `Tab`: reaches each chip. - `Enter` `Space`: shows that series alone; a second press shows all. A finger scrolls the page over the plot, and a tap keeps its period until a tap elsewhere. ##### Screen reader - Plot: an image named by `ariaLabel`. - Period: only a period reached by the keyboard is read, in a polite live region, as "September 2026: Orders 1,240, Events 380", followed by the sum of `tooltipTotal`, and "in progress" for the period still going. - Table: a hidden table holds every period and every series, including a series shown alone; its caption is the chart's name and each mark ("Plan 1,100"). - Legend: a group named "Series; press one to show it alone", each chip `aria-pressed`. - Hidden: the bars, the values over them, the tooltip and the marks' names. - Empty: alone, a group named by `ariaLabel` that says there is no data. ##### States Rest, a period under the pointer or the keys (the tooltip), one series alone, side by side and `stack`, a loss below the zero line, a zero as a sliver and a `null` as a gray stub, `highlight` with the rest quiet, `marks` named under the chart, `labels="value"`, `partial` with its hatched bar and the dashed periods to come, nothing to draw alone. In a UiChartCard: its first load as pale bars, nothing to draw held still, a refresh dimmed by the card, bled to the sides without a value axis. ##### When to use another component - A flow over time: UiAreaChart. - The parts of a whole at one moment: UiDonutChart. - A trend with no axes beside a number: UiSparkline `bar`. - The card with its title, headline and data states: UiChartCard around it. #### Examples ##### Plan ```vue ``` ##### Side by side ```vue ``` ##### Stacked ```vue ``` ##### Loss ```vue ``` ##### Zero ```vue ``` ##### Partial ```vue ``` ##### Many ```vue ``` ##### In a card ```vue ``` #### API Props: - `x`: `DateValue[]` (required) — The periods: one date each, `2026-09` a month, `2026-09-24` a day; text that is not a date stays as it is. - `series`: `BarSeries[]` (required) — The series, in the order they take the chart colors, five at most; each is `key`, `label` and `values`. - `ariaLabel`: `string` (required) — What the chart shows, for a screen reader: "Revenue by month". - `grain`: `ChartGrain` = `month | day | weekday | week` (default `"month"`) — What one period stands for: the labels and the tooltip read the dates by it; `weekday` names a day of one week, "Tue". - `format`: `ChartFormat` (default `"number"`) — How values read in the legend, the tooltip and the table; `money` writes the currency code after the digits, `1,234.00 USD`. The axis prints compact figures without the unit. - `height`: `number` (default `200`) — The height in pixels, the time axis included; the legend is below it. - `yAxis`: `boolean` (default `true`) — The value axis with its grid; off for a chart that only shows a shape. - `legend`: `boolean` (default `true`) — The legend with each series' total, a press shows one series alone. - `highlight`: `number | number[]` — The period in light, or several (the months that met a plan); the others go quiet. - `stack`: `boolean` (default `false`) — The series stand one on another, the parts of a whole; without it they stand side by side. - `labels`: `"none" | "value"` = `none | value` (default `"none"`) — `value` prints the value over each period: the bar's, a stack's sum, or the tallest of bars side by side. - `marks`: `{ at: number; label: string; }[]` (default `[]`) — Lines of reference across the plot, each at a value with a label: `[{ at: 1100, label: 'Plan' }]`. - `tooltipTotal`: `{ label: string; of: string[]; }` — A sum of some series under them in the tooltip and the table: `{ label: 'Revenue', of: ['orders', 'events'] }`. - `partial`: `boolean` (default `false`) — The newest period with data is still going: its bar is hatched, its tooltip and table say so, and the empty periods after it are to come. Anything else lands on the root element: native attributes, aria-*, data-testid. #### How it behaves ##### A gap, not a zero - When: a period has no data. - Props: `series[].values` with `null` where there is no data. - Guarantee: A `null` keeps a thin gray stub in its place, never a bar of zero. A real zero keeps a sliver in the series color, so a month with no data and a month with nothing in it never look alike. - Without it: Two empty slots: a month with no data and a month with nothing in it look the same. - Code: `` - Your part: Pass `null` where there is no data, and `0` only for a real zero. - Proof, in the repository's tests (UiBarChart.spec.ts, not in the package): "a missing value keeps a thin stub, never a zero bar", "a zero period stays visible and distinct from a missing one" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#gap-not-zero ##### In progress, not a drop - When: the newest period is not over. - Props: `partial`. - Guarantee: The latest period with data is hatched and labeled “in progress” in its tooltip, its keyboard readout and the data table for screen readers. Future periods are drawn as dashed outlines, so data that is not yet due looks different from data that is missing. - Without it: A partial September total looks like a complete month with half of August’s sales, even though September is still underway. - Code: `` - Your part: Set `partial` while the period runs, and remove it once the period closes. - Proof, in the repository's tests (UiBarChart.spec.ts, not in the package): "its tooltip and table say it is in progress", "the empty periods after it are to come: dashed outlines, not gray stubs" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#partial-not-drop ##### Whole text - When: the periods are names, not dates, and are longer than their band. - Props: `x`. - Guarantee: Each category keeps a label under its bar, shortened to fit when necessary. The bar’s tooltip and the data table available to screen readers provide the full name. - Without it: Long names are thinned out until only one label in four is left on the axis. - Code: `` - Your part: Nothing; keep the names short where you can. - Proof, in the repository's tests (UiBarChart.spec.ts, not in the package): "a category period keeps its label, cut to its band, and the table names it whole" - Pattern for all components: https://grani.arseni.work/patterns/finding#whole-text ##### Values by keys - When: you need the values, not only the shape. - Props: `ariaLabel`. - Guarantee: Tab reaches the plot and shows the newest period. The arrow keys, Home and End move between periods, and a screen reader announces each with every series, adding “in progress” for a period still running. A data table available to screen readers holds every value and each reference mark. - Without it: Bars whose numbers exist only in a tooltip that appears on hover. - Code: `` - Your part: Name the chart with `ariaLabel`. - Proof, in the repository's tests (UiBarChart.spec.ts, not in the package): "the plot is one tab stop: focus says the newest period, the arrows walk", "a hidden table holds every value for a screen reader" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#values-by-keys The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Button (`UiButton`) > Start an action and see when it is running, with further presses blocked while the button is loading. A component of grani 0.1.0. Actions, stable, since 0.1.0. Page: https://grani.arseni.work/components/button #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Ignore extra presses while a request runs. - Know why an action is unavailable. - Keep nearby content in place while loading. - Know when a link opens a new tab. Implemented by the app: - Data fetching, saving, permissions and business rules. - Set `loading` from the press until the request answers. - Word the reason as the next step (“Add the client’s email first”) and clear `disabled` once it is done. Without a reason the button stays natively disabled, out of the Tab order. - Pass the address in `to`; nothing else is needed. #### Why it works this way A button for an action, with loading and toggle states. With `to` it renders a link styled as a button. **Look.** `variant` is the weight of the presentation, `tone` is the meaning. **Loading.** `loading` shows a spinner in place of the label and refuses a second click, including one meant for your own handler. The width holds, also with the text of the `#loading` slot; an empty `loadingText` leaves the spinner alone. **Toggle.** `pressed` makes it a toggle: `aria-pressed`, a check before the label, and the brand frame. It is for a chip that turns on and off by itself, as a filter (Unpaid, Overdue, Mine); one choice among a few is UiRadioGroup `segmented`. Off, it holds the room of its check, so pressing it moves nothing beside it. **Disabled.** `disabled` alone is the native state: a flat plate, out of the Tab order, and silent, so a person cannot learn why it does not press. With `disabledReason` it keeps the same plate but stays in the Tab order, refuses the press (a submit too, and a menu it would open, by the arrows as well), and says why: in a tooltip on hover, keyboard focus, a press and a tap, and as its description to a screen reader ("Add the client's email first"). The tooltip stands inside the button, loaded the first time it is off with a reason, so the button stays its one element: a reason that comes or goes under the focus keeps it, and a ref or a UiTooltip around it still reaches the button; while it is off, the reason shows in place of that UiTooltip's words. With `to`, the link turns into a button when disabled, a new element, so the focus does not stay. **Links.** With `to` it renders a RouteLink that looks like a button. Another site, or `target="_blank"`, opens in a new tab and says so: an arrow for the eye, a phrase for a screen reader. A `javascript:` or `data:` address is refused: it stays a button, and dev warns. **Placement.** It sits in the footers of a drawer and a confirm, in toolbars beside a search, and in the triggers of filters, periods and columns. One `primary` to a surface, last in its footer: it is the step the surface is for. UiCopyButton is built on it, and UiButtonGroup joins it with its neighbors into one capsule. **Attributes.** Anything else passed to it lands on the root element: native button and form attributes, `aria-*`, `data-testid`. ##### Keyboard - `Tab`: reaches it. - `Enter` `Space`: press it; not while `loading`, nor while disabled with a reason, which the focus still reaches. A held Enter is one press: its repeats press nothing. - `ArrowDown` `ArrowUp`: as a menu's trigger, open it; not while it refuses the press. With `to`, a link: - `Enter`: follows it; `Space` does not, as in the browser. ##### Screen reader - Name: the label; a lone glyph is UiIconButton, which carries its own name. - Loading: says busy (`aria-busy`); the label is hidden from the reader and the loading text becomes the name: "In progress…" by default, `loadingText` or the `#loading` slot. The spinner is hidden. With an empty `loadingText` the label stays the name, unseen but still read, so the button is never nameless. - Pressed: `pressed` is `aria-pressed`. - Disabled with a reason: `aria-disabled`, and the reason is its description, read once. - New tab: said in words after the label, where the eye sees an arrow. ##### States Rest, hover on a device that hovers, focus, disabled, disabled with a reason (the plate, the focus, the hint), loading and pressed, in both themes. A loading button keeps its width, so nothing beside it moves. ##### When to use another component - Text inside prose: UiLink. - A lone glyph: UiIconButton. - A choice among options: UiRadioGroup `segmented`. - Buttons joined into one capsule: UiButtonGroup around them. #### Examples ##### Variants ```vue ``` ##### Sizes ```vue ``` ##### Loading ```vue ``` ##### Pressed ```vue ``` ##### Link ```vue ``` ##### Dashed ```vue ``` ##### Disabled ```vue ``` #### API Props: - `type`: `"button" | "submit" | "reset"` = `button | submit | reset` (default `"button"`) — Native type; `submit` sends the form the button sits in. A link has none. - `to`: `string` — A route or a URL: the button becomes a link. An absolute address opens in a new tab; `javascript:` and `data:` are refused, and it stays a button. - `target`: `"_blank" | "_self"` = `_blank | _self` — With `to`: `_blank` or `_self` decides the tab; unset, a new tab for an absolute address only. - `rel`: `string` — With `to`: replaces the default `noreferrer` (`nofollow`, `sponsored`); `noopener` stays on a new tab. - `loading`: `boolean` (default `false`) — A request is running: spinner, `aria-busy`, no clicks; it wins over `disabled`. - `disabled`: `boolean` (default `false`) — Natively disabled: a flat plate, out of the Tab order; with `disabledReason`, focusable and saying why. A link turns back into a button. - `disabledReason`: `string` — Why it cannot be pressed now, while `disabled`: it stays in the Tab order, refuses the press, and says the reason in a tooltip on hover, focus, a press and a tap, and as its description to a screen reader. A blank one is none. - `variant`: `ButtonVariant` = `outline | primary | secondary | ghost | dashed` (default `"primary"`) — Weight of the presentation: brand fill, gray plate, white with a border, or quiet. - `tone`: `ButtonTone` = `brand | success | warning | danger` (default `"brand"`) — Meaning of the action; `brand` means none in particular. Every variant supports every tone. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder: `xs` 28px for a micro action, `sm` 36px, `md` 44px for forms. Unset, the app's size (`createGrani({ size })`), `sm` by default. - `block`: `boolean` (default `false`) — Full width, as in a drawer footer. - `loadingText`: `string` — Label while loading; the default is a translated "In progress…". Empty, the spinner stands alone (a glyph button). - `pressed`: `boolean` — A toggle: `aria-pressed`, and on, a check and the brand frame. Unset, it is no toggle. A primary button is never one. Attributes are placed by the component itself: its description says where. Slots: - `loading` with `Record` — Replaces the label while loading. - `default` with `Record` — The label; a glyph goes here too, before the text. The button sizes it. #### How it behaves ##### One press, one request - When: its request is on its way. - Props: `loading` while the request runs. - Guarantee: The button shows a spinner and text such as “Sending…”, so you know the action has started. Further presses, by mouse or keyboard, reach neither your handler nor the form’s submit action, and keyboard focus stays on the button. - Without it: Pressing Send again while the invoice is on its way submits the form twice. - Code: `Send` - Your part: Set `loading` from the press until the request answers. - Not covered: The button does not know about the request: without `loading`, every press reaches your handler. It stops extra presses in the interface; making the operation safe to repeat on the server remains the app’s job. - Proof, in the repository's tests (UiButton.spec.ts, not in the package): "a disabled or loading button does not fire click", "refuses the click before the caller's listener", "a loading submit button does not send its form" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Says why - When: it is disabled for a reason the person can act on. - Props: `disabled` with `disabledReason`. - Guarantee: The button looks disabled but stays in the Tab order. It cannot be activated, submit a form or open its menu, including through the arrow keys. A tooltip explains why the button is unavailable when you hover, focus, click or tap it, and a screen reader announces the same reason as its description. If the reason appears or disappears while the button has keyboard focus, focus stays on it. - Without it: A grey button that Tab passes over and nothing explains: the person hunts for what is missing. - Code: `Save` - Your part: Word the reason as the next step (“Add the client’s email first”) and clear `disabled` once it is done. Without a reason the button stays natively disabled, out of the Tab order. - Not covered: The reason is one line, not a list of every missing field: a long form says its errors at the fields (UiField) when Save is pressed instead. A button with `to` is a link while it works and a new button element while it is off, so keyboard focus does not stay across that change. - Proof, in the repository's tests (UiButton.spec.ts, not in the package): "disabled with a reason, it stays in the Tab order and refuses a press, a submit too", "the reason is the button’s description, read once, and the hint on focus says it", "a reason with the button enabled is nothing: the button presses as ever", "a reason that comes and goes keeps the button, its focus and its ref", "a menu button that refuses its press opens nothing, by click or by arrow", "a press on a button off with a reason shows the reason, by mouse and by a tap on a menu button", "a blank reason is no reason: the button stays natively disabled, out of the Tab order", "a mouse press shows the reason, and the pointer leaving takes it away", "a reason that comes while the keyboard is on the button shows at once", "a hint that fails to load costs nothing but the hint: no error, the reason still read" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#off-says-why ##### Stable dimensions - When: its loading text is longer than its label, or it is a toggle. - Props: `loading`, with `loadingText` or the `#loading` slot; `pressed`. - Guarantee: From the first render, the button reserves room for both its label and its loading text, so “Save” becomes “Saving…” without moving anything beside it. A toggle that is off reserves the room for its check mark the same way, so pressing it moves nothing. - Without it: “Save” grows into “Saving…” and pushes Cancel aside just as you reach for it. - Code: `Save` - Your part: Nothing: pass the loading text to the button (`loadingText` or the `#loading` slot) instead of swapping its label yourself. - Proof, in the repository's tests (UiButton.spec.ts, not in the package): "reserves the loading width even when the loading prop arrives after the first render", "the idle width sizer carries the #loading slot text", "a toggle that is off holds the room of its check, so pressing it moves nothing" - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt ##### New tab said - When: its `to` is another site, or it has `target="_blank"`. - Props: `to`. - Guarantee: It shows the same arrow after its label as a link does, and a screen reader announces “opens in a new tab” in the same way. - Without it: Pressing the button opens another tab without warning, and Back there does not return to this page. - Code: `Status page` - Your part: Pass the address in `to`; nothing else is needed. - Proof, in the repository's tests (UiButton.spec.ts, not in the package): "an address with a protocol opens in a new tab and says so", "a new tab is seen as well as said: an arrow, and a caller target gets rel and the notice" - Pattern for all components: https://grani.arseni.work/patterns/opening#new-tab-said The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`, `useUiT`. ### Copy button (`UiCopyButton`) > Copy a value in one press and see whether it reached the clipboard. A component of grani 0.1.0. Actions, stable, since 0.1.0. Page: https://grani.arseni.work/components/copy-button Also called copy to clipboard, clipboard button. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See whether an action worked. - Know why an action is unavailable. - Read the full text when a label is shortened. - Copy a whole value in one press. - Keep keyboard focus where you are working. Implemented by the app: - Data fetching, saving, permissions and business rules. - Mount `` once in the app: the error message, and a success one with `toast`, show there. Add `toast` only where the button leaves the screen after the press, as in a menu. - Word a reason of your own as the next step (“Publish the page first”). Without one, `disabled` alone takes the button out of the Tab order. - Keep the label of a framed or ghost copy button to a few words; show a long value there with `truncate`, cut in the middle. - Pass the whole value in `value`, and shorten only what is shown, with `truncate`. #### Why it works this way A button that copies `value` to the clipboard and shows a check for 1.2 s. Take it beside an id, a key or any value a person copies. **Outcome.** A failure marks the glyph for 1.2 s, as a success shows its check, and also shows an error toast in the app's UiToaster, so mount one; a success shows a toast only with `toast`, since the check in view has already said it. The toast is not read aloud, because the live region has said it. Every copy button on the page refreshes the one toast instead of piling up more. Where the browser has no Clipboard API (a page over plain HTTP), the copy goes through a field no one sees, and the focus comes back. **Shapes.** Without text it is an icon button named "Copy", with the `Copy` glyph or your `icon`. With `label` or `truncate` it is a button with text; `truncate` shows the value cut in the middle in monospace (`613aa1…4510e0`) and copies it whole. A line break or another control character in the kept half of a cut value draws as a mark (`⏎`, a control picture), never left invisible: it still copies with the character itself. A `label` is a few words, as a button's; a value too long for its place is shown with `truncate`, cut in the middle. **Ghost.** A `ghost` one is text with no plate. Its glyph appears on hover and focus of this button only (always on a screen without hover), so a column of ids stays quiet. In a cell, `flush` pulls it back by its own padding and border, so the text starts on the column line. Beside a neighbor it keeps its box, because a negative margin there would overlap the neighbor. **Outline.** An `outline` one is a framed button: plate, frame and glyph always shown, since a frame around an empty space reads as a hole. **Inline.** An `inline` one is a word inside a line of text (the small line of a drawer's header): no plate, no height, no padding, the line's size and ink, and a small glyph always after it, since a glyph that waits for hover leaves a gap. It needs a `label` or `truncate`. A label longer than the line is cut with an ellipsis, whole in a tooltip (UiTruncate), and the glyph stays. **Secrets.** `label` and `truncate` put the text on the screen and in the accessible name, so a secret goes in with neither. **Off.** An empty `value` keeps the button in the Tab order and says "Nothing to copy", as a `disabledReason` says yours (UiButton's): in a tooltip and as its description. `disabled` alone takes it out of the Tab order. **Placement.** It sits in a table's copy column (named "Copy" and the value), in a UiInput's `#end`, in a drawer's header line, and beside UiPasswordField's input, before its generate button. Beside other copy buttons, each one takes its own `ariaLabel`, which holds the text it shows: a voice command says what it sees. A cut value is named by its cut by default ("Copy 613aa1…4510e0"). ##### Keyboard A native button in every shape. - `Tab`: reaches it, an empty `value` too; `disabled` takes it out, unless it has a `disabledReason`. - `Enter` `Space`: copy; the focus stays on it after the copy. A `ghost` one shows its glyph on keyboard focus, as on hover. ##### Screen reader - Name: "Copy" for a glyph alone, "Copy" and the visible text for a cut value, `label` as it is, or `ariaLabel`; "Copied" never joins the name. - Outcome: "Copied" or "Could not copy" (when the clipboard refuses), from one polite live region outside the button, inside an open modal window when the button is in one, since the window hides the page from a reader. It is heard even after the button has left the view, and said again for a second copy in the same window. - Off with a reason: `aria-disabled`, and "Nothing to copy" or the `disabledReason` is its description. - Glyph: hidden. - Toast: not read, since the region has said it. ##### States At rest: a glyph alone, a cut id in monospace, or a label. Copied: a check for 1.2 s. Refused: an alert glyph for 1.2 s, no check, "Could not copy" and an error toast. Nothing to copy (an empty `value`) or `disabled`: the disabled ink, the reason in a tooltip. A long `inline` label: cut, whole in a tooltip. `ghost` shows the glyph on hover and focus only (always on a screen without hover), `outline` always, `inline` a small one after the word; `flush` in a cell; `xs`, `sm` and `md`; joined in a UiButtonGroup; a success toast with `toast`. ##### When to use another component - A glyph that does anything else: UiIconButton. - An action with text: UiButton. - A value cut to fit with nothing to copy: UiTruncate. - A secret shown as text on the screen: never; it is copied with neither `label` nor `truncate`. #### Examples ##### In a table ```vue ``` ##### Variants ```vue ``` ##### Sizes ```vue ``` ##### With a toast ```vue ``` ##### Long text ```vue ``` ##### Disabled ```vue ``` #### API Props: - `value`: `string` (required) — What goes to the clipboard, whole. Empty, the button is off and says there is nothing to copy. - `label`: `string` — Visible text beside the glyph, and the name as it is ("Copy link"); for a value shown as itself pass `ariaLabel` too. It wins over `truncate`. - `truncate`: `number` — Shows `value` cut in the middle to about this many characters, in monospace; the copy is still whole. - `variant`: `"inline" | "outline" | "ghost"` = `inline | outline | ghost` (default `"ghost"`) — `ghost` is text alone, its glyph on hover, for an id in a table; `outline` is a framed button with its glyph always shown; `inline` is text inside a line of text, its size and ink, with a small glyph after it. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder. Unset, the app's size (`createGrani({ size })`), `sm` by default. - `flush`: `boolean` (default `false`) — In a cell: the text starts on the column line, the button's own padding and border taken back. `ghost` only. - `disabled`: `boolean` (default `false`) — No copy; the button stays in place. - `disabledReason`: `string` — Why it cannot copy now, while `disabled`: it stays in the Tab order and says the reason in a tooltip and as its description, as UiButton's. An empty `value` says "Nothing to copy" by itself. - `toast`: `boolean` (default `false`) — Also say a success as a toast, where the button leaves the view once pressed; a failure always is one. - `ariaLabel`: `string` — Accessible name; defaults to "Copy" for a glyph alone and "Copy " for a truncated value. - `icon`: `Component` — The glyph at rest, a Lucide component; `Copy` when unset. Two copy buttons side by side tell what they copy by it: `Code`, `Bot`. Anything else lands on the root element: native attributes, aria-*, data-testid. Events: - `copy` (`ok: boolean`) — The value reached the clipboard (`true`) or the clipboard refused (`false`). #### How it behaves ##### Outcome shown - When: you copy. - Props: none; `toast` where the button leaves the view once pressed. - Guarantee: The button shows a check mark for 1.2 seconds, and a screen reader announces “Copied”, even if the button has left the screen. If clipboard access is refused, a warning mark replaces the check mark for the same 1.2 seconds, a screen reader announces “Could not copy”, and an error message appears; several copy buttons that fail at once share one message. - Without it: A press with no answer: you copy again, or paste the old value. - Code: `` - Your part: Mount `` once in the app: the error message, and a success one with `toast`, show there. Add `toast` only where the button leaves the screen after the press, as in a menu. - Proof, in the repository's tests (UiCopyButton.spec.ts, not in the package): "after a copy the glyph is a check for 1.2 s, and a reader hears Copied outside the button", "a copy whose button left the view is still heard", "two buttons that fail share one toast", "a refused clipboard emits copy false and shows no check" - Pattern for all components: https://grani.arseni.work/patterns/waiting#answered ##### Says why - When: there is nothing to copy yet, or copying is off for a reason. - Props: `disabled` with `disabledReason`; an empty `value` needs neither. - Guarantee: An empty value keeps the button in the Tab order and says “Nothing to copy” in a tooltip and as its description, and a press copies nothing. A `disabledReason` is said the same way. - Without it: A copy button that Tab skips beside an empty field, and nobody says there is nothing to copy yet. - Code: `` - Your part: Word a reason of your own as the next step (“Publish the page first”). Without one, `disabled` alone takes the button out of the Tab order. - Proof, in the repository's tests (UiCopyButton.spec.ts, not in the package): "an empty value keeps the button in the Tab order and says there is nothing to copy", "disabled with a reason passes the reason on", "an empty value copies nothing and the button is off" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#off-says-why ##### Whole text - When: an `inline` label is longer than its line. - Props: `label` with `variant="inline"`. - Guarantee: The label keeps one line, cut with an ellipsis, and its whole text shows in a tooltip while the cut is real. The copy glyph stays in view, and the accessible name is never cut. - Without it: A long reference that pushes the glyph off the end of its line. - Code: `` - Your part: Keep the label of a framed or ghost copy button to a few words; show a long value there with `truncate`, cut in the middle. - Not covered: The tooltip opens on hover and tap, not on keyboard focus; a screen reader hears the whole name. - Proof, in the repository's tests (UiCopyButton.spec.ts, not in the package): "a long inline label stands in a UiTruncate, the glyph kept" - Pattern for all components: https://grani.arseni.work/patterns/finding#whole-text ##### Copied whole - When: beside an id, a key or a link. - Props: `value`, and `truncate` where the value is too long to show. - Guarantee: One press puts the whole value on the clipboard, even when it is shown cut in the middle. For 1.2 seconds the button shows a check mark, and a screen reader announces “Copied”, so you know the copy succeeded. If the browser refuses clipboard access, no check mark appears. - Without it: A copy of what is shown: the cut id lands in the clipboard, ellipsis and all. - Code: `` - Your part: Pass the whole value in `value`, and shorten only what is shown, with `truncate`. - Not covered: For a secret, set neither `label` nor `truncate`: both put text on the screen and in the button's accessible name. - Proof, in the repository's tests (UiCopyButton.spec.ts, not in the package): "a glyph alone is an icon button named Copy; it copies the value whole", "truncate shows the id cut in the middle in monospace and copies it whole; the glyph waits for hover", "after a copy the glyph is a check for 1.2 s, and a reader hears Copied outside the button", "a refused clipboard emits copy false and shows no check" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#copy-value ##### Focus stays near - When: you copy from the keyboard. - Props: none. - Guarantee: Keyboard focus stays on the button after the copy, also where the browser has no Clipboard API (a page over plain HTTP) and the copy goes through a field no one sees, so Tab goes on from there. - Without it: Focus left in a hidden field after a copy, and the next Tab starts from nowhere. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiCopyButton.spec.ts, not in the package): "focus stays on the button after a copy", "copied the old way, through a field no one sees, the focus comes back to the button" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-stays The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useClipboard`, `useToast`, `useControlSize`, `announce`, `useUiT`. ### Button group (`UiButtonGroup`) > Keep related controls together, such as a main action and its menu or a field and its submit button. A component of grani 0.1.0. Actions, stable, since 0.1.0. Page: https://grani.arseni.work/components/button-group Also called segmented buttons, joined buttons, split button. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Name each field once for screen readers. Implemented by the app: - Data fetching, saving, permissions and business rules. - Name the group by what its buttons do together, such as Pagination or Message actions; a group with no name warns in development. #### Why it works this way Bordered controls of one size joined into one capsule (`outline`, never `ghost`). Take it for a few related actions side by side in a toolbar, or to join a search with its button. **Nesting.** A nested group is spaced, not joined. A `UiSeparator` between nested groups draws a line across the row; between two joined buttons, their shared border is the line. **Filled buttons.** Filled buttons (`primary`, `secondary`) have no border to share, so two of them side by side are split by a 1px seam instead. **Split button.** A split button is a primary UiButton next to a primary UiIconButton with a chevron that opens a UiMenu, as in a UiCrudDrawer's footer; an `outline` button and an `outline` arrow make the quieter one. In a row, a UiIconButton last and right after a button with text is that arrow, and the group draws it narrower than a square: a part of the button beside it, not a button of its own. **Children.** A child in error is lifted over its neighbors, so its red frame shows whole. Children must be direct: a button inside a wrapper cannot be joined. The size is each child's, or the app's step for all of them, so give one `size` to every child or to none. `orientation` makes the group vertical. ##### Keyboard None of its own: Tab walks its children one by one, each with its own keys, and the focused child is lifted over its neighbors, so its ring shows whole. ##### Screen reader - Group: a `group` named by `ariaLabel`. - In a field group: inside a `UiField group`, the legend names it (`aria-labelledby`) and the field's text describes it (`aria-describedby`), with its own `ariaLabel` winning. - In a plain field: inside a plain UiField, the input in the group is the control and the group takes nothing. - Nested: a nested group keeps its own name. - No name: a group with no name warns in dev. ##### States No state of its own; it shows its children's: a focused or hovered child lifted; a child in error lifted over its neighbors with its red frame whole; a disabled child beside a filled one, with a reason too, not lifted on hover; a link child joined as a button is. Horizontal or vertical (`orientation`), on every step of the size ladder, nested with a gap. ##### When to use another component - One choice among a few: UiRadioGroup `segmented`. - A lone action: UiButton. - The search and the filters over a list: UiTableToolbar. #### Examples ##### Split button ```vue ``` ##### Field ```vue ``` ##### Vertical ```vue ``` ##### Sizes ```vue ``` ##### Disabled ```vue ``` #### API Props: - `ariaLabel`: `string` — Name of the group for a screen reader: "Pagination", "Period". Inside `UiField group` the legend names it. - `orientation`: `"horizontal" | "vertical"` = `horizontal | vertical` (default `"horizontal"`) — Controls in a row or in a column. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `default` with `Record` — Bordered controls of one size, a nested group, a `UiSeparator`. #### How it behaves ##### One name - When: it holds a row of joined buttons. - Props: `ariaLabel`, or a `UiField group` around it. - Guarantee: The group is named once: by its own `ariaLabel`, or by the legend of the `UiField group` around it, with the field's description or error announced as its description. Its own `ariaLabel` wins over the legend, and each button keeps its own name. - Without it: A screen reader encounters several buttons with no explanation of what they do together. - Code: `…` - Your part: Name the group by what its buttons do together, such as Pagination or Message actions; a group with no name warns in development. - Not covered: Inside a UiField without `group`, the input in the group is the named control, and the group takes no name from the field. - Proof, in the repository's tests (UiButtonGroup.spec.ts, not in the package): "in a group field the legend names it and the error describes it", "its own ariaLabel wins over the legend" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice The patterns: https://grani.arseni.work/patterns ### Icon button (`UiIconButton`) > Offer a compact action, such as Search or Close, with an icon and an accessible name for screen readers. A component of grani 0.1.0. Actions, stable, since 0.1.0. Page: https://grani.arseni.work/components/icon-button Also called icon-only button, glyph button. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Ignore extra presses while a request runs. - Know why an action is unavailable. - Name each field once for screen readers. Implemented by the app: - Data fetching, saving, permissions and business rules. - Set `loading` from the press until the request answers. - Word the reason as UiButton’s; `ariaLabel` still names the action. - Name the action, not the icon: Send a reminder, not Bell. Wrap it in UiTooltip where people need to see the name. #### Why it works this way A button for an action, drawn as a single Lucide glyph in a square target. **Look.** The button sizes the glyph; `variant` is the presentation, `tone` the meaning. **Name.** Its name is `ariaLabel`, a required prop rather than the native attribute, since the button has no text to be named by; an empty one warns in dev. No hint shows the name: where a sighted user needs it, wrap the button in a UiTooltip, which then does not read the name twice. **Toggle.** `pressed` makes it a toggle, as on UiButton: `aria-pressed`, the brand frame and plate, and the system highlight under forced colors. A `primary` square is never a toggle. **Split.** Last in a row of UiButtonGroup, right after a button with text, it is the arrow of a split button, `primary` or `outline`: the group draws it narrower than a square (28 wide at `sm`), its glyph unchanged, so it reads as a part of the button beside it. A `#trailing` tail keeps its pill. **Tail.** A `#trailing` tail (a count) keeps its width while loading. **Disabled.** As on UiButton: `disabled` alone takes no focus and says nothing; with `disabledReason` the square keeps the focus, refuses the press, and says why in a tooltip and as its description ("Paid invoices cannot be deleted"). A UiTooltip around it for its name keeps naming it while it works; while it is off, the reason shows instead. **Placement.** It sits in the header of a modal or a drawer (the close cross and the record arrows), in a table row's actions, in a capsule's `#end` beside the value (a clear button), on a pager and as a calendar's arrows, in a toast's corner, in a UiListItem's `#actions`, and as the trigger of a popover or a menu. A `primary` one is the arrow of a split button, beside a primary UiButton in a UiButtonGroup. Its neighbors are UiButton (with text) and UiCopyButton, whose glyph-only form is one of these. ##### Keyboard A native button. - `Tab`: reaches it; `disabled` takes no focus, unless it has a `disabledReason`. - `Enter` `Space`: press it. `loading` refuses the press before your handler sees it, and keeps the focus. A held Enter is one press: its repeats press nothing. `type="submit"` sends the form it sits in. ##### Screen reader - Name: `ariaLabel`, which is required; the glyph is hidden (`aria-hidden`). - Loading: says busy (`aria-busy`) and disabled (`aria-disabled`) while the button keeps its name. - Pressed: `pressed` is `aria-pressed`, only on a toggle. - Disabled with a reason: `aria-disabled`, and the reason is its description, read once. - Hint: none shows the name; a UiTooltip you wrap around it shows it to the eye, and it is not read twice. ##### States Rest; hover; focus (a `danger` one keeps the red ring); pressed (the brand frame and plate, the system highlight under forced colors); loading (a spinner one step larger than the glyph, the color kept, the width kept with a `#trailing` tail); disabled (the glyph grayed, the shape kept), with a reason the focus kept and the hint shown. `primary`, `outline` and `ghost` in `neutral`, `brand` and `danger`; `xs`, `sm` and `md`; with a tail, a pill that keeps its height; in both themes. ##### When to use another component - Text: UiButton. - Copying a value: UiCopyButton. - A choice among options: UiRadioGroup `segmented`. - A primary square as a toggle: never; it is the arrow of a split button in UiButtonGroup. #### Examples ##### Variants ```vue ``` ##### Sizes ```vue ``` ##### Toggle ```vue ``` ##### Tooltip ```vue ``` ##### In a field ```vue ``` ##### Trailing ```vue ``` ##### Loading ```vue ``` ##### Disabled ```vue ``` #### API Props: - `type`: `"button" | "submit" | "reset"` = `button | submit | reset` (default `"button"`) — Native type; `submit` sends the form the button sits in. - `icon`: `Component` — A Lucide icon component, e.g. `Pencil` from `@lucide/vue`. - `variant`: `IconButtonVariant` = `outline | primary | ghost` (default `"outline"`) — Presentation: `primary` is the brand fill (the arrow of a split button), `outline` a white square with a border, `ghost` only the glyph until hovered. - `tone`: `IconButtonTone` = `brand | neutral | danger` (default `"neutral"`) — Meaning of the glyph: gray, brand, or a dangerous action. `pressed` on overrides it with the brand frame. - `ariaLabel`: `string` (required) — The accessible name, for a screen reader; no hint shows it, so wrap the button in UiTooltip where a sighted user needs it. Required: the button has no visible text. - `disabled`: `boolean` — Natively disabled: out of the Tab order, grayed; with `disabledReason`, focusable and saying why. - `disabledReason`: `string` — Why it cannot be pressed now, while `disabled`, as UiButton's: it stays in the Tab order, refuses the press, and says the reason in a tooltip and as its description. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder: `xs` 28px for a dismiss in a corner, `sm` 36px, `md` 44px for forms. Unset, the app's size (`createGrani({ size })`), `sm` by default. - `loading`: `boolean` (default `false`) — A request is running: spinner instead of the glyph, `aria-busy`, no clicks; focus stays, as in `UiButton`. - `pressed`: `boolean` — A toggle (pin, bookmark), as UiButton's `pressed`: `aria-pressed`, and on, the brand frame and plate. Unset, it is no toggle. A `primary` square is never one. Attributes are placed by the component itself: its description says where. Slots: - `default` with `Record` — A glyph in place of `icon`; the button sizes it. - `trailing` with `Record` — A short tail after the glyph, such as a count; the square grows wider on one line. #### How it behaves ##### One press, one request - When: its request is on its way. - Props: `loading` while the request runs. - Guarantee: A spinner replaces the icon, a second press by mouse or keyboard is stopped before it reaches your handler, and keyboard focus stays on the button. - Without it: Pressing the bell twice while sending is slow sends the client two reminders. - Code: `` - Your part: Set `loading` from the press until the request answers. - Not covered: The button does not know about the request: without `loading`, every press reaches your handler. - Proof, in the repository's tests (UiIconButton.spec.ts, not in the package): "keeps focus when loading starts and refuses the click before the caller", "fires click when enabled and never when disabled or loading" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Says why - When: a row’s action is off for a reason the person can act on. - Props: `disabled` with `disabledReason`. - Guarantee: The square keeps its disabled look and its focus, refuses the press, and says why in a tooltip and as its description, beside its own name. A UiTooltip you wrap around it for its name keeps naming it while it works; while it is off, the reason shows instead. - Without it: A grey glyph that Tab passes over: Delete is off, and nobody says that paid invoices stay. - Code: `` - Your part: Word the reason as UiButton’s; `ariaLabel` still names the action. - Proof, in the repository's tests (UiIconButton.spec.ts, not in the package): "disabled with a reason, it stays in the Tab order, refuses a press and is described by it", "off with a reason, it shows the reason on keyboard focus and on a tap", "a reason that comes and goes keeps the square, its focus and its ref", "as a menu button that refuses its press, it opens nothing by arrow", "a UiTooltip around it names it while it works and says the reason while it is off, with no warning" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#off-says-why ##### One name - When: it shows an icon with no text. - Props: `ariaLabel`, required. - Guarantee: Its name comes from `ariaLabel` alone, and the icon is hidden from screen readers. On its own it shows no tooltip; a UiTooltip around it shows the name on screen, and a screen reader does not announce it a second time. - Without it: An icon announced only as “button”, or its name announced twice: once as the label and again as the hint. - Code: `` - Your part: Name the action, not the icon: Send a reminder, not Bell. Wrap it in UiTooltip where people need to see the name. - Not covered: While it is off with a `disabledReason`, the tooltip shows the reason in place of the name; a screen reader still hears the name, then the reason. - Proof, in the repository's tests (UiIconButton.spec.ts, not in the package): "ariaLabel is the accessible name; no tooltip and no title show it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`. ### Calendar (`UiCalendar`) > Choose a day or date range from a month view, using the mouse or keyboard. A component of grani 0.1.0. Dates, stable, since 0.1.0. Page: https://grani.arseni.work/components/calendar Also called date grid, month view, inline calendar. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Respect date limits without losing a value. - See a date range before you pick its end. - See which days are the weekend. - Save the day you picked, in any time zone. - Mark today in the app’s time zone. - Pick with Enter without sending the form. - Keep your browser’s keyboard shortcuts. - Move by month or year from the keyboard. - Jump to a month from the heading. - Open with keyboard focus where the task begins. - Pass a group with one press of Tab. - Keep turning pages from the keyboard. - Describe calendar marks in words. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass `min` and `max` as `YYYY-MM-DD` days, and let the calendar choose the month it opens on. - If today should follow a fixed time zone rather than each browser’s, set it once with `createGrani({ timeZone })`. - Place the field that comes next after the calendar, not inside it. - Give each kind of dot a `label` that says what it means; a dot without one is announced only as “marked”. #### Why it works this way A month to pick a day from. Take it on a page of its own, or inside a picker's popover. **Value.** The value is an ISO day (`2026-09-24`): a day, not a moment. **Today.** Today is marked on the client only, because the server's today may be another day. **Weekend.** The names of the days of rest in the header are bold, as the locale counts them: Saturday and Sunday in most places, Friday and Saturday in Israel or Saudi Arabia. A due day put on a day off is seen by its column, and the days themselves stay as they are, so nothing competes with the chosen day, today, a span or the marks. **Window.** Days outside `min` and `max` cannot be picked, and the arrows stop at them. When the calendar opens on a month outside them, the arrows can always walk back into the window. **Marks.** Taken days (`unavailable`) are struck through, and `marked` days carry a dot; a screen reader hears both in words. **Range.** With `range` the value is a span, `{ from, to }`, and the calendar picks it: the first day pressed starts a span, the second ends it, in either order, and only then is the span sent. A half-made span is the calendar's own: a span set from outside drops it. TypeScript checks the value against `range` written as it is (`range` or none), so a calendar whose `range` changes at run time is two tags, one under `v-if` and one under `v-else`: `:range="flag"` does not compile. **Preview.** Before the second press the band runs from the first day to the day under the pointer, or to the day with the keyboard focus, and the line under the month counts its days ("7 days"), so a week that came out eight days long is seen before it is set. Only a day the second press can pick ends it, and nothing is sent until that press. A taken day (`unavailable`) stops a span: while it waits, the days past the nearest taken day on either side cannot end it and show disabled, so a booking never takes in a day already booked. The line says the next step while no span is drawn and the length of a picked span after; it is always there, so the month does not move. **Size.** It has one step, `sm`, whatever the app's step: the day cells are 36 px and the header matches them. **Placement.** In the popover of UiDatePicker, as the second step of UiDateRange's popover, or on a page of its own, beside what the host puts under it (a value, quick picks, a time). **Empty.** No day is `null`; in a span, an open end is `null`. ##### Keyboard The keys follow the WAI-ARIA date grid. One day is the tab stop. In the days: - `ArrowLeft` `ArrowRight`: move a day toward that side. On a right-to-left page the week runs from the right, so `ArrowRight` moves to the day before. - `ArrowUp` `ArrowDown`: move a week. - `Home` `End`: go to the ends of the week. - `PageUp` `PageDown`: move a month. - `Shift+PageUp` `Shift+PageDown`: move a year. - `Enter` `Space`: pick the day in focus. - `Escape`: belongs to the host. - `Alt` `Ctrl` `Meta`: a chord with them stays the browser's, in the days and in the months. A move that starts inside the window stops at its edge. The caption opens the months of the year, to jump far. There: - `ArrowLeft` `ArrowRight` `ArrowUp` `ArrowDown`: walk the 3 × 4 grid. On a right-to-left page the grid runs from the right, so `ArrowRight` moves to the month before. - `Home` `End`: go to its ends. - `Escape`: goes back to the days. The header arrows page by a month, or by a year in the months view; when an arrow turns off at the window's edge, the focus moves to the caption. On a right-to-left page the previous arrow stands at the right and points there. ##### Screen reader - Grid: named by the month shown, from a live caption, so a page turn is read out; with `range` it is `aria-multiselectable`. - Day: a button named by its full date, then by what its dots mean and, when it is taken, by the word unavailable. - Today: `aria-current="date"`. - Weekend: a header cell is named by its day's full name; its weight is for the eye alone. - Chosen day: `aria-selected` on its cell. - Disabled day: a day that cannot be picked is `aria-disabled`. - Month: a month button says its month and year; the month shown is `aria-current`. - Range: with `range` a live region says "Choose the first day", then "Now choose the last day". - Preview: while a span waits for its end, the focused day says after its date how long the span to it would be ("7 days"); the line under the month is hidden, since it says the same. - Two calendars: when two stand on one page, `ariaLabel` names each whole calendar as a group; the grid keeps the month as its name. ##### States Empty (it opens on today, or on the window's nearest day), a chosen day, a span, a span drawn to the pointed or focused day before its second press with its length under the month, days outside `min` and `max` (disabled; a chosen day out there keeps a frame), taken days struck through, marked days with one or two dots, today in the brand ink, the names of the days of rest in bold, and the months view. The months view is as tall as the days, so nothing jumps when you switch. ##### When to use another component - A day in a form field: UiDatePicker, which wraps this. - A period in a toolbar: UiDateRange. - A time of day: UiTimePicker beside it. #### Examples ##### Range ```vue ``` ##### Min and max ```vue ``` ##### Marked ```vue ``` ##### Quick picks ```vue ``` ##### With time ```vue ``` ##### Date of birth ```vue ``` ##### Two on a page ```vue ``` ##### Russian ```vue ``` #### API Props: - `min`: `string` — The first day that can be picked, inclusive. - `max`: `string` — The last day that can be picked, inclusive. - `marked`: `(string | CalendarMark)[]` — Days with a dot under the number: a day, or `{ day, tone }` for a colored dot; a day listed twice has two dots, two at most. `grani.calendar.marked` says what they mean (the app's `translate` words it). - `unavailable`: `string[]` — Days that cannot be picked though they fall within `min` and `max` (booked, closed): struck through. - `ariaLabel`: `string` — A name for the whole calendar where two stand on one page ("Check-in"); the grid keeps the month as its name. - `range`: `boolean` — Pick a span of days, two presses in either order, instead of one day. - `modelValue`: `string | DateSpan | null` — The chosen day as `YYYY-MM-DD`, or with `range` the span `{ from, to }` (`v-model`); `null` for none and for an open end. Anything else lands on the root element: native attributes, aria-*, data-testid. Events: - `update:modelValue` (`value: string | DateSpan`) — A day was picked, or with `range` a whole span: a half-made span is not sent. #### How it behaves ##### Limits shown - When: The calendar has `min` or `max`, for example for a date of birth. - Props: `min`, `max`. - Guarantee: With no date selected, the calendar opens on the nearest allowed month. Paging by month or year stays within the bounds, and keyboard focus never lands on an unavailable day. If the selected date is far outside the range, the arrows still let you navigate back into it. An invalid bound is ignored with a warning. - Without it: A date-of-birth calendar opens on the current month, where no day can be picked, and you page back eighteen years to reach one. - Code: `` - Your part: Pass `min` and `max` as `YYYY-MM-DD` days, and let the calendar choose the month it opens on. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "the arrows and the months view stop at the window; a page stops at its edge day", "an empty calendar whose window ends before today opens on the window's last month", "a chosen day far outside the window can be walked back by the arrows and the year arrows", "a month arrow from inside the window never leaves the focus on a day outside it", "a malformed bound is ignored and warned about, not a dead calendar" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#limit-shown ##### Range seen first - When: it picks a range. - Props: `range`. - Guarantee: After the first day, the band runs to the day under the pointer, or to the day with the keyboard focus, and the line under the month counts its days. A screen reader hears the count after the focused day’s date. A day the second press cannot pick does not end the band, and a taken day stops it: the days past it show disabled. - Without it: Only the first day shows until the second press, and a range one day too long is noticed after it is applied. - Code: `` - Your part: Nothing. - Not covered: A touch screen has no pointer resting on a day: there the count shows once the range is picked. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "the band runs to the pointed day and the line counts its days; nothing is chosen or sent until the press", "the keys draw the band to the focused day, which says its length, and take it from a resting pointer", "a day the press cannot pick does not end the band, and the pointer leaving takes it back", "a pointer resting on the grid does not take the band back from the keys when the month turns", "while a span waits for its end, the days past the first taken day cannot end it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#span-seen-first ##### Weekend named - When: a month is shown. - Props: none; the days come from the app’s `locale`. - Guarantee: The names of the days of rest in the header are bold, so their columns are found at a glance. The days themselves look like any other, so the chosen day, today, a range and the marks keep their look. A screen reader hears each column by its full day name. - Without it: Every day name looks the same, and a Saturday is found by counting columns from the start of the week, which moves with the language. - Code: `` - Your part: Nothing; `createGrani({ locale })` sets whose weekend it is. - Not covered: The weekend is the locale’s, not a team’s schedule: a shift that works on Saturday still sees it in bold. Days the app closes go in `unavailable`. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "the header names the locale’s days of rest in bold, wherever its week starts", "a day of rest is drawn as any other day of the month: no ground, the same hover", "a weekday column is named by its full day" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#weekend-shown ##### Day stays the day - When: a day is picked. - Props: `v-model`. - Guarantee: It sends the day as `YYYY-MM-DD`, a day and not a moment, in every time zone the specs run in. - Without it: A picked day sent as midnight lands on the day before east of UTC. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "a click picks a day; a day of the next month picks into it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#day-stays-day ##### Mark today in the app’s time zone - When: today is in the month shown. - Props: none; the zone is the app's. - Guarantee: Today is marked in the app’s time zone, or the browser’s time zone when none is configured. The marker appears only after the calendar runs in the browser, and a screen reader identifies that day as the current date. - Without it: The server’s date is marked as today while it is still yesterday where you are. - Code: `` - Your part: If today should follow a fixed time zone rather than each browser’s, set it once with `createGrani({ timeZone })`. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "today is not marked before mount", "today is marked as the current date, once mounted", "today is the day of the app time zone, past midnight there before UTC" - Pattern for all components: https://grani.arseni.work/patterns/opening#readers-today ##### Enter picks - When: The calendar is inside a form. - Props: none. - Guarantee: Enter or Space on a day picks it, and the form around it is not submitted. - Without it: Enter on a day submits the half-filled form. - Code: `
` - Your part: Nothing; every button in the calendar is `type="button"`, so none of them submits the form. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "inside a form a pressed day picks it and the form is not submitted" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#enter-picks ##### Shortcuts kept - When: keyboard focus is on a day or a month. - Props: none: every UiCalendar. - Guarantee: The arrow keys move between days; with Alt or Cmd held they move nothing and the browser takes the key, so Alt+Left still goes back a page. The months view does the same. - Without it: Alt+Left, meant to go back a page, moves the focus to the day before instead. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "Alt or ⌘ with an arrow moves no day and lets the browser have the key", "in the months view too, a chord moves no month and leaves the key" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#shortcuts-kept ##### Move by month or year from the keyboard - When: A day has keyboard focus. - Props: none. - Guarantee: Use Left and Right to move by day, Up and Down by week, and Home and End to reach the week’s first and last days. Page Up and Page Down move by month; add Shift to move by year. The original day number is restored when a later month has it, so May 31 → April 30 → March 31. - Without it: A date a year away takes twelve clicks on a small arrow button. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "arrows move a day and a week; PageUp a month, Shift+PageDown a year; Home and End the week", "PageUp from the 31st lands on the 31st again where a month has one" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#page-by-key ##### Jump to a month from the heading - When: The day you need is many months away. - Props: none. - Guarantee: The heading opens a grid of the year's months, the same height as the days, so the calendar does not change size. Keyboard focus starts on the month shown, the arrows move across the grid, and a screen reader announces each month with its year, such as “March 2026”. Choosing a month opens its days with keyboard focus on one day of it, and Escape returns to the days without closing the popover or window around the calendar. - Without it: Reaching a day next autumn means paging through every month in between. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "the caption opens the months of the year; a month opens its days", "the months view: the shown month has focus, arrows move over the grid", "leaving the months view after a year step leaves exactly one focusable day in the shown month", "a month button says its month and year", "Escape in the months view goes back to the days and does not reach the host" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#months-jump ##### Focus starts inside - When: it opens. - Props: none. - Guarantee: The calendar shows the selected month and places keyboard focus on the selected day, ready for the arrow keys. With no selection, it starts on today, or the nearest allowed day if today is outside the allowed range. - Without it: The calendar opens on today while your date is in March, or with no day for keyboard focus to start on. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "shows the month of the value, the value picked and one focusable day marked to open on" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-start ##### One Tab per group - When: a month of days is shown. - Props: none: every UiCalendar. - Guarantee: Tab reaches the previous-month button, the month heading, the next-month button and the days as four separate stops. Within the days, start on the selected day or today. Use the arrow keys to move by day or week, Page Up and Page Down to change months, and Enter to choose. The next Tab leaves the day grid. - Without it: Every day is a separate Tab stop, so getting to the field after the calendar can take more than thirty key presses. - Code: `` - Your part: Place the field that comes next after the calendar, not inside it. - Not covered: The header’s month arrows and the month’s name take a press of Tab each, so a month is four presses of Tab, not one. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "one day is a tab stop, so a focus trap around the calendar goes round it", "arrows move a day and a week; PageUp a month, Shift+PageDown a year; Home and End the week" - Pattern for all components: https://grani.arseni.work/patterns/opening#one-stop ##### Focus kept - When: you move between months with its arrows. - Props: none. - Guarantee: When an arrow turns off at the first or last allowed month, keyboard focus moves to the month caption between the arrows. When you pick a day of the next month, focus stays on that day. - Without it: Keyboard focus is lost on the turned-off arrow: it falls to the page, and a screen reader loses its place. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "the arrow that reaches the window's edge hands the focus to the caption", "a pick on a day of the next month keeps focus on that day" - Pattern for all components: https://grani.arseni.work/patterns/waiting#pager-keeps-focus ##### Describe calendar marks in words - When: Days are marked, taken or outside the allowed range. - Props: `marked`, `unavailable`, `min`, `max`. - Guarantee: Each day button is named by its full date, then by the labels of its dots, then by the word “unavailable” when it is taken, for example “Thursday, September 10, 2026, overdue”. Today is marked as the current date. A day outside `min` and `max` is disabled for a screen reader and drawn in the disabled colors, not faded and not struck through, so it does not look like a taken day. - Without it: A screen reader announces only the date, so the dot that means overdue and the strike that means taken are lost to anyone who cannot see them. - Code: `` - Your part: Give each kind of dot a `label` that says what it means; a dot without one is announced only as “marked”. - Proof, in the repository's tests (UiCalendar.spec.ts, not in the package): "a dot says its label to a reader; a taken day says it is unavailable", "today is marked as the current date, once mounted", "a chosen day outside min and max is the disabled state, not a fade", "a span day outside min and max is disabled and not struck" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#state-in-words The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useToday`, `useUiT`. ### Card (`UiCard`) > Group one part of a page into a card with a title, content and an optional footer. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/card Also called panel, section card. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See where the app is working. - Keep nearby content in place while loading. Implemented by the app: - Data fetching, saving, permissions and business rules. - Bind `loading` to the request; the wording comes from the library. Over `#empty` the card adds nothing: give its Retry `loading`, which shows the wait on the button and refuses a second press. - Bind `loading` to the request. A body with its own layout, such as a table or a chart, draws its own placeholders. #### Why it works this way A surface with an optional header, body and footer, each filled by you. Take it to group related content on a page. **Header.** The header is a title with a description, with `#media` before it and `#actions` beside it (centered on the title and its line), or a whole `#header` (a tab strip, a toolbar) running edge to edge. A line stands under the header by default; `:divided="false"` drops it. A body that opens with a UiHeader legend gets no line, since the legend is the line. **Body.** The body is padded, or runs edge to edge without `padded` (rows, a table, an image). `align="center"` puts its content in the middle (a 404), and `empty` puts `#empty` there. **Image.** `#image` puts media at the `top`, `bottom`, `start` or `end`, edge to edge. A side image stands above or below the body when the card is under 384px wide, at the media's own height; a card with a side image takes its parent's width. **Footer.** The footer stands under a line, with its actions at the end, plain or `muted`. A full-width action alone in it (`block`) stands on the body's gutter, lined up with the fields above. `#divider` takes the place of that line (a progress bar). Without `footerPadded`, the footer's children run edge to edge and share the width (split buttons, tabs). **Gutter.** The card passes its gutter (16, or 24 at `md`) to what is inside, so a UiList of rows or a separator lines up with the title. A body that ends in a UiList drops its bottom gutter, so the last row stands on its own inset as the rows above it do. A section of a drawer runs a UiList that is its first or last child to its line. **Plain.** `variant="plain"` drops the frame: the three parts alone, 16 apart, with no line under the header unless `divided`. The footer keeps its own line. **Overlays.** In the body of a drawer or a popover, a card is a flat section (no frame, no padding), with a UiSeparator the overlay places between sections. A window frames its cards. **Title.** The title is the card's one heading, and its level follows the nesting. A long word in the title breaks inside its column, beside the action. **Hosts.** UiChartCard, UiDataTable and UiListField are built on it; UiFilter and UiCrudDrawer take cards as their sections. **Status.** `tone` colors the frame of a card in trouble, and a badge in `#actions` says it in words; a toned card with neither `#actions` nor `#header` warns in development. `disabled` mutes the words and makes the card a disabled group. `loading` and `empty` stand in for the body. ##### Keyboard Not interactive: the frame takes no focus. - `Tab`: reaches the controls you put in `#actions`, `#header`, the body and `#footer`, which keep their own tab stops, in the order they stand. A tab in a whole `#header` and a button in an edge-to-edge footer ring inward, inside the frame. A focused linked image in `#image` rings the card, not itself. While the card loads, `#empty` stays, so a focus on its Retry stays too; when the content takes its place, the focus goes to the card, never to the page. ##### Screen reader - Title: a heading whose level follows the nesting: `h2` on a page, `h3` in a card; the description follows it. - Loading: `loading` marks the card `aria-busy`, and a live region says it is loading, or updating when there is content. Bind `loading` where the card loads, so the region is in place before the words. - Disabled: `disabled` makes the card a group named by its title, `aria-disabled`. - Whole header: a whole `#header` replaces the title and names nothing inside. - Tone: `tone` colors the frame only: the words are a badge in `#actions`. ##### States Rest; `loading` on a first load, a skeleton of three lines; `loading` over content, the content kept and not mounted again, dimmed to 60% with the title, the card's own controls not, and one spinner in the card's middle; `empty`, with `#empty` in the middle (a first load's skeleton wins over it, a later load leaves it as it is); `disabled`, muted words on a muted surface; `tone` `danger` or `warning` on the frame; `plain`, no frame; a flat section inside an overlay. A side image stands above the body when the card is under 384px wide. ##### When to use another component - A list screen with a toolbar and pages: UiDataTable. - A chart with its figure: UiChartCard. - One row of text with a thing at its end: UiListItem. - A choice among cards: UiRadioGroup `card`. - A notice with an action: UiListItem in a `tone`. #### Examples ##### Edge to edge ```vue ``` ##### Image ```vue ``` ##### Plain ```vue ``` ##### Tone ```vue ``` ##### Disabled ```vue ``` ##### Long ```vue ``` ##### States ```vue ``` ##### Divider ```vue ``` ##### Header ```vue ``` ##### Footer ```vue ``` #### API Props: - `title`: `string` (default `""`) — The heading of the card; its level follows the nesting: `h2` on a page, `h3` in a card, and so on. - `description`: `string` (default `""`) — One line under the title that says what the card holds. - `variant`: `"outline" | "plain"` = `outline | plain` (default `"outline"`) — `outline` is the framed surface; `plain` has no frame, no fill, no padding of its own: the three parts alone, on the gutter around it. - `size`: `"sm" | "md"` = `sm | md` (default `"sm"`) — The gutter: `sm` is `--space-surface` (16, the default), `md` is `--space-surface-md` (24). Rows, separators and lists inside line up on it. - `divided`: `boolean` — The line under the header; on by default for a framed card, off for a section of an overlay (the overlay draws the lines between sections). `false` lets the body follow the title directly. - `padded`: `boolean` (default `true`) — The body's padding; `false` runs it edge to edge, for rows, a table or an image. Rows keep their text on the gutter. - `align`: `"start" | "center"` = `start | center` (default `"start"`) — `center` stands the body's content in the middle, with room around it: a 404; `empty` puts `#empty` there. - `imagePosition`: `"start" | "top" | "bottom" | "end"` = `start | top | bottom | end` (default `"top"`) — Where `#image` stands: above the header, below the footer, or beside the card at the `start` or the `end`. - `footerVariant`: `"plain" | "muted"` = `plain | muted` (default `"plain"`) — `muted` fills the footer; `plain` keeps it on the surface. The line above it stays either way. - `footerPadded`: `boolean` (default `true`) — The footer's padding; `false` runs its children edge to edge, sharing the width: split buttons, tabs. - `tone`: `"warning" | "danger"` = `warning | danger` — Meaning of the frame: `danger` for a card in trouble (a failed connection), `warning` for one that needs a look. The words say it too: a badge in `#actions`. - `disabled`: `boolean` (default `false`) — The card cannot be used now: its words go muted and it is a disabled group named by its title. Controls inside are the caller's to disable. - `loading`: `boolean` — The body is on its way. With nothing in the body yet (the first load) a skeleton stands in its place; over content (a refresh) the content stays, dimmed to 60% with the title, under one spinner in the card's middle; `#actions`, a whole `#header` and `#footer` keep their color and still work. `aria-busy` either way. Bind it (`false` too) where the card loads, so its live region is there before the words. A body with a shape of its own (a table, a chart) draws its own skeleton. - `empty`: `boolean` (default `false`) — There is nothing to show: `#empty` stands in the body's place, in the middle. A first load's skeleton wins over it; a later load leaves it as it is: give its Retry `loading`. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `header` with `Record` — The whole header in place of the title line: a tab strip, a toolbar. It runs edge to edge. - `media` with `Record` — Before the title: a glyph plate, an avatar. Glyphs are 20px. - `actions` with `Record` — At the end of the header, on the center of the title and its line: a button, a badge, a switch, a UiMenu. - `default` with `Record` — The body of the card. - `empty` with `Record` — In the body's place while `empty`: a UiNoData, which drops its own frame in a card. - `image` with `Record` — Media edge to edge at `imagePosition`: an image in a UiAspectRatio, a gradient. - `footer` with `Record` — Under a line: the actions stand at the end; a note or a total takes `me-auto`. - `divider` with `Record` — In place of the line above the footer, edge to edge: a UiProgress `h-1`, whose track is the line's color. #### How it behaves ##### Visible work - When: it is loading over existing content. - Props: `loading`. - Guarantee: During a refresh, the content and the title dim to 60% and one undimmed spinner turns in the middle of the card, over it, so nothing in the card moves, and its status region says “Updating…”. The card’s own buttons keep their color and still work. A first load uses placeholders without an additional indicator. - Without it: The card only turns pale, and you cannot tell that it is waiting for an answer. - Code: `…` - Your part: Bind `loading` to the request; the wording comes from the library. Over `#empty` the card adds nothing: give its Retry `loading`, which shows the wait on the button and refuses a second press. - Not covered: Only the card’s own controls, in `#actions`, a whole `#header` and `#footer`, keep their color; a button in the body dims with the content and still works. - Proof, in the repository's tests (UiCard.spec.ts, not in the package): "A refresh shows its work: the card dims what it shows and one spinner turns in its middle, over it, so nothing moves.", "A first load is a skeleton alone: no spinner for the same wait.", "loading over content keeps it at 60% and says updating; with nothing yet it draws the skeleton" - Pattern for all components: https://grani.arseni.work/patterns/waiting#visible-work ##### Stable dimensions - When: it loads, on a first load and on a refresh. - Props: `loading`, bound where the card loads, `false` included. - Guarantee: The first load shows three placeholder lines. During a refresh, the card keeps what it shows mounted, its content dimmed or its empty state unchanged, so the card keeps its space and any input already entered in that content. - Without it: The content gives way to a spinner during a refresh: the card shrinks, and everything below it jumps up, then back down. - Code: `…` - Your part: Bind `loading` to the request. A body with its own layout, such as a table or a chart, draws its own placeholders. - Proof, in the repository's tests (UiCard.spec.ts, not in the package): "a refresh dims the body in place: the content is not mounted again", "loading over content keeps it at 60% and says updating; with nothing yet it draws the skeleton", "a load after the empty state keeps it in place: the card keeps its height" - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt The patterns: https://grani.arseni.work/patterns #### Built on it - [No data](https://grani.arseni.work/components/no-data): Understand why an area is empty and what you can do next. Built with what `grani` exports: `useUiT`. ### Chart card (`UiChartCard`) > Show a chart with its title and key number; the card keeps its space while loading and says when the result is empty or an update failed. A component of grani 0.1.0. Charts, stable, since 0.1.0. Page: https://grani.arseni.work/components/chart-card Also called kpi card, stat card, metric card. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Give every empty list a way forward. - Keep the chart when a refresh fails. - Keep nearby content in place while loading. Implemented by the app: - Data fetching, saving, permissions and business rules. - Provide a useful next action in `#empty`, such as choosing another period, importing data or creating the first record. - Keep the old data in the chart, pass the reason in words as `error`, and clear `error` once a load succeeds. - Keep `value` and `caption` as `null` while they load, so their lines keep their space. #### Why it works this way A card around a chart that handles its data states, so the chart only draws. Take it for a chart on a page, alone or in a grid of cards. **Layout.** A title with its description, with `#actions` on the same line (a link, a UiDelta); an optional headline (`value` in the chart's format, with a `caption` under it); the chart in the body; a note in `#footer`. There is no line under the title: the headline and the chart follow it on one surface. **Data states.** One `loading` and one `empty` tell the states apart, as in a table. A figure beside the chart follows its data: a UiDelta in `#actions` draws nothing on a first load, with nothing to draw and after a failed load, and a note given as bare text in `#footer` becomes a held line of its own height; a control, a link and a footer of markup stay. A refresh keeps everything, and a failed refresh keeps it beside "Not updated". **First load.** Skeletons take the place of the headline and the note. The chart in the slot reads `CHART_SKELETON` and draws its own skeleton; for a chart that has none, the card draws a band of bars. A refresh keeps the chart, dimmed to 60%, with a spinner in the middle of the card. **Empty.** Nothing to draw keeps the chart's place: the chart reads `CHART_EMPTY` and holds its first-load shape, unseen. `#empty` (a UiNoData that says why and what to do next, or the card's own words) stands in the same cell, and the taller of the two sets the height. A slot whose content is switched off by `v-if` counts as no slot. **Errors.** A failed load holds the place the same way, with UiNoData `danger`, the reason and Retry in the cell. A failed refresh keeps the chart and says "Not updated" on the title line, with Retry as a reload glyph; the reason is in its tooltip and in the one live region of the title line. Retry asks for nothing while `loading`. **Bleed.** `bleed="sides"` runs the chart to the card's sides and hands it the gutter through `CHART_BLEED`, so the chart keeps its legend and axis ends on the gutter. `bleed="full"` takes the bottom edge too when there is no footer. A bled chart has no value axis. **Placement.** On UiCard, beside other cards in a page's grid, with UiDelta in `#actions`. A single figure with a trend is this card with `bleed="full"`. Rows with small lines are UiListItem rows in its body. ##### Keyboard The card takes no tab stop of its own. - `Tab`: walks what is in it: the controls in `#actions`, the Retry glyph of a failed refresh, the chart's plot and legend, the Retry of a failed load. - `Enter` `Space`: press a Retry. When a Retry disappears under the focus, the focus goes to the Retry that took its place, or to the card, never to the page. A focus on a chart control stays where it is when the error clears. ##### Screen reader - Status: one status region on the title line, present in every state, says "Loading the chart…" on the first load, "Updating…" on a refresh, and "Could not refresh chart data" with the reason after a failed refresh. - Retry: the glyph of a failed refresh is a button named "Retry" with the reason in its tooltip. - Failed load: UiNoData `danger`, "The chart did not load", the reason and Retry. - Empty: nothing to draw is "No data for this period", or the caller's `#empty`. - Busy: while loading, the card is `aria-busy`; a chart held under the words is hidden and inert. ##### States Rest, the first load (a skeleton for the headline, the note and the chart's own), a refresh (the chart and the headline dimmed to 60%, a spinner in the middle), nothing to draw (`empty`, the chart's place kept), a failed load (`error` with `empty`), a failed refresh ("Not updated" on the title line, the chart kept); with and without a headline, a caption and a footer note, `bleed="sides"` and `bleed="full"`. ##### When to use another component - A chart alone in a page section: the chart itself, UiAreaChart, UiBarChart or UiDonutChart. - Rows with small lines: UiListItem rows in a UiList. - A change against the period before: UiDelta in `#actions`. - A table of the figures: UiTable. #### Examples ##### Delta ```vue ``` ##### Rows ```vue ``` ##### Plan ```vue ``` ##### Partial ```vue ``` ##### Saved ```vue ``` ##### Stacked ```vue ``` ##### States ```vue ``` #### API Props: - `title`: `string` (required) — What the chart shows: "Spending and income by month". - `description`: `string` (default `""`) — One line under the title: what is counted, what is left out. - `size`: `"sm" | "md"` = `sm | md` (default `"sm"`) — The gutter, as UiCard's: `sm` 16, `md` 24. - `value`: `string | number | null` — A headline over the chart, a total: a number in `format`, or text already written. `null` keeps its line while it loads, and blank while there is nothing to draw. - `caption`: `string | null` — A line under the headline: the shares, the span it covers. `null` keeps its line as `value` does. - `format`: `ChartFormat` (default `"number"`) — How a number `value` reads, `money` with the currency code after the digits; the chart inside takes its own. - `loading`: `boolean` (default `false`) — Data is on its way: a skeleton while there is none yet, the chart dimmed over data already shown. - `empty`: `boolean` (default `false`) — There is no data to draw: with `loading` it is the first load, without it `#empty`. - `bleed`: `"sides" | "full"` = `sides | full` — The chart runs to the card's `sides`, its words on the gutter, or to the floor as well (`full`, with no footer); for a chart without a value axis. - `error`: `string | null` (default `null`) — Why the data did not load, in words for the reader, shown with Retry; an empty string fails with a general reason. Over a chart already shown: "Not updated" on the title line. Anything else lands on the root element: native attributes, aria-*, data-testid. Events: - `retry` — Retry was pressed after a failed load or refresh. Slots: - `default` with `Record` — The chart. It reads the card's first load and draws its own skeleton; anything else here is shown as it is. - `actions` with `Record` — On the title line, at the end: a link to the details, a UiMenu. - `footer` with `Record` — A note under the chart: an average, the span it covers. Bare text is a note at the start, in a note's size and color; markup lays itself out, a `me-auto` note before its actions at the end. - `empty` with `Record` — In the chart's place when there is nothing to draw: a UiNoData that says why, with the next step in its `#actions`. It may be taller than the chart; the card grows to it. #### How it behaves ##### Way forward - When: there is nothing to draw for the period. - Props: `empty`, `#empty`. - Guarantee: In place of the chart the card says “No data for this period” with a short hint, or shows what the app put in `#empty`, such as a UiNoData with its next step. - Without it: Empty axes that look like a chart of zeros. - Code: `` - Your part: Provide a useful next action in `#empty`, such as choosing another period, importing data or creating the first record. - Proof, in the repository's tests (UiChartCard.spec.ts, not in the package): "nothing to draw is the card’s own words, or the caller’s #empty" - Pattern for all components: https://grani.arseni.work/patterns/finding#empty-next-step ##### Kept on fail - When: a refresh fails while a chart is shown. - Props: `error` without `empty`, `@retry`. - Guarantee: The previous chart stays fully visible and keeps its space. “Not updated” and Retry appear beside the title; the reason is shown in a tooltip and announced by a screen reader. - Without it: The error takes the chart’s place, and the numbers you were reading are gone. - Code: `` - Your part: Keep the old data in the chart, pass the reason in words as `error`, and clear `error` once a load succeeds. - Proof, in the repository's tests (UiChartCard.spec.ts, not in the package): "a failed refresh keeps the chart and says it on the title line, the reason in the tooltip", "a failed refresh is said in the live region with its reason" - Pattern for all components: https://grani.arseni.work/patterns/waiting#kept-on-fail ##### Stable dimensions - When: the data loads, refreshes, comes back empty or fails. - Props: `loading`, `empty`, `error`. - Guarantee: On the first load, placeholders reserve space for the chart and its headline. A refresh keeps the chart visible and dimmed. An empty result or a failed first load shows its message in the chart’s space; a failed refresh keeps the existing chart and marks it as not updated. - Without it: A small spinner first, then a chart that pushes the cards below it down. - Code: `` - Your part: Keep `value` and `caption` as `null` while they load, so their lines keep their space. - Proof, in the repository's tests (UiChartCard.spec.ts, not in the package): "the first load hands the chart its skeleton and says it loads", "a refresh keeps the chart in view, dimmed and busy, and says it is updating", "the words of a failed load add no height of their own above and below" - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Checkbox (`UiCheckbox`) > Choose yes or no by clicking the box or its label; the tick shows the value the app accepted. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/checkbox Also called check box, tick box. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See the value the app actually accepted. - Submit the values shown in the form. - Go straight to the field that needs fixing. - Show when only some are selected. - Toggle or pick by clicking the words. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the value the app holds as `modelValue`; to refuse a change, leave that value unchanged. - Give it a `name` where the form is sent natively. - Set `invalid`, or pass the error to its UiField. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Set `indeterminate` while some but not all items are on, and decide what a press on the box does, for example turn them all on. - Put the text in the default slot, not beside the component. #### Why it works this way A native checkbox with its text in the slot, for a yes or no in a form. The whole row is the label, so a click on the text toggles it too. **Mark.** The box is drawn as the library's 18px mark. It stands at the first line (18px in a 20px line, 1px down), so a long label wraps beside it. **Model.** The box shows the model: when the parent refuses a change, or a form resets, the box goes back to the model. **Mixed.** `indeterminate` is the mixed state of a "select all" box. It is display only: a press ends it only when the parent says so. **Standalone.** It takes `ariaLabel` (the slot text wins when both are given) and `invalid`. `invalid` draws a red border and sets `aria-invalid`; put the reason beside it, or use UiField. With no text of its own, as in a table's rows, the box takes a press in a 24px square around it. **Size.** `size` is the step of the control ladder: the box is 16, 18 or 20 px, the text stays `text-sm` and the box stands at its first line. **Field.** Inside UiField it reads id, error and required from the field. When the field has a `label`, the slot text names the box and the field label heads the row. **Attributes.** Attributes and listeners land on the input; `class` and `style` on the label. **Placement.** Beside UiSwitch and UiRadio in a form column. The first column of UiTable is this component, and the rows of a multiple UiSelect and of UiFilterList draw the same mark from `utils/checkMark`. ##### Keyboard A native checkbox. - `Tab`: reaches it. - `Space`: toggles it; `disabled` blocks it. A press on a mixed box leaves it mixed unless the parent resolves it. When the parent refuses a press, the box goes back to the model. ##### Screen reader - Name: a checkbox named by the slot text, else by `ariaLabel`, else by the field's label: one name, never two. A box with no name warns in dev. - State: checked, not checked, or, with `indeterminate`, mixed. - Errors: `invalid` sets `aria-invalid`; inside UiField the description and the error are read with it. - Required: inside UiField `aria-required` says it is required, except in a group, where the legend says it once. ##### States Unchecked, checked, mixed, hover (an empty box darkens), focus, disabled (a solid plate, not a fade), invalid (a red border, kept under the pointer), and required, with the star after its own text when no field label carries it. ##### When to use another component - A setting applied at once: UiSwitch. - One answer out of a few: UiRadioGroup. - Several picks from a list in a panel: UiSelect `multiple`. #### Examples ##### Select all ```vue ``` ##### Sizes ```vue ``` ##### States ```vue ``` ##### Field ```vue ``` ##### In a row ```vue ``` ##### Refused ```vue ``` ##### In a form ```vue ``` #### API Props: - `modelValue`: `boolean` (required) — Whether the checkbox is checked (`v-model`). - `indeterminate`: `boolean` (default `false`) — Shows a mixed state when some of the items it represents are checked. Display only. - `name`: `string` — Native name for forms. - `value`: `string` — Native value submitted with the form when checked. - `id`: `string` — Own id when standalone. Inside UiField, the field's id takes precedence. - `disabled`: `boolean` (default `false`) — Prevents toggling. - `invalid`: `boolean` (default `false`) — Marks the control as invalid. Inside UiField, the error state sets this automatically. - `ariaLabel`: `string` — Accessible name when the slot is empty and there is no field label; slot text wins over it. - `required`: `boolean` (default `false`) — Standalone only: the box must be checked, said to a reader and to a form (native `required`), with a star after its text. Inside UiField the field's `required` says so. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder: the box is 16, 18 or 20 px beside `text-sm`. Unset, the app's size (`createGrani({ size })`), `sm` by default. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: boolean`) — The checkbox was toggled. Slots: - `default` with `Record` — Label text displayed next to the checkbox. Clicking the label toggles the checkbox. #### How it behaves ##### Back to the value - When: the app refuses a change, or the form is reset. - Props: `v-model`, or `modelValue` with `@update:model-value`. - Guarantee: The tick follows the app’s value, not your press: if the app keeps its old value, the box goes back to it. - Without it: The box stays ticked after the change failed: the screen shows Share as on while the app has it off. - Code: `Share` - Your part: Pass the value the app holds as `modelValue`; to refuse a change, leave that value unchanged. - Proof, in the repository's tests (UiCheckbox.spec.ts, not in the package): "a vetoed change puts the DOM back on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`, `value`. - Guarantee: A ticked box sends its `value` under its `name`, an empty one sends nothing, and after a form reset the box and its tick show the app's value again. - Without it: A box on screen that the form never sends. - Code: `Newsletter` - Your part: Give it a `name` where the form is sent natively. - Proof, in the repository's tests (UiCheckbox.spec.ts, not in the package): "in a form a checked box sends its value and an empty one sends nothing", "a form reset leaves the box on its model, glyph and DOM agreeing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Focus on the error - When: a required box is left empty on submit. - Props: `invalid`, or the error of its UiField. - Guarantee: When the box is the first field with an error, keyboard focus lands on it, drawn with a red border, so you can tick it from the keyboard straight away. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the box that needs a tick yourself. - Code: `I accept the terms` - Your part: Set `invalid`, or pass the error to its UiField. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiCheckbox.spec.ts, not in the package): "a box marked invalid is what focusInvalid lands on" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Some selected - When: it selects all of a group and only some items are on. - Props: `indeterminate`. - Guarantee: The box shows a dash and is announced as mixed. A press reports the new value to the app, and the box stays mixed until the app resolves it. - Without it: A select-all box that looks empty while two of three items are ticked. - Code: `All` - Your part: Set `indeterminate` while some but not all items are on, and decide what a press on the box does, for example turn them all on. - Proof, in the repository's tests (UiCheckbox.spec.ts, not in the package): "indeterminate sets the DOM property and draws the fill", "a press on a mixed box that the parent does not resolve leaves the DOM mixed" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#mixed-says-some ##### Click the label - When: its text is in the slot. - Props: the default slot. - Guarantee: The whole row is the label: a click on the text toggles the box, and a long text wraps beside it. - Without it: The 18 px box is the only target, and a click on the words does nothing. - Code: `Send me the receipt by email` - Your part: Put the text in the default slot, not beside the component. - Proof, in the repository's tests (UiCheckbox.spec.ts, not in the package): "the text is inside the label, so a click on it toggles" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#label-is-target The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`. ### Code input (`UiCodeInput`) > Enter a one-time code by typing, pasting or, where the browser supports it, autofill from a message, and let the app know when the code is complete. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/code-input Also called otp input, one-time code input, pin input, verification code. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Type, paste or autofill a code. - Let the app check a code as soon as it is complete. - Keep only the characters a code allows. - See the value the app actually accepted. - Submit the values shown in the form. - Go straight to the field that needs fixing. - Show where the next code character goes. - Keep your browser’s keyboard shortcuts. Implemented by the app: - Data fetching, saving, permissions and business rules. - Keep `autocomplete` set to `one-time-code` for verification-code autofill unless the field serves another purpose. Autofill availability depends on the browser and message source. - Check or send the code in `@complete`. If the code is wrong, set the value back to `''`, so the same code can be typed again. - For a code that is not digits only, set `inputMode="text"`, which allows letters and digits, or pass your own `pattern`. Set `length` when the code is not six characters long. - Pass the code the app holds as `modelValue`; to clear a wrong code, set it to `''`. - Give it a `name` where the form is sent natively. - Set `invalid`, or pass the error to its UiField. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. #### Why it works this way A field for a one-time code, drawn as one cell per character. Take it for a sign-in or confirm step. **One input.** One real input lies invisible over the cells and holds the value. So the label, autofill of a code from SMS (`one-time-code`, which a caller's `autocomplete` replaces), paste and Backspace all work as the browser does them; the cells only draw the characters. The lit cell follows the real focus through `:focus-within`, and the hover is drawn from the root, since the input covers the cells. **Caret.** The caret stays at the end: a click moves it there, and so does the next character after any key or touch that moved it. A selection (double click, drag, select all) is left alone, so the code can be copied. **Characters.** `inputMode` picks the keyboard, `pattern` the allowed characters. A pasted text longer than the code is filtered first, then cut, so a code inside an SMS survives. A whole code pasted over characters already typed takes their place. **Size.** Cells stand on the control ladder, `md` by default, because it is the one control on its screen. **Complete.** `complete` fires once when typing, paste or autofill fill the code. A value set from outside is filtered and cut like a typed one but does not fire it, so a restored draft never submits itself. **Model.** The input shows the model: when the parent refuses a change, or a form resets, it goes back to the model. The parent clears a wrong code to `''`, so the same code can be typed again. **Field.** Standalone it takes `invalid` (a red border and halo, and `aria-invalid`; put the reason beside it) and `ariaLabel`; inside UiField it reads id, error and required. **Attributes.** Attributes and listeners land on the input, `class` and `style` on the root. **Placement.** Alone in a sign-in or confirm step, or in a UiField on a card or in a window, after the password. ##### Keyboard One real input over the cells: typing fills the next cell, and a paste or an autofill fills the code at once, dropping characters the pattern refuses. - `Backspace`: removes the last character. - `ArrowLeft` `ArrowRight` `ArrowUp` `ArrowDown` `Home` `End`: do not move the caret, with `Ctrl` or `Meta` too. - `Alt+ArrowLeft` `Alt+ArrowRight`: stay the browser's, a page back or forward; on a Mac, where they jump a word in a text box, the caret comes back to the end. Nothing is emitted while an IME composes. `disabled` blocks the input, and `focus()` does nothing on it then. ##### Screen reader - Field: one text box named by `ariaLabel` or the field's label, with a hint read with it that says how many characters the code has. A code with no name warns in dev. - Cells: hidden from a screen reader; the input holds the value. - Errors: `invalid` sets `aria-invalid`; inside UiField the description and the error are read with it, and `aria-required` says it is required. ##### States Rest, focus (the cell for the next character lit, the last one on a full code), partly filled, full, hover (the border drawn from the root), disabled (muted, inert), invalid (red border and halo on the cells, `aria-invalid` on the input), and two halves with `separator`. ##### When to use another component - A password: UiInput `type="password"`. - A number or a text of any length: UiInput. - A code shown to copy and never typed: UiCopyButton. #### Examples ##### Separator ```vue ``` ##### Letters ```vue ``` ##### Sizes ```vue ``` ##### States ```vue ``` ##### Refused ```vue ``` ##### In a form ```vue ``` ##### In a window ```vue ``` #### API Props: - `modelValue`: `string` (required) — The entered code (`v-model`); empty is `''`. - `length`: `number` (default `6`) — Number of characters in the code, a whole number from 1 to 100; anything else is held to that and warned about in development. - `inputMode`: `"numeric" | "text"` = `numeric | text` (default `"numeric"`) — Keyboard shown on a phone. `numeric` for SMS, PIN, and authenticator codes. `text` for codes with letters. - `pattern`: `string | RegExp` — Allowed characters, tested one character at a time (not the whole code). A RegExp or its source. When omitted, `numeric` allows digits and `text` allows letters and digits. - `disabled`: `boolean` (default `false`) — Prevents entering and editing the code. - `invalid`: `boolean` (default `false`) — Marks the control as invalid. Inside UiField, the error state sets this automatically. - `required`: `boolean` — Standalone only: a value is needed, said to a reader and to a form (native `required`). Inside UiField the field's `required` says so. - `name`: `string` — Native name for forms. - `id`: `string` — Own id when standalone. Inside UiField, the field's id takes precedence. - `ariaLabel`: `string` — Accessible name when there is no visible label. - `size`: `ControlSize` = `xs | sm | md` (default `"md"`) — Step of the control ladder for the cells. - `separator`: `boolean` (default `false`) — Shows a short rule between the code groups. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: string`) — The code changed. - `complete` (`value: string`) — The code reached its length. #### How it behaves ##### Type, paste, autofill - When: a code arrives. - Props: `autocomplete` (`one-time-code` by default). - Guarantee: One input supports typing, pasting and, where the browser offers it, autofill from a message. Pasted text keeps only the allowed characters and is cut to the code’s length: for a numeric code, “Your code is 482911, do not share it.” becomes 482911. This filters characters; it does not identify a code among several numbers in a message. - Without it: Six inputs of one character each: a paste puts one character in the first cell, and the rest of the code is lost. - Code: `` - Your part: Keep `autocomplete` set to `one-time-code` for verification-code autofill unless the field serves another purpose. Autofill availability depends on the browser and message source. - Proof, in the repository's tests (UiCodeInput.spec.ts, not in the package): "a pasted SMS keeps the code inside it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#code-any-way ##### Complete once - When: The last character of the code is entered. - Props: `@complete`. - Guarantee: `complete` fires once, with the code, when typing, a paste or an autofill fills the last cell. It fires again only after the code is edited and filled anew. A value set from outside, such as a restored draft, fires nothing. - Without it: A draft restored into the field is sent for checking as soon as the page opens, before you have typed anything. - Code: `` - Your part: Check or send the code in `@complete`. If the code is wrong, set the value back to `''`, so the same code can be typed again. - Proof, in the repository's tests (UiCodeInput.spec.ts, not in the package): "a value set from outside does not fire complete", "complete fires again after a completed code is edited and filled anew", "typing at a full code fires nothing when the model was over length" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#complete-once ##### Wrong kind refused - When: Characters the code does not allow are typed or pasted. - Props: `pattern`, `inputMode`, `length`. - Guarantee: Only characters that match the code's pattern stay, and the code is cut at its length. A value set from outside, such as a restored draft, is filtered and cut in the same way. - Without it: Letters end up in a numeric code, and you find out only when the server refuses a code that could never be valid. - Code: `` - Your part: For a code that is not digits only, set `inputMode="text"`, which allows letters and digits, or pass your own `pattern`. Set `length` when the code is not six characters long. - Proof, in the repository's tests (UiCodeInput.spec.ts, not in the package): "keeps only allowed characters and cuts at the length; complete fires when full", "a value from outside is filtered like a typed one" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#code-refuses-wrong-kind ##### Back to the value - When: the app refuses a change, or the form is reset. - Props: `v-model`, or `modelValue` with `@update:model-value`. - Guarantee: If the app refuses a change, the field and its cells show the app’s code again. A form reset brings the app’s code back too. - Without it: The cells show a code the app did not accept. - Code: `` - Your part: Pass the code the app holds as `modelValue`; to clear a wrong code, set it to `''`. - Proof, in the repository's tests (UiCodeInput.spec.ts, not in the package): "a change the parent refuses puts the input back on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: The code is sent under its `name`, and after a form reset the input and its cells show the app's value again. - Without it: Code cells on screen that the form never sends. - Code: `` - Your part: Give it a `name` where the form is sent natively. - Proof, in the repository's tests (UiCodeInput.spec.ts, not in the package): "in a form it sends the code under its name", "a form reset leaves the input on its model, cells and DOM agreeing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Focus on the error - When: a code is wrong or missing on submit. - Props: `invalid`, or the error of its UiField. - Guarantee: When the code is the first field with an error, keyboard focus lands in its input, and the cells show a red border and halo, so you can type the code again straight away. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `` - Your part: Set `invalid`, or pass the error to its UiField. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiCodeInput.spec.ts, not in the package): "a code marked invalid is what focusInvalid lands on" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Cursor moves on - When: you type a code. - Props: none. - Guarantee: While the field has keyboard focus, the cell for the next character is highlighted with a text cursor in it; once the code is full, the last cell is highlighted. Backspace removes the last character, and the highlight moves back. - Without it: A text cursor lost among six boxes. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiCodeInput.spec.ts, not in the package): "the cell for the next character is marked; a full code marks the last one", "Backspace takes the last character and the next cell moves back" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#code-moves-on ##### Shortcuts kept - When: a one-time code is typed. - Props: none: every UiCodeInput. - Guarantee: Alt with an arrow goes to the browser, so Alt+Left still goes back a page. - Without it: Alt+Left, meant to go back a page, moves the cursor inside the code instead. - Code: `` - Your part: Nothing. - Not covered: Ctrl or Cmd with an arrow keeps the cursor after the last character: a code is typed from its end, so the field holds that key. - Proof, in the repository's tests (UiCodeInput.spec.ts, not in the package): "Alt with an arrow goes to the browser (back a page); ⌘ or Ctrl with one keeps the caret at the end" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#shortcuts-kept The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Confirm (`UiConfirm`) > Check a decision before an action that cannot be undone, and show progress while the confirmed action runs. A component of grani 0.1.0. Overlays, stable, since 0.1.0. Page: https://grani.arseni.work/components/confirm Also called confirmation dialog, confirm dialog, alert dialog. It works on top of [Modal](https://grani.arseni.work/components/modal). #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See whether an action worked. - Ignore extra presses while a request runs. - Confirm only what cannot be undone. - Open with keyboard focus where the task begins. Implemented by the app: - Data fetching, saving, permissions and business rules. - Supply `action` so the dialog can track the request. Add `success` only when the result is not already clear from a change on the page. - Pass the request as `action` instead of running it after the answer, and give it a timeout of its own: a request that never settles keeps the dialog open. - Ask only before what cannot be reversed. A change that can be reversed is done at once and offered back with Undo in a toast. Remove the row inside `action`, with the request: a Retry after a failure runs the same `action`, and removes it then. - Ask with `confirm`, and name the action on its button with `confirmLabel`, such as Delete or Leave. #### Why it works this way The host of `useConfirm`, mounted once at the app root beside UiToaster. Take it to ask a yes-or-no question before an action. **Root.** A question asked in an app without one is never shown and never answered, and dev says so once. **Window.** It asks in a UiModal with `role="alertdialog"`. A press on the backdrop does not answer; Escape and the cross answer no. Asked from a window, it hides that window until it is answered; the window then comes back, with the focus on what asked. **Action.** With an `action`, the window sends the request, waits for it, and closes into a toast. If the request fails, the toast's Retry asks the question again. A new question answers the open one `false`; an action already on its way then reports nothing. Remove a row inside `action`, so the Retry after a failure removes it too; a UiTable then takes the focus from the window to the same button of the row now in its place. Every way out is refused while the request runs, so one that never settles holds the window open: give the `action` a timeout of its own, which fails it into the error toast. **Leaving.** A question is never left hanging. When the app has a router and the page changes, or when UiConfirm itself unmounts, the open question is answered no. A question whose action is already running is not cut off: the request's outcome answers it. ##### Keyboard The window's keys; the focus starts on the cancel button, and so does a new question asked in place of an open one. - `Tab`: goes round the two buttons. - `Enter` `Space`: press the one in focus. - `Escape`: answers no; while the action is running, it is refused with a shake. While the action is running, a second press on the confirm button sends nothing. On close the focus returns to what asked. ##### Screen reader - Window: an alert dialog (`role="alertdialog"`, `aria-modal`) named by `title` ("Are you sure?" when there is none) and described by `message`, the sentence in its body. - Confirm button: while the action runs, says busy (`aria-busy`) under its spinner. - Outcome: a failure is announced at once as an error toast with Retry; a success is announced only with `success`. ##### States Asked, with Cancel and Confirm; `tone="danger"`, with a red confirm button; busy, with the confirm button loading and every close refused; answered, closed with nothing more, or with a success toast when `success` is set; failed, closed into an error toast whose Retry asks again. A new question, a change of page or an unmounted host answers the open one with `false`. ##### When to use another component - A short form or a detail: UiModal. - A question with a field in it (a reason to type): UiModal `role="alertdialog"`. - A record: UiCrudDrawer. - An outcome with nothing to answer: a toast from `useToast` in UiToaster. #### Examples ##### Plain ```vue ``` #### API No props, events or slots of its own. Anything else lands on the root element: native attributes, aria-*, data-testid. ##### `useConfirm()` Asks a question of the person: `if (await confirm({ message })) remove()`. The `` mounted once at the app root shows it and answers; a new question answers the open one `false`. Returns: - `confirm`: `(options: ConfirmOptions | string) => Promise` `ConfirmOptions`: `if (await confirm({ message })) remove()`, answered by one ``; a new question answers the open one `false`. - `title`: `string` — The question in a few words; defaults to "Are you sure?". - `message`: `string` — What happens, in a sentence: "The order and its payments are removed." - `confirmLabel`: `string` — The confirm button's label: the action itself, "Delete", beats "Confirm". - `cancelLabel`: `string` — The cancel button's label; defaults to "Cancel". - `tone`: `"brand" | "danger"` — `danger` for what cannot be undone: the confirm button turns red. - `action`: `(() => Promise)` — The request the answer sends. The window waits for it (the button loads, a second press sends nothing), then closes; a failure closes into an error toast whose Retry asks again. Every way out is refused while it runs, so give it a timeout of its own: a promise that never settles holds the window. - `success`: `string` — The success toast, when the result is not in view (a row left a list below the window); without it the window only closes. - `error`: `string` — The error toast's title; defaults to "It did not go through". #### How it behaves ##### Outcome shown - When: a question asked with an `action`. - Props: `success` and `error` beside `action`. - Guarantee: The dialog closes when its `action` finishes. If the action fails, an error message appears with Retry, which opens the confirmation again. If it succeeds, a message appears only when you supply `success`, for cases where nothing on the page shows the result. - Without it: The dialog closes, and nothing says the delete failed. - Code: `await confirm({ title, message, action, success: 'Order deleted', error: 'Could not delete' })` - Your part: Supply `action` so the dialog can track the request. Add `success` only when the result is not already clear from a change on the page. - Proof, in the repository's tests (UiConfirm.spec.ts, not in the package): "a failure closes the question into an error toast whose Retry asks again", "success says so in a toast only when asked to" - Pattern for all components: https://grani.arseni.work/patterns/waiting#answered ##### One press, one request - When: a question asked with an `action`. - Props: `confirm({ ..., action: () => request })`. - Guarantee: The confirmation stays open while its `action` runs. Its button shows a spinner and ignores further presses. Escape and a click outside do not close the dialog until the request finishes. - Without it: The dialog closes before the result is known, and a quick second press can send the delete again. - Code: `await confirm({ title, message, confirmLabel: 'Delete', action: () => api.remove(id) })` - Your part: Pass the request as `action` instead of running it after the answer, and give it a timeout of its own: a request that never settles keeps the dialog open. - Proof, in the repository's tests (UiConfirm.spec.ts, not in the package): "the window waits for the action: the button loads, a second press sends nothing, Escape is refused" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Undo, not a question - When: an action with irreversible consequences, such as permanently deleting a record or sending a message that cannot be recalled. - Props: `confirm({ title, confirmLabel, tone: 'danger' })`. - Guarantee: Nothing happens until you answer the question. The confirmation button names the action, such as “Delete”, and keyboard focus starts on Cancel. The dialog returns `true` or `false`, and an `action` you pass runs only after confirmation. - Without it: “Are you sure?” before every delete, until the question gets answered without reading. - Code: `await confirm({ title: 'Delete ORD-1041?', message: 'This cannot be undone.', confirmLabel: 'Delete', tone: 'danger', action: () => api.delete(id).then(() => remove(id)) })` - Your part: Ask only before what cannot be reversed. A change that can be reversed is done at once and offered back with Undo in a toast. Remove the row inside `action`, with the request: a Retry after a failure runs the same `action`, and removes it then. - Not covered: A row of a list of your own removed inside `action` takes the focus with it to the page: the window gives it back to a button that is gone. In a UiTable the focus goes to the same button of the row now in its place. - Proof, in the repository's tests (UiConfirm.spec.ts, not in the package): "asks in an alert dialog and answers true on confirm", "cancel, Escape and the cross answer false", "a row removed inside the action goes after a failure too: the Retry runs the same action", "a row removed inside the action hands the focus on too: the window gives it to the same button of the row now in its place", "Cancel with an action runs nothing, even a press on the confirm button in the same moment" - Pattern for all components: https://grani.arseni.work/patterns/waiting#ask-what-stays ##### Focus starts inside - When: you are asked to confirm an action. - Props: none: every question asked with `confirm`. - Guarantee: Keyboard focus starts on Cancel, the safe answer, so an Enter pressed by habit cancels the action instead of confirming it. - Without it: Keyboard focus starts on the red Delete button, and an Enter pressed by habit deletes the order. - Code: `await confirm({ title: 'Delete ORD-1041?', message: 'This cannot be undone.', confirmLabel: 'Delete', tone: 'danger' })` - Your part: Ask with `confirm`, and name the action on its button with `confirmLabel`, such as Delete or Leave. - Proof, in the repository's tests (UiConfirm.spec.ts, not in the package): "asks in an alert dialog and answers true on confirm" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-start The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useConfirm`, `useToast`, `useUiT`. ### Count (`UiCount`) > Show a compact count beside a label, or a dot when only the presence of an update matters. A component of grani 0.1.0. Status, stable, since 0.1.0. Page: https://grani.arseni.work/components/count Also called counter badge, count badge, notification count. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Keep counts short and in place. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the real count. While it is unknown, pass `NaN`, which draws nothing, or hide the count with `v-if`: an unset `count` is 0. When the count stands alone, say its unit in its accessible name. #### Why it works this way A number beside a label. Take it for the count on a tab, a button or a row; on the corner of an icon or an avatar take UiIndicator. **Shape.** One digit sits in a circle, more digits in a capsule, and above `max` it shows `{max}+`. `soft` is quiet; `solid` draws attention. **Values.** A fraction is rounded down, an infinite count shows the overflow, a negative or missing one is `0`, and one that is not a number draws nothing: unknown is not empty. **Placement.** It sits on a button's label, a tab (UiTabs), a navigation row (UiAppShell) and a UiFilter trigger, and serves as the mark of UiIndicator. The root is a ``. ##### Keyboard Not interactive: it takes no focus, and reaches the keyboard only through its host, a button, a tab or a row. ##### Screen reader - Name: a bare number means nothing: the host names it with its label ("Inbox, 4"); standing alone, `ariaLabel` makes it an image named with its unit ("4 unread"). - Dot: a `dot` is hidden. - Change: not announced. ##### States One digit in a circle, more in a capsule, `{max}+` above `max`, `0` shown; `soft` pale or `solid` saturated, `warning` always solid; `dot`, with no number; `sm` 16px and `md` 20px. No hover or focus of its own. A count that is not a number draws nothing; a bad `max` falls back. Both warn in dev, once per value. ##### When to use another component - A label: UiBadge. - A change against the value before: UiDelta. - A count on the corner of an icon or an avatar: UiIndicator around it. #### Examples ##### Tones ```vue ``` ##### Zero and overflow ```vue ``` ##### Alone ```vue ``` ##### On buttons ```vue ``` ##### In a list of places ```vue ``` ##### Mark ```vue ``` ##### Dot ```vue ``` #### API Props: - `count`: `number` (default `0`) — The number. `0` is shown; hide it with `v-if` when zero means nothing. - `max`: `number` (default `99`) — The largest count shown as is; above it the count reads `{max}+`. A whole number from 1. - `tone`: `Tone` = `brand | neutral | success | warning | danger | info` (default `"neutral"`) — Meaning: `brand` for the product's own counts, a verdict, or `neutral`. - `variant`: `"soft" | "solid"` = `soft | solid` (default `"soft"`) — `soft` pale plate for a quiet total, `solid` saturated fill for a count that calls; `warning` is always solid, its pale plate reads muddy. - `size`: `"sm" | "md"` = `sm | md` (default `"md"`) — `sm` 16px for a corner mark or a dense row, `md` 20px on a button, a tab or a navigation row. - `dot`: `boolean` (default `false`) — A dot without a number: there is something new, how much does not matter. 8px, 6px at `sm`. - `ariaLabel`: `string` — The name of a count that stands alone, the number with its unit: "4 unread". In a tab, a row or a button their label names it. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `default` with `Record` — A short mark instead of the number, such as "!"; it takes the same circle. #### How it behaves ##### Compact counts - When: beside a label. - Props: `count` and `max` (99 by default). - Guarantee: The count is never longer than its largest form, such as 99+ above `max`. A value that is not a number is hidden; a known total of zero is shown as 0. - Without it: A long number widens its button or tab and pushes its neighbors aside, and a bad value shows as “NaN”. - Code: `Inbox ` - Your part: Pass the real count. While it is unknown, pass `NaN`, which draws nothing, or hide the count with `v-if`: an unset `count` is 0. When the count stands alone, say its unit in its accessible name. - Not covered: It is as wide as its digits, so a count that gains a digit moves what stands beside it by a few pixels. It does not hide a 0; for a mark that should disappear at zero, use UiIndicator. - Proof, in the repository's tests (UiCount.spec.ts, not in the package): "Above max it reads 99+: one digit is a circle, two grow a capsule.", "A count that is not a number shows nothing, with a warning in development.", "a negative count reads 0", "an unset count is 0, and a count that is not a number draws nothing" - Pattern for all components: https://grani.arseni.work/patterns/waiting#compact-counts The patterns: https://grani.arseni.work/patterns ### Crud drawer (`UiCrudDrawer`) > Edit a record beside its list and open related lists within the same panel. A component of grani 0.1.0. Overlays, stable, since 0.1.0. Page: https://grani.arseni.work/components/crud-drawer Also called edit panel, record drawer, side form, detail panel. It works on top of [Drawer](https://grani.arseni.work/components/drawer). #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Open a related record inside the drawer. - Submit a form with Enter. - Step through records in one drawer. - Save and start the next record. Implemented by the app: - Data fetching, saving, permissions and business rules. - Swap the body and the title for the step, pass the titles of the levels above in `path`, and go to the level that `back` names. - Set `submitting` while the request runs, and close the drawer or mark the field in error when it answers. - Pass the record’s place in the list as `index` and the list’s length as `total`, load the record whose index `move` carries, and set `persistent` while there is unsaved input. - Set `submitting` while saving. `submit` includes `another: true` for Save and add another, and `another: false` for Save or Enter. After a successful save with `another: true`, clear the form (a default may stay) and keep the drawer open. #### Why it works this way A record opened for editing in a side panel, over the list it came from, laid over UiDrawer. Take it to edit a row opened from a UiDataTable without leaving the list. **Surface.** The panel is UiDrawer: its width, header, gutter, stack, scroll lock and focus kept in. The record adds the form round the body and the footer, Save and Cancel, the loading and the failed states, the steps of `path` and the walk through the list, and leads the exits: Escape and the backdrop go a level up at a step, the cross is refused while a level above holds input. **Kept pages.** In a page that a `KeepAlive` keeps, an open record stays open when the page is deactivated, as UiDrawer's panel does. Close it (`open` false) when the route changes. **Header.** `title` names the record ("Northwind Studio"). The small line under it (`description`, or a `#description` built from the library's parts) starts with the status as a small mark (UiBadge `inline` `sm`), then an id to copy (UiCopyButton `inline`), then a fact, cut when long and shown whole on hover. `#status` beside the title is for the rare record whose status is the point. `#actions` takes at most one icon button of your own, before the cross. With `index` and `total` (two records or more), ↑ and ↓ walk the list (`move`); they are refused while input is unsaved. The walk is the record's: at a step they are gone, since a level above may hold unsaved input, and the way up comes first. The parent loads the record; at the edge of a page it turns the list's page and keeps the opened row marked. At the end of the list, the arrow that can go no further hands the focus to the other arrow. **Gutter.** The drawer's: the body is padded by `--space-drawer` (24px, 16 in a dense product), and the lines inside it keep the same space. **Body.** The body is a form. After a refused save, Save is ready again as soon as the person moves to fix the field, whatever the control (a select fires no typing). The footer is pinned and holds Cancel and Save; with `saveAnother`, Save carries a menu with "Save and add another", for entering a batch of new records. That is why `submit` carries `{ another }` (true from "Save and add another": save, clear the form, keep the drawer open), where UiModal's `submit` carries nothing: a window sends one way. An action that sends a request by itself (delete, archive, a status) stays in the body: a card of UiListItem `as="button"` rows in a UiList after the fields, with the dangerous one last in `tone="danger"`. The body is a chain of UiCard sections, direct children of the slot, separated in one of two ways. By default a line stands between neighbors, and the sections need no titles. With `:divided="false"`, each section after the head of the record starts with a UiHeader `legend`, the line and the title in one, and the sections stand 48 apart, the step between blocks; a dev warning names a section without its legend. Do not put a form of your own in the slot. **Delete.** An action that takes the record from the list closes the drawer first, waits a tick (`await nextTick()`), then removes the row: a UiTable then hands the focus to the same control of the row now in its place. A delete asked through UiConfirm with an `action` does the same inside the action, so a Retry deletes too; the question gives the focus back to the drawer's row, and the table hands it to the row now in its place. **Steps.** A record with records inside opens them as steps of the same drawer, never as a second drawer. The parent swaps the body and the title and passes the levels above in `path`; the header shows a ‹ button named by the level above. ‹, Escape and the backdrop emit `back` with the level to go to, and Cancel goes one level up, so leaving a step never drops the input of a level above. A `persistent` step refuses with a shake. The cross closes the whole drawer; with `persistentAbove` (a level above holds unsaved input) it shakes instead. `error` works at a step as it does at the top. The focus goes to the first field of a new step, and back to its row on the way up. Each level has one job. A record is a form with a card of its actions (UiListItem `as="button" step` rows). What a record holds is a `readonly` list level (UiListField over the rows). A level that decides (a queue) is `readonly` with the verdict in the body, and a decline opens its note first. `readonly` keeps only the way back in the footer, and at the top level not even that. A path the header cannot name in one line needs a page instead. ##### Keyboard - `Enter`: in a single-line field, saves; in a text box it breaks the line. - `Meta+Enter` `Ctrl+Enter`: save from a text box too (`Meta` is Cmd). - `Escape`: closes, or goes one level up at a step; shakes instead while input is unsaved. - `Tab`: stays inside the panel. A refused save puts the focus on the first field in error. The up and down buttons in the header of the record walk the list when `index` and `total` are given. The focus goes to the first field once the record is in, and returns to the opener on close. It never lands on a button that sends a decision: on a level with no field, the panel takes it. ##### Screen reader - Window: a modal dialog named by the record's title (`aria-labelledby`). - Body: says it is loading (`aria-busy`); a refused save marks the field in error (`aria-invalid`). - Way up: at a step, a button named by the level above. - Refused way out: a close, a step or the list's arrows; a shake for the eye, and the reader keeps the focus where it was and hears why: input not saved, or a request still on its way. ##### States `loading` shows a skeleton and holds Save; `#loading` gives one in the form's own geometry. A failed load (`error`) shows UiNoData `tone="danger"` with Retry in the body; the header keeps only the title, and Save is held. Pass `error` with the words and listen to `retry`. `submitting` spins Save and refuses every close. A submit the host refuses by marking a field as it gets it shakes the drawer, as a refused close does, and the focus goes to that field. `persistent` guards unsaved input on this level: the backdrop and Escape shake instead of closing. These guard only the drawer's own exits. A close from outside (`open` set to false, for example by the browser's Back on a deep link) is for the host to guard, before it changes `open`. ##### When to use another component - A panel to read or to set at once, with no form: UiDrawer. - A short task that is not a record: UiModal. - A question about the record (delete it?): UiConfirm above the drawer. - A second drawer over the first: never; in UiCrudDrawer a record inside a record is a step of the same drawer (`path`). #### Examples ##### Queue ```vue ``` ##### Several ```vue ``` ##### Steps ```vue ``` #### API Props: - `title`: `string` (required) — The record's name, the dialog's name for a screen reader: "Santa", not "Edit operation". - `description`: `string` (default `""`) — What the record is and a detail: "operation · 31 Aug". - `loading`: `boolean` (default `false`) — The record is still loading: a skeleton in the body, Save held. - `submitting`: `boolean` (default `false`) — Saving: a spinner on Save, Cancel held, and no way to close until it ends. - `submitDisabled`: `boolean` (default `false`) — Save is held: nothing to save yet, or the form cannot be sent for a reason of your own; a load that failed is `error`. - `persistent`: `boolean` (default `false`) — Unsaved input on this level: the backdrop and Escape are refused with a shake; Cancel and the cross still close. - `persistentAbove`: `boolean` (default `false`) — At a step: a level above holds unsaved input. The cross, which closes them all, shakes instead; ‹, Escape and Cancel go one up as ever. - `error`: `string | null` (default `null`) — Why the record could not be loaded, in words for the reader: the body says it with Retry (`retry`), the header keeps the title alone, Save is held and nothing is guarded. - `submitLabel`: `string` (default `""`) — Save's label; defaults to "Save". - `saveAnother`: `boolean` (default `false`) — A menu on Save with "Save and add another", for a batch of new records: the parent saves (setting `submitting` while it does), clears the form and keeps the drawer open; once the form changed from what was sent, the cursor goes to its first field, a default kept in it or not. - `cancelLabel`: `string` (default `""`) — Cancel's label; defaults to "Cancel". - `path`: `string[]` (default `[]`) — The levels above this one, from the top: `['Groceries', 'Fruit']`. Set, the header shows ‹, named by the last. - `index`: `number` (default `0`) — The record's place in the list it was opened from, from 0, across its pages; with `total`, the header walks the list. - `total`: `number` (default `0`) — How many records the list holds: two or more, the header of the record shows ↑ ↓, which emit `move`; a step shows none. - `readonly`: `boolean` (default `false`) — Nothing on this level waits for Save, every row goes at once: the footer holds only the way back; at the top there is no footer, the cross closes. - `divided`: `boolean` (default `true`) — A line between neighboring sections, the default; `false` parts them by their titles and the gap: a long form of different parts. - `size`: `"md" | "lg"` = `md | lg` (default `"md"`) — The panel's width: `md` 512px for a record, the default; `lg` 768px for what reads wide, a text or a code. - `open`: `boolean` (default `false`) Attributes are placed by the component itself: its description says where. Events: - `submit` (`options: { another: boolean; }`) — Save was pressed or Enter was hit in a single-line field; once per request. `another` is true from "Save and add another": save, then clear the form for the next record and keep the drawer open. - `retry` — Retry of a load that failed was pressed; the parent loads the record again. - `update:open` (`value: boolean`) — The new value of `open`, for `v-model:open`. - `close` — The drawer closed: Cancel, the cross, the backdrop or Escape. - `back` (`to: number`) — The way up was asked for: ‹, Escape, the backdrop or Cancel at a step. `to` is the index in `path` to show; the parent swaps the level. - `move` (`to: number`) — The up or down button in the header: `to` is the index of the record to show in the drawer; the parent loads it. Slots: - `default` with `Record` — The form: fields, or UiCard sections separated by the drawer. No `
` of your own. - `status` with `Record` — Beside the title, for a record whose status is the point; a status as usual is a small mark in `#description`. - `description` with `Record` — The small line under the title in your own markup, in place of `description`: an id to copy (UiCopyButton), facts. One line. - `actions` with `Record` — Icon buttons before the cross: open as a page, more actions. Ghost UiIconButton. - `loading` with `Record` — While `loading`, a skeleton in the geometry of the form that comes, so nothing moves when it does; two fields by default. #### How it behaves ##### Step in - When: a level is opened from a row of the level above. - Props: `path`, the titles of the levels above; `back` with the level to go to. - Guarantee: The related view opens inside the same drawer, with focus on its first field and a named Back button, such as “Back to Fruit”. Escape, Cancel and clicking the backdrop return one level instead of closing the whole drawer. The previous draft is retained, and focus returns to the row that opened the view. - Without it: A second window over the first: the record behind it is hidden, and closing the window drops keyboard focus to the page. - Code: `…` - Your part: Swap the body and the title for the step, pass the titles of the levels above in `path`, and go to the level that `back` names. - Proof, in the repository's tests (UiCrudDrawer.spec.ts, not in the package): "a step shows a back button named by the level above, and no breadcrumb", "a step in focuses its first field; the way up gives focus back to the row it left from", "a backdrop press at a step goes back one level first, as Escape does, so it never discards an ancestor level's unsaved input" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#open-related ##### Submit a form with Enter - When: You are typing in a single-line field of the record. - Props: the drawer’s own Save; `submitting` while the save runs. - Guarantee: Enter saves the record, as pressing Save does. A second Enter sends no second `submit` until the save ends, or until you move in the form or change it. - Without it: You have to leave the field and move to Save, and a quick second Enter sends the record twice. - Code: `…` - Your part: Set `submitting` while the request runs, and close the drawer or mark the field in error when it answers. - Proof, in the repository's tests (UiCrudDrawer.spec.ts, not in the package): "Enter in a single-line field saves: the fields and Save stand in one form", "the footer is exactly Cancel and Save; Save submits the form", "a second Enter before submitting arrives sends no second request" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#enter-saves ##### Step through records in one drawer - When: The drawer opens a record from a list of two records or more. - Props: `index` and `total`, the `move` event. - Guarantee: Previous record and Next record (↑ and ↓ in the header) ask for the record before or after this one; at the first or the last record that way is off. While `persistent` marks unsaved input, or `submitting` a save, they refuse with a shake and your input stays. At a step into what the record holds they are gone: you go up first, so the walk never drops the input of a level above. - Without it: You close the drawer, find the next row and open it again for every record. - Code: `…` - Your part: Pass the record’s place in the list as `index` and the list’s length as `total`, load the record whose index `move` carries, and set `persistent` while there is unsaved input. - Proof, in the repository's tests (UiCrudDrawer.spec.ts, not in the package): "index and total walk the list: ↑ and ↓ emit move, held at the ends", "unsaved input or a save on its way refuse to walk the list, with a shake", "without total there is no list to walk", "at a step the arrows are gone, and they are back one level up" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#walk-records ##### Save and add another - When: you enter a batch of new records. - Props: `saveAnother`, and `submit` read for `another`. - Guarantee: The Save menu offers Save and add another. When that save finishes and the app clears the form, keyboard focus moves to its first field, ready for the next record, even when that field keeps a default such as a currency. If the save fails and the form is left as it was sent, focus stays where it was. - Without it: The drawer closes after each save, and every next record starts with opening it again. - Code: `…` - Your part: Set `submitting` while saving. `submit` includes `another: true` for Save and add another, and `another: false` for Save or Enter. After a successful save with `another: true`, clear the form (a default may stay) and keep the drawer open. - Not covered: The drawer tells a cleared form from a failed save by comparing the fields with what they held when Save and add another was pressed. A form reset to exactly what was sent counts as a failed save, unless every field is empty; an answer that adds or removes a field counts as a cleared form. - Proof, in the repository's tests (UiCrudDrawer.spec.ts, not in the package): "it asks with another: true, and Save and Enter with another: false", "when the save for another ends, the cursor is in the first field of the clean form", "saved to add another, the cursor goes to the first field though it keeps its default", "a save for another that failed leaves the cursor alone though the first field is empty" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#save-and-new The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useSubmitLock`, `focusInvalid`, `useUiT`. ### Data table (`UiDataTable`) > Find and work with records in one place: search and filters above a table with selection and pages, and clear loading and error states. A component of grani 0.1.0. Data, stable, since 0.1.0. Page: https://grani.arseni.work/components/data-table Also called data grid, list page, crud table, admin table. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Start a new search on the first page. - Give every empty list a way forward. - Land on a page with records after a delete. - Show missing data as a gap. - Act on several selected rows at once. - Keep nearby content in place while loading. - Search while the table stays in place. - Compare numbers in aligned columns. - Search once typing pauses. - Reload or share a list as you see it. - Select or clear a range of rows. Implemented by the app: - Data fetching, saving, permissions and business rules. - Load the page the table asks for, with the search and the sort it comes with. - A UiFilter in `#filters` counts by itself, and Reset filters resets it. Pass `filtered` while another filter of yours is on, and clear it when `reset-filters` arrives. Give the step only your app knows in `#empty-actions`, such as New invoice, or creating the client that was searched for; without it, no records yet is the words alone. - Keep `total` up to date, and load the page the table asks for through `v-model:page`. - Pass `null` where there is no value, and `0` only for a real zero. - Put the actions in `#bulk` and apply them to the selection the slot gives you. - Leave `total` unset until the first answer. - Set `loading` while the request runs. For a filter of your own, also empty `items` in the same step that sets `loading`, until its answer comes: the placeholder rows then take their place, as many as were in view. - Set `loading` while the answer is on its way, and `total` when it comes. - Call `useListAddress` once in the page, with your filters and their empty values (`[]` for several, `null` for one), bind the view, and load the rows for it. A filter of ids is named in `numbers`. The sort the list opens on goes in the `sort` option, since assigning it to the view afterwards would overwrite the address. `sortKeys` names the columns the address may sort by; without it, check the key before it reaches your request. #### Why it works this way A whole list screen in one card: the toolbar, the table, the selection strip, and a footer with the count and the pages. Take it for a list page that searches, sorts, selects and pages. **State.** It draws what it is given and owns no state. The page, the sort, the selection and the search are `v-model`s; the rows and the total come from outside. It knows no router, API or storage: a page or an engine wires those. The selection stays as the page, the search and the filters change: the strip counts rows out of view, and `#bulk` gets their keys too. A page that wants only what is in view clears the selection on those changes. **Columns.** A column's `type` says how its values are drawn: money, date, status, copy or progress, text by default. A `#cell.` slot draws a domain cell and wins over the type. A money column stands at the end, digit under digit, unless it says `align: 'start'`. `columns` is the list, fixed; or the state from `useTableColumns` over it, as UiTableToolbar's `columns` is, and then the table draws the visible ones and the toolbar gets the columns button. **Hidden sort.** The sort is the list's order, as a link made it; the columns are the person's view. A sort on a column they hid stays, and a line above the table tells it ("Sorted by Amount, descending"), since no header can. **Slots.** `#filters` narrows the list, `#actions` acts on it, `#bulk` acts on the selection, and `#row-actions` acts on one row. **Empty.** No empty list is a dead end. A search or a filter that found nothing offers its way back, Clear search and Reset filters; `#empty-actions` adds the app's own step beside them, such as creating what was searched for. With no records yet the table can offer nothing of its own, so `#empty-actions` is the step there, such as adding the first record; `#empty` replaces it all. **Attributes.** They reach the table, so `aria-label` names the table, its page checkbox, its scroll box and its pages; `class` and `style` stay on the card. **Built from.** One card of UiTableToolbar, UiTable and UiPagination, standing straight on a page. **Pages.** A new search, a new sort, or a choice the person changes in a UiFilter of `#filters` asks for page one; a value the app sets from outside does not, so a linked page keeps its place. A page past the last (after a delete, or on a narrower list) asks for the last. **Loading.** The page loads the rows, and the page drops an answer that is not for its latest query, as First table in the README does: the table shows the rows it is given. While loading, the table keeps the last `total`. The page fetches when its models change, not inside the `update:search` handler: that handler runs before the move to page one that follows it. A new search draws skeleton rows at once, as many as were in view; a sort or a page of the same search keeps the rows, dimmed. A filter of the page's own empties `items` while its answer is out, in the same step that sets `loading`, and the skeleton takes their place too. The table stands on a floor its skeleton, its empty answer and its failed load share, so nothing under it moves while the person types; the search field keeps the focus and shows no spinner. ##### Keyboard - `Tab`: walks the toolbar, then the page checkbox, a sortable header, each row's checkbox and the controls in `#row-actions`, then the pages. - `Enter` `Space`: on a sortable header, a button, turn it. - `Escape`: clears the search. A row is reached through a control in `#row-actions`, never by a click on the row alone. Rows marked by the app while the focus is in the toolbar hand it to the strip's clear button, as the strip covers the toolbar; the strip gone, the focus goes to the page checkbox. ##### Screen reader - Table: named by the `aria-label` the page gives it. - Sortable header: says its direction (`aria-sort`); a sort a press asked for is said once the app sends it back. - Row checkbox: named by `rowLabel`. - Selection strip: says how many rows are selected through a live region. Its count, the range and the pager's page count are written as the app's language groups digits ("1,234"). - Refresh: says it is updating (`aria-busy`), then the range the load brought ("1–20 of 57"). - Empty page: says why. - Failed load: says why and offers Retry. ##### States Loading (a skeleton on a first load), loaded, refreshing (the rows kept, dimmed to 60%), empty with nothing yet, empty because of a filter (change or reset the filters), failed (the words and Retry), a selection with its strip, and a page past the last, which asks for the last. A page is drawn whole, not windowed. Measured in Chromium (production build, six typed columns and checkboxes): 250 rows mount in about 70 ms and 1000 in about 320 ms, a third of it the browser's layout, and a sort redraws 1000 rows in about 150 ms. Past a few hundred rows, page. ##### When to use another component - A table with no toolbar and no pages: a bare UiTable. - A list of names that grows from its last row: UiListField. - Rows of settings or actions: UiListItem rows in a UiList. #### Examples ##### States ```vue ``` ##### Searching ```vue ``` ##### Far page ```vue ``` #### API Props: - `columns`: `DataTableColumn[] | TableColumns` (required) — The columns in order, each with how its values are drawn: fixed; or the state from `useTableColumns` over them, and then the table draws its `visible` and the toolbar gets the columns button. - `items`: `T[]` (required) — Rows of the current page. - `total`: `number` — Records in the whole list; gives the count and the pages. Unset, there is no footer. Leave it unset until the first answer (a 0 would move a linked page back to one) and keep the last value while a page loads. - `page`: `number` (default `1`) — Current page, from 1 (`v-model:page`). A new search or sort asks for 1; a page past the last asks for the last once the list is not loading. - `pageSize`: `number` (default `25`) — Records per page; the count and the pages are derived from it. - `rowLabel`: `((row: T) => string)` — Names a row for its checkbox: "Select Anna" instead of "Select row". - `rowKey`: `RowKey` — What tells rows apart; `id` when unset. - `sort`: `TableSort` (default `null`) — The current sort (`v-model:sort`); the parent orders the rows. - `selectable`: `boolean` (default `false`) — A checkbox on every row; the selection strip replaces the toolbar while rows are selected. - `selection`: `Set` (default `new Set()`) — Keys of the selected rows (`v-model:selection`). - `isRowSelectable`: `((row: T) => boolean)` — Which rows can be selected. - `search`: `string` (required) — The search text (`v-model:search`); the field is always there. - `searchPlaceholder`: `string` — What the search looks in: its placeholder and its name. Unset, "Search" and the table's `aria-label` ("Search Invoices"), so two lists on one page keep their searches apart. - `size`: `ControlSize` = `xs | sm | md` — Step of the toolbar row: `xs` for a dense row, `sm` (36 px), `md` for a comfortable one; unset, the app's size (`createGrani({ size })`). The slots get it as `{ size }`. - `loading`: `boolean` (default `false`) — Rows are on their way. - `error`: `string | null` (default `null`) — Why the rows could not be loaded, in words; shown with a retry. - `filtered`: `boolean` (default `false`) — A filter of the page's own is on: an empty page says so and offers "Reset filters", which sends `reset-filters`. A UiFilter in `#filters` and the block's own search count by themselves. - `clickable`: `boolean` (default `false`) — Rows answer a click with `row-click`. The keyboard reaches a record through a control in `#row-actions`. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `retry` — The retry of a failed load was pressed. - `update:page` (`page: number`) — Asks for a page: a pressed number or arrow, page one after a new search or sort, the last page when the list shrank under the current one. - `update:sort` (`sort: TableSort`) — Asks for a sort: a column and its direction, or `null` to drop it; the parent orders the rows. - `update:selection` (`selection: Set`) — A new set of the selected keys; the old set is never changed in place. - `update:search` (`value: string`) — The search text: after a pause in typing, at once on Enter or on clearing it. - `row-click` (`row: T, event: MouseEvent`) — A row was clicked with `clickable` on. - `reset-filters` — "Reset filters" in the empty state was pressed: the UiFilter components in `#filters` are reset already, the page clears its other filters. Slots: - `filters` with `{ size: ControlSize; }` — What narrows the list, after the search: a period, filters. - `actions` with `{ size: ControlSize; }` — What acts on the list: catalog buttons, then the main action last (the columns menu comes from `columns`). - `bulk` with `{ count: number; selection: Set; size: ControlSize; }` — What acts on the selected rows; shown in the selection strip. - `row-actions` with `{ row: T; rowIndex: number; }` — Controls of one row, in a column kept at the end. - `empty` with `{ filtered: boolean; }` — Instead of the empty-page text. - `empty-actions` with `{ searched: boolean; filtered: boolean; size: ControlSize; }` — The app's next step for an empty list: beside Clear search and Reset filters when nothing matched, alone under "No records yet". #### How it behaves ##### From page one - When: a list that pages gets a new search, a new sort or a changed filter. - Props: `v-model:page`, `v-model:search`, `v-model:sort`, UiFilter in `#filters`. - Guarantee: A new search, a press on a sortable header, or a choice the person changes in a UiFilter (a tick, Only, Reset) asks for page one through `v-model:page`. A filter or a page the app sets from outside moves nothing, so a linked page keeps its place. - Without it: Page 4 of the new results, or an empty page that reads as no results, while the first matches stand on page one. - Code: `` - Your part: Load the page the table asks for, with the search and the sort it comes with. - Not covered: A filter that is not a UiFilter, and a tick in a list of a UiFilter of several dimensions, do not move the page: set it to one where they change. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "a new search or sort asks for page one", "a person's change of a filter asks for page one; a filter set from outside does not" - Pattern for all components: https://grani.arseni.work/patterns/finding#search-from-page-one ##### Way forward - When: a search or a filter leaves no rows, or there are no records yet. - Props: `v-model:search`, `filtered`, `@reset-filters`, `#empty-actions`. - Guarantee: A search with no match says “No matching records” and offers Clear search, Reset filters while a filter is on, and your own step beside them, such as adding the client searched for. With no records yet your step stands alone under the words. A failed load says why and offers Retry. - Without it: An empty table body that says neither why it is empty nor how to get the rows back. - Code: `` - Your part: A UiFilter in `#filters` counts by itself, and Reset filters resets it. Pass `filtered` while another filter of yours is on, and clear it when `reset-filters` arrives. Give the step only your app knows in `#empty-actions`, such as New invoice, or creating the client that was searched for; without it, no records yet is the words alone. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "a search with no match offers to clear it, and the press asks for an empty search", "a filter that matches nothing offers to reset it, and the offer follows filtered", "a search with no match keeps Clear search and Reset filters, and the app's action stands beside them", "a list with no records yet says so and offers the app's action under its words", "a filter chosen in #filters makes an empty list offer Reset filters, which clears it" - Pattern for all components: https://grani.arseni.work/patterns/finding#empty-next-step ##### Last page lands - When: rows are deleted, or the page asked for is past the last. - Props: `v-model:page`, `total`, `page-size`. - Guarantee: After you delete the last row of the last page, the table requests the previous page while records remain, so you see rows instead of an empty page. If the current page is past the end for any other reason, the table waits until `loading` is false and then requests the last valid page based on the updated `total`. - Without it: After you delete the last row of the last page, you are left on an empty page, and the list looks empty while it still has records. - Code: `` - Your part: Keep `total` up to date, and load the page the table asks for through `v-model:page`. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "removing the last row of the last page lands on the page before", "a page beyond the last asks for the last one once the list is not loading" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#last-page-lands ##### A gap, not a zero - When: a typed cell has no value. - Props: `columns[].type`. - Guarantee: A money, date or progress cell with no value shows the table’s dash: a missing progress is never a bar at 0. - Without it: A task with no progress reported is drawn as an empty bar and read as not started. - Code: `` - Your part: Pass `null` where there is no value, and `0` only for a real zero. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "draws typed cells with the library cells and an empty value as a dash", "a progress cell with no value is the empty-cell dash, never a bar at 0" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#gap-not-zero ##### Bulk action - When: selected rows take an action. - Props: `#bulk`. - Guarantee: The toolbar becomes a selection strip showing how many rows are selected, with the count also announced by a screen reader. The `#bulk` slot receives the count, selection and size for the app’s actions. A separate button clears the selection. - Without it: Actions far from the rows they act on, or no way to act on several rows at once. - Code: `` - Your part: Put the actions in `#bulk` and apply them to the selection the slot gives you. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "the bulk slot gets the count, the selection and the size; the pager and the clear take the size", "shows the selection strip with the count, the bulk slot and a clear that empties the selection" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#bulk-action ##### Stable dimensions - When: the first page loads. - Props: `loading`, `total`. - Guarantee: The footer already has its space while the first page loads, so the total appears without moving the content below. Sorting or paging keeps the existing rows visible and dimmed. A new search preserves the space as described in “Search while the table stays in place”. - Without it: The footer appears with the total and pushes the page down. - Code: `` - Your part: Leave `total` unset until the first answer. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "holds the footer while the first page loads, so nothing moves when the total arrives" - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt ##### Still while loading - When: a new search or filter is running, or a sort or page change within the same search. - Props: `search`, `loading`. - Guarantee: A new search replaces the previous rows, empty message or error with placeholders, matching the previous row count and at least the height of an empty or error message. Sorting or paging keeps the existing rows visible and dimmed. A failed request replaces the rows with a Retry message at the same height, and pressing Retry restores the placeholders. The search field keeps your text and keyboard focus and shows no spinner of its own. - Without it: The list dims for one letter and blanks for the next, and everything below it jumps up and down while you type. - Code: `` - Your part: Set `loading` while the request runs. For a filter of your own, also empty `items` in the same step that sets `loading`, until its answer comes: the placeholder rows then take their place, as many as were in view. - Not covered: An answer with more or fewer rows than were in view changes the table’s height once, when it arrives. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "a new search draws skeleton rows at once, in place of the rows of the last answer", "the skeleton holds as many rows as were in view, and never fewer than the floor", "the empty answer and the failed load stand on the floor the skeleton fills", "a failed new search shows the plate with Retry, not the rows of the last answer; Retry brings the skeleton", "a sort or a page of the same search keeps the rows in place, dimmed", "while a search is out, the field keeps the focus and the typing, and draws no spinner" - Pattern for all components: https://grani.arseni.work/patterns/waiting#still-list ##### Digits line up - When: a column is `type: 'money'`. - Props: `type: 'money'`; `align: 'start'` turns it off. - Guarantee: A money column aligns to the end with equal-width digits by itself, its heading too, so amounts of different lengths line up. `align: 'start'` keeps it at the start. - Without it: Amounts of different lengths start at the left edge, and 1,111.00 cannot be compared with 98.50 by eye. - Code: `` - Your part: Nothing for money. Give other numeric columns `align: 'end'`, and format them alike down the column. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "a money column aligns to the end by itself; align start keeps it at the start" - Pattern for all components: https://grani.arseni.work/patterns/finding#digits-line ##### Asks once - When: the answer to a search comes. - Props: `v-model:search`, `loading`, `total`. - Guarantee: The text goes out once the typing pauses, through its toolbar; once the rows of the answer are in, a screen reader hears how many were found, “1–10 of 12”. Nothing is said while the answer is on its way. - Without it: A list that changes under a screen reader with no word of what the search found. - Code: `` - Your part: Set `loading` while the answer is on its way, and `total` when it comes. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "the range is said after each load, and nothing while one runs" - Pattern for all components: https://grani.arseni.work/patterns/finding#typing-asks-once ##### In the address - When: its view is bound to `useListAddress`. - Props: `useListAddress()`, `v-model:search`, `v-model:sort`, `v-model:page`, each filter’s `v-model`. - Guarantee: A reload, a link and Back open the list with the same search, filters, sort and page. A typed search changes the address in place; a page, a sort or a filter adds a step that Back undoes. A page past the last, from an old link, is corrected in place, so Back never lands on it again. - Without it: A reload or a shared link opens the list from its start, and the person sets the search, the filters and the page again. - Code: `const view = useListAddress({ status: [] })` - Your part: Call `useListAddress` once in the page, with your filters and their empty values (`[]` for several, `null` for one), bind the view, and load the rows for it. A filter of ids is named in `numbers`. The sort the list opens on goes in the `sort` option, since assigning it to the view afterwards would overwrite the address. `sortKeys` names the columns the address may sort by; without it, check the key before it reaches your request. - Not covered: The columns shown are a person’s preference, kept in the browser, not in the address. A page rendered by a server is drawn with the defaults, and the address lands once it is mounted. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "a view kept in the address opens on its search and page, and a typed search is written back in place", "a page past the last, from the address, is corrected in place, so Back leaves it" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#list-link ##### Range select - When: rows can be selected. - Props: `selectable`, `v-model:selection`. - Guarantee: Shift with a second row checkbox selects every row between it and the one pressed before, and the app receives the whole range in one update of `v-model:selection`. - Without it: Fifty presses to select fifty rows. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDataTable.spec.ts, not in the package): "Shift with a second row checkbox passes the whole range up through the block" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#range-select The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useTableColumns`, `useControlSize`, `useUiT`. ### Date picker (`UiDatePicker`) > Type a date as your language writes it, or pick it from a calendar or a preset, while the field keeps a steady width. A component of grani 0.1.0. Dates, stable, since 0.1.0. Page: https://grani.arseni.work/components/date-picker Also called date input, date field, calendar input. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Type a date or a time as your language writes it. - Save the day you picked, in any time zone. - See the day of the week beside a date. - Keep the end of a period after its start. - Pick a common date in one press. - Leave invalid date values empty instead of guessing. - Submit the values shown in the form. - Open a panel on the side where it fits. - Keep nearby buttons and fields in place. - Close only the top layer with Escape. - Respect date limits without losing a value. - See which days are the weekend. - Keep “Last month” and “Today” up to date. - Close a popup without closing the form behind it. - Close an open picker when its field is disabled. - Clear a field and enter its replacement immediately. - Open with keyboard focus where the task begins. - Continue from where you opened the panel. Implemented by the app: - Data fetching, saving, permissions and business rules. - Store it as a date, not a timestamp; turn it into a moment only where one is meant, in the zone that is meant. - Bind `min` to the start field’s value, and refuse the save while the end reads invalid, saying why in the field’s error. - Pass the ids that fit the field in `presets`, or `true` for all seven (Today, Yesterday, A week ago, Tomorrow, In 3 days, In a week, In 2 weeks), and add your own with `{ label, days }`. - Pass a `YYYY-MM-DD` day or `null`. - Give it a `name`. - Give the field a width where the row needs one; without it, the field is as wide as its widest date. - Pass `min` and `max`, and explain the limit in the field's description. #### Why it works this way A form field for one day, in the input capsule on the control ladder. Type the day, or open a calendar to pick it from. **Value.** The value is an ISO day (`2026-09-24`); the field shows it in the locale's format with its day of the week, and its year only when it is not this one ("Thu, Sep 24" this year, "Thu, Mar 12, 1987"), so a due date put on a Saturday, or a day one off, is seen before it is kept. Cut in a narrow column, the whole day shows in a tooltip on hover; a screen reader hears it whole. Without `presets` the field runs no clock: a page left open past New Year shows last year's days without their year until the field is created again, as when the page is reloaded; a typed day reads today as it is at the key. `focus()` puts the focus on the field. **Popover.** The capsule's button opens a UiPopover with a UiCalendar on the chosen day, or on today. A pick closes it. **Typing.** A digit typed on the field starts a day as the app's language writes one: its parts in the locale's order (`12.03.1987` in Russian, `03/12/1987` in American English), any mark between them, or digits alone (`12031987`, `120387`); a part left out is today's (`12.03` is this year's) and a two-digit year is the nearest one at most ten years ahead. The field shows what was typed, and the calendar opens on the day it reads, chosen and in focus, or shows no day while it reads none. Enter takes it; a day that does not exist, or one outside `min` and `max`, is not taken, never guessed: the field is marked invalid until the text changes, and a reader hears why. A press away from the field and its calendar takes it too when it is written in full, day, month and a four-digit year or eight digits, as a text field keeps what was typed; a stray digit or a year half typed (`03/12/19`) is dropped there, not guessed into a day, and a reader hears why. Escape drops it, as do the cross and a preset. Digits of other scripts (`٢٤`, `۲۴`, `24`) count as theirs, eight of them make a day whatever marks stand between, and a year in the locale's calendar (`2530` in Thai) is read as that calendar's; a calendar whose months differ (Persian, Hijri) takes no typing, its calendar is the way in. **Clear.** `clearable` adds a cross that empties the field, as in UiSelect. A required field never gets it: there, empty is an error, not a state. **Presets.** `presets` puts days counted from today under the field: one click instead of the calendar, for the days people enter most often. `true` gives Today, Yesterday, A week ago, Tomorrow, In 3 days, In a week and In 2 weeks; a list picks among them by id (`['today', 'yesterday', 'weekAgo']` for a day spent, `['tomorrow', 'inAWeek']` for a deadline) or adds the app's own, `{ label, days }`, with `days` from today, negative for the past; one whose days are not a finite number, or reach past the year 9999, is left out and said in development, as an id the field does not know is. They move on at midnight while the page stays open. A preset is an action, not a toggle: the field says the day it set, and no button holds room for a check, so three stand on one line. **Window.** `min` and `max` are read as the calendar reads them: a malformed bound counts as no bound, and bounds given backwards make the same window. A day outside them is kept and reads invalid; a preset outside them is disabled. **Empty.** No day is `null`: the field sends `null` when cleared and reads `null` (or an empty string) as no day. **Standalone.** It takes `invalid`, `required` and `ariaLabel`; inside UiField it reads id, error and required from the field. **Width.** The field keeps its width when a day is picked or cleared and when it turns disabled: the text is sized to the widest date the locale prints, and the cross keeps its place. **Combobox.** The button is a combobox, as in UiSelect: the chosen date is its value, and the capsule draws the focus ring. ArrowDown or Alt+ArrowDown opens the calendar. **Attributes.** `class` and `style` land on the root, everything else on the button. The root is the capsule, or, with `presets`, a column that holds the capsule and the presets under it. Without presets the capsule is the root, so a UiButtonGroup can join it to a UiTimePicker or a button. **Placement.** In a form column under UiField, beside UiInput and UiSelect on the same ladder step, before a UiTimePicker for a day and its hours; in a window's or a drawer's form as well as on a page. ##### Keyboard The button is a combobox. - `Tab`: reaches it; the presets are buttons that it reaches too. - `Enter` `Space` `ArrowDown` `Alt+ArrowDown`: open the calendar; `Ctrl` or `Meta` with an arrow stays the browser's. - `0–9`: starts a typed day and opens the calendar on it. While a day is typed, on the field or in the panel: - `0–9` `.` `/` `-` `Space`: go on typing it. - `Backspace`: takes back the last key. - `Enter`: takes the day it reads and closes; a day that does not exist is not taken. On a day the arrows moved to it picks that day, as the calendar does. In the panel the focus lands on the day it opens on, and the calendar's keys apply: - `Tab`: cycles inside the panel. - `Escape`: closes it, drops a day being typed, and returns the focus to the button; a pick does the same, inside a window too. The cross clears the value and leaves the focus on the button, also while the calendar is open. When the field turns disabled, the calendar closes and the focus stays on the field. ##### Screen reader - Field: a combobox named by `ariaLabel` or the field's label. Its value is the chosen date as the locale prints it, or the placeholder. - Errors: `required` sets `aria-required`; a day outside the window, a typed day refused, or a field error sets `aria-invalid`; the field's description and error are read with it. - Panel: named by the field's label; the calendar in it speaks as UiCalendar does. - Presets: a group named in words, each a button that sets its day; the field says the day. - Clear: the cross has a name. - Typing: the field says what is typed; the focus stands on the day it reads, named by its full date; a refused Enter says "Not a date" or that the date is outside the allowed range. ##### States Empty, a day picked, open (the calendar on the chosen day, on today, or on the window's nearest day), disabled (the plate, no calendar, no cross, nothing sent), invalid (standalone, or from a field error), a day outside `min` and `max` (kept, reads invalid), a preset outside the window (disabled), and `clearable` with a day. The width stays the same in all of them. ##### When to use another component - A period in a toolbar: UiDateRange. - A month on a page with no field: UiCalendar alone. - A time of day: UiTimePicker beside it. #### Examples ##### Presets ```vue ``` ##### Limits ```vue ``` ##### States ```vue ``` ##### Sizes ```vue ``` ##### Row ```vue ``` ##### Window ```vue ``` ##### Narrow ```vue ``` ##### Russian ```vue ``` #### API Props: - `modelValue`: `string | null` (default `null`) — The day, `YYYY-MM-DD` (`v-model`); `null` for none. - `placeholder`: `string` (default `""`) — Shown while empty; defaults to "Choose a date". - `min`: `string` (default `""`) — The first day that can be picked, inclusive. - `max`: `string` (default `""`) — The last day that can be picked, inclusive. - `presets`: `boolean | (DatePresetId | DatePreset)[]` (default `false`) — Days from today under the field, as one-click picks: `true` for the default seven, or a list of ids and `{ label, days }`. - `clearable`: `boolean` (default `false`) — A cross that empties the field, as UiSelect's; never shown on a required field. - `required`: `boolean` (default `false`) — Standalone only: empty is an error, said to a reader, and no cross. Inside UiField the field's `required` says so. - `disabled`: `boolean` (default `false`) — The field cannot be changed: a disabled plate, no calendar, nothing sent with a form. - `invalid`: `boolean` (default `false`) — Standalone only: the value is wrong. Inside UiField the field's error says so. - `ariaLabel`: `string` — Standalone only: the accessible name. Inside UiField the label names it. - `name`: `string` — Sends the day with a form, under this name. - `id`: `string` — Id of the button, standalone; inside UiField the field's id wins. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder. Unset, the app's size (`createGrani({ size })`), `sm` by default. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: string | null`) — A day was picked, or `null` when cleared. #### How it behaves ##### Typed as written - When: a digit is typed on the field. - Props: none; the order comes from the app’s `locale`. - Guarantee: The field shows what you type, the calendar opens on the day it reads, selected and focused, and Enter accepts it. A click away from the calendar also accepts it when the date is written in full (day, month and a four-digit year), so a stray digit never changes the field; Escape discards it, wherever focus is in the calendar. A missing part is taken from today, and a two-digit year is the nearest one, at most ten years ahead. A day that does not exist, or one outside `min` and `max`, is not accepted, and a screen reader announces why. - Without it: A date of birth from a passport is paged to month by month, thirty-nine years back, or typed into a plain text field that reads `03/12` as the twelfth of March in one country and the third of December in another. - Code: `` - Your part: Nothing; `createGrani({ locale })` sets the order of the parts. - Not covered: Words are not read: “tomorrow” or “12 March” is the calendar’s and the presets’ to give. A calendar whose months differ from the Gregorian one (Persian, Hijri) takes no typing; the Buddhist year of Thai is read as written. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "digits typed on the field open the calendar on the day they read, and Enter takes it", "reads the parts in the app’s language: day first in Russian", "a day that does not exist is not taken, nor a day outside min and max", "a press away takes a day typed as Enter would; Escape still drops it", "Escape drops a typed day when the focus is on the panel itself", "a press away from a text that reads no day says so", "a stray digit or a year half typed is dropped on a press away, and said", "a day written in full is taken on a press away, with its marks or as eight digits", "th-TH: the year is read in the calendar the field writes it in", "fa-IR: a day of another calendar is not typed at all, never read as a Gregorian one" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#date-as-written ##### Day stays the day - When: a day is picked. - Props: `v-model`, and `name` in a form. - Guarantee: The model and the form get the day as `YYYY-MM-DD`, and the field shows that same day, whatever the time zone of the device or of `createGrani({ timeZone })`. - Without it: The field keeps midnight where the person is, the server reads it in UTC, and the 24th is saved as the 23rd. - Code: `` - Your part: Store it as a date, not a timestamp; turn it into a moment only where one is meant, in the zone that is meant. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "a picked day reaches the model and the form as that day, whatever the zone of the device or the app" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#day-stays-day ##### Weekday shown - When: it holds a day. - Props: none. - Guarantee: The field writes the weekday before the date, in the app’s language, and stands as wide as its widest weekday beside its widest month, so a pick never moves what is beside it. - Without it: The field says “Oct 24, 2026”, and that it is a Saturday comes out when nobody is at work. - Code: `` - Your part: Nothing. - Not covered: Cut in a narrow column, the whole day shows in a tooltip on hover, not on the field itself. Without `presets` the field runs no clock: a page left open past New Year shows last year’s days without their year until the field is created again, as when the page is reloaded. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "shows the day by the locale, or the placeholder when empty", "the field stands over the widest date and keeps the place of the cross, so a pick keeps its width" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#weekday-said ##### End after start - When: it is the end of a period, its `min` bound to the start. - Props: `:min="from"`. - Guarantee: In the end field’s calendar, days before the start are disabled. If the start moves past the end you chose, the end stays visible, marked invalid and still submitted, so you can correct it instead of losing it. - Without it: The end takes any day, and a period from the 25th to the 20th is saved as if it were fine. - Code: `` - Your part: Bind `min` to the start field’s value, and refuse the save while the end reads invalid, saying why in the field’s error. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "an end bound to its start keeps its day and reads invalid once the start moves past it", "a day outside min and max is kept and reads invalid" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#end-after-start ##### Common days - When: Most entries fall on the same few days. - Props: `presets`. - Guarantee: Buttons with days counted from today stand under the field, one press each, and the field shows the day a press set. They are actions, not toggles, so they stay narrow: Today, Yesterday and A week ago fit on one line under a field 288 px wide. - Without it: You open the calendar to pick yesterday, every time. - Code: `` - Your part: Pass the ids that fit the field in `presets`, or `true` for all seven (Today, Yesterday, A week ago, Tomorrow, In 3 days, In a week, In 2 weeks), and add your own with `{ label, days }`. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "presets pick today and yesterday in one click; the field shows the day they set", "presets true gives the seven default days, in order" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#common-days ##### Unreadable is empty - When: The value is not a valid day. - Props: none. - Guarantee: The field shows its placeholder, has no × and submits an empty value, and a warning appears in development. - Without it: A field built on `Date` rolls 31 February over to 3 March and shows a day nobody entered, or shows “Invalid Date”. - Code: `` - Your part: Pass a `YYYY-MM-DD` day or `null`. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "a value that is no day shows empty, has no cross, sends an empty value, and warns in dev" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#unreadable-is-none ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: It sends the day as `YYYY-MM-DD` under its `name`, the day the field shows, and an empty value while it has none. - Without it: A date field written as a button and a calendar, which the form never sends. - Code: `` - Your part: Give it a `name`. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "name sends the day with a form", "a disabled picker sends nothing with a form", "a value that is no day shows empty, has no cross, sends an empty value, and warns in dev" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Opens where it fits - When: its field stands near the bottom of the window. - Props: none. - Guarantee: The calendar opens above the field when the room below is too short for it and the room above is longer, and under it otherwise. It never covers the field and never leaves the window. - Without it: A calendar under a field at the end of a long form runs off the screen, and you scroll the page to reach the end of the month. - Code: `` - Your part: Nothing. - Not covered: The room is measured in the window: a scrolling box inside the page does not count, since the panel is drawn over the page, not inside the box. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "near the bottom of the window the calendar opens above the field; near the top, below it" - Pattern for all components: https://grani.arseni.work/patterns/opening#opens-where-fits ##### Steady width - When: a day is picked or cleared, or the field turns off. - Props: none. - Guarantee: Choosing or clearing a date does not move neighboring fields. The field reserves enough width for the longest date in the app’s format or its placeholder, and keeps space for the clear button even when it is hidden. - Without it: A field as wide as its text: it changes width when a date replaces the placeholder or the clear button appears, and pushes the amount field beside it. - Code: `` - Your part: Give the field a width where the row needs one; without it, the field is as wide as its widest date. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "the field stands over the widest date and keeps the place of the cross, so a pick keeps its width", "disabling keeps the width: the cross's place stays" - Pattern for all components: https://grani.arseni.work/patterns/finding#steady-width ##### Escape, top first - When: its calendar is open inside a window or a drawer. - Props: none. - Guarantee: Escape closes the calendar alone and returns keyboard focus to the field; the window and its half-filled form stay. - Without it: Escape on the calendar closes the window too, and what you typed in its form is lost. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "Escape in an open calendar inside a modal closes the calendar only" - Pattern for all components: https://grani.arseni.work/patterns/closing#escape-top ##### Limits shown - When: The field has `min` or `max`. - Props: `min`, `max`. - Guarantee: A date outside the allowed range stays visible, is marked invalid and is still submitted with the form. The calendar opens within the range, and a quick-date button for a day outside it is disabled. Reversed `min` and `max` bounds are read as one range; an invalid bound is ignored. - Without it: A saved day outside the range is replaced without notice, or a quick-date button picks a day the form then refuses. - Code: `` - Your part: Pass `min` and `max`, and explain the limit in the field's description. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "a preset outside min and max is held", "an app's own preset is its words and its days from today; one outside min and max is disabled", "a day outside min and max is kept and reads invalid", "presets follow the calendar's window: backwards bounds are one window, a malformed bound is none" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#limit-shown ##### Weekend named - When: you open the calendar. - Props: none. - Guarantee: The calendar it opens names the days of rest in bold, so a due date on a day off is seen before it is picked. - Without it: A due date set on a Saturday is noticed when nobody is at work to meet it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "its calendar names the days of rest in bold" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#weekend-shown ##### Period rolls - When: it has presets and the page stays open past midnight. - Props: `presets`. - Guarantee: The presets move on within a minute of midnight, in the app’s time zone: after midnight, Today picks the new day. - Without it: After midnight, Today still picks the day before, and Yesterday the day before that. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "presets follow midnight" - Pattern for all components: https://grani.arseni.work/patterns/finding#named-period-rolls ##### Outside, top first - When: its calendar is open inside a window. - Props: none. - Guarantee: A click away from the calendar closes the calendar alone, and the window stays open. - Without it: A click beside the calendar closes the window under it as well, with the form in it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "a click away from the calendar inside a modal closes the calendar and keeps the modal" - Pattern for all components: https://grani.arseni.work/patterns/closing#press-top ##### Off closes - When: it is disabled while its calendar is open. - Props: `disabled`. - Guarantee: The calendar closes, and keyboard focus moves from the calendar to the field instead of falling to the page. - Without it: The calendar stays open over a field that no longer accepts a day. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "disabling closes an open calendar", "disabling while the calendar has the focus keeps it on the field, not on the body" - Pattern for all components: https://grani.arseni.work/patterns/closing#off-closes ##### Cleared in place - When: The field is `clearable` and holds a day. - Props: `clearable`. - Guarantee: The × empties the field and leaves keyboard focus on the field's button, also while the calendar is open, which then closes. A required field has no ×. - Without it: The × empties the field and keyboard focus is lost, so you have to find the field again. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "the cross clears and leaves focus on the trigger; at xs it fits inside the capsule", "a clear with the calendar open closes it and leaves the focus on the field" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#cleared-in-place ##### Focus starts inside - When: you open the calendar. - Props: none. - Guarantee: Continue from the date already selected: the calendar opens on that date and gives it keyboard focus. With no selection, it starts on today, or the nearest allowed day if today is outside the allowed range. - Without it: The calendar opens on today while your date is in March, and you page back to it month by month. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "opens a calendar on the chosen day; a pick closes it and returns focus to the button", "with no value and today outside the window it opens on the nearest day" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-start ##### Focus comes back - When: you pick a day or close the calendar. - Props: none. - Guarantee: Keyboard focus returns to the date field’s button, also when the picker sits inside a window. - Without it: After a pick, keyboard focus falls to the page: a screen reader loses its place, and you look for the form again. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDatePicker.spec.ts, not in the package): "opens a calendar on the chosen day; a pick closes it and returns focus to the button" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-back The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useToday`, `useControlSize`, `useUiT`, `announce`. ### Date range (`UiDateRange`) > Filter records by a period such as “This month”, which stays current, or choose two dates in either order. A component of grani 0.1.0. Dates, stable, since 0.1.0. Page: https://grani.arseni.work/components/date-range Also called date range picker, period picker, period filter. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Keep the current period until both days are picked. - See a date range before you pick its end. - See which days are the weekend. - Save the day you picked, in any time zone. - Count the last day of a period whole. - Keep “Last month” and “Today” up to date. - Keep nearby buttons and fields in place. - Notice when there are more options below. - Pick range ends in either order. - Continue from where you opened the panel. - Keep turning pages from the keyboard. Implemented by the app: - Data fetching, saving, permissions and business rules. - Count the last day whole when you query by moments. - Query with these two moments, not with the bare days, and pass the zone the app gave `createGrani({ timeZone })`, its `locale`, and the filter’s `min` and `max`. - Store `preset` together with the dates, including in the URL, so the component knows whether to recalculate a named period or preserve a custom range. Query with `periodBounds`: it counts a named period from today itself, so the first request, on the server too, asks for the new month and not the days saved with the link. #### Why it works this way A toolbar filter by period, with named presets and a custom range. Take it in a table toolbar to narrow a list by dates. **Button.** The button shows the days ("Aug 1 – 31"), with the year only when it is not this year. A whole year shows as the year ("2025"), and no bounds show as All time. One bound reads "From Aug 1" or "Until Aug 31". The button is as wide as its widest named period, so picking among them never moves the row. **Panel.** The panel is a UiPopover with a list edge to edge, as in UiSelect, from the shortest period to the longest: Today, Yesterday, This week, Last 7 days, then the months with Last 30 days among them, then the years and All time. The chosen preset is checked in the list. Each preset shows the days or the months it covers, a week from the day the language's week starts; a pick applies it and closes the panel. "Custom range…", under a line, turns the panel into a calendar on the current span. The first day picked starts a span; the second ends it, applies it as `custom` and closes the panel. The row above the calendar goes back to the list. No fields appear beside the button, so the toolbar keeps its width. **Window.** `min` and `max` bound what can be picked: a preset partly outside them is cut at the bound ("This month" with `max` today runs to today), one wholly outside is disabled, and the calendar stops at them. All time stays no bounds. **Stepper.** `stepper` puts two arrows before the button that move the period back and forward by its own length: a month or a quarter by whole months, a week or a custom span by its days. They stand before it, so at the start of a row (a toolbar's `#start`) a longer label, a week across the turn of the year, never moves them from under the pointer. The step lands on a preset's name when it matches one, so stepping forward from last month gives This month, which rolls with today; where two presets share the days, the one the period last had wins. An arrow is disabled when there is nowhere to go: All time, one bound, or past the window. A step the window cuts keeps only the days inside it, and the next step moves by those. A step counts from the model, so a host that applies it late takes two quick presses as one. **Model.** One `v-model` holds what a URL carries, `{ from, to, preset }` (`DateRangeValue`): ISO days, `null` for no bound, and the named preset, `null` for none. A pick sends the whole period at once. A named preset wins over its days: in the browser its days are counted again from today, so "Last month" opened next month shows the new last month. `custom` keeps its days. With no `preset`, the days decide: days mean `custom`, no days mean All time. For a query, `periodBounds(value, { timeZone, locale, min, max })` gives the moments the period covers, its last day whole by the app's clock, a named preset counted from today as the button shows it, on the server too: ask for `at >= start && at < end`. **Placement.** In a table toolbar, beside the search, UiFilter and UiTableColumns; or in a form under a UiField label. **Field.** Inside UiField the field's label is its name, read before the days, for the button, its panel and the arrows' group; a click on the label opens the panel. The field's id, error and words reach the button. **Disabled.** `disabled` turns off the button and both arrows, and closes the panel if it is open. ##### Keyboard On the button: - `Tab`: reaches it. - `Enter` `Space`: open the panel on the list, with the chosen preset highlighted. On an arrow (`stepper`): - `Tab`: reaches them, before the button. - `Enter` `Space`: move the period; the focus stays on the arrow, or goes to the button when the arrow turns off at the window's end. In the list: - `ArrowDown` `ArrowUp`: move, passing over a preset outside the window. - `Home` `End`: jump. - `Enter` `Space`: apply the preset and close the panel. - `ArrowDown`: past the last preset, reaches "Custom range…". On "Custom range…": - `ArrowUp`: goes back to the list. - `Enter` `Space`: open the calendar with the focus on its day. In the calendar, which takes UiCalendar's keys and picks the span: - `Enter` `Space`: on the row above it, go back to the list, on the custom row. Anywhere in the panel: - `Escape`: closes the panel and returns the focus to the button, as a pick does; a half-picked span is dropped on close. ##### Screen reader - Button: its name comes before its days, "Period, Aug 1 – 31", or `ariaLabel` in its place, or the field's label inside UiField, with `aria-haspopup` and `aria-expanded`. - Panel: a dialog with the same name. - Presets: a listbox with that name, each option with the days or months it covers, the chosen one selected and one outside the window disabled. - Custom row: marked as the current choice (`aria-current`), and its days describe it. - Calendar: a live region says "Choose the first day", then "Now choose the last day". - Row above the calendar: named "Custom range, back to the list". - Arrows: named "Previous period" and "Next period", in a group with the button's name ("Issued, group"), so two on one page are told apart; after a step the new days are said, since the focus stays on the arrow. ##### States A named preset, a custom span, one bound ("From Aug 1", "Until Aug 31"), a whole year, All time; the list open, the calendar with a first day picked; a period set from outside while the panel is open, which moves the highlight and drops a half pick; presets cut by the window and one disabled outside it; the arrows, one disabled at the window's end; the whole control disabled; the button at `xs`, `sm` and `md`. ##### When to use another component - One date in a form: UiDatePicker. - A day alone on a page: UiCalendar. - Filters by other dimensions: UiFilter. - A date in a column: UiDateCell. #### Examples ##### Sizes ```vue ``` ##### States ```vue ``` ##### Field ```vue ``` ##### Outside ```vue ``` ##### Stepper ```vue ``` ##### Window ```vue ``` ##### Russian ```vue ``` #### API Props: - `modelValue`: `DateRangeValue | null` (default `null`) — The period (`v-model`): `from` and `to` as `YYYY-MM-DD`, `null` for no bound, and `preset`, the named period or `custom` for days picked on the calendar. A named one wins over the days: they are counted again from today. A `null` preset makes days `custom` and none All time. - `id`: `string` — Own id of the button, for a label elsewhere. Inside UiField, the field's id takes precedence. - `align`: `"start" | "center" | "end"` = `start | center | end` (default `"start"`) — Which edge of the button the panel lines up with. - `ariaLabel`: `string` — What the button filters by, read before the days: "Period" by default, "Paid" for a column of payment days. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder, as the rest of the toolbar row. Unset, the app's size (`createGrani({ size })`), `sm` by default. - `min`: `string` — The first day a period can reach, inclusive: a preset partly before it is cut there, one wholly before it is disabled. - `max`: `string` — The last day a period can reach, inclusive: a preset partly after it is cut there, one wholly after it is disabled. - `stepper`: `boolean` (default `false`) — Two arrows before the button that move the period back or forward by its own length: a month by a month, a week by seven days. - `disabled`: `boolean` (default `false`) — The period cannot be changed: the button and the arrows are off, and an open panel closes. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: DateRangeValue`) — The whole period at once: a preset, a span picked on the calendar (`custom`), or a named preset counted again from today. #### How it behaves ##### Half pick dropped - When: you start a custom range and leave it unfinished. - Props: none. - Guarantee: Nothing is applied until both days are picked. Closing the panel after one day drops that day and keeps the period as it was, and a period set from outside drops it too, so your next click starts a new range. - Without it: Each click applies at once: the first day becomes “from that day on”, and the list changes for a range you never finished. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "a half-made span is dropped on close: reopened, the panel is the list again", "a period set from outside while the calendar is half picked drops the half pick" - Pattern for all components: https://grani.arseni.work/patterns/closing#half-pick-dropped ##### Range seen first - When: you pick a custom range. - Props: none. - Guarantee: The calendar of Custom range draws the range before your second press and counts its days, while the period in effect stays as it was. - Without it: A custom range is seen only once it is applied, and a list filtered one day short looks like a list with nothing more in it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "a custom span is drawn to the pointed day and counted before the second press" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#span-seen-first ##### Weekend named - When: you pick a custom range. - Props: none. - Guarantee: The calendar of Custom range names the days of rest in bold, so a range that starts or ends on a day off is seen while you pick it. - Without it: A report period that ends on a Sunday looks the same as one that ends on a Friday. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "the calendar of a custom span names the days of rest in bold" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#weekend-shown ##### Day stays the day - When: a custom range is picked. - Props: none. - Guarantee: The period’s ends are days, `from` and `to` as `YYYY-MM-DD`, so a range never shifts by a day. - Without it: A period sent as two midnights starts and ends a day early on a server in UTC. - Code: `` - Your part: Count the last day whole when you query by moments. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "Custom range opens the calendar on the span; two days make it custom, in either order, and close" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#day-stays-day ##### Last day whole - When: the app queries records by the moment they happened. - Props: `periodBounds(period, { timeZone })`. - Guarantee: It gives the moments the period covers: midnight of its first day and midnight after its last, by the clock of the zone given, so a query for `at >= start && at < end` counts the last day whole, a day the clock changes on too. - Without it: A query for `at <= "2026-10-31"` stops at the midnight that starts the 31st, and that day’s invoices are missing from the report. - Code: `const { start, end } = periodBounds(period, { timeZone: 'Europe/Moscow', locale: 'ru' })` - Your part: Query with these two moments, not with the bare days, and pass the zone the app gave `createGrani({ timeZone })`, its `locale`, and the filter’s `min` and `max`. - Not covered: The filter does not run the query: an app that compares timestamps with the bare days still loses the last day. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "a period picked here gives a query its moments, the last day whole, by periodBounds" - Pattern for all components: https://grani.arseni.work/patterns/finding#last-day-whole ##### Period rolls - When: a named period is kept, in a URL or a saved view. - Props: `v-model` with `preset`. - Guarantee: A saved view on Last month still shows the previous calendar month when opened later: the period is recalculated when the page opens and when the day changes, and the updated dates are sent through `v-model`. A preset that has no dates yet gets them filled in. - Without it: Days saved once: “Last month” shows the same month forever. - Code: `` - Your part: Store `preset` together with the dates, including in the URL, so the component knows whether to recalculate a named period or preserve a custom range. Query with `periodBounds`: it counts a named period from today itself, so the first request, on the server too, asks for the new month and not the days saved with the link. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "a named preset whose days went stale is re-resolved against today, on mount and across a month end", "a preset without days in the model fills them" - Pattern for all components: https://grani.arseni.work/patterns/finding#named-period-rolls ##### Steady width - When: it stands in a toolbar. - Props: none. - Guarantee: The button reserves the width of the widest named period, and of All time and Custom range, so picking one of them does not move the search and filters beside it. With `stepper` the arrows stand before the button, so at the start of a row, as in a toolbar’s `#start`, a longer period never moves them from under the pointer. - Without it: A button as wide as its text, so each pick shifts the rest of the row. - Code: `` - Your part: Nothing. - Not covered: A custom span of another year, or across the turn of a year, can be wider than every named period: the button then grows toward the end of the row. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "the button stands over the widest named period, so a pick keeps its width" - Pattern for all components: https://grani.arseni.work/patterns/finding#steady-width ##### Five and a half rows - When: there are more presets than fit. - Props: none. - Guarantee: The list of presets is cut at five and a half rows, so the half row shows there are more below. - Without it: A list cut at a row edge that looks finished. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "the list is cut at five rows and a half" - Pattern for all components: https://grani.arseni.work/patterns/finding#five-rows ##### Either order - When: You choose a custom range in the calendar. - Props: none. - Guarantee: After the first day, the calendar asks for the other one (“Now choose the last day”) and the range in effect stays as it was. The second day completes the range in either order: the earlier day is the start, the later the end, the range applies as `custom`, and the panel closes. - Without it: The first press is always the start, so choosing the end first gives a range that ends before it begins. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "Custom range opens the calendar on the span; two days make it custom, in either order, and close" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#either-order ##### Focus comes back - When: the period panel closes. - Props: none. - Guarantee: Whether you close the panel by choosing a preset, finishing a custom range or pressing Escape, keyboard focus goes back to the period button. - Without it: The panel closes and keyboard focus falls to the page: a screen reader loses its place, and you look for the filters again. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "closing returns the focus to the button: a preset, a custom span, Escape" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-back ##### Focus kept - When: you step a period with its arrows up to `min` or `max`. - Props: `stepper`, with `min` or `max`. - Guarantee: The arrow you pressed keeps keyboard focus, and a screen reader announces the new days. When a step reaches the end of the window and the arrow turns off, focus moves to the period button after the arrows. - Without it: Keyboard focus is lost on the turned-off arrow: it falls to the page, and a screen reader loses its place. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDateRange.spec.ts, not in the package): "the arrow that turns off at the window hands the focus to the button" - Pattern for all components: https://grani.arseni.work/patterns/waiting#pager-keeps-focus The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useToday`, `announce`, `useControlSize`, `useUiT`. ### Delta (`UiDelta`) > See how much a value changed as a signed percentage, and whether the change is for the better. A component of grani 0.1.0. Charts, stable, since 0.1.0. Page: https://grani.arseni.work/components/delta Also called trend indicator, change indicator, percent change. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Show a shrinking loss as an improvement. - Name states in words, not color alone. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass both values as they are, signs included, and do not count the percent yourself. - Set `invert` when lower is better, so the word agrees with the color. #### Why it works this way A badge with the signed percent a value moved against the one before. Take it beside a number, beside a UiSparkline, or on a chart card's title line. **Counting.** The library counts the percent against the size of `previous`, so a smaller loss reads as growth. No previous value, a zero one, or a change over 10 000% draws nothing. **Meaning.** Growth is `success` and a fall is `danger`; `invert` swaps them when lower is better. The sign and a word for a screen reader ("better", "worse") carry the meaning, so it does not rest on color alone. **Placement.** On a UiChartCard's title line, beside a UiSparkline in a UiListItem row's `#actions`, and beside a number. On a chart card it reads the card's state as the chart does: while the card has no data (a first load, nothing to draw, a failed load) it draws nothing, and comes back with the data; a refresh keeps it. The badge never shrinks, so a long label beside it cannot cut off the sign or the digits; the label wraps when the host is narrow. The root is a ``. ##### Keyboard Not interactive: no tab stop, no keys. ##### Screen reader - Value: the percent is hidden and a verdict is read in its place, the change and a word: "+12%, better" or "-8%, worse"; no change is read as 0% with no verdict. - Label: the label after the badge is read as text, after the verdict. ##### States Growth (`success`), a fall (`danger`), the two swapped by `invert`, no change (`neutral`, 0%), and nothing drawn: no `previous`, a zero one, a change past 10 000%, or a chart card around it with no data. Two sizes, `sm` and `md`; no hover or focus state of its own. ##### When to use another component - The trend itself: UiSparkline beside it. - A state word: UiBadge. - A count: UiCount. - A share of a whole: UiProgress. #### Examples ##### Lower is better ```vue ``` ##### Losses ```vue ``` ##### Beside a sparkline ```vue ``` ##### Sizes ```vue ``` #### API Props: - `current`: `number` (required) — The value now. - `previous`: `number | null` (default `null`) — The value it is compared with; `null`, missing, zero or not finite draws nothing. - `invert`: `boolean` (default `false`) — Lower is better: a fall is `success`, growth `danger`. - `label`: `string` (default `""`) — What it is compared with, shown after the badge: "vs August". A screen reader reads it as text after the verdict. - `size`: `"sm" | "md"` = `sm | md` (default `"sm"`) — `sm` on a chart card's title line, in a row or a cell; `md` beside a larger heading. Anything else lands on the root element: native attributes, aria-*, data-testid. #### How it behaves ##### Smaller loss is growth - When: the value before is below zero: a loss, a deficit, a negative balance. - Props: `current`, `previous`. - Guarantee: The percentage is calculated against the absolute value of `previous`: −100 to −50 is +50%, better; −100 to −150 is −50%, worse. For a metric where lower is better, set `invert`: it reverses the better/worse assessment, not the sign of the percentage. - Without it: `(current - previous) / previous` shows -50% in red for a loss that halved, so an improvement reads as a fall. - Code: `` - Your part: Pass both values as they are, signs included, and do not count the percent yourself. - Proof, in the repository's tests (UiDelta.spec.ts, not in the package): "a negative base still reads by the direction of the move", "invert with a negative base: a smaller loss is growth, so it is worse" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#loss-shrinks ##### Words, not only color - When: it shows a change. - Props: none: every UiDelta. - Guarantee: The sign appears in the badge beside the color, and a screen reader announces the change with a word: “+12%, better”, “-8%, worse”. - Without it: A green or red pill whose meaning is lost on anyone who does not tell the colors apart. - Code: `` - Your part: Set `invert` when lower is better, so the word agrees with the color. - Proof, in the repository's tests (UiDelta.spec.ts, not in the package): "says better or worse for a screen reader, not only in color" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#level-not-by-colour The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Donut chart (`UiDonutChart`) > Compare the parts of a total in a ring chart, with the total in the center and each part available from the keyboard. A component of grani 0.1.0. Charts, stable, since 0.1.0. Page: https://grani.arseni.work/components/donut-chart Also called pie chart, ring chart, share chart. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Read the full text when a label is shortened. - Read every chart value from the keyboard. Implemented by the app: - Data fetching, saving, permissions and business rules. - If you pass a `format` function, return a short figure when it is called with the `compact` option. - Name the chart with `ariaLabel`. #### Why it works this way The parts of a whole as a ring, with the total in the middle. Take it for a breakdown at one moment. **Parts.** Each part is a `key`, a `label` and a `value`, drawn clockwise from twelve o'clock in the order given, so the caller sorts them. Colors follow that order, five at most: past five, the fifth part is "Other", the sum of the rest, in the gray used for leftovers. **Small values.** A part that is not a positive number takes no room and shows 0%. A real part under half a percent shows "<1%" and an arc no thinner than the gap between arcs. A whole of nothing is the bare ring with a total of 0; no data at all is a dash. **Legend.** The legend stands beside the ring, or under it when the host is narrow, and gives each part its value and its share. **Shown part.** A shown part widens, and the middle says its name, value and share, sized to fit the hole; a figure too long for it is written short there ("5.3T"), whole in the legend and the table; a `format` function is asked for that short figure with `{ compact: true }`. The pointer over a part or its legend row shows that part, and a tap keeps it shown until a tap elsewhere. In a card. In a UiChartCard it draws a pale ring on the card's first load and holds it still when there is nothing to draw. When the card bleeds it, the legend keeps to the gutter. **Placement.** In a UiChartCard's body, under its headline or in place of one, since the total is in the ring; or alone in a page section. ##### Keyboard The ring is one tab stop; an empty ring is no tab stop. - `Tab`: reaches the ring; focus shows the first part. - `ArrowLeft` `ArrowRight`: step through the parts, stopping at the first and the last; each part is read out. - `Home` `End`: jump to the first and the last part; each part is read out. - `Alt` `Ctrl` `Meta`: a chord with them stays the browser's. A tap on an arc or a legend row shows that part and hands the focus to the ring, so it stays until a tap elsewhere; the legend takes no focus of its own. ##### Screen reader - Ring: an image named by `ariaLabel`. - Part: a part reached by the keyboard is read in a polite live region as "Groceries: 1,240, 34%". - Table: a hidden table, captioned with the chart's name, holds every part with its value and share, each part folded into Other on a row of its own ("Other: Taxi"), and the total in its footer. - Hidden: the middle of the ring and the legend, since the table says the same. ##### States Rest with the total in the middle, a part shown (it widens, the middle says its name, value and share, its legend row is marked), more than five parts with Other, a part of nothing at 0%, a real part under half a percent as "<1%", a whole of nothing as the bare ring and 0, no data at all as a dash; with and without the legend, which goes under the ring when the host is narrow. In a UiChartCard: its first load as a pale ring, nothing to draw held still, a refresh dimmed by the card. ##### When to use another component - The parts period by period: UiBarChart with `stack`. - One share of a whole: UiProgress. - A flow over time: UiAreaChart. - The card with its title and data states: UiChartCard around it. #### Examples ##### Other ```vue ``` ##### Nothing ```vue ``` ##### Small ```vue ``` #### API Props: - `parts`: `DonutPart[]` (required) — The parts, in the order they go round the ring and take the chart colors: the caller sorts. Past five, the fifth is "Other", the sum of the rest. - `ariaLabel`: `string` (required) — What the chart shows, for a screen reader: "Spending by category, September". - `totalLabel`: `string` (default `""`) — What the total in the middle counts: "Visitors"; "Total" when unset. - `format`: `ChartFormat` (default `"number"`) — How values read in the middle, the legend and the table; `money` writes the currency code after the digits, `1,234.00 USD`. - `height`: `number` (default `160`) — The diameter of the ring in pixels. - `legend`: `boolean` (default `true`) — The legend beside the ring, each part with its value and share. Anything else lands on the root element: native attributes, aria-*, data-testid. #### How it behaves ##### Whole text - When: the total or a part is too long for the hole. - Props: `format`. - Guarantee: The center of the chart uses a compact total, such as “5.3T”, so it fits. The legend and the data table available to screen readers provide the full number. - Without it: A total cut to “5,300,000,00…” in the middle of the ring. - Code: `` - Your part: If you pass a `format` function, return a short figure when it is called with the `compact` option. - Proof, in the repository's tests (UiDonutChart.spec.ts, not in the package): "a total too long for the hole is written short there, and in full in the table" - Pattern for all components: https://grani.arseni.work/patterns/finding#whole-text ##### Values by keys - When: you need the parts, not only the ring. - Props: `ariaLabel`. - Guarantee: Tab reaches the ring; the arrow keys, Home and End move between the parts, and each is announced, for example “Groceries: 1,240, 34%”. A data table available to screen readers holds every part, including the ones folded into Other, and the total. - Without it: Shares a screen reader never announces, and parts folded into Other that nobody can read. - Code: `` - Your part: Name the chart with `ariaLabel`. - Proof, in the repository's tests (UiDonutChart.spec.ts, not in the package): "the ring is one tab stop; the arrows walk the parts and each is said", "a hidden table holds every part, its share and the total" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#values-by-keys The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Drawer (`UiDrawer`) > Read or edit details in a side panel without leaving the list or page behind it. A component of grani 0.1.0. Overlays, stable, since 0.1.0. Page: https://grani.arseni.work/components/drawer Also called side panel, sheet, off-canvas panel, slide-over. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Close only the top layer with Escape. - Open with keyboard focus where the task begins. - Continue from where you opened the panel. Implemented by the app: - Data fetching, saving, permissions and business rules. - Put the field where the task starts first in the panel’s content. - Open the panel from a button or row that will remain on the page when the panel closes. An action in the panel that removes its record closes the panel first, waits a tick (`await nextTick()`), then removes the row: a UiTable then hands keyboard focus to the same control of the row now in its place. #### Why it works this way A panel at the inline end of the screen, full height, over the page it came from. Take it for what is read or set beside a list without leaving it: the settings of a view, the details of a row. **Closing.** `title` names the panel and `description` describes it. The backdrop, Escape and the cross close it. Escape works only while the panel is the top layer, so a confirm over it closes first. `persistent` guards unsaved input: the backdrop and Escape are refused with a shake, but the cross still closes, since pressing it is an explicit intent. `submitting` refuses every close while a request is out. These guard only the panel's own exits; `open` set to false from outside is for the host to guard. **Layout.** Attributes land on the dialog. The panel takes the full height at the inline end (the left in a right-to-left page), `md` 512px wide or `lg` 768px for what reads wide, and the whole width on a narrow screen. The page under it does not scroll; a long body scrolls between the header and the footer. The body is padded by `--space-drawer`; the header and the footer stand on it at the sides, so the title, the content and the footer's buttons share one edge. The footer's buttons share its width, as in a window. Cards in the body are its sections, flat, with a line between neighbors drawn by UiSeparator. **Kept pages.** In a page that a `KeepAlive` keeps, a panel left open stays open when the page is deactivated: its layer, its Escape and its scroll lock go on over the next page. Close it (`open` false) when the route changes. **Header.** The title is one line, cut when long and shown whole on hover. The line under it is `description`, or `#description` in your own markup: a status mark, an id to copy. `#actions` takes an icon button or two before the cross. Changes at once. A panel whose controls apply as they change (a setting, a filter) has no footer: there is nothing to confirm, and the cross closes it. A footer is for a panel that sends something. A filter that covers the list it narrows is that case: its ticks wait in a draft, the footer's button says what applying will show ("Show 12 invoices"), and closing any other way drops the draft. ##### Keyboard The focus starts on the first field (a drawn select, date, switch, scale, drop zone or date range too), not on the cross. With no field it starts on a body longer than the panel, so the arrows scroll it at once, else on the panel itself. - `Tab`: goes round inside the panel; a body longer than the panel is one of its stops. - `ArrowUp` `ArrowDown` `PageUp` `PageDown` `Home` `End`: scroll that body once it holds the focus. - `Escape`: closes the panel, only while it is the top layer; `persistent` answers it with a shake, and `submitting` refuses it. On close the focus returns to where it was. ##### Screen reader - Window: a modal dialog (`aria-modal`) named by `title` (`aria-labelledby`) and described by the line under it. - Cross: named "Close". - Long body: a region named by the title, so the reader says what the keys scroll through. - Refused close: a shake for the eye; the reader keeps the focus where it was and hears why, from a status region inside the drawer: input not saved, or a request still on its way. ##### States A settings panel that applies at once, with no footer; a read-only detail of rows; a panel with a footer; `persistent` shaking at the backdrop and Escape; a long body that scrolls between the header and the footer; a narrow screen, where the panel takes the whole width. ##### When to use another component - A record to edit or create: UiCrudDrawer, with Save, loading and steps, built on this panel. - A short task for the person's whole attention: UiModal. - A small panel of controls under a button: UiPopover, with the page live. - A second panel over this one: never; what opens from it is a UiModal for a short task, or a step of the same panel. Development warns when a drawer opens over another; a window or a question over it is fine. #### Examples ##### Persistent ```vue ``` ##### Long ```vue ``` #### API Props: - `title`: `string` (required) — The panel's name, read first by a screen reader; required, a dialog without one is mute. - `description`: `string` (default `""`) — One line under the title, read right after it. - `size`: `"md" | "lg"` = `md | lg` (default `"md"`) — The panel's width: `md` 512px, the default; `lg` 768px for what reads wide, a text or a code. - `persistent`: `boolean` (default `false`) — Unsaved input: the backdrop and Escape are refused with a shake; the cross still closes. - `submitting`: `boolean` (default `false`) — A request is out: every close is refused with a shake. - `open`: `boolean` (default `false`) — Whether the panel is open (`v-model:open`); every way out sets it false and emits `close`. Attributes are placed by the component itself: its description says where. Events: - `update:open` (`value: boolean`) — The new value of `open`, for `v-model:open`. - `close` — The panel closed: the cross, the backdrop or Escape. Slots: - `default` with `Record` — The body. It scrolls when long; the header and the footer stay. UiCard sections are parted by a line. - `description` with `Record` — The line under the title in your own markup, in place of `description`: a status mark, an id to copy. One line. - `actions` with `Record` — Icon buttons before the cross: open as a page, copy a link. Ghost UiIconButton. - `footer` with `Record` — Actions, sharing the footer's width in equal parts: the cancel is `secondary` and first, the main action last. Leave it out for a panel that applies at once. #### How it behaves ##### Escape, top first - When: a window or a list is open over the panel. - Props: none: every UiDrawer. - Guarantee: Escape closes the topmost dialog or list while the drawer remains open. A later Escape reaches the drawer, where it goes back a level or closes the drawer at its top level. - Without it: One Escape closes the window and the panel under it, and what you typed in the panel goes with them. - Code: `…` - Your part: Nothing. - Proof, in the repository's tests (UiDrawer.spec.ts, not in the package): "Escape closes only the layer on top: a window over the drawer first" - Pattern for all components: https://grani.arseni.work/patterns/closing#escape-top ##### Focus starts inside - When: the panel opens. - Props: none: every UiDrawer. - Guarantee: Start filling in the form immediately: focus goes to its first field, including a select or switch. The close button does not take focus first. If there are no fields, focus goes to the panel itself. - Without it: Keyboard focus starts on the close button, so the first Enter closes the panel you just opened. - Code: `…` - Your part: Put the field where the task starts first in the panel’s content. - Proof, in the repository's tests (UiDrawer.spec.ts, not in the package): "focus goes to the first field, not the cross; without fields to the panel" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-start ##### Focus comes back - When: the panel closes by Escape, a press on the backdrop, the close button, or `open` set to false. - Props: none: every UiDrawer. - Guarantee: Keyboard focus goes back to what opened the panel, most often a row of the list, so you continue down the list from the row you opened. - Without it: The panel closes with keyboard focus inside it, focus falls to the page, and you look for your place in the list again. - Code: ` …` - Your part: Open the panel from a button or row that will remain on the page when the panel closes. An action in the panel that removes its record closes the panel first, waits a tick (`await nextTick()`), then removes the row: a UiTable then hands keyboard focus to the same control of the row now in its place. - Not covered: A row removed before the panel closes takes the opener with it, and keyboard focus falls to the page. So does a delete asked through UiConfirm with an `action`: the question gives the focus back to the panel’s row, which left with the panel. - Proof, in the repository's tests (UiDrawer.spec.ts, not in the package): "focus returns to the opener on close" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-back The patterns: https://grani.arseni.work/patterns #### Built on it - [Crud drawer](https://grani.arseni.work/components/crud-drawer): Edit a record beside its list and open related lists within the same panel. Built with what `grani` exports: `useUiT`. ### Dropzone (`UiDropzone`) > Add files by dragging or browsing; a message names each file that cannot be added and says why. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/dropzone Also called file upload, file input, drag and drop upload, uploader. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Drop files anywhere on the upload card. - Know what was refused and why. - See upload progress and when it ends. - Avoid adding the same entry twice. - Keep keyboard focus where you are working. - See whether an action worked. - Ignore extra presses while a request runs. - See the value the app actually accepted. - Submit the values shown in the form. - Go straight to the field that needs fixing. - Know what fits when a list has a limit. - Know how to add files from a folder. Implemented by the app: - Data fetching, saving, permissions and business rules. - Set the limits with `accept`, `maxSize` and `maxFiles`; the wording comes from the library. - Bind `busy` and `progress` while the upload runs, and set `progress` to 100 before `busy` turns false: an upload that ends below 100 is not marked done. Set `failed` when it fails, at any percent, and clear it when the next upload starts; handle the `cancel` and `retry` events. - Show a failed upload in the field’s error. - Bind `busy` while the upload runs, and answer a cancel or a Retry by changing `busy` or `failed`. - Pass the files the app holds as `modelValue`. - Give it a `name` where the form is sent natively. - Set `invalid`, or pass the error to its UiField. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Set `maxFiles`. #### Why it works this way A form control for choosing files, by drag and drop or with a Browse button. It lists the chosen files, each with a remove button. **Model.** `v-model` holds the chosen files and is always an array. The file input follows the model, a form reset included. **Limits.** It accepts files by `accept`, `maxSize` (bytes) and `maxFiles`. A refused file is named with the reason, and `reject` is emitted. **Upload.** The consumer uploads the files. Bind `progress` to show the upload and `busy` to block changes meanwhile. The rows not yet uploaded carry it: a spinner in the glyph's place, the percent in the size's, and the row filling behind its words with a quiet plate; with no percent the spinner turns and nothing fills. Browse holds, refusing, and the zone keeps its height. With no row to carry it, a bar runs along the bottom of the zone. The cross of an uploading row emits `cancel`: one upload runs for all the files it carries, so the app stops it and ends `busy`. An upload that ends at 100 with no `failed` marks its files uploaded and is announced, with the number it delivered; a later upload carries only the files not yet uploaded. A cancel the app has answered is announced too. A failure is `failed`, once `busy` ends, at any percent, 100 included: the rows the upload carried and did not deliver take an alert glyph, the reason (the string given, or Not uploaded) and Retry, which emits `retry`, and give up their size to make room; a folded list opens on them. A file added after it is a new file. Cancel and Retry ask once, until the app answers by changing `busy` or `failed`. **Field.** Inside a UiField it reads id, error and required; standalone it takes `invalid` and `ariaLabel`. **Attributes.** Listeners and `aria-*` land on the Browse button, other attributes on the file input that the form submits, `class` and `style` on the root. A native `required` keeps a form from sending with no file: Browse says it in words, and the form's check puts the focus on Browse, turns the zone invalid and says why under it, until a file comes. **Page drops.** While it is mounted, a file dropped anywhere on the page outside a drop target does not open in the browser. **Placement.** In a form column under a UiField label, beside UiInput and UiTextarea, on a page, in a window or in a drawer's section. ##### Keyboard - `Tab`: reaches Browse, a native button; the file input itself is out of the tab order. - `Enter` `Space`: on Browse, open the file dialog. Each listed file's cross is a button. A remove from the keyboard keeps the focus inside the card: on the row now in its place, the one above, or Browse. A folded list opens on its row, which is a button too. Dropping is for the pointer. `disabled` and `busy` block Browse and the remove crosses, and Browse keeps the focus while busy; an uploading row's cross cancels the upload, and a failed row's Retry is a button. ##### Screen reader - Browse: named by `ariaLabel` or the field's label, then by its own word; the file input under it is hidden from a screen reader. - Description: the hint, the field's words, a required field (in words, since a button takes no `aria-required`) and the last refusal describe Browse; `invalid` sets `aria-invalid` on it. - Status: a status region, in the DOM from the start, says what was added, removed and uploaded, that an upload is running, and why it failed. - Refusal: an alert that names the files. - Files: a list; an uploaded row says so in words, an uploading row's percent is a progress named by its file, and a failed row reads its reason after its name. - Dragging: dragging over changes the words, not only the color. ##### States Rest, dragging over (the zone lights up and the words change), busy (each pending row a spinner, a filling plate, the percent and a cross that cancels; no fill while the percent is unknown; the layout kept), uploaded (a check on each row and on the folded line, no cross unless `removable` allows it), failed (an alert glyph, the reason with no size, Retry and the remove cross on each row the upload left), refused (the reason under the list, by name), with files (a row per file, folded past three with `multiple`), invalid (a red dashed border), and disabled (the plate, an inert button, no drop). ##### When to use another component - Names typed or pasted one per line: UiListField. - A typed value: UiInput. - Files already on a record: UiListItem rows in a UiList, listed without choosing. #### Examples ##### Multiple ```vue ``` ##### Upload ```vue ``` ##### Many ```vue ``` ##### States ```vue ``` ##### Refused ```vue ``` ##### Form ```vue ``` ##### Drawer ```vue ``` #### API Props: - `modelValue`: `File[]` (required) — The chosen files (`v-model`); always an array, one entry at most without `multiple`. - `accept`: `string` — Accepted file types, native `accept` syntax: `image/*`, `.pdf,.docx`, `application/pdf`. - `multiple`: `boolean` (default `false`) — Allows choosing several files; without it a new file replaces the old one. - `maxFiles`: `number` — Maximum number of files, with `multiple`; ignored without it. - `maxSize`: `number` — Maximum file size in bytes; also said under the main line unless `#hint` is given. - `disabled`: `boolean` (default `false`) — Prevents choosing, dropping and removing. - `invalid`: `boolean` (default `false`) — Marks the control as invalid. Inside UiField, the error state sets this automatically. - `name`: `string` — Native name of the file input: a plain form submit sends the listed files. - `id`: `string` — Own id when standalone. Inside UiField, the field's id takes precedence. - `ariaLabel`: `string` — Name when there is no visible label; the browse button reads it followed by its own words, "Choose a file" or "Choose files". - `progress`: `number | null` (default `null`) — Upload progress 0 to 100 shown with `busy`; `null` when unknown. The files listed when it reaches 100 are marked uploaded. - `busy`: `boolean` (default `false`) — Work in progress: the rows not yet uploaded carry it, choosing and removing wait until it ends. - `failed`: `string | boolean` (default `false`) — The last upload failed: the rows not uploaded say so, with this reason when it is a string, and offer Retry. - `removable`: `boolean | "auto"` = `false | true | auto` (default `"auto"`) — Whether the files can be removed. `auto`: yes, except the ones a finished upload marked. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`files: File[]`) — The chosen files changed: added, replaced or removed. - `retry` — Retry was pressed on a row a failed upload left: start the upload again. - `reject` (`rejections: FileRejection[]`) — Files refused on choose or drop, with the reason each (`FileRejection` from `types/ui`). - `cancel` — The cross of an uploading row was pressed: stop the upload and end `busy`. Slots: - `default` with `Record` — The main line: what to drop here. - `hint` with `Record` — The second line: the constraints, by default the size limit. #### How it behaves ##### Drop anywhere - When: a file is dragged over the card. - Props: none. - Guarantee: The whole card accepts drops, including its file rows. A file dropped outside the card and any other drop target does not open in the tab. The card highlights only for dragged files, not for dragged text. - Without it: A drop a few pixels past the dashed line opens the file in the tab, and the form is gone. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "a drop anywhere on the card lands, not only on the dashed field", "a file dropped outside the card does not reach the browser default", "dragging text over the zone does not light it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#whole-zone-is-target ##### Refusal named - When: files are refused. - Props: `accept`, `maxSize`, `maxFiles`. - Guarantee: You can tell which files need attention: each rejection reason names up to three affected files and counts any others. A batch with unsupported types and oversized files gets a sentence for each reason, announced as an alert by a screen reader. - Without it: Some files are missing from the list, with no word about which or why. - Code: `` - Your part: Set the limits with `accept`, `maxSize` and `maxFiles`; the wording comes from the library. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "a refusal names the files it refused, three and then how many more", "every reason of a mixed batch is told" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#refused-by-name ##### Upload shown - When: the app uploads the files. - Props: `busy`, `progress`, `failed`. - Guarantee: Every file not yet uploaded shows the one `progress` you give: a percentage in place of its size and a bar behind its name. The percentage is capped at 100, and the card keeps its layout when the upload starts. When `busy` turns false with `progress` at 100, the files get check marks and a screen reader announces how many were uploaded. If the upload fails, each file left behind shows the reason you give in `failed`, or “Not uploaded”, with Retry, and a screen reader announces the reason once the upload stops. - Without it: A spinner somewhere on the page, and no sign of which file is uploading, whether it has finished or which files a failure left behind. - Code: `` - Your part: Bind `busy` and `progress` while the upload runs, and set `progress` to 100 before `busy` turns false: an upload that ends below 100 is not marked done. Set `failed` when it fails, at any percent, and clear it when the next upload starts; handle the `cancel` and `retry` events. - Not covered: One percentage for the whole upload: a file does not show progress of its own. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "progress out of range is clamped in the bar, the number and aria-valuenow", "the zone keeps one layout when busy turns on: Browse holds, the bar runs along the foot", "a finished upload is said in the status region", "an upload that ends below 100 is not marked done or said", "a failed upload marks the files it left with the reason and a Retry", "a failure is said in the status region once the upload stops" - Pattern for all components: https://grani.arseni.work/patterns/waiting#upload-shown ##### No duplicates - When: the same file is dropped or chosen again. - Props: `multiple`. - Guarantee: A file already listed, with the same name, size and date, is refused and named as already in the list; the list keeps one copy, and `reject` reports it with the reason `duplicate`. - Without it: The same invoice listed twice and uploaded twice. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "the same file twice is refused as a duplicate" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#no-twice ##### Focus stays near - When: A file is removed from the keyboard. - Props: `multiple`. - Guarantee: Keep removing files from the keyboard: focus moves to the next file’s remove button, then the previous one if needed, or Choose files when no suitable file remains. Files without a remove button, such as completed uploads, are skipped. - Without it: Keyboard focus is lost with the removed file, and you have to tab back into the drop zone. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "removing a file with the keyboard keeps the focus inside the card", "removing skips a locked row: the focus lands on the next removable one" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-stays ##### Outcome shown - When: files are added, removed or uploaded. - Props: none. - Guarantee: A screen reader announces what was added, removed or uploaded, such as “a.pdf added” or “2 files uploaded”, and a refused file is announced as an alert with its reason. - Without it: The list changes without a word to a screen reader. - Code: `` - Your part: Show a failed upload in the field’s error. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "added and removed files are said in the status region", "a finished upload is said in the status region" - Pattern for all components: https://grani.arseni.work/patterns/waiting#answered ##### One press, one request - When: an upload is running. - Props: `busy`. - Guarantee: Browse ignores presses but keeps keyboard focus, dropped files are ignored, and removing a file waits until the upload ends. The cross that cancels the upload and Retry after a failure each ask once, until the app answers by changing `busy` or `failed`. - Without it: A second file can be dropped into an upload that is half done. - Code: `` - Your part: Bind `busy` while the upload runs, and answer a cancel or a Retry by changing `busy` or `failed`. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "busy: the row carries the upload, Browse refuses but holds, the drop is ignored, removing waits", "Browse keeps the focus while busy", "two quick presses on cancel or Retry ask once, until the app answers" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Back to the value - When: the app refuses a change, or the form is reset. - Props: `v-model`, or `modelValue` with `@update:model-value`. - Guarantee: If the app refuses the files you drop or pick, the file list and the files the form would send stay as they were. After a form reset, both still match the app’s files. - Without it: The list shows a file the app did not accept. - Code: `` - Your part: Pass the files the app holds as `modelValue`. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "a change the parent refuses leaves the list and the input on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: The submitted files match the list shown in the component, including after a form reset: a native file input behind the card tracks the app’s file list and submits it under the field’s `name`. - Without it: A file list on screen that the form never sends. - Code: `` - Your part: Give it a `name` where the form is sent natively. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "in a form it sends the files under its name", "a form reset leaves the native input on its model" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Focus on the error - When: a required file is missing on submit. - Props: `invalid`, or the error of its UiField. - Guarantee: When the dropzone is the first field with an error, keyboard focus lands on its Browse button, and the zone shows a red dashed border, so you can open the file picker straight away. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `` - Your part: Set `invalid`, or pass the error to its UiField. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "a dropzone marked invalid is what focusInvalid lands on" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Up to the limit - When: files would go past `maxFiles`. - Props: `multiple`, `maxFiles`. - Guarantee: The files that fit are added; the rest are refused and named with the limit, such as “c.pdf: up to 2 files.”, and `reject` reports them with the reason `count`. - Without it: A drop of three files into a list that allows two takes all three, and the error comes only when the form is saved. - Code: `` - Your part: Set `maxFiles`. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "refuses files beyond maxFiles and keeps the ones that fit" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#nearly-full ##### Folder refused - When: a folder is dropped. - Props: none. - Guarantee: A dropped folder is rejected with an explanation, even when the browser provides no file for it. Files dropped alongside it are processed separately and added if accepted. - Without it: A folder taken as an empty file of 0 bytes. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiDropzone.spec.ts, not in the package): "a dropped folder is refused with a reason", "a folder that gives no file handle is still refused as a folder" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#folder-refused The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Field (`UiField`) > Give a control its label, hint and error message, connected so a screen reader reads them with it. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/field Also called form field, form group, label with hint and error. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Show the error where the hint was. - Announce required fields in words. - Name each field once for screen readers. - Open a field from its label. - Go straight to the field that needs fixing. Implemented by the app: - Data fetching, saving, permissions and business rules. - Write the error as an instruction for fixing the value, about as long as the hint. - Set `required` on the field instead of typing an asterisk into the label. - Keep one control per field; put a row of controls in a field with `group`. - Pass the error in `error`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. #### Why it works this way The words around one form control: a label above, a description or an error below. Take it around a control that needs a label, and with `group` around a group of them. **Wiring.** It connects the words to the control by id, `aria-describedby`, `aria-invalid` and `aria-required`. Grani's own controls read it by themselves; a bare element takes the slot props. **Groups.** One field holds one control. A group of controls (radios, checkboxes, a UiButtonGroup row) takes `group`: the field becomes a fieldset named by its legend. A bare element in a group takes `labelId` for its `aria-labelledby`. **Required.** Required shows a star. For a screen reader it is `aria-required` on a single control, or a word in the legend of a group, never on each control in it. **Disabled.** The label mutes when the control inside is disabled. The field judges by the control, not by a button in `#meta`. **Placement.** In a form column, a window or a drawer body. ##### Keyboard No keys of its own: Tab reaches the control inside it. The label is a native label: a click on it presses the control, as it puts the cursor in an input, so a select opens its list and a dropzone its file dialog. ##### Screen reader - Label: names the control, through `for` and the id, or through `labelId` for a control that cannot take a label. - Description: the description, the error and `#meta` are read with the control (`aria-describedby`). - Error: announced when it appears (`role="alert"`); it marks the control `aria-invalid`. - Required: `required` is `aria-required` on a single control. - Group: a `group` is a fieldset named by its legend, which says "required" once, in words, and never on each control. ##### States Rest, required (a star), with a description, in error (the error replaces the description; the label keeps its ink), disabled (the label mutes with the control), and `group`. ##### When to use another component - Words on the left and the control on the right: UiListItem `as="label"`. - A control that carries its own words: UiCheckbox, UiRadio or a UiSwitch with text keeps them in its slot. - A title over a section of fields: UiHeader `legend`. #### Examples ##### Required ```vue ``` ##### Error ```vue ``` ##### Group ```vue ``` ##### Disabled ```vue ``` ##### Meta ```vue ``` #### API Props: - `label`: `string` — Visible label above the control. - `description`: `string` — Supporting text below the control: a format, a limit, a unit. Hidden while `error` is present. - `error`: `string` — Error text below the control, one string; it marks the control invalid. Empty means valid. Read out when it appears. - `required`: `boolean` (default `false`) — An asterisk after the label and `aria-required` on the control; in a group the legend says it once. - `for`: `string` — Id of the control the label points to; the description and error ids derive from it. Generated when omitted. - `group`: `boolean` (default `false`) — The control is a group of controls (radios, checkboxes): the field is a `fieldset`, the label its `legend`, and the group takes `labelId` for `aria-labelledby`. No `#meta`. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `default` with `{ id?: string | undefined; labelId?: string | undefined; describedBy?: string | undefined; invalid: boolean; required: boolean; }` — The control. A grani control connects through `useField`; a bare element takes the slot props. - `meta` with `Record` — Content at the right end of the label row, aligned with the label: a counter, a hint icon. Read with the control as a description. #### How it behaves ##### Error replaces hint - When: an error appears under a field that has a description. - Props: `description`, `error`. - Guarantee: The error takes the hint’s place, so you can find the correction where you were already reading the instructions. If it fits in the same space, the fields below do not move. A screen reader announces the error and the control’s invalid state. The label keeps its usual color, and the hint and its connection to the control return when the error clears. - Without it: The error stacks under the hint, the field grows, and the next field jumps down under the pointer. - Code: `…` - Your part: Write the error as an instruction for fixing the value, about as long as the hint. - Proof, in the repository's tests (UiField.spec.ts, not in the package): "an error is announced, marks the control invalid and replaces the description; the label keeps its ink", "when the error clears the description returns and the wiring follows" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#error-takes-the-hints-place ##### Required in words - When: a field must be filled. - Props: `required`. - Guarantee: The label shows an asterisk, and `aria-required` identifies the requirement to a screen reader. In a `group`, “required” is included once with the group’s label. For a dropzone, it is included in the Browse button’s description. - Without it: The asterisk is visible, but a screen reader does not announce it, so some people discover the requirement only when the form is rejected. - Code: `…` - Your part: Set `required` on the field instead of typing an asterisk into the label. - Proof, in the repository's tests (UiField.spec.ts, not in the package): "required puts an asterisk on the label and aria-required on the control", "a required group of radios hears "required" once, from the legend", "a required field around a dropzone says required in words" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#required-in-words ##### One name - When: it wraps any control. - Props: `label`. - Guarantee: A screen reader identifies the control by its label and reads its hint or error with it. The field connects the label to the control through its id, or through `labelId` when the control cannot use a native label. Two controls in one field trigger a development warning. - Without it: A control announced twice, or with no name, and its hint never announced. - Code: `` - Your part: Keep one control per field; put a row of controls in a field with `group`. - Proof, in the repository's tests (UiField.spec.ts, not in the package): "labels the control through a generated id", "warns in dev when a field holds two controls", "a bare element in a group field takes labelId for aria-labelledby and gets no id", "a required group of input and select marks the legend once, not each control" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice ##### Label opens - When: it holds a single control. - Props: none: any UiField. - Guarantee: A click on the label presses the control, as it puts the cursor in a text field: a select opens its list and a dropzone its file dialog. It holds for the control the label points at by its id. - Without it: A label set above the control by hand and not tied to it: a click on it does nothing, and the list opens only from the select itself. - Code: `` - Your part: Nothing. - Not covered: A radio group and a scale take a `group` field, whose legend presses nothing: each option takes its own click. - Proof, in the repository's tests (UiField.spec.ts, not in the package): "the label resolves to the control, so a click on it focuses the control", "a click on the label of a select opens its list, as a click on the select does", "a click on the label of a dropzone opens the file dialog, as Browse does" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#label-opens ##### Focus on the error - When: a form is sent with this field in error. - Props: `error`. - Guarantee: The control is marked invalid so the form can find it, bring it into view and move focus to it. In a `group` field, focus goes to the selected radio option, or the first if none is selected. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `…` - Your part: Pass the error in `error`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiField.spec.ts, not in the package): "a group field in error: focusInvalid lands on a radio inside the group" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Filter (`UiFilter`) > Open filter choices from a toolbar button and narrow a list by one or more groups of options. A component of grani 0.1.0. Data, stable, since 0.1.0. Page: https://grani.arseni.work/components/filter Also called filter dropdown, faceted filter, filter button. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Keep one option and clear the rest in one press. - Find what you chose at the top of a long filter. - Keep Reset in one place. - Keep nearby buttons and fields in place. - Clear a search in one step. - Jump to an option by letter. - Continue from where you opened the panel. - See where the app is working. - Pick with Enter without sending the form. - Open with keyboard focus where the task begins. - Pass a group with one press of Tab. Implemented by the app: - Data fetching, saving, permissions and business rules. - With one dimension, nothing. With several sections, pass `count` and clear the sections’ lists when `reset` arrives. - Set `loading` while the options are loading; with several sections, set it on each UiFilterList. If the options failed to load, set `disabled` and explain the failure on the page. #### Why it works this way A toolbar button that narrows a list by one dimension, or by several at once. Take it in a table toolbar, after the search. **Button.** It shows a filter glyph, its label and a chevron. Once something is chosen, the count replaces the glyph in a slot of the same width, on a pale plate (up to 9, then "9+"), so the button keeps its size. Attributes land on the button. **Panel.** The panel under the button is a UiPopover. Reset stands in its footer under a line and is held while nothing is chosen, so the list above does not move under the pointer when the first box is ticked. **One dimension.** Pass `items` and `v-model`; the filter counts and resets itself. A value counts once, however often `v-model` holds it. A value with no option among `items` (an option the server dropped, an old link) still counts and still filters: no row shows it, and Reset clears it. **Several dimensions.** Put UiCard sections in the default slot, each with a UiFilterList and no title (a popover has no heading), and name each list by its `ariaLabel`; a line parts two neighbors, as in every overlay. The slot is read once, when the filter is made: the sections may change, but whether there are any may not. The lists mount only while the panel is open, so the host does the rest: it passes `count`, the number of dimensions with a value (status and channel make 2), holds each list's `loading`, and clears the lists on `reset`. **Placement.** In a table toolbar's `#start` (UiDataTable `#filters`), after the search and beside UiDateRange. Bind the slot's `size` on it. **Table.** In UiDataTable's `#filters`, a choice the person changes (a tick, Only, Reset) starts the list on page one, and an empty list the filter narrowed offers "Reset filters"; a value the app sets moves no page. Options that failed to load. The filter has no error of its own: the host sets `disabled` on it, so the button does not open, and says what failed where the list's own error stands. ##### Keyboard On the button: - `Tab`: reaches it. - `Enter` `Space`: open the panel; the focus goes to the list, or to its search when there are six options or more. In the panel, where the list is one tab stop and Reset the next; from six options up the search takes the list's tab stop, and its cross is one more once it holds text: - `ArrowDown` `ArrowUp`: move the highlight. - `Home` `End`: jump. - `Enter` `Space`: tick. - `A–Z`: jumps to the first option that starts with the letter. - `Enter`: in the search, ticks the highlighted row (the first match once there is text) and does not submit a form around the filter. - `Escape`: clears a search that has text and stays in the panel; otherwise closes the panel and returns the focus to the button, as a pick and close does. ##### Screen reader - Button: the label, "Categories", and, once something is chosen, the count in words after it (", 2 active"; the digit on the badge is hidden), with `aria-haspopup` and `aria-expanded`. An `aria-label` of the caller's replaces the whole name, the count included. - Panel: a dialog named by the button, the count in words included. - List: one multi-select listbox named by the label, each option saying whether it is ticked; with sections, each list is named by its `ariaLabel`. - Nothing found: a status region that is always present and is filled when the search leaves nothing. ##### States Rest, open, something chosen (the count in place of the glyph), Reset held while nothing is chosen, `loading`: a first load in the options' shape, a refresh with the rows held and dimmed; a search from six options and its empty result, several dimensions in sections; the button at `xs`, `sm` and `md`. ##### When to use another component - A period: UiDateRange. - The columns a table shows: UiTableColumns. - One value in a form: UiSelect (`multiple` for several). - Commands: UiMenu. #### Examples ##### Many options: a search ```vue ``` ##### Sizes ```vue ``` ##### Loading ```vue ``` ##### Several dimensions ```vue ``` #### API Props: - `label`: `string` (required) — The dimension, on the button: "Categories". - `items`: `SelectOption[]` (default `[]`) — One dimension: its options, as a select's (`SelectOption`). - `modelValue`: `SelectValue[]` (default `[]`) — One dimension: the chosen values. - `count`: `number` — Several dimensions: how many of them have a value (status and channel is 2), for the button and Reset. Required with sections: without it the button shows nothing chosen and Reset stays held. - `loading`: `boolean` (default `false`) — One dimension: the options are on their way. With none yet, rows in their shape; with options in view, they stay, held and dimmed. Several: each list takes its own. - `align`: `"start" | "center" | "end"` = `start | center | end` (default `"start"`) — Which edge of the button the panel lines up with. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder, as the rest of the toolbar row. Unset, the app's size (`createGrani({ size })`), `sm` by default. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `reset` — Reset was pressed; one dimension clears itself as well. - `update:modelValue` (`value: SelectValue[]`) — One dimension: the chosen values. Slots: - `default` with `Record` — Several dimensions: UiCard sections without a title, each with a UiFilterList named by `ariaLabel`. Leave empty for one. #### How it behaves ##### Only this - When: a filter of three options or more is open. - Props: `items`, `v-model`. - Guarantee: The highlighted row shows Only at its end; it, or Alt+Enter, keeps that option alone and unticks the others. A fixed option stays as it is. - Without it: Unticking every other box, one by one, to see a single value. - Code: `` - Your part: Nothing. - Not covered: A filter of one or two options offers no Only: a single tick does the same. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "Only on the highlighted row keeps that option alone" - Pattern for all components: https://grani.arseni.work/patterns/finding#only-this ##### Chosen first - When: the panel of a filter of six options or more opens with something chosen. - Props: `items`, `v-model`. - Guarantee: The chosen options stand first, in their own order, and the rest follow; the order is the one the panel opened with, so nothing moves while you tick. - Without it: The ticks of a long filter scattered down the list, below its fold. - Code: `` - Your part: Nothing. - Not covered: Five options or fewer keep their order, which is read at a glance and may mean something, such as Paid, Due, Overdue. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "a long filter opens with the chosen options at its top" - Pattern for all components: https://grani.arseni.work/patterns/finding#chosen-first ##### Reset held - When: the panel is open and you tick its first box. - Props: none. - Guarantee: Reset is present below the divider from the moment the panel opens. It stays disabled until an option is selected, then becomes available without moving any rows. - Without it: A Clear link that appears above the list at the first tick, so the rows move down under the pointer. - Code: `` - Your part: With one dimension, nothing. With several sections, pass `count` and clear the sections’ lists when `reset` arrives. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "Reset is in the same place before and after the first tick, held while nothing is chosen" - Pattern for all components: https://grani.arseni.work/patterns/finding#reset-held ##### Steady width - When: something is chosen in it. - Props: none. - Guarantee: The count replaces the filter icon in a slot of fixed width, up to 9 and then “9+”, so the button and everything beside it in the toolbar stay in place. - Without it: A count added after the label: the button grows at the first tick and pushes the period filter beside it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "the funnel and the count share one fixed slot, so the width holds", "a count above 9 reads 9+ and is spoken exactly" - Pattern for all components: https://grani.arseni.work/patterns/finding#steady-width ##### Search clears - When: it has six options or more, and so a search field. - Props: none. - Guarantee: Escape in a search with text empties it and keeps the panel open, with keyboard focus still in the search; the next Escape closes the panel. - Without it: A plain field in a popover, where the Escape meant for the text closes the whole panel. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "in the panel a search with text clears on Escape, and the panel stays open" - Pattern for all components: https://grani.arseni.work/patterns/finding#search-clears ##### Letter jumps - When: its list has no search: five options or fewer. - Props: none. - Guarantee: A letter moves the highlight to the first option that starts with it; the same letter again goes on to the next one, and from the last match back to the first. Enter or Space then ticks the highlighted option. - Without it: A column of checkboxes where a letter does nothing, so you press the Down arrow once for each option above the one you want. - Code: `` - Your part: Nothing. - Not covered: From six options the list gets a search field, and letters type into the search instead of jumping. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "a letter jumps to the next option that starts with it" - Pattern for all components: https://grani.arseni.work/patterns/finding#letter-jumps ##### Focus comes back - When: you close the filter’s panel with Escape. - Props: none. - Guarantee: Keyboard focus goes back to the filter button, so the next Tab continues along the toolbar from there. - Without it: Keyboard focus is lost with the closed panel: it falls to the page, a screen reader loses its place, and you look for the button again. - Code: `` - Your part: Nothing. - Not covered: If the panel is closed by a click outside it, keyboard focus stays where the click put it. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "Escape closes the panel and returns the focus to the button" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-back ##### Visible work - When: the options of a single-group filter are loading. - Props: `loading`. - Guarantee: Loading is shown in the options panel: placeholders on a first load, and during a refresh the existing options dimmed and unavailable, with “Updating…”. - Without it: The panel says “Nothing found” while the options are still on their way. - Code: `` - Your part: Set `loading` while the options are loading; with several sections, set it on each UiFilterList. If the options failed to load, set `disabled` and explain the failure on the page. - Not covered: The filter button itself shows no progress indicator: the wait is shown in the panel, where the options are. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "a first load shows rows in the options' shape; a refresh keeps the options, dimmed, and says "Updating…"" - Pattern for all components: https://grani.arseni.work/patterns/waiting#visible-work ##### Enter picks - When: The filter is inside a form and has a search box. - Props: none. - Guarantee: Enter in the search box ticks the first match and never submits the form around the filter. - Without it: In a search box inside a form, Enter submits the form before anything is ticked. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "Enter in the search does not submit a form around the filter" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#enter-picks ##### Focus starts inside - When: you open the filter’s panel. - Props: none. - Guarantee: Start choosing with the arrow keys as soon as the panel opens. With six or more options, focus starts in the search field so you can narrow the list by typing. - Without it: The panel opens with keyboard focus left on the filter button, and you press Tab before you can choose. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "opened, the focus goes to the list, or to its search from six options up" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-start ##### One Tab per group - When: the filter’s panel is open. - Props: none. - Guarantee: Tab reaches the options as one group, or their search field when present. Reset is the next stop when available, so the number of options does not add more Tab presses. - Without it: Each option is its own checkbox, so twelve categories take twelve presses of Tab before you reach Reset. - Code: `` - Your part: Nothing. - Not covered: Once the search holds text, its clear button (the cross) appears and adds one more press of Tab. - Proof, in the repository's tests (UiFilter.spec.ts, not in the package): "the list is one tab stop, and Reset the next" - Pattern for all components: https://grani.arseni.work/patterns/opening#one-stop The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`, `useUiT`. ### Filter list (`UiFilterList`) > Narrow results by ticking options that apply at once; a search field appears when there are six or more. A component of grani 0.1.0. Data, stable, since 0.1.0. Page: https://grani.arseni.work/components/filter-list Also called checkbox list, multi-select list, option list. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Finish a word in any script before it is sent. - See where the app is working. - Clear a search in one step. - Notice when there are more options below. - Jump to an option by letter. - Pick with Enter without sending the form. - Open with keyboard focus where the task begins. - Keep keyboard focus where you are working. - Pass a group with one press of Tab. Implemented by the app: - Data fetching, saving, permissions and business rules. - Set `loading` while the options are loading. - Place the list inside a UiPopover, UiFilter or UiTableColumns. #### Why it works this way The checklist of one filter dimension, where each tick applies at once. Take it inside the panel of a UiFilter or UiTableColumns. **Behavior.** It follows UiSelect with `multiple`: a listbox of check-mark rows that runs edge to edge across the panel. **Search.** With more than five options, a search row at the top narrows the list, by label and by an option's `keywords`, and says when nothing is found; it leaves the accents of Latin and Cyrillic letters out, so "munchen" finds München. Its cross clears the text. When the options drop to five or fewer the search leaves with its text, and the focus it held goes to the list; when they grow to six the focus the list held goes into the new search. **Chosen first.** From six options, the ones chosen when the list opens stand at its top, in their own order, so a long list shows what is chosen without scrolling; the order stays while the list is open, so a tick never moves a row under the pointer. Five or fewer keep their order, which is read at a glance and may mean something. With `groups`, a name stands over the chosen and over the rest, a line between them, so the order explains itself. **Only.** The highlighted row (the one under the pointer, the one a tap ticked, the one the keys lit) offers "Only", which keeps that option alone and unticks the others; a fixed option stays as it is. A list of one or two options offers none: a single tick does the same. **Options.** `loading` with no options yet draws five rows in their shape, a box and a line; a list of up to five takes their place, a longer one adds its search above them, since the count is not known before they come. With options in view it is a refresh: they stay, held and dimmed. An option can be `disabled` (shown, but fixed as it is) and can carry a `description` at its end. The highlight stays on a row that is drawn when the options change. The chosen values should be among the options: a value with no option has no row. **Attributes.** `class` and `style` land on the root, other attributes on the listbox. **Placement.** In the panel of a UiPopover (UiFilter, UiTableColumns), alone or as a flat UiCard section. It needs a name: `ariaLabel`, or the title of its card; in development an unnamed list warns. ##### Keyboard The list is one tab stop, or its search is when it has one. In the list: - `ArrowDown` `ArrowUp`: move the highlight. - `Home` `End`: jump. - `Enter` `Space`: tick the highlighted row; a `Space` after letters typed over the list belongs to the word. - `Alt` `Enter`: keep the highlighted option alone, unticking the others. - `A–Z`: jumps to the first option that starts with the letter; the same letter again goes on to the next, and letters typed together find a word. In the search: - `ArrowDown` `ArrowUp`: move the highlight too. - `Enter`: ticks the first match. - `Alt` `Enter`: keeps the highlighted option alone. - `Escape`: clears the text and stays in the panel. - `Space` `A–Z`: type. A press on a row ticks it without taking the focus. ##### Screen reader - List: a multi-select listbox (`aria-multiselectable`) named by `ariaLabel`, or by the title of the UiCard it stands in. - Row: an option that says whether it is ticked (`aria-selected`), is held when `disabled` or `loading` (`aria-disabled`), and has its `description` read at its end. - Highlight: `aria-activedescendant`, on the list or on the search, which controls the list (`aria-controls`). - Only: the list and the search carry `aria-keyshortcuts="Alt+Enter"`; the word on the row is for the pointer and hidden. - Nothing found: read from a status region that is always present and is filled when the search leaves no row. - Loading: `aria-busy` on the root; the first load is said in the status region ("Loading options…"), its rows in the options' shape hidden. ##### States Rest, a highlighted row, ticked rows, a `disabled` row with its note, a first load in the options' shape, a refresh with every row held and dimmed, a search from six options, a narrowed list and its empty result; a list cut at five and a half rows that scrolls inside itself. ##### When to use another component - The button and the panel around it: UiFilter or UiTableColumns. - One value: UiSelect. - A box or two in a form: UiCheckbox. - A list of names that grows: UiListField. #### Examples ##### Search ```vue ``` ##### Fixed ```vue ``` ##### Loading ```vue ``` #### API Props: - `items`: `SelectOption[]` (required) — The options, as a select's: a value (as in the URL) and a label; `disabled` shows one fixed as it is, `description` puts a word at its end. - `modelValue`: `SelectValue[]` (required) — The chosen values. - `loading`: `boolean` (default `false`) — The options are being replaced: the rows are held. - `ariaLabel`: `string` (default `""`) — A name for the list; inside a UiCard with a title the title names it. - `groups`: `{ chosen: string; rest: string; }` — From six options, where the chosen stand first: a name over them and over the rest, a line between. Unset, they stand first without names. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: SelectValue[]`) — The chosen values after a tick. #### How it behaves ##### Words in the making - When: its search is typed through an input method. - Props: more than five options. - Guarantee: The Enter that picks the word ticks no row, the late Enter Safari sends included. - Without it: A row ticked by the key that only meant to finish a word. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiFilterList.spec.ts, not in the package): "an Enter that confirms an IME composition in the search field does not toggle the active row", "an Enter Safari sends to confirm a composition (keyCode 229) ticks nothing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#word-as-composed ##### Visible work - When: its options are loading. - Props: `loading`. - Guarantee: On the first load, five placeholder rows show that options are still arriving. During a refresh, the existing options stay visible but dimmed and unavailable, with the message “Updating…”. - Without it: An empty panel says “Nothing found” while the options are still on their way. - Code: `` - Your part: Set `loading` while the options are loading. - Not covered: A list of more than five options adds its search field above the rows only once they arrive, because their number is not known before. - Proof, in the repository's tests (UiFilterList.spec.ts, not in the package): "a refresh keeps the options in view, dimmed and busy, with no rows of skeleton", "a first load draws rows in the options' shape and says it loads, never "Nothing found"" - Pattern for all components: https://grani.arseni.work/patterns/waiting#visible-work ##### Search clears - When: it has six options or more, and so a search field. - Props: none. - Guarantee: The cross or Escape empties a search that has text and keeps keyboard focus in it. That Escape stops there, so the popover around the list stays open; the next Escape reaches the popover. - Without it: A search field in a popover where Escape closes the whole panel instead of clearing the text. - Code: `` - Your part: Nothing. Do not add a clear button of your own. - Proof, in the repository's tests (UiFilterList.spec.ts, not in the package): "the search field clears its own text on Escape, keeping the popover open, before Escape can close it", "the cross empties the search and keeps the focus in it" - Pattern for all components: https://grani.arseni.work/patterns/finding#search-clears ##### Five and a half rows - When: there are many options. - Props: none. - Guarantee: The list is cut at five and a half rows, so the half row shows there is more below. When you move with the arrow keys, the highlighted row is scrolled into view inside the list. - Without it: A list cut at a row edge that looks finished. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiFilterList.spec.ts, not in the package): "a long list is cut at five rows and a half" - Pattern for all components: https://grani.arseni.work/patterns/finding#five-rows ##### Letter jumps - When: the list has no search. - Props: five options or fewer. - Guarantee: A letter moves the highlight to the first option that starts with it; the same letter again goes on to the next one, and from the last match back to the first. Letters typed together find a word, its spaces too. Enter or Space then ticks the highlighted option. - Without it: A column of checkboxes where a letter does nothing, so you press the Down arrow once for each option above the one you want. - Code: `` - Your part: Nothing. - Not covered: From six options the list gets a search field, and letters type into the search instead of jumping. - Proof, in the repository's tests (UiFilterList.spec.ts, not in the package): "keyboard as in a select: arrows move, Home and End jump, Enter and Space tick, a letter jumps", "the same letter pressed again goes on to the next option that starts with it", "a Space after letters typed over the list belongs to the word: New J reaches New Jersey" - Pattern for all components: https://grani.arseni.work/patterns/finding#letter-jumps ##### Enter picks - When: The filter list is inside a form. - Props: none. - Guarantee: Enter ticks the highlighted option, in the list or from its search box, and never submits the form around it. - Without it: Pressing Enter to tick an option submits the half-filled form. - Code: `
` - Your part: Nothing. - Proof, in the repository's tests (UiFilterList.spec.ts, not in the package): "an Enter inside a form ticks and never submits it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#enter-picks ##### Focus starts inside - When: it opens in a panel with six options or more. - Props: none. - Guarantee: Start typing to narrow the options as soon as the panel opens: keyboard focus goes straight to the list’s search field. - Without it: The panel opens with keyboard focus left on the button that opened it, and you press Tab before you can type a search. - Code: `` - Your part: Place the list inside a UiPopover, UiFilter or UiTableColumns. - Proof, in the repository's tests (UiFilterList.spec.ts, not in the package): "opened in a popover, a list of six and more puts the focus in its search" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-start ##### Focus stays near - When: The number of options crosses six, so the search box appears or disappears. - Props: none. - Guarantee: When the list reaches six options, a search field appears; if the list had keyboard focus, focus moves into the search so you can start typing. If the count falls below six while the search has focus, the search text clears and focus returns to the list. The arrow keys remain available for choosing options. - Without it: The search box is removed while it has keyboard focus, focus leaves the panel, and you have to find the list again. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiFilterList.spec.ts, not in the package): "when the options drop under six while the search holds text and focus, the text goes and the focus stays in the list", "options that arrive with six or more while the list holds the focus hand the focus to the search" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-stays ##### One Tab per group - When: a filter has many options. - Props: none. - Guarantee: Tab reaches the list, or its search field when one is present. Use the arrow keys to move between options, Home and End to reach the first or last, and Enter or Space to select. Tab then moves to the next control. - Without it: Each option is its own checkbox, so a filter of twelve categories takes twelve presses of Tab to get past. - Code: `` - Your part: Nothing. - Not covered: Once the search holds text, its clear button (the cross) appears and takes a press of Tab of its own, so the list then takes two. - Proof, in the repository's tests (UiFilterList.spec.ts, not in the package): "keyboard as in a select: arrows move, Home and End jump, Enter and Space tick, a letter jumps" - Pattern for all components: https://grani.arseni.work/patterns/opening#one-stop The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Header (`UiHeader`) > Introduce a page or section with a title, a short explanation and the actions available there. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/header Also called page header, section header, title bar. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. Implemented by the app: - Data fetching, saving, permissions and business rules. #### Why it works this way The name of a page (`page`) or of a section (`legend`). Take `page` once at the top of a page, `legend` over each part of a long form or a card. **Page.** `page` is the `h1` at the top of UiAppShell's main area, with one line under it and the actions on the right, centered vertically on the title and its line together, as in UiListItem. It does not go inside a card, a modal or a drawer, which name themselves. **Legend.** `legend` is a small title in capitals on a line, like a fieldset's legend, beside the fields or rows it names. In a surface, the line runs to its edges and the title sits on the gutter, where the fields start. In a body that runs edge to edge, it sits on its rows' text, with 16 above it when it comes first. First in a UiCrudDrawer section with `:divided="false"`, it stands in for the section's title. Under the title of a window, a drawer or a card, it is one level deeper (`h3` under their `h2`). **Spacing.** A page header has no margin of its own: it and the page's content stand in a UiStack, `gap="title"`, 24 apart. A legend after other content opens a new section, so it adds 32px above itself. On top of the 16 gap of a `flex` or `grid` column, that puts it 48 below the content above, the step between blocks. ##### Keyboard Not interactive: the title takes no focus. - `Tab`: reaches the controls in `#actions`, which keep their own tab stops, after the title in the reading order; a legend holds none. ##### Screen reader - Page: `page` is the one `h1` of the page, and its description is a paragraph under it. - Legend: `legend` is a heading whose level follows its place: `h2` alone, the card's level in an untitled card, one level deeper under the title of a card, a window or a drawer. - Lines: borders that say nothing. ##### States None of its own: a long title wraps, and the line keeps a stub beside it. In dev, a `page` header inside a card, a modal or a drawer warns, and a `legend` with a description or an action warns and drops them. ##### When to use another component - The title of a card, a window or a drawer: UiCard, UiModal or UiDrawer carry it themselves (`title`). - The name of a field: UiField. - A strip of tabs over a block: UiTabs. - A row with a thing at its end: UiListItem. #### Examples ##### Legend ```vue ``` ##### Card ```vue ``` ##### Long ```vue ``` #### API Props: - `title`: `string` (required) — The name: the page's `h1`, or the section's title on its line. - `description`: `string` (default `""`) — One line under a page's title. A list page has none: the title names the list. A legend has none. - `variant`: `"legend" | "page"` = `legend | page` (default `"page"`) — `page` is the first line of a page; `legend` names a section on the line above it. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `actions` with `Record` — Actions of the page, right of the title: a primary button, a menu. A legend holds none. ### Indicator (`UiIndicator`) > Mark an item that needs attention with a count or dot in its corner. A component of grani 0.1.0. Status, stable, since 0.1.0. Page: https://grani.arseni.work/components/indicator Also called notification dot, status dot, badge dot. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Keep counts short and in place. - Reduce motion without hiding loading. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the real count, and name the state on the element it marks, using the slot’s `text`, such as “Inbox, 99+”. Do not draw a 0 yourself. #### Why it works this way A count or a dot on the corner of what it marks. Take it on an icon button, an avatar (`round`) or a place's icon in UiAppShell's collapsed rail when something waits there; a number beside a label is UiCount. **Mark.** The mark is a UiCount and follows its rules: a fraction is rounded down, an infinite count shows `{max}+`, and a bad one shows nothing. A ring in the surface color, or in `--ui-indicator-ring`, cuts it out from what it sits on. **Overflow.** Half of the mark stands outside the element, so a clipping ancestor crops it, and a UiButtonGroup does not join it. The root is a ``. ##### Keyboard Not interactive: the mark takes no focus and no pointer; the element in the slot keeps its own tab stop. ##### Screen reader - Mark: hidden (`aria-hidden`). - Name: the host names the state on the element it marks, using the slot's `{ count, text, visible }`: "Inbox, 99+", or "new" while a dot is visible. - Change: not announced. ##### States Hidden at `0` unless `dot`; a count, `{max}+` above `max`; a dot; `pulse`, a ring in the tone that keeps expanding and stops under reduced motion; `round`, on the edge of a circle; the four corners, mirrored in RTL; `sm` and `md`; the ring of the surface or of `--ui-indicator-ring`. A bad count hides the mark and warns in dev. ##### When to use another component - A number beside a label: UiCount. - A label: UiBadge. - A status in a row of a table: UiBadge `inline`. #### Examples ##### Dot ```vue ``` ##### Pulse ```vue ``` ##### Sizes ```vue ``` ##### On a muted surface ```vue ``` ##### Corners ```vue ``` ##### Max ```vue ``` ##### Tones ```vue ``` #### API Props: - `count`: `number` (default `0`) — The number on the corner; `0` hides the mark unless `dot` is set. - `max`: `number` (default `99`) — The largest count shown as is; above it the mark reads `99+`. - `dot`: `boolean` (default `false`) — A dot instead of a number: online, unsaved, something new. - `tone`: `Tone` = `brand | neutral | success | warning | danger | info` (default `"danger"`) — Meaning of the mark: `danger` for unread by default, `success` for online. - `placement`: `Placement` = `top-end | top-start | bottom-end | bottom-start` (default `"top-end"`) — Which corner of the element holds the mark. - `size`: `"sm" | "md"` = `sm | md` (default `"md"`) — `sm` on an `xs` icon button or a small avatar, `md` on an `sm` or `md` icon button. - `pulse`: `boolean` (default `false`) — A ring that keeps expanding: the state is live. Stops under reduced motion. - `round`: `boolean` (default `false`) — The element is a circle (an avatar, as wide as it is tall): the mark sits on its edge, not on the corner of its box. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `default` with `{ count: number; text: string; visible: boolean; }` — The element that carries the mark: an icon button, an avatar. Gets the count to name the state from. #### How it behaves ##### Compact counts - When: on the corner of what it marks. - Props: `count` and `max` (99 by default). - Guarantee: The indicator stays compact: above `max`, it shows a shortened count such as 99+. It disappears at zero or when the value is not a number. - Without it: A large number such as 1284 spills out of its corner, a red 0 stays when nothing is waiting, and a bad value shows as “NaN”. - Code: `` - Your part: Pass the real count, and name the state on the element it marks, using the slot’s `text`, such as “Inbox, 99+”. Do not draw a 0 yourself. - Not covered: It does not announce the count to a screen reader; the element it marks does. - Proof, in the repository's tests (UiIndicator.spec.ts, not in the package): "Past max the mark reads 99+, at any corner.", "At zero the mark is gone; a dot shows without a count.", "A count that is not a number hides the mark and warns in development.", "a negative count is none: the mark is gone, as at zero" - Pattern for all components: https://grani.arseni.work/patterns/waiting#compact-counts ##### Calm motion - When: it pulses (`pulse`) and reduced motion is on. - Props: `pulse`, and the system setting `prefers-reduced-motion: reduce`. - Guarantee: The pulse stops and the mark stays. The pulse calls for attention rather than showing work, so it is the kind of movement reduced motion removes. - Without it: A ring keeps expanding around the mark although you asked for less movement. - Code: `` - Your part: Nothing. Use `pulse` for something that needs attention, never for work in progress. - Proof, in the repository's tests (UiIndicator.spec.ts, not in the package): "Its pulse stops when the system asks for reduced motion: it calls, it does not work." - Pattern for all components: https://grani.arseni.work/patterns/waiting#calm-motion The patterns: https://grani.arseni.work/patterns ### Input (`UiInput`) > Enter a single line of text, a password or a number in the app’s local format, such as 1 234,50 or 1,234.50. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/input Also called text field, text input, number input, search field. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Make small changes without losing decimal precision. - Enter numbers in the app’s local format. - Change a number with the arrow keys. - Show a password without leaving the field. - Submit the values shown in the form. - See the value the app actually accepted. - Finish a word in any script before it is sent. - Clear a search in one step. - Go straight to the field that needs fixing. - Clear a field and enter its replacement immediately. - Keep your browser’s keyboard shortcuts. - Announce required fields in words. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass `step`, and `min` and `max` where the value has bounds. - The model contains a number written as text with a decimal point, such as `1234.50`, or `null` for an empty field. Text the field cannot read for sure reaches the model as written, and the model alone cannot tell it from a number: in Russian, 2,500 and an unsure 2.500 both arrive as `2.500`. Listen to `unsure`: it is `true` while the field holds such text, typing included, and every field says it again when it mounts, so refuse to save while it is `true`. - Pass `step`, and `min` and `max` where the value has bounds. Check typed values yourself: only the arrows stop at the bounds. - Give it a `name` where the form is sent natively. - Pass the value the app holds as `modelValue`. - Set `invalid`, or pass the error to its UiField. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Set `required` on the UiField. #### Why it works this way A one-line text field in a capsule. Take it for text, a number or a password, with addons at either end. **Capsule.** The capsule carries the border, background, height and focus ring. `#start` and `#end` hold addons: a unit, an icon, a button, a counter. Addons share the capsule but are not part of the value. A unit is its short form, two or three letters (`kg`, `pcs`), not a word. A button last in `#end` sits as close to the edge as the password eye does. **Password.** A `password` gets an eye that shows the characters; `v-model:passwordVisible` drives it from outside. The eye keeps the focus in the value. It never offers the browser's own spellcheck, autocapitalize or autocorrect, shown or hidden, so a password never reaches a server's suggestions. To generate and copy a password, take UiPasswordField. **Numbers.** A number is `numeric`, never `type="number"`: that type rejects the comma of a Russian keyboard and empties the field. With `step` the field draws its own arrows and moves by the step on ArrowUp and ArrowDown, the caret kept where it was and the digits the value was written with (`10.99` goes to `11.00`). `min` and `max` bound the arrows, not the typing; an arrow stops at its bound, one off the step's grid too. A number with a unit in `#end` is as wide as its digits, the unit after them; give it a `placeholder` (`0.00`), or an empty field shows the unit alone at the start. TypeScript checks against `numeric` written as it is: `bare` with it does not compile, and a field that turns numeric at run time is two tags, one under `v-if` and one under `v-else`: `:numeric="flag"` does not compile. **Bare.** `variant="bare"` drops the capsule: for a field in a window's header, such as a search over its results in UiModal's `#header`. **Search.** A `search` with text shows a cross that clears it; Escape clears it too. With `loading` (its results on their way) the cross turns into its spinner, the same button, so a focus on it stays, and clearing waits: a press and an Escape clear nothing then. **Value.** The value is text, numbers included: the owner parses it when it uses it, and a number passed in is read as its text. Empty follows the library's one rule: a text field is empty at `''`; a `numeric` one holds a number, so empty is `null`, and it sends `null` when emptied. The field shows the model: when the owner refuses a change, or a form resets, it goes back to it. A native form gets a number as the field shows it, `12,5` in a locale with a decimal comma. **Locale.** A number is read in the app's locale: "1 234,50" pasted in a Russian app is 1234.50, "1,234" is a thousand in English, and so is "1.234" in German. Text the locale cannot read for sure ("1,23" in English), or with no digit at all, is kept as written, reaches the model as it is and is marked invalid on blur. The model cannot tell such text from a number ("2,500" and an unsure "2.500" are both `2.500` in Russian), so the field says it: `unsure` is `true` while it holds such text, and the app refuses to save then. **Field.** Standalone it takes `invalid` and `ariaLabel`; inside UiField it reads id, error and required from the field. **Attributes.** Attributes and listeners land on the input (`pattern`, `autocapitalize`, `@blur`); `class` and `style` on the capsule. A change the field makes itself (an arrow, the cross, Escape) comes through `update:modelValue` alone, not a DOM `input` event. **Placement.** In UiField; joined with a button in UiButtonGroup (a search); inside UiPasswordField; as the search of UiTableToolbar; as the count of UiStepper, between its minus and plus; and in a form column beside UiTextarea, UiSelect and UiDatePicker. ##### Keyboard A native text field. - `Tab`: reaches it; a button in `#end`, or the cross of a search with text, comes next, one step later, since the number arrows are out of the tab order. - `ArrowUp` `ArrowDown`: with `numeric` and `step`, move the value by the step without moving the caret, and stop at `min` and `max`; with Shift, Alt, Ctrl or Cmd held they are the browser's. - `Escape`: in a `search` with text, clears it and goes no further; while its results are on their way it clears nothing and still goes no further; in an empty one it passes on. Keys do nothing while an IME composes. The eye and the arrows keep the focus and the caret in the value. ##### Screen reader - Field: a text box named by `ariaLabel` or the field's label, never by the placeholder. A field with no name warns in dev. - Errors: `invalid` sets `aria-invalid`; inside UiField the description and the error are read with it, and `aria-required` says it is required. - Eye: a toggle button with its own name and `aria-pressed`. - Arrows: named by the field, "Increase Price" and "Decrease Price". - Search cross: has a name. - Loading: the field and the cross say busy (`aria-busy`); in a search a status region in the capsule, there from the start, says "Searching". ##### States Rest, hover, focus (the capsule draws the ring), disabled (its own plate), readonly (looks ordinary, cannot be edited), invalid (red border and halo; disabled wins over it), with addons, `numeric` with its arrows, `password` shown or hidden, a `search` with its cross, a `search` with `loading` (the spinner in the cross's place), and `bare` with no capsule. ##### When to use another component - Several lines: UiTextarea. - A pick from a list: UiSelect. - A day: UiDatePicker. - A count of things, with minus and plus: UiStepper. - A mark from 1 to N: UiScaleInput. - A new password with generate and copy: UiPasswordField. - A one-time code: UiCodeInput. #### Examples ##### Sizes ```vue ``` ##### States ```vue ``` ##### Addons ```vue ``` ##### Bare ```vue ``` ##### Password ```vue ``` ##### Numbers ```vue ``` ##### Refused ```vue ``` ##### Form ```vue ``` ##### Russian ```vue ``` #### API Props: - `type`: `"text" | "search" | "email" | "password" | "url" | "tel"` = `text | search | email | password | url | tel` — Native type, `text` by default. A number stays `text` with `numeric`: `type="number"` has its own spinner and rejects a comma. - `name`: `string` — Native name for forms. - `id`: `string` — Own id when standalone. Inside UiField the field's id takes precedence, so the label always points at the control. - `placeholder`: `string` — Hint shown while empty. Not a replacement for a label. - `autocomplete`: `string` — Browser autofill hint: `email`, `current-password`. - `inputmode`: `"none" | "numeric" | "text" | "search" | "email" | "url" | "tel" | "decimal"` = `none | numeric | text | search | email | url | tel | decimal` — Keyboard to show on a phone. - `maxlength`: `number` — Maximum number of characters. - `readonly`: `boolean` — Prevents editing while keeping the control focusable: something else sets the value. - `disabled`: `boolean` — Prevents input and interaction. - `loading`: `boolean` — A request for this value is on its way, as a search: a spinner at the end in the cross's place, and clearing waits. - `invalid`: `boolean` — Marks the control invalid: red border and halo. Inside UiField the error sets this. - `required`: `boolean` — Standalone only: a value is needed, said to a reader and to a form (native `required`). Inside UiField the field's `required` says so. - `ariaLabel`: `string` — Accessible name when there is no visible label. A prop, not `aria-label`: the root is the capsule, the name belongs to the control. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder. Unset, the app's size (`createGrani({ size })`), `sm` by default. - `passwordVisible`: `boolean` — Whether a `password` shows its characters (`v-model:passwordVisible`). Uncontrolled when omitted: the eye keeps the state. - `modelValue`: `string | null` (required) — The value as text (`v-model`): empty is `''`, or `null` with `numeric`. A number is plain text (`1234.5`) parsed by its owner on use. - `variant`: `"capsule" | "bare"` = `capsule | bare` — `capsule` (the default) is the text field. `bare` has no capsule: the field is a window's header, edge to edge, larger type (a search over its results); the window draws the line under it. Text or search only, never `numeric`: no ring, no halo, `size` has no say. - `numeric`: `boolean` — A number as text: tabular monospaced digits, decimal keyboard, a comma becomes a point, and with an `#end` unit the value sizes to its content so the unit sits right after it. - `step`: `number` — With `numeric`: arrows in the capsule and ArrowUp/ArrowDown move the value by this much (`0.1` for money, `1` for pieces). A value written more precisely moves by its own last digit. - `min`: `number` — With `numeric`: the lowest value the arrows reach. Typing is not clamped: the form validates. - `max`: `number` — With `numeric`: the highest value the arrows reach. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: string | null`) — The value changed: text, `''` when emptied, or with `numeric` the number as text and `null` when emptied. - `update:passwordVisible` (`visible: boolean`) — The eye was toggled. - `unsure` (`unsure: boolean`) — With `numeric`: `true` while the field holds text the locale cannot read for sure, which the model carries as written; `false` when it does not. Said on mount, then on every change, right after the model it concerns. Slots: - `start` with `Record` — Addons before the value: a unit, an icon, a text. - `end` with `Record` — Addons after the value: a unit, a counter, a button. #### How it behaves ##### Cents kept - When: a number is stepped with the arrows. - Props: `numeric` with `step`. - Guarantee: With a fractional `step`, the arrows use the value’s last decimal place when it is finer than the step: 10.44 with a step of 0.1 becomes 10.45. Results are rounded to the precision in use, so 0.2 plus 0.1 becomes 0.3. - Without it: A price that jumps from 10.44 to 10.54 under the arrow, or that shows 0.30000000000000004 after two clicks. - Code: `` - Your part: Pass `step`, and `min` and `max` where the value has bounds. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "a click moves by the precision of the value: a cent stays a cent" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#cent-stays-cent ##### Number as written - When: a number is typed or pasted as people write it. - Props: `numeric`. - Guarantee: The number is read in the app's locale: 1,234.50 is 1234.50 in English, and 1 234,50 is 1234.50 in Russian. A pasted number with both a point and a comma, such as 1.234,56, is read by the mark that comes last. Text the locale cannot read for sure, or text with no digit at all, stays as written and is marked invalid when you leave the field. - Without it: A native number field that does not accept the thousands comma and hands the app an empty amount. - Code: `` - Your part: The model contains a number written as text with a decimal point, such as `1234.50`, or `null` for an empty field. Text the field cannot read for sure reaches the model as written, and the model alone cannot tell it from a number: in Russian, 2,500 and an unsure 2.500 both arrive as `2.500`. Listen to `unsure`: it is `true` while the field holds such text, typing included, and every field says it again when it mounts, so refuse to save while it is `true`. - Not covered: The locale is the app's, set with `createGrani({ locale })`. `unsure` comes right after the model it concerns, so read it when saving, not inside the `update:modelValue` handler. A locale switched at run time reads the shown text again but does not rewrite the model. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "a comma becomes a point in the emitted value and in the control", "a pasted formatted number is read the way it is written", "a paste drops the grouping marks of the locale and keeps the magnitude", "a paste the locale cannot read for sure is kept as written and marked invalid", "typing keeps the marks as typed; the field is read on blur", "text with no digit in a number field is marked invalid on blur", "the field tells the app when the model holds text the locale cannot read for sure, and when it no longer does", "while a number is typed the app hears unsure at once, and the red mark waits for blur", "a number field mounted again over a reset model clears the app flag the last one left" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#number-as-written ##### Arrows step - When: it is `numeric` with a `step`. - Props: `numeric`, `step`, `min`, `max`. - Guarantee: ArrowUp and ArrowDown, or the Increase and Decrease buttons, adjust the value without moving the text cursor. Changes follow the step and decimal-precision rules, keep the digits the value was written with (10.99 goes to 11.00), and stop at `min` and `max`, including a bound off the step’s grid. The buttons are skipped when tabbing, so Tab continues to the next field. - Without it: The only way to change a number is to type it again. - Code: `` - Your part: Pass `step`, and `min` and `max` where the value has bounds. Check typed values yourself: only the arrows stop at the bounds. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "ArrowUp and ArrowDown step and do not move the caret; other keys pass", "ArrowUp at max and ArrowDown at min emit nothing", "a step keeps the caret where it was", "an arrow stops at a bound off the step grid, by key and by button", "a step writes plain digits, never an exponent, and keeps the cents it was written with" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#number-by-arrows ##### Eye keeps the cursor - When: it is a password field. - Props: `type="password"`. - Guarantee: The eye button shows or hides the password without moving keyboard focus or the text cursor out of the field, so you can continue typing immediately. `v-model:passwordVisible` lets the app control visibility too. - Without it: An eye that takes keyboard focus: the next key you type does not reach the password, and you have to click back into the field. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "pressing the eye with a pointer keeps the focus in the value", "the eye keeps the caret where it was" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#eye-keeps-field ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: The value is sent under its `name`, and after a form reset the field shows the app's value again. - Without it: A field on screen that the form never sends. - Code: `` - Your part: Give it a `name` where the form is sent natively. - Not covered: A `numeric` field sends the number as it shows it: `12,5` in a locale that writes a decimal comma, while the model holds `12.5`. A server that reads such a form reads the comma, or the app sends the model itself. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "in a form it sends its value under its name", "a form reset leaves the input on its model", "Enter settles the number before the form is sent, in the same task", "in a comma locale a native form gets the number as the field shows it, 12,5" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Back to the value - When: the app refuses a change, or the form is reset. - Props: `v-model`, or `modelValue` with `@update:model-value`. - Guarantee: A refused change disappears from the field, and a form reset brings back the app’s value. - Without it: The field shows text the app did not accept. - Code: `` - Your part: Pass the value the app holds as `modelValue`. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "a change the parent refuses puts the input back on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Words in the making - When: a word is typed through an input method. - Props: none: every UiInput. - Guarantee: `v-model` gets nothing while the word is composed and the whole word once it is picked. A page that redraws the field meanwhile keeps the characters being typed, and Escape and the arrows belong to the input method. - Without it: A model that changes with every reading, and a redraw that drops what was being composed. - Code: `` - Your part: Nothing; keep the model as the field sends it. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "emits nothing mid-composition and the final value at its end", "a re-render of the host mid-composition does not reset the characters being typed", "Escape and the arrow keys during an IME composition do nothing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#word-as-composed ##### Search clears - When: a search field has text. - Props: `type="search"`. - Guarantee: A cross clears it and keeps keyboard focus in the field. Escape clears it too and goes no further. In an empty search Escape passes on, so an Escape meant for something around the field, such as a dialog, still reaches it. - Without it: A search you can clear only by selecting its text and deleting it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "a search with text shows a cross that clears it and keeps the focus", "Escape clears a search with text and goes no further; on an empty one it passes", "Escape in a search whose results are on their way goes no further and clears nothing" - Pattern for all components: https://grani.arseni.work/patterns/finding#search-clears ##### Focus on the error - When: a form is sent with this field in error. - Props: `invalid`, or the error of its UiField. - Guarantee: When this is the first field with an error, focus moves into it so you can correct the value immediately. A red border and focus ring make the field easy to find. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `` - Your part: Set `invalid`, or pass the error to its UiField. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "an input marked invalid is what focusInvalid lands on" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Cleared in place - When: A search field has text. - Props: `type="search"`. - Guarantee: The × empties the field and leaves keyboard focus in it. - Without it: The × empties the field and keyboard focus is lost, so you have to find the field again. - Code: `` - Your part: Nothing; do not add a × of your own in `#end`. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "a search with text shows a cross that clears it and keeps the focus" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#cleared-in-place ##### Shortcuts kept - When: a number field with arrows. - Props: `numeric` with `step`. - Guarantee: ArrowUp and ArrowDown alone change the number by the step. With Alt, Ctrl or Cmd held the number stays and the browser moves the text cursor, and with Shift it selects, as in any text field. - Without it: Cmd+Up, meant to move the cursor to the start, adds one to the price instead. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "a numeric field steps on an arrow alone; with Alt, Ctrl or Meta the caret is the browser’s", "Shift with an arrow selects, as the browser does: no step" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#shortcuts-kept ##### Required in words - When: it must be filled. - Props: `required` on its UiField. - Guarantee: Inside a required UiField, the input is announced as required (`aria-required`), and the field draws the asterisk. - Without it: An asterisk that a screen reader skips. - Code: `` - Your part: Set `required` on the UiField. - Proof, in the repository's tests (UiInput.spec.ts, not in the package): "inside UiField it takes the id, the error and required from the field" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#required-in-words The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`, `useUiT`. ### Password field (`UiPasswordField`) > Set a new password by typing or generating one, check it on screen and copy it; a screen reader confirms the generation without reading the password. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/password-field Also called password input, password generator. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See whether an action worked. - See the value the app actually accepted. - Submit the values shown in the form. - See the password you generate. - Generate a password instead of inventing one. - Name each field once for screen readers. - Announce a new password without reading it. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the password the app holds as `modelValue`. - Give it a `name`, and give the repeat field a name of its own. - Bind the field with `v-model`; the generated password appears when the app accepts the new value. Use `@generate` to copy it into a repeat-password field. - Set `length` where your policy asks for more than 16 characters; a value under 10 is raised to 10. Check any other rule of your policy yourself. - Put it in a UiField with a `label`, or give it `ariaLabel`. #### Why it works this way A field for setting a new password, with buttons to show, copy and generate it. Signing in and signing up take UiInput `type="password"` instead. **Layout.** The eye that shows the password sits inside the field (UiInput). Beside the field, 8px apart, stand a copy button (UiCopyButton, once there is a value) and, at the end, a generate button (`generatePassword`). Generate is last, so it never moves under the pointer when copy appears. `:copy="false"` leaves copy out, where a password must not reach the clipboard. **Generate.** A generated password has a lower-case letter, an upper-case letter, a digit and a symbol. Once the owner accepts it, it is shown at once, announced to a screen reader without its value, and emitted through `generate`, so a form can fill its "repeat" field itself. When the owner refuses it, nothing is said or shown. **Hiding.** Emptying the field hides the password again. **Empty.** The password is text, and empty is `''`, never `null`. **Attributes.** `class` and `style` land on the row, everything else on the input. **Placement.** In UiField, which gives the label, description and error, beside UiInput `type="password"` for the repeat. **Signing in.** It is not for signing in or signing up: there, a generated password the person never saw is one they cannot type again. ##### Keyboard A row. - `Tab`: reaches the input, its eye, then copy, then generate. - `Enter` `Space`: on generate, fill the field and show the password; on copy, copy it. Neither submits the form around them. When the copy button disappears under the focus (the value emptied, the field disabled), the focus moves to generate. `disabled` blocks typing and generate and takes the copy button away; `readonly` blocks generate and keeps copy. ##### Screen reader - Input: named by the field's label or by `ariaLabel`; `invalid` sets `aria-invalid` on it, and the field's words are read with it. - Buttons: copy and generate are icon buttons with their own names; copy says it copies the password. - Generate: a generated password is announced without its value, again on a second generate. - Copy: UiCopyButton says the outcome from the page's live region, or from the open window's. - Eye: says whether the password is shown (`aria-pressed`). ##### States Empty (no copy button), with a value (copy appears before generate, which never moves), shown or hidden by the eye, generated (shown at once), disabled (all three blocked), readonly (copy kept, generate blocked), and invalid. ##### When to use another component - Signing in or signing up: UiInput `type="password"`. - The repeat field beside it: UiInput `type="password"` too. - A secret given to copy and never typed: UiCopyButton with an `ariaLabel` and neither `label` nor `truncate`, which would put it on the screen and in its name. #### Examples ##### Sign in ```vue ``` ##### Options ```vue ``` ##### Sizes ```vue ``` ##### Error ```vue ``` ##### States ```vue ``` ##### Refused ```vue ``` ##### Form ```vue ``` #### API Props: - `modelValue`: `string` (required) — The password (`v-model`); empty is `''`. - `name`: `string` — Native name for forms. - `placeholder`: `string` — Hint shown while empty. - `autocomplete`: `"new-password" | "current-password"` = `new-password | current-password` (default `"new-password"`) — Password manager hint: `new-password` when setting one, `current-password` when signing in. - `disabled`: `boolean` (default `false`) — Nothing can be typed, generated or copied. - `readonly`: `boolean` (default `false`) — The password is shown and copied but not changed: typing and generate are held. - `invalid`: `boolean` (default `false`) — Marks the input invalid standalone. Inside UiField the error sets this. - `id`: `string` — Own id of the input when standalone. Inside UiField the field's id takes precedence. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder for the field and both buttons. Unset, the app's size (`createGrani({ size })`), `sm` by default. - `length`: `number` (default `16`) — Length of a generated password; never under 10. - `ariaLabel`: `string` — Accessible name of the input when no UiField label names it. - `copy`: `boolean` (default `true`) — The copy button beside the field; `false` leaves it out, where a password must not reach the clipboard. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: string`) — The value changed, typed or generated. - `generate` (`value: string`) — A generated password the owner took: it holds no typo, a form may copy it into its repeat field. Not fired when the owner refused it. #### How it behaves ##### Outcome shown - When: a password is generated or copied. - Props: none. - Guarantee: When a password is generated, a screen reader confirms it without reading the password. The copy button announces its own result. Generating or copying again produces a new confirmation. - Without it: A press with no answer: you cannot tell whether anything happened. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiPasswordField.spec.ts, not in the package): "says a password was generated, without saying it, and says it again on a second generate" - Pattern for all components: https://grani.arseni.work/patterns/waiting#answered ##### Back to the value - When: the app refuses a change, or the form is reset. - Props: `v-model`, or `modelValue` with `@update:model-value`. - Guarantee: If the app refuses a generated password, the field does not reveal it and a screen reader does not announce it. A form reset brings back the app’s value. - Without it: The field shows a generated password the app did not accept, and you might copy it. - Code: `` - Your part: Pass the password the app holds as `modelValue`. - Proof, in the repository's tests (UiPasswordField.spec.ts, not in the package): "a generate the owner refuses says nothing, reveals nothing and emits no generate" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: The password is sent under its `name`, a generated one included, and after a form reset the field shows the app's value again. Generate and Copy are plain buttons, so pressing them inside a form does not send it. - Without it: A password field the form never sends, or a Generate button that sends the form before it is filled in. - Code: `` - Your part: Give it a `name`, and give the repeat field a name of its own. - Proof, in the repository's tests (UiPasswordField.spec.ts, not in the package): "in a form it sends the generated password under its name; a reset keeps it on its model", "generate and copy inside a form do not submit it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Password shown - When: a password is generated. - Props: none; Generate is the button at the end. - Guarantee: The generated password appears in plain text and the copy button becomes available once the app accepts the value. Clearing the field turns password hiding back on for the next password you type. - Without it: A password set behind dots that you never saw and cannot type again. - Code: `` - Your part: Bind the field with `v-model`; the generated password appears when the app accepts the new value. Use `@generate` to copy it into a repeat-password field. - Proof, in the repository's tests (UiPasswordField.spec.ts, not in the package): "generate fills the field, shows it and says so; the copy button wakes up", "hides the password again once the value is emptied" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#made-is-seen ##### Strong on offer - When: you set a new password. - Props: `length` (16 by default, never under 10). - Guarantee: Generate is one press away, and the password it gives has a lowercase letter, an uppercase letter, a digit and a symbol, at the length set by `length`. - Without it: You are left to invent a password that the policy may refuse. - Code: `` - Your part: Set `length` where your policy asks for more than 16 characters; a value under 10 is raised to 10. Check any other rule of your policy yourself. - Proof, in the repository's tests (UiPasswordField.spec.ts, not in the package): "generate fills the field, shows it and says so; the copy button wakes up" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#strong-on-offer ##### One name - When: it sits in a field. - Props: `ariaLabel`, or the label of its UiField. - Guarantee: The input is named by the field's label, or by `ariaLabel` when the field has no label; the show, copy and generate buttons have names of their own, such as “Copy the password”. - Without it: Three unnamed buttons beside an unnamed input. - Code: `` - Your part: Put it in a UiField with a `label`, or give it `ariaLabel`. - Proof, in the repository's tests (UiPasswordField.spec.ts, not in the package): "inside UiField the input takes the field id and the label names it", "inside a UiField with no label the input keeps its ariaLabel", "the copy button says it copies the password" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice ##### Not read aloud - When: you generate a password. - Props: none. - Guarantee: Each password the app accepts is confirmed to a screen reader without including the password in the announcement. Generating another one repeats the confirmation. - Without it: The confirmation reads the new password aloud, so anyone nearby can hear it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiPasswordField.spec.ts, not in the package): "says a password was generated, without saying it, and says it again on a second generate" - Pattern for all components: https://grani.arseni.work/patterns/waiting#made-is-not-spoken The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`, `useUiT`. ### Link (`UiLink`) > Follow an underlined link in text, marked when it opens a new tab. A component of grani 0.1.0. Actions, stable, since 0.1.0. Page: https://grani.arseni.work/components/link Also called hyperlink, anchor, text link. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Name states in words, not color alone. - Know when a link opens a new tab. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the address in `to`. To open it in the current tab, set `target="_self"`; this also removes the new-tab arrow and announcement. #### Why it works this way A link inside text, underlined at rest. Take it for a link in prose, a caption or a note. **Underline.** The underline tells the link apart from the words around it, since its color alone may not. **New tab.** Another site (or `target="_blank"`) opens in a new tab and says so: an arrow for the eye, a phrase for a screen reader. **Scripts.** An address that runs script (`javascript:`, `data:`) is not linked: the text stays, in the ink of the words around it, with no line and no href, and dev warns. So is no address at all, an empty `to` or one the app's data left out (`null`) or holds as no string (a number): a menu built from data renders, on a server too, with that item as text. A link focused when its address goes away drops the focus with it, as any element that leaves. **Router.** A path of the app (`/records/7`, `/people/john.doe`) goes through the link component given to `createGrani({ link })`, else the app's `RouterLink`, else a plain anchor; another site, `mailto:`, `tel:` and a file the server hands out (`/llms.txt`, `/report.pdf`) are plain anchors. An address padded with spaces is read trimmed. **Placement.** It sits in prose, a caption, a card's footer note, or a header beside the title. Its neighbors are UiButton `to` (an action) and UiListItem `to` (a whole row as the link), and it never goes inside either: a link in a link warns in dev. **Wrapping.** It never wraps, so a name or a command reads as one piece. A link is a few words: a long one is written shorter, not broken across lines. ##### Keyboard An anchor. - `Tab`: reaches it; a refused address (`javascript:`, `data:`) or none is plain text and takes no focus. - `Enter`: follows it; `Space` does not, as in the browser. ##### Screen reader - Name: the text. - New tab: another site, or `target="_blank"`, adds "opens in a new tab" after the text, where the eye sees an arrow, which is hidden. The notice follows the real target, so a `mailto:` or `tel:` link says nothing. - Attributes: native ones (`aria-current`, `hreflang`, `lang`, `title`) reach the anchor. An `aria-label` replaces the name and the notice with it: write "opens in a new tab" into it. ##### States Rest, underlined in the tone's ink (`brand`, `info`, `neutral`); hover, in the hover ink; focus. External with the arrow; internal, mail and phone without it. Always on one line. No disabled state: a refused or missing address shows as text. ##### When to use another component - An action: UiButton, with `to` when it looks like a button. - A whole row as the link: UiListItem `to`. - A link inside either: never; a link in a link (UiListItem or UiButton with `to`) warns in dev. #### Examples ##### Tones ```vue ``` ##### Target and rel ```vue ``` #### API Props: - `to`: `string` (required) — A route or a URL; an absolute address is external, `mailto:` and `tel:` are not. `javascript:` and `data:` are refused: the text shows with no link. - `tone`: `LinkTone` = `brand | neutral | info` (default `"brand"`) — `brand` is the link color; `info` the signal blue, a link to look at; `neutral` a quiet link in a caption. - `target`: `"_blank" | "_self"` = `_blank | _self` — `_blank` or `_self` overrides the default: a new tab for an external address only. - `rel`: `string` — Replaces the default `noreferrer`, e.g. `nofollow`, `sponsored`, `ugc`; `noopener` stays on a new tab. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `default` with `Record` — The link text. #### How it behaves ##### Words, not only color - When: it sits in a line of text. - Props: none: every UiLink. - Guarantee: It is underlined at rest in its own color, so it stands apart from the words around it without relying on color, or on hover, which a touch screen does not have. - Without it: A link distinguished by color alone looks like ordinary text to anyone who cannot see that difference. - Code: `the order` - Your part: Nothing. Do not remove the underline to make a link quieter; use `tone="neutral"`. - Proof, in the repository's tests (UiLink.spec.ts, not in the package): "is underlined at rest: a touch screen has no hover" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#level-not-by-colour ##### New tab said - When: its address is another site, or it has `target="_blank"`. - Props: `to` with an absolute address. - Guarantee: You see an arrow after the last word, a screen reader announces “opens in a new tab” after the text, and the new tab opens without access to the page that opened it. - Without it: A new tab opens without warning: Back does nothing there, and a screen reader gives no sign that the page moved to another tab. - Code: `the help center` - Your part: Pass the address in `to`. To open it in the current tab, set `target="_self"`; this also removes the new-tab arrow and announcement. - Not covered: Any absolute URL is treated as an external link, even if it points to the app’s own site. Use `target="_self"` when that link should open in the current tab. - Proof, in the repository's tests (UiLink.spec.ts, not in the package): "an external address opens in a new tab, safely, and says so to a screen reader", "an external link draws an arrow after the text, an internal one does not", "target overrides the default, and the screen-reader notice follows the real target" - Pattern for all components: https://grani.arseni.work/patterns/opening#new-tab-said The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### List (`UiList`) > Present related rows in a card, separated by lines so each item is easy to distinguish. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/list Also called list group, stack of rows. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. Implemented by the app: - Data fetching, saving, permissions and business rules. #### Why it works this way Rows one under another, divided by lines. Take it around UiListItem rows wherever they stand in a column; a card of rows is UiCard `:padded="false"` around a UiList. **Lines.** The list divides its rows: a line stands above every row that follows a row, or the block a row opened (UiReveal), drawn over the row's own air, so a row is one height wherever it stands. A notice is a row too, with the lines of any row; a framed row (`outline`, `muted`) stands alone, with no line above it or after it. **Edges.** In a surface with a gutter (a padded card, a window, a drawer's section) the list runs its rows edge to edge, through the gutter, and their text stays on it. Standing first or last in a drawer's section, its own child and not inside a block of the section's, it reaches the section's line: the section says which of its children is first and last, so a section of rows alone is a list between two lines, and a list wrapped in a block after a paragraph keeps its place. Last in a padded card's body, the card drops its bottom gutter under it. In a card without padding the rows keep the card's inset. **Items.** Every child is an item of the list: a row, a UiReveal with its row and its block, a notice. A notice stands among the rows as a row of the list, not as the card it is alone: the rows' edges, height and lines, its tone a 3px edge at the start along the whole row, clipped by the card's corners when it comes first or last; `variant="soft"` on it fills the row with its tone's pale plate as well. A child that is not a row (a separator, a plain block) stands as an item too, with no line above or after it. ##### Keyboard Nothing of its own: a link or a command row is a tab stop, a label row reaches its control, a plain row takes no focus. ##### Screen reader - List: `role="list"`, kept where a style drops a list's semantics, each child a list item, so a screen reader says how many rows there are and which one it is on. ##### States Rows of plain text, links, fields and commands; a notice among them, plain or `soft`; a long list in a card; a list at the top or the bottom of a drawer's section, line to line; on a narrow screen the rows wrap their text and keep their actions beside it. ##### When to use another component - A list in a panel: the panel draws it itself, as a UiSelect or a UiMenu does. - A table of columns: UiTable. - A list that grows from its last row: UiListField over a UiList. #### Examples ##### Mixed ```vue ``` ##### Padded ```vue ``` ##### Notice ```vue ``` ##### Long ```vue ``` ##### Narrow ```vue ``` #### API Slots: - `default` with `Record` — The rows: UiListItem, a UiReveal around one, a notice. Each child is one item of the list. ### List field (`UiListField`) > Search a list and add entries from one field. Paste a column to add several entries, and recover your text if adding fails. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/list-field Also called tag input, token input, chips input, multi-value field. It works on top of [List item](https://grani.arseni.work/components/list-item). #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Get your text back when adding fails. - Know what was refused and why. - Avoid adding the same entry twice. - Know what fits when a list has a limit. - Paste a list to add several entries. - See whether an action worked. - Ignore extra presses while a request runs. - Give every empty list a way forward. - See the value the app actually accepted. - Finish a word in any script before it is sent. - Clear a search in one step. - Go straight to the field that needs fixing. - Clear a field and enter its replacement immediately. - Pick with Enter without sending the form. - Keep your browser’s keyboard shortcuts. - Keep keyboard focus where you are working. - Name each field once for screen readers. Implemented by the app: - Data fetching, saving, permissions and business rules. - Return the request's promise from `@add`, and let it reject when the request fails; a handler that throws counts as a failure too. - Return a clear correction from `check`, such as “Enter a complete email address, including the domain.”. - List the current names in `existing`, and highlight the row that `@taken` names. Put a new name into `existing` before the promise `@add` returned settles: one put there later can be added twice. - Set `limit`. It is counted against the names in `existing`, so keep `existing` up to date. - Return the request’s promise from the add handler so the field can report its progress and failure. Show success in the updated list, for example by highlighting the new row. - Return the request’s promise from `@add`, and put the new names into `existing` before it settles, so the next add finds them. Give the request a timeout. - Say in `empty` what the list holds when the default words do not fit, such as “No tags yet: type one and press Enter.”. - Keep `v-model:query` on the text the app holds. - Pass the error to its `UiField group`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Name the list by what it holds, such as Tags or Allowed domains. #### Why it works this way One field that searches a UiList of UiListItem rows and adds to it. Take it when a list level holds only a list, such as a step of UiCrudDrawer. **Layout.** It is a behavior laid over a UiList of UiListItem rows. The field is the first row of a framed card, and the rows run under it, in a UiList the field draws round the slot. **Search.** Typing filters. The default slot gets the `query` (and the names `added` here, to put on top) and draws the matching rows, each with UiListItem `highlight`, so the typed letters show why a row was found. The row named exactly as typed, else the first match, is ready for Enter and marked with a band. Enter opens a row that is a button or a link; under plain rows it adds the text, so a name added by mistake is offered back with Undo in a toast, by the app. **Adding.** When no name in `existing` matches the text exactly, an "Add ↵" button stands at the field's end beside the cross (a screen reader hears "Add …" with the name), and Enter adds the text. A pasted column adds every line. **Full list.** A full list hides the field and shows a line that says so; the focus moves to that line. **Items.** Every item goes through `useAddItems` (`split`, `fix`, `check`, `limit`, and a request that gives the text back when it fails), and a line under the field says what happened. The items leave by `@add`, as UiLog's older events come by `@more`: the handler sends them, and until the promise it returns settles a second Enter or Add sends nothing; the person can keep typing. A promise that never settles holds the field until it unmounts: give the request a timeout. **Glyph.** The icon at the start of the field shows its state: a search icon at rest, a spinner while an item is being added, and an alert after a failure or a bad item, until the text changes. A repeat only gets the line. **Add only.** With `search` off the field only adds: a plus replaces the search icon, Enter adds, and the list is not filtered. Take it for a short list inside a form, with every row in view. **Value.** The list's rows are the value, and they belong to the caller: it draws them in the slot, names them in `existing` and adds to them in `@add`, after its request. Repeats and `limit` are counted from `existing`, so it holds the new names by the time the request's promise settles; a name put there later can be added twice. The component holds no list of its own, so it takes no `v-model` of one. The field's text is a draft, `v-model:query`, not a form value; it follows its model after the owner refuses a change and after a form reset. A draft refused by `check` reads as invalid to a screen reader, yet a Save around it goes: it is not the form's to fix. **Draft.** The text goes with its level: in UiCrudDrawer, ‹ and the backdrop step up and drop it. To keep it, hold the level `persistent` while `query` is not empty: ‹ and the backdrop shake instead until the text is added or cleared. The footer's Back and the cross are the explicit ways out, and they still leave. **Field.** Inside `UiField group` the legend names it once and says "required" once (the draft itself is never required); the field's description and error describe it. A plain UiField label points at its input, and a required one marks it `aria-required`. **Attributes.** `disabled` blocks the field, Add and the cross. Caller attributes land on the input, and ours only where we have one. `required` and `name` would be the draft's: the browser would refuse an empty draft and post it. Leave them off; a required list is UiField's. ##### Keyboard Typing filters. - `ArrowDown` `ArrowUp`: move the band over the matches, past the last one to "Add ↵", and round again, while the cursor stays in the field; with Shift, Alt, Ctrl or Cmd held they are the browser's. - `Enter`: opens the row under the band, or adds the typed text when no row is there; never submits the form around it. - `Cmd+Enter` `Ctrl+Enter`: the form's, as in any field: a record's form around the field saves, without the draft. - `Escape`: empties the field, or clears the line under it, and stops there; on an empty field it passes to the host. The cross and Add keep the focus in the field, from the keyboard too. When a row with the focus in it is removed, the focus moves to its neighbor; when the list fills up under the focus, the focus moves to the line that says so. ##### Screen reader - Field: named by `ariaLabel`, else by the placeholder, by a plain UiField's label or by a group's legend. - Line under it: what happened to an item, or a full list; an alert that describes the field. - Enter hint: a live region says what `Enter` would do: open the named row, add the typed name, or that an item is on its way. - Buttons: "Add" is named with the typed name, and the cross has a name. - Errors: inside UiField the description and the error describe it, and a refused item sets `aria-invalid`. ##### States At rest with a search icon (a plus with `search` off), typing with the band on the first match and "Add ↵" at the end, adding (a spinner; Enter waits, typing goes on), refused (an alert icon and the line, until the text changes), a repeat (the line only), empty (a line saying how to start), nothing matching ("Add ↵" alone), full (no field, the line says so), and disabled (the field and the cross blocked, on the plate, no Add). ##### When to use another component - A list with a toolbar and pages: UiDataTable. - Picks from a fixed set: UiSelect `multiple`. - One text value: UiInput. - Rows that nobody adds here: UiListItem rows in a UiList. #### Examples ##### Empty ```vue ``` ##### Disabled ```vue ``` ##### Invite ```vue ``` ##### Cleanup ```vue ``` ##### Limit ```vue ``` ##### Options ```vue ``` ##### Sizes ```vue ``` ##### Russian ```vue ``` ##### Field ```vue ``` #### API Props: - `placeholder`: `string` (default `""`) — The hint in the empty field: "Find or add a subcategory". Defaults to "Find or add…". - `ariaLabel`: `string` (default `""`) — The field's name for a screen reader; defaults to the placeholder. - `empty`: `string` (default `""`) — The line of an empty list; defaults to "Nothing here yet: type a name and press Enter". - `search`: `boolean` (default `true`) — Typing filters the list and Enter opens the first match. Off, the field only adds: a short list inside a form, where every row is in view. - `existing`: `string[]` (default `[]`) — The names in the list: an exact one, in any case, is found, not offered again. - `split`: `"lines" | "lists"` = `lines | lists` (default `"lines"`) — `lines`: a pasted list splits by line. `lists`: also by commas and semicolons, typed or pasted. - `fix`: `((item: string) => string)` — Tidies an item on its way in: `(s) => s.toLowerCase()`. A change is named under the field. - `check`: `((item: string) => string | undefined)` — Why an item cannot be, in the product's words, or nothing when it can. - `limit`: `number` — The most the list holds, counted from `existing`: full, the field is gone. - `onAdd`: `((items: string[]) => unknown)` — “Add …”, Enter or a pasted list (`@add`): the items, tidied, checked, without repeats. Return the request's promise to hold the field; a rejection gives the text back. Nothing returned, the field is not held. Put the new names into `existing` before the promise settles: repeats and `limit` are counted from it. - `disabled`: `boolean` (default `false`) — Nothing can be typed, added or cleared: the field, Add and the cross are held, on the disabled plate. - `id`: `string` — Own id of the input when standalone. Inside UiField the field's id takes precedence. - `size`: `ControlSize` = `xs | sm | md` — Step of the field's row, as a list row's: 44 px at `xs` and `sm`, 56 at `md`. Unset, the app's size (`createGrani({ size })`), `sm` by default. - `query`: `string` (default `""`) — The text in the field (`v-model:query`): what the list is filtered by, and what “Add …” adds. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:query` (`value: string`) — The new value of `query`, for `v-model:query`. - `taken` (`name: string`) — A repeat was refused: the name as the list has it, to point at its row. Slots: - `default` with `{ query: string; added: string[]; }` — The rows that match `query`, UiListItem with `highlight`; `added` are the names added here, newest first, to put on top. #### How it behaves ##### Text back - When: Adding a typed entry fails. - Props: `@add` returning the request's promise. - Guarantee: While the request runs, a second Enter or Add sends nothing, the field shows a spinner, a screen reader announces “Adding…”, and you can keep typing. If the request fails, or the handler throws, the text returns to the field with a line saying it could not be added and that Enter tries again. If you typed something new while the request ran, that text stays instead, and the line only says what could not be added. - Without it: The field empties as soon as you press Enter; if the request then fails, the text is gone and you type it again. - Code: `` - Your part: Return the request's promise from `@add`, and let it reject when the request fails; a handler that throws counts as a failure too. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "a request says Adding… with a spinner; a failure gives the text back with a line", "an onAdd that throws gives the text back with a line", "typing goes on while the add is on its way, and the next Enter sends it", "a line that says Enter tries again is true: after a failure the field holds the failed text", "a pasted list that fails does not say Enter tries again over an empty field", "a list waiting behind a failing add: the failure is still said, the failed text is back (draft bound)" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#text-back ##### Refusal named - When: An item fails its `check`. - Props: `check`. - Guarantee: The rejected item stays in the field to be corrected, with the reason from `check` on the line under it, which a screen reader announces. The valid items entered together with it are added. - Without it: A rejected entry disappears without a word, and you have to type it again. - Code: `` - Your part: Return a clear correction from `check`, such as “Enter a complete email address, including the domain.” - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "check refuses with the reason and the text stays", "split, fix and check go the same way: a list typed with commas, tidied, the bad one kept" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#refused-by-name ##### No duplicates - When: An item is already in the list. - Props: `existing`, `@taken`. - Guarantee: A name is a duplicate when it matches a complete name in `existing`, ignoring capitalization: “Fruit” matches “fruit”, but “Fru” is a different entry. A duplicate is skipped and identified below the field, and `@taken` reports the existing name so the app can highlight its row. A name repeated within one paste is handled the same way. - Without it: A duplicate is either dropped without a word or added a second time. - Code: `` - Your part: List the current names in `existing`, and highlight the row that `@taken` names. Put a new name into `existing` before the promise `@add` returned settles: one put there later can be added twice. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "a pasted column adds every line, repeats pointed at", "Enter adds even when a row holds the same letters; a repeat is refused with a line", "a repeat inside one paste is pointed at, not dropped without a word" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#no-twice ##### Up to the limit - When: The list has a `limit`. - Props: `limit`. - Guarantee: Only as many entries as still fit are added, and you can see how much of a pasted list was accepted. A full list replaces its input with a message such as “Limit reached: 3 items” or “Could not add 1 item: this list allows up to 3.” If the input had keyboard focus, focus moves to the message. - Without it: The list takes an eleventh entry when ten are allowed, and the error appears only when the form is saved. - Code: `` - Your part: Set `limit`. It is counted against the names in `existing`, so keep `existing` up to date. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "a limit fits a paste into what is left; full, the field is gone and says what did not fit", "reaching the limit one item at a time says the list is full and hands it the focus", "a list that fills while a line stands says it is full, as well as what the line said" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#nearly-full ##### Paste many - When: A column of values is pasted into the field, or entries are typed. - Props: none; `split`, `fix` and `check` decide what a list is, how an entry is tidied and what passes. - Guarantee: Typed or pasted, every entry goes the same way: split with `split`, trimmed, normalized with `fix`, then validated with `check`. The accepted lines of a paste reach `@add` together, while duplicates are skipped and identified in a message below the field. A column pasted while an add is on its way waits for it, then goes in as a list; when that add fails, its line and its text stay. - Without it: A single-line field drops the line breaks, so three pasted addresses run together into one entry that nobody can mail. - Code: `` - Your part: Nothing extra: `@add` receives the pasted items as one array, as it does a typed item. Set `split` to decide whether commas and semicolons separate items too, `fix` for what can be corrected automatically, such as letter case, and `check` for what cannot. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "a pasted column adds every line, repeats pointed at", "split, fix and check go the same way: a list typed with commas, tidied, the bad one kept", "a column pasted while an add is on its way is not swallowed: it waits and goes in as a list", "a list waiting behind an add is not sent after the field was disabled" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#paste-many ##### Outcome shown - When: an item is added, refused or fails. - Props: none. - Guarantee: While an entry is being added, the field shows progress and a screen reader announces “Adding…”. Validation errors explain what to correct. A failed request restores the text and explains how to retry. Error messages appear below the field and are announced as alerts. - Without it: The field changes without a word, and you cannot tell whether the item was added. - Code: `` - Your part: Return the request’s promise from the add handler so the field can report its progress and failure. Show success in the updated list, for example by highlighting the new row. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "a request says Adding… with a spinner; a failure gives the text back with a line" - Pattern for all components: https://grani.arseni.work/patterns/waiting#answered ##### One press, one request - When: an item is being added. - Props: `@add` returning a promise. - Guarantee: While the add is on its way, a second Enter or Add sends nothing. You can keep typing, and the text waits for your next Enter. - Without it: An impatient second Enter sends the same item twice. - Code: `` - Your part: Return the request’s promise from `@add`, and put the new names into `existing` before it settles, so the next add finds them. Give the request a timeout. - Not covered: A promise that never settles holds the field until it unmounts: Escape and `disabled` do not let it go. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "pressing Enter twice while the add is on its way sends once", "typing goes on while the add is on its way, and the next Enter sends it" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Way forward - When: the list is empty, or nothing matches the typed text. - Props: `empty` for the words of an empty list. - Guarantee: An empty list says how to start: “Nothing here yet: type a name and press Enter.” A search that finds nothing offers to add what was typed, with Add ↵ at the end of the field and Enter. - Without it: An empty card under a field, with no word on what to type or that Enter adds. - Code: `` - Your part: Say in `empty` what the list holds when the default words do not fit, such as “No tags yet: type one and press Enter.” - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "an empty list says how to start; a query or a list hides the line", "an empty list counts nothing and offers the name right after it", "a search that finds nothing offers to add what was typed" - Pattern for all components: https://grani.arseni.work/patterns/finding#empty-next-step ##### Back to the value - When: the app refuses a change to the text in the field. - Props: `v-model:query`. - Guarantee: The field shows the text as the app holds it, after a refused change and after a form reset. - Without it: The field shows text the app did not accept. - Code: `` - Your part: Keep `v-model:query` on the text the app holds. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "a change the parent refuses puts the input back on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Words in the making - When: an entry is typed through an input method. - Props: none: every UiListField. - Guarantee: The Enter that picks the word adds nothing; the entry is added by the Enter after it. - Without it: Half a word added to the list, and the rest of it typed as a second entry. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "the Enter that confirms an IME composition adds nothing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#word-as-composed ##### Search clears - When: the field has text. - Props: none. - Guarantee: The cross empties the field and keeps keyboard focus in it, from the keyboard too. Escape empties it as well, or clears the line under it, and goes no further, so a drawer around the list stays open; on an empty field Escape reaches the drawer. - Without it: Escape in the field closes the whole drawer instead of clearing what you typed. - Code: `` - Your part: Nothing. Do not add a clear button of your own. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "Escape empties the field and stays; empty, it goes on to the host", "no line of keys under the list; the cross empties the field", "the cross and Add, pressed by keyboard, keep the focus in the field", "Escape with only a line under the field clears the line and stops there" - Pattern for all components: https://grani.arseni.work/patterns/finding#search-clears ##### Focus on the error - When: a form is sent with this list in error. - Props: the error of its `UiField group`. - Guarantee: When this is the first field with an error, focus moves to its input so you can correct or add an entry immediately. The input uses the enclosing field’s id and error message. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `` - Your part: Pass the error to its `UiField group`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Not covered: A draft refused by `check` is not the form’s error: it reads as invalid to a screen reader, yet Save sends, and the focus stays on Save. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "inside UiField the input takes the field id and invalid, and focusInvalid lands on it", "a refused draft is not the form’s refusal: Save sends once, the focus stays on Save" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Cleared in place - When: The × or Add is pressed. - Props: none. - Guarantee: Both × and Add keep keyboard focus in the input, including when activated from the keyboard, so you can clear the text or add an entry and type the next one immediately. - Without it: After clearing or adding, keyboard focus is lost, and you go back to the field to type the next entry. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "the cross and Add, pressed by keyboard, keep the focus in the field" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#cleared-in-place ##### Enter picks - When: The list field stands in a form, such as a drawer’s. - Props: none. - Guarantee: Enter opens the row under the band, or adds the typed text when no row is there, and never submits the form around it. The band starts on the row named exactly as typed, else on the first match, so “fruit” opens Fruit, not Fruit juice above it. - Without it: Enter, meant to add a tag, saves the half-filled record. - Code: `
` - Your part: Nothing. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "in a form Enter opens the row under the band or adds the text, and the form is not sent", "rows that arrive after the query still take the band: Enter opens the first match, adds nothing", "the band starts on the row named exactly as typed: “fruit” opens Fruit, not Fruit juice above it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#enter-picks ##### Shortcuts kept - When: the field has keyboard focus. - Props: none. - Guarantee: ArrowUp and ArrowDown alone move the band over the matches; with Shift held they select text, and with Alt, Ctrl or Cmd held the browser moves the text cursor. Cmd+Enter or Ctrl+Enter adds nothing: it reaches the form around the list, so a drawer saves. - Without it: Cmd+Enter, meant to save the drawer, adds the half-typed name to the list instead. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "with Shift, Alt, Ctrl or Cmd an arrow is the browser’s, as in any text field: the band stays", "Cmd or Ctrl with Enter is the form’s: nothing is added and the key goes on" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#shortcuts-kept ##### Focus stays near - When: A row is removed from the keyboard. - Props: none: every UiListField. - Guarantee: After you remove an entry, keyboard focus moves to the remove button of the next row, or of the previous row if there is none, and to the input when no entries remain, so you can remove several without finding the button each time. Adding with Enter clears the input and keeps focus there for the next entry. - Without it: Keyboard focus is lost with the removed row, and you have to find the list again. - Code: ` ` - Your part: Nothing. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "Enter adds what is offered, empties the field and keeps the focus", "removing a row puts the focus on its neighbor", "the rows change in place (splice): the removed row’s neighbor takes the focus", "when the list has room again, a focus on the line that said it was full goes back into the field" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-stays ##### One name - When: it sits inside a UiField with `group`. - Props: `ariaLabel`, the placeholder, or the legend. - Guarantee: The input is named once, by the field's legend or by its own `ariaLabel`, and the field's error is announced as its description. - Without it: An input announced with two names, or only by its placeholder. - Code: `` - Your part: Name the list by what it holds, such as Tags or Allowed domains. - Proof, in the repository's tests (UiListField.spec.ts, not in the package): "inside UiField group the input is named by the legend and described by the error" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useAddItems`, `useUiT`, `useControlSize`. ### List item (`UiListItem`) > Show one row with a title and description and an action or value beside it. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/list-item Also called list row, setting row, cell. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Ignore extra presses while a request runs. - Show the error where the hint was. - Go straight to the field that needs fixing. - Name states in words, not color alone. - Toggle or pick by clicking the words. - Know when a link opens a new tab. - Open a related record inside the drawer. - Name each field once for screen readers. - Announce required fields in words. Implemented by the app: - Data fetching, saving, permissions and business rules. - Set `loading` from the press until the request answers. - Write the error as what to do, about as long as the description. - Pass the error in `error`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Write the title and the text; the tone word comes from the library. An icon of your own in `#media` must show the tone as plainly. - Put exactly one control in `#actions`: a label names one control. - Pass the address in `to`; nothing else is needed. - When the row is pressed, open the step in the drawer the row stands in. - Put one control in `#actions`, with no text or `ariaLabel` of its own. - Set `required` on the row instead of typing an asterisk into the title. The app still checks the answer. #### Why it works this way One row, a title and its text on the left and a thing on the right. Take it for every row: a plain one, a link, a setting, a command, a notice; rows one under another stand in a UiList. **Anatomy.** It is the library's one row: media, text and actions, centered on the row. A component with text on the left and a thing on the right stands on a UiListItem and does not draw its own row. **Modes.** A row is one of four, told apart by its props and checked by TypeScript: a plain row; a link (`to`); a field (`as="label"`); a command (`as="button"`, with `step`, `loading` and `tone="danger"`). A prop of another mode is a type error, such as `step` on a link or `error` on a command. **Link.** With `to`, the whole row is a link ending in a chevron that slides on hover; a page on another site opens in a new tab and ends in an arrow instead, as with UiLink. A link holds no interactive actions, a badge or a text at most, because a button inside a link makes two controls in one. **Lines.** A row draws no line of its own: the UiList it stands in divides its rows, so a card of rows is UiCard `:padded="false"` around a UiList. **Notice.** With `tone` a plain row is a notice, read as a status, with the glyph of its tone first (`#media` gives another) and its tone said to a screen reader; `xs` outline buttons go in `#actions`. It replaces a callout: a title, text, an action. Alone (over a form, in a toast) it is drawn like a toast: a white card whose start edge is in the tone. In a UiList it is a row of the list: the rows' edges, height and lines, no card of its own, and the tone's 3px edge at the start along the whole row; `variant="soft"` fills that row with the tone's pale plate as well, as on a soft badge. **Field.** `as="label"` makes the row a field: the title names the one control in `#actions` (a UiSwitch, a UiCheckbox, a UiSelect), the description or `error` describes it, `required` marks it, and a click anywhere on the row reaches it. A setting row is this, with a UiSwitch in `#actions`. **Command.** `as="button"` makes the row a command: the whole row is a button, with a status glyph in `#actions` (a check, a trash can). `loading` puts a spinner in the glyph's place and refuses clicks while the focus stays. `tone="danger"` is the red ink of a command that cannot be undone. `step` ends the row in a chevron, as on a link: the row goes one level in, to a step of UiCrudDrawer (Rules, Move to), with a count or a word in `#actions`. Such rows one after another form the action block of a record. In UiMenu a command row is an item of the menu, drawn by the menu as a row of its panel list. **Lit.** `lit` lights a row for a moment: the one UiListField just added, or the one a refused repeat points at. **Placement.** It stands in a UiList in a card, in a drawer section, in a dropzone's file list; alone in a toast and in a table's error row and bulk bar. Its neighbors are UiField (a label above its control) and UiButton. A link or a command row holds no control in `#actions`, and warns if it does. The chevron and the text follow the reading direction. **Highlight.** `highlight` marks the letters a search found. ##### Keyboard A plain row takes no focus; the controls in `#actions` do. With `to`, the whole row is a link: - `Enter`: follows it. With `as="button"`, a native button: - `Enter` `Space`: press it; `loading` or `disabled` refuses the press in the capture phase, before the router sees it, keeping the focus while loading. A held Enter is one press: its repeats press nothing. `as="label"` reaches its one control with a click anywhere on the row; the control keeps its own keys. ##### Screen reader - Row: the title, then the description; `highlight` marks letters for the eye only. - Label row: names its control by the title (`aria-labelledby`), describes it by the description or the error (`aria-describedby`), and marks it `aria-invalid` and `aria-required`; the error is an alert. - Notice: a `status` that says its tone in words before the title. It appears with its words, which is often not read out; what must be heard goes through a toast (UiToaster). - Link: to another site, says it opens a new tab. - Command: says busy while loading (`aria-busy`, `aria-disabled`). - Glyphs: the chevron and the tone glyph are hidden. - In a list: in a UiList it is a list item. ##### States Rest; hover and focus as a band on a link or a command; `disabled`, in the disabled ink and cursor, not faded; `loading`, a spinner in place of the glyph, with the ink kept; `tone` as the ink of a command (`danger` red, `brand` for the one that creates something new), or as a notice on a plain row (a card alone, a row with the tone's edge in a list, `soft` filled there); `lit`, a moment of brand color; a label row whose control is disabled, with the title muted; `step`, a chevron at the end. A row of a list is 44px tall at `sm` and 56px at `md`: a second line of text grows it, a control in `#actions` does not (up to the row's own height, 44 and 56). ##### When to use another component - A field with its label above its control: UiField. - An action on its own: UiButton. - A list of names that grows from its last row: UiListField. - A message about a request: a toast through UiToaster. - Why a list is empty: UiNoData. #### Examples ##### Link ```vue ``` ##### Long text ```vue ``` ##### Search hit ```vue ``` ##### Setting ```vue ``` ##### Notice ```vue ``` ##### Actions of a record ```vue ``` ##### Lit and brand ```vue ``` ##### Sizes ```vue ``` ##### Alone ```vue ``` ##### States ```vue ``` #### API Props: - `title`: `string` — The primary line of text. - `description`: `string` — Supporting text below the title. - `variant`: `"soft" | "outline" | "plain" | "muted"` = `soft | outline | plain | muted` — `plain` for a row among rows, `outline` for a standalone row, `muted` for a filled surface. On a notice in a UiList, `soft` fills the row with its tone's pale plate as well; `plain` keeps the surface. Alone the notice is its card either way. - `size`: `RowSize` = `sm | md` — `md` for pages, `sm` for drawers and dense lists. Unset: the host's (a sidebar says `sm`), else `md`. Rows keep their own scale, 44 at `sm` and 56 at `md`; the app's size (`createGrani({ size })`) does not move them. - `highlight`: `string` — Letters of the title to mark, in any case: the query of a search over the rows. The first match is marked. - `lit`: `boolean` — A moment of brand color: the row just came, or a refused repeat points at it. Set it, clear it after a second. - `as`: `"label" | "button" | "div"` = `label | button | div` — The root element: `label` for a field, `button` for a command; a plain row is a `div`. - `to`: `string` — A route or URL. Makes the whole row a link; `actions` then hold no controls. A disabled one is a disabled button, not a link; `javascript:` and `data:` are refused, as on UiLink. - `target`: `"_blank" | "_self"` = `_blank | _self` — With `to`: `_blank` or `_self` decides the tab; unset, a new tab for an absolute address only. - `rel`: `string` — With `to`: replaces the default `noreferrer` (`nofollow`, `sponsored`); `noopener` stays on a new tab. - `step`: `boolean` — With `as="button"`: the row goes one level in, a step of a drawer; a chevron at the end. - `disabled`: `boolean` — A command or link row that cannot be taken now: no rights, nothing to do. - `loading`: `boolean` — A command or link row is running: a spinner in place of the glyph, clicks refused, focus kept. - `error`: `string` — With `as="label"`: an error in place of the description, read with the control and marking it invalid. - `required`: `boolean` — With `as="label"`: an asterisk after the title and `aria-required` on the control. - `tone`: `"brand" | "success" | "warning" | "danger" | "info"` = `brand | success | warning | danger | info` — Meaning. On a plain row a notice: alone a white card whose start edge is in the tone, in a UiList a row with the tone's edge, read as a status. On a command or link row the ink alone: `danger` for an irreversible action, `brand` for the one that makes something new (“Add …”). Anything else lands on the root element: native attributes, aria-*, data-testid. Events: - `click` (`event: MouseEvent`) — The row was clicked; a command row is silent while `loading` or `disabled`. Slots: - `default` with `Record` — Own text in place of `title` and `description`, when two lines are not enough. On a label row it names nothing: give the control its own name. - `media` with `Record` — Content on the left: a glyph, an avatar or an image. The caller sizes avatars and images; glyphs are 20px. - `actions` with `Record` — Controls on the right: a button, a badge, a switch. Passive content only on a link or a command row (`to`, `as="button"`): a control there is a control inside a control, and warns. #### How it behaves ##### One press, one request - When: a command or link row is loading. - Props: `as="button"` or `to`, with `loading` set while the request runs. - Guarantee: A spinner replaces the icon, and further presses are stopped before your listener or the router sees them, so a link row does not navigate. Keyboard focus stays on the row. - Without it: A second press on a slow row sends the request again. - Code: ` ` - Your part: Set `loading` from the press until the request answers. - Proof, in the repository's tests (UiListItem.spec.ts, not in the package): "Loading puts a spinner in the glyph's place and refuses the press; the button stays enabled.", "a loading link row refuses the click in the capture phase, ahead of the router" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Error replaces hint - When: a setting row with a description shows an error. - Props: `description`, `error`. - Guarantee: The error takes the description’s place and is announced, and the control is marked invalid. When the error clears, the description comes back. - Without it: The error stacks under the description, and a screen reader does not hear it with the control. - Code: `…` - Your part: Write the error as what to do, about as long as the description. - Proof, in the repository's tests (UiListItem.spec.ts, not in the package): "an error replaces the description, is an alert and marks the control invalid", "When the error clears, the description comes back and describes the control again." - Pattern for all components: https://grani.arseni.work/patterns/filling-in#error-takes-the-hints-place ##### Focus on the error - When: a form is sent with a setting row in error. - Props: `error`. - Guarantee: The row marks its control invalid, so when it is the first field with an error, keyboard focus lands on the control. - Without it: Keyboard focus stays on Save, and you look for the row yourself. - Code: `…` - Your part: Pass the error in `error`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiListItem.spec.ts, not in the package): "in a label row with an error, focusInvalid lands on its control" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Words, not only color - When: it is a notice: a plain row with `tone`. - Props: `tone`. - Guarantee: A screen reader announces its tone in a word before the title (Note, Done, Warning, Error), and the tone's icon is drawn beside the colored edge, so color never carries the tone alone. - Without it: A colored edge alone: in grayscale, a warning and a success look the same. - Code: `` - Your part: Write the title and the text; the tone word comes from the library. An icon of your own in `#media` must show the tone as plainly. - Not covered: An icon given in `#media` replaces the tone's icon, and the row cannot check that yours still shows the tone. - Proof, in the repository's tests (UiListItem.spec.ts, not in the package): "A notice says its tone in words before its title, not by color alone.", "each tone of a notice draws its own glyph, and #media replaces it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#level-not-by-colour ##### Click the label - When: it is a setting row with `as="label"`. - Props: `as="label"`, with one control in `#actions`. - Guarantee: The whole row is the label of its control: a click on the title, the description or the space between toggles it, and a screen reader announces the title as the control's name. - Without it: A click on the words does nothing; only the small switch at the end of the row responds. - Code: ` ` - Your part: Put exactly one control in `#actions`: a label names one control. - Not covered: Two controls in one row warn in development, and neither gets a clear name. - Proof, in the repository's tests (UiListItem.spec.ts, not in the package): "A click anywhere on a setting row, its title included, toggles the switch it names." - Pattern for all components: https://grani.arseni.work/patterns/filling-in#label-is-target ##### New tab said - When: the whole row is a link to another site. - Props: `to`. - Guarantee: The row opens the site in a new tab and ends in an arrow instead of the chevron, and a screen reader announces “opens in a new tab” after the row’s title. - Without it: The row opens another tab without saying so. - Code: `` - Your part: Pass the address in `to`; nothing else is needed. - Proof, in the repository's tests (UiListItem.spec.ts, not in the package): "a row linking to another site opens in a new tab and says so", "a row to another site ends in the new-tab arrow, not the chevron" - Pattern for all components: https://grani.arseni.work/patterns/opening#new-tab-said ##### Step in - When: a row leads one level in. - Props: `as="button"` with `step`, a count or a word in `#actions`. - Guarantee: A chevron at the end of the row shows that it opens a deeper view. It sits beside any count or text, mirrors for right-to-left languages, and the whole row acts as one button. - Without it: A row that opens a second window over the drawer, or one that looks like every other button. - Code: ` ` - Your part: When the row is pressed, open the step in the drawer the row stands in. - Proof, in the repository's tests (UiListItem.spec.ts, not in the package): "a command row with step ends in a chevron beside its actions; a plain command has none", "a step row chevron mirrors under rtl, slides only when enabled, and its text aligns to the start" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#open-related ##### One name - When: it is a setting row with `as="label"`. - Props: `as="label"`, `title`. - Guarantee: The title names the control in `#actions` once, and the description is read with it. - Without it: A switch at the end of a row with no name, or a name other than the words beside it. - Code: `` - Your part: Put one control in `#actions`, with no text or `ariaLabel` of its own. - Proof, in the repository's tests (UiListItem.spec.ts, not in the package): "the title names the control, the description describes it, the row is its label", "two controls in one label row warn in dev: a label names one" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice ##### Required in words - When: a setting row must be answered. - Props: `required`. - Guarantee: The title shows an asterisk, and the control in `#actions` is announced as required (`aria-required`). - Without it: An asterisk that a screen reader skips. - Code: `…` - Your part: Set `required` on the row instead of typing an asterisk into the title. The app still checks the answer. - Proof, in the repository's tests (UiListItem.spec.ts, not in the package): "required puts an asterisk on the title and aria-required on the control" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#required-in-words The patterns: https://grani.arseni.work/patterns #### Built on it - [List field](https://grani.arseni.work/components/list-field): Search a list and add entries from one field. Paste a column to add several entries, and recover your text if adding fails. Built with what `grani` exports: `useUiT`. ### Log (`UiLog`) > Follow a record's history: who changed what and when, with complete old and new values shown together. A component of grani 0.1.0. Data, stable, since 0.1.0. Page: https://grani.arseni.work/components/log Also called timeline, activity log, history, audit log. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Read old and new values in full. - See whether an action worked. - Ignore extra presses while a request runs. - Give every empty list a way forward. - Keep nearby content in place while loading. - Keep reading as older events load. - See when each event happened. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass each change in `changes` with `from` and `to`, the values as they were before and after. - Reject the `@more` promise when the load fails. - Return the promise of the load from `@more`. - Append older events to `items`; do not replace the array when loading more history. - Resolve the `@more` promise once the older events are in `items`. - Set the time zone in `createGrani({ timeZone })`; otherwise the server and the browser may show different hours and days. #### Why it works this way The history of a record on a timeline, newest first. Take it as the last section of a record's drawer or page. The app's part. It sorts `items` newest first (an unsorted log repeats its day headings), keys the log per record (`:key="record.id"`), so a filter or a failed "older" of one record does not stay on the next, and returns the promise from `@more` and `@retry`, which is what holds their buttons. **Event.** Each event is a node with who did what to what ("Anna changed Status"), when, and the value before and after. One field reads on its own line ("Status Waiting → Approved", a status in badges); several fields stand in a small table (field, was, became). Words that came with the event, such as a reason to decline, stand under it. An event with no one behind it (`actor: null`, a release in a changelog) opens with what was done; an unset actor is the system. `anchor` gives the event's row an id, so a link can point at it. **Node.** The node is a dot in the event's tone, or, with `node="actor"`, the face of who did it, for a log where the person matters most. **Timeline.** A line joins the nodes, so it is a timeline, not a list of rows, and does not stand on UiListItem. **Placement.** The last section of a record's drawer or page, after its form and its card of facts (UiListItem rows). It only tells, so a drawer level that ends in it has no footer. **Filters.** `filters` offers a choice over the list by the events' `kind`, "All" first: one kind at a time, so it is a choice, not toggles. Up to three kinds stand as a segmented choice; four or more go into a select, so the filter never runs past the card. A filter that leaves nothing loaded says so and offers "Show all". **Load.** While the first page comes (`loading` with no events) the log draws rows in the shape of events, a node and a line of text, so nothing moves when they arrive. A failed first load (`failed`) says so with Retry, which calls `@retry` and is held while its promise runs. No events is said, not left blank. With events in view, `loading` is a refresh: they stay, dimmed and busy, and "Load older events" waits; `failed` leaves them and says so under them, with Retry. The states stand on UiNoData and UiSkeleton, as every data state of the library does. **Older events.** `hasMore` with `@more` ends the list in "Load older events", held while the promise runs. A failed load says so under the list and leaves the button to retry. When older events arrive, what was on screen stays in place and the count that came is said. **Days.** When every event's `at` is a Date, the events stand under their days: "Today", "Yesterday", then the weekday and the date, with the year when it is not this one. Each event then reads its hour alone, and the timeline runs on through the headings: one history. It is decided over all of `items`, so a filter never turns the headings on or off. A time given as a string cannot be told apart by day, so such a log keeps every date on its line: the hour for today, the date otherwise. A Date that is no moment reads "—" under the day before it. **Time.** The exact moment is in the time's title, in the app's time zone (`createGrani({ timeZone })`). Set the zone, or the server and the browser may show different hours and days. **Rhythm.** 24 between events, 8 inside one, so where an event ends reads at a glance. **Values.** A value is never cut: a long one wraps. A `from` or `to` that is `null` or an empty text is no value: no `from` reads as set, no `to` as cleared; a 0 is a value. In a table of changes an arrow stands between what it was and what it became, as on one line; in a card too narrow for three columns each row stands as a stack, the field over the pair, so a long value keeps the card's width. Among faces (`node="actor"`) the system has a plate of a face's size with a glyph. ##### Keyboard The filter is a segmented UiRadioGroup, or a UiSelect past three kinds; "Show all", "Load older events" and Retry are buttons; the events take no tab stop. - `Tab`: reaches the filter, then goes on to "Show all", Retry or "Load older events". - `ArrowLeft` `ArrowRight`: move the segmented filter from kind to kind; the select opens on its own keys. - `Enter` `Space`: press the button. When "Load older events" disappears under the focus, the focus goes to the first new event, else the last event shown, else the filter, never to the page; so does a Retry that brought the history, whether the app draws it before or after its promise ends, and a Retry that failed again hands it to the new Retry. A refresh leaves the focus on "Load older events", which waits. ##### Screen reader - List: named by `ariaLabel` when no heading names it. - Event: read as its words: who did what to what, and when; with no one named, what to what. - Days: each day is an item of the log, its events a list named by its heading. - One change: reads "Status Waiting became Approved" (the arrow is hidden and "became" is said in its place). - Several changes: stand in a table named "Changes" with the columns Field, Previous value and New value; the arrows between them are hidden. - Filter: a radiogroup, or a combobox past three kinds, named "Show events", the kind that is on chosen. - Older events: a status region says how many came ("3 older events loaded"), once: a refresh or another kind shown lets it go. - Failed load: an alert under the list; a failed first load is an alert with Retry. - First load: a status says the history is loading; the rows in its shape are hidden. ##### States Events under their days or with their dates, as dots in their tone or as faces, the system's plate among faces, an event with no one named, one change on a line, several in a table, a note; filters with the kind that is on; `hasMore` with "Load older events", held while the promise runs, failed with its line and the button left to retry, and the loaded count said; nothing yet (`empty`), and a filter that leaves nothing loaded, with "Show all"; the first load (`loading`), and a first load that failed (`failed`), with Retry held while it runs. ##### When to use another component - The facts of a record: UiListItem rows in a UiList. - A list of records: UiTable. - A request answered: a toast of UiToaster. - A timeline the person edits: never; it only tells. #### Examples ##### Faces ```vue ``` ##### Changes ```vue ``` ##### Versions ```vue ``` ##### No one named ```vue ``` ##### Older ```vue ``` ##### Load ```vue ``` #### API Props: - `items`: `LogEvent[]` (required) — The events, newest first. - `node`: `"dot" | "actor"` = `dot | actor` (default `"dot"`) — `dot`: a dot in the event's tone. `actor`: the face of who did it, the system a plate with a glyph. - `filters`: `{ value: string; label: string; }[]` (default `[]`) — Kinds to choose from over the list, matched against each event's `kind`; "All" comes first. Up to three stand as segments, more go into a select. - `hasMore`: `boolean` (default `false`) — Older events exist: the list ends in "Load older events", which calls `@more`. - `onMore`: `(() => unknown)` — "Load older events" (`@more`): load the events before the last one. Return a promise to hold the button while it runs and to say a failure; nothing returned, it is not held. - `empty`: `string` (default `""`) — The line when there are none; defaults to "No activity yet". - `loading`: `boolean` (default `false`) — The first load is on its way: rows in the shape of events, nothing else. - `failed`: `boolean` (default `false`) — The first load failed: says so, with Retry. - `onRetry`: `(() => unknown)` — Retry (`@retry`): load the history again. Return a promise to hold the button while it runs. - `ariaLabel`: `string` (default `""`) — The list's name for a screen reader, when no heading names it. - `filter`: `string` (default `""`) — The kind that is on: an event `kind`, or '' for all. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:filter` (`value: string`) — The new value of `filter`, for `v-model:filter`. #### How it behaves ##### Values never cut - When: an event changed values. - Props: `changes` with `from` and `to`. - Guarantee: No value is cut: a long one wraps, so the old and the new value read in full. One change stands on its own line, such as “Status Waiting → Approved”; several stand in a table with the field, the previous value and the new value. - Without it: Values cut to one line: two long addresses that differ at the end look the same. - Code: `` - Your part: Pass each change in `changes` with `from` and `to`, the values as they were before and after. - Proof, in the repository's tests (UiLog.spec.ts, not in the package): "a long was and became wrap and keep both values readable, none is cut" - Pattern for all components: https://grani.arseni.work/patterns/finding#was-and-became ##### Outcome shown - When: older events fail to load. - Props: `@more` rejecting. - Guarantee: A message below the list says the load failed (“Could not load older events. Try again.”), and “Load older events” stays available to try again. - Without it: The button silently does nothing after a failure. - Code: `` - Your part: Reject the `@more` promise when the load fails. - Proof, in the repository's tests (UiLog.spec.ts, not in the package): "a rejected onMore does not go unhandled and the button stays clickable to retry" - Pattern for all components: https://grani.arseni.work/patterns/waiting#answered ##### One press, one request - When: older events load on request. - Props: `@more` returning a promise. - Guarantee: “Load older events” calls `@more` once and ignores further presses until its promise settles. - Without it: The button sends a second request while the first is still on its way. - Code: `` - Your part: Return the promise of the load from `@more`. - Proof, in the repository's tests (UiLog.spec.ts, not in the package): "Show older calls @more once and is held while the promise runs" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Way forward - When: a filter hides every loaded event. - Props: `filters`. - Guarantee: It says “No events of this kind are loaded.” and offers Show all, which turns the filter off and brings back every loaded event. - Without it: An empty timeline that reads as if nothing ever happened to the record. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiLog.spec.ts, not in the package): "a filter that hides every loaded event says so and offers all" - Pattern for all components: https://grani.arseni.work/patterns/finding#empty-next-step ##### Stable dimensions - When: older events arrive. - Props: `hasMore`, `@more`. - Guarantee: Older events are added after the last one; what was already on screen stays where it was. - Without it: The list is redrawn from the top, and you have to find your place again. - Code: `` - Your part: Append older events to `items`; do not replace the array when loading more history. - Proof, in the repository's tests (UiLog.spec.ts, not in the package): "older events append after the last one and leave the nodes already shown" - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt ##### Focus after loading - When: older events load on request. - Props: `hasMore`, `@more`. - Guarantee: If “Load older events” had focus and disappears after the final load, focus moves to the first new event. A screen reader announces the result, such as “2 older events loaded”. - Without it: The button disappears with keyboard focus on it; focus falls to the page, and a screen reader loses its place. - Code: `` - Your part: Resolve the `@more` promise once the older events are in `items`. - Proof, in the repository's tests (UiLog.spec.ts, not in the package): "Show older keeps the place: focus moves to the first new event when the button goes, and the count is said" - Pattern for all components: https://grani.arseni.work/patterns/waiting#keep-place ##### Exact time - When: events carry a Date. - Props: `at` as a Date. - Guarantee: When every event uses a Date value, the history groups events by day and shows each event’s time. Headings identify Today, Yesterday or the weekday and date, adding the year when needed. Hover over a time for the full date and time in the app’s time zone. If any timestamp is supplied as text, events are not grouped by day and each retains its own date. - Without it: “2 days ago” with no way to tell the hour. - Code: `` - Your part: Set the time zone in `createGrani({ timeZone })`; otherwise the server and the browser may show different hours and days. - Proof, in the repository's tests (UiLog.spec.ts, not in the package): "a Date of today reads the hour, another day its date, another year its year; the exact moment is on the time" - Pattern for all components: https://grani.arseni.work/patterns/finding#exact-time The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useToday`, `useUiT`. ### Menu (`UiMenu`) > Open a menu of available actions, such as exporting records, changing a row or managing your account. A component of grani 0.1.0. Overlays, stable, since 0.1.0. Page: https://grani.arseni.work/components/menu Also called dropdown menu, context menu, action menu. It works on top of [Popover](https://grani.arseni.work/components/popover). #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Ignore extra presses while a request runs. - Jump to an option by letter. Implemented by the app: - Data fetching, saving, permissions and business rules. - Set `loading` on the item from its click until the command answers. - Give the items labels that start with different letters where you can, so one press reaches each of them. #### Why it works this way A list of commands that opens under a button. Take it for actions that have no room beside their subject, such as the actions of a table row. **Trigger.** The trigger is your own button in `#trigger` (a UiButton, or a UiIconButton with three dots); the menu gives it `aria-haspopup`, `aria-expanded` and the toggle. **Items.** Items are UiListItem command rows (`as="button"`) in the default slot, with groups split by a UiSeparator. The menu draws every one as a `menuitem`, a row of its panel list (`utils/panelList`, shared with UiSelect, UiFilterList and UiDateRange): edge to edge, lit as the same band by the arrows and by the pointer, with the row's title, description, glyphs, tone, `loading`, `disabled` and `step`. A click on an item runs it and closes the menu, unless the item is `loading` or `disabled`. A menu runs commands only: a link row (`to`) or a label row in it warns in development, since it would be a button going nowhere; a place to go is a link row outside the menu, or a command whose `@click` navigates. A text or a row inside a component of your own warns too: it is not an item. **Popover.** It stands on the panel core UiPopover stands on, which gives the panel, its place beside the button and its place on the overlay stack; the menu adds the items and the keys. It holds commands only. A panel of controls (filters, a period, columns) is a plain popover, because it needs the focus moved into it and kept there, which a menu does not do. **Kept pages.** In a page that a `KeepAlive` keeps, a menu left open stays in the body when the page is deactivated, as a popover does. Close it when the route changes. **Placement.** It sits under a button of its own, in the trailing slot of a UiButtonGroup, and in a table row's `#row-actions`. Opened from a drawer or a window, it paints above it. ##### Keyboard As in the WAI-ARIA menu button. On the button: - `Enter` `Space` `ArrowDown`: open it on the first item. - `ArrowUp`: opens it on the last item. With `Alt` the arrows open it too; `Ctrl` or `Meta` with one stays the browser's. In the open menu: - `ArrowDown` `ArrowUp` `Home` `End`: move; the arrows wrap and skip a disabled item. - `A–Z`: goes to the next item that starts with the letter. - `Escape` `Tab`: close it and return the focus to the button, and so does running an item, before its command runs: a command that removes the button's row leaves the focus to the list. A held Enter is one press: it opens the menu once and runs one item. A click outside closes it, and the focus stays where the click went. Opened with the mouse, it puts the focus on the panel, not on an item. ##### Screen reader - Button: says it opens a menu (`aria-haspopup="menu"`) and whether it is open (`aria-expanded`). - Panel: a `menu` named by the button (`aria-labelledby`; your own id on the button wins). - Items: each a `menuitem` outside the tab order; a `loading` item says busy (`aria-busy`), and a `disabled` one is a disabled button. ##### States Closed, or open beside the button on its start or end edge (`align`); while the button is scrolled out of the window, it waits hidden, still open, and the focus it held comes back with it. A `loading` item spins and refuses the click, and the menu stays open; a `disabled` item is skipped by the arrows. Past five commands, the panel takes a measured height that shows half of the sixth row, and scrolls. With every item switched off by `v-if` it opens empty, the focus on the panel: give the button `disabled` instead. ##### When to use another component - A panel of controls (filters, a period, columns): UiPopover. - A value to pick: UiSelect. - A few actions side by side: UiButtonGroup. #### Examples ##### Row ```vue ``` ##### States ```vue ``` ##### Long ```vue ``` ##### Window ```vue ``` #### API Props: - `align`: `"start" | "end"` = `start | end` (default `"end"`) — Which edge of the button the menu lines up with; start and end follow the reading direction. Attributes are placed by the component itself: its description says where. Slots: - `trigger` with `Record` — The button that opens the menu: one element or component, it keeps its own look and name. - `default` with `Record` — The items: `UiListItem as="button"` rows placed directly (not inside a component of your own), groups split by a `UiSeparator`. An item's `@click` that stops the click keeps the menu open with the focus on its button: let it through. #### How it behaves ##### One press, one request - When: an item’s command is running (`loading` on the item). - Props: `loading` on the UiListItem command row. - Guarantee: While the item is marked `loading`, a spinner replaces its icon and further clicks are ignored. The menu stays open until the command finishes. - Without it: A second click on a slow item runs the command again. - Code: `` - Your part: Set `loading` on the item from its click until the command answers. - Proof, in the repository's tests (UiMenu.spec.ts, not in the package): "a busy item refuses the click and the menu stays open" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Letter jumps - When: the menu is open. - Props: none: every UiMenu. - Guarantee: Type a letter to move focus to the next menu item that starts with it. Repeat it to cycle through matching items, returning to the start after the last match. If no item matches, focus stays where it is. - Without it: A letter does nothing, and you move through the menu with the arrow keys, one item at a time. - Code: ` ` - Your part: Give the items labels that start with different letters where you can, so one press reaches each of them. - Proof, in the repository's tests (UiMenu.spec.ts, not in the package): "a letter jumps to the next item whose label starts with it, wrapping" - Pattern for all components: https://grani.arseni.work/patterns/finding#letter-jumps The patterns: https://grani.arseni.work/patterns ### Modal (`UiModal`) > Complete a short task in a dialog, such as filling a form or reviewing a record's details. A component of grani 0.1.0. Overlays, stable, since 0.1.0. Page: https://grani.arseni.work/components/modal Also called dialog, modal dialog, window. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See one window at a time. - Go straight to the field that needs fixing. - Submit a form with Enter. - Submit from a text box with Ctrl or Cmd+Enter. Implemented by the app: - Data fetching, saving, permissions and business rules. - Give a question about the window `role="alertdialog"` (UiConfirm has it). Bind `open` with `v-model`: the first window closes by its `update:open` and says `close`, as on any close. Mark a window holding input `persistent`, so another window does not take its place. - Check the form in your `submit` handler and set the field's `error` there, before any `await`: the window looks for the first error right after the submit. An error the server returns after an `await` is focused when `submitting` falls: keep `submitting` true while the request is out and mark the field no later than you lower it. In a custom form, mark the field, wait for the render (`await nextTick()`), then call `focusInvalid(root)` from `grani` with an element of yours around the fields. - Make the main action a `type="submit"` button in `#footer`, and set `submitting` while the save runs. #### Why it works this way A window over the page for one task: a short form, a question, a detail. Take it when the task needs the person's whole attention. **Closing.** `title` names the window and `description` describes it. The backdrop, Escape and the cross close it. Escape works only while the window is the top layer, so a confirm over a drawer closes first. `persistent` guards unsaved input: the backdrop and Escape are refused with a shake, but the cross still closes, since pressing it is an explicit intent. `submitting` refuses every close while a request is out. These guard only the window's own exits; `open` set to false from outside is for the host to guard. Over a window. One window is seen at a time. A window opened from a window takes its place: the first closes, and when the new one closes the focus returns to what opened the first. A question (`role="alertdialog"`, UiConfirm) is about the window under it: it hides that window, out of sight and out of reach, and when it is answered the window comes back as it was, the focus on what asked. A window holding unsaved input (`persistent`) or a request (`submitting`) is not replaced: it waits hidden in the same way. The tint on the page stays as it was. A drawer is not hidden: a window over it leaves it in view. **Layout.** Attributes land on the dialog. The page under it does not scroll; a long body scrolls between the header and the footer. The footer's buttons share its width, so two buttons take half each. **Kept pages.** In a page that a `KeepAlive` keeps, a window left open stays open when the page is deactivated: its layer, its Escape and its scroll lock go on over the next page. Close it (`open` false) when the route changes. **Question.** `role="alertdialog"` is a question that needs an answer (UiConfirm): the backdrop does not close it, and the focus starts on the first button of the footer, the safe one. **Result.** `align="center"` shows a result after an action: `#media` (a glyph) over a centered title, with no lines between the parts. **Header.** `#header` puts a header of the window's own in place of the title and the cross (a search field over its results); the title stays for a screen reader. **Form.** With `form` the body and the footer become a form that sends `submit`. The main action is `type="submit"`; Enter in a field, and Cmd+Enter or Ctrl+Enter from a text box, send it. The shortcut presses that button, so while the button is held or spinning, the shortcut does nothing either. One request gets one submit. A submit that leaves a field `aria-invalid` puts the focus on the first such field and scrolls it into view; the form is ready again as soon as the person moves to fix it. An answer that comes later and marks a field does the same when `submitting` falls, unless the person went on typing in another field meanwhile. Do not put a form of your own in the slot. `submit` carries nothing: a window has one way to send, its main action. UiCrudDrawer's `submit` carries `{ another }`, since its Save can also mean "save and start the next record". ##### Keyboard The focus starts on the first field (a drawn select, date, switch, scale, drop zone or date range too), not on the cross. With no field it starts on a body longer than the window, so the arrows scroll it at once, else on the window itself; `role="alertdialog"` starts it on the first button of the footer. On close the focus returns to where it was. - `Tab`: goes round inside the window; a body longer than the window is one of its stops. - `ArrowUp` `ArrowDown` `PageUp` `PageDown` `Home` `End`: scroll that body once it holds the focus. - `Escape`: closes the window, only while it is the top layer; `persistent` answers it with a shake, and `submitting` refuses it. With `form`: - `Enter`: in a field, sends the form. - `Meta+Enter` `Ctrl+Enter`: send it from a text box too (`Meta` is Cmd). A submit that leaves a field `aria-invalid` puts the focus on the first one. ##### Screen reader - Window: a modal dialog (`aria-modal`) named by `title` (`aria-labelledby`) and described by `description`. With `#header` the title is still read, and a question (`alertdialog`) with no description is described by its body. - Cross: named "Close". - Long body: a region named by the title, so the reader says what the keys scroll through. - Errors: a refused submit marks the field in error `aria-invalid`. - Refused close: a shake for the eye; the reader keeps the focus where it was and hears why, from a status region inside the window: input not saved, or a request still on its way. A question refuses a press beside it with the shake alone: its answers are in reach. - Refused submit: the reader hears the field in error the focus moved to. - A window under another: out of the reader's reach until it comes back; then the reader hears it and the control the focus returned to. ##### States A form; a long body that scrolls between the header and the footer; a detail of rows running edge to edge (`padded` off); a question (`role="alertdialog"`); a result (`align="center"`); a search (`#header`); a window opened from a window, in its place; a question over a window, the one under hidden. `persistent` shakes at the backdrop and Escape; a submit the host refuses by marking a field as it gets it shakes too; `submitting` refuses every close and a second submit. Sizes `sm` and `md`, in both themes. ##### When to use another component - A record to edit: UiCrudDrawer. - A side panel beside a list: UiDrawer, to read or set at the side of the screen. - A small panel of controls under a button: UiPopover, with the page live. - A yes-or-no question: UiConfirm, built on this window. - The outcome of an action: a toast from `useToast` in UiToaster. #### Examples ##### Long ```vue ``` ##### Detail ```vue ``` ##### Question ```vue ``` ##### Over a window ```vue ``` ##### Result ```vue ``` ##### Search ```vue ``` #### API Props: - `title`: `string` (required) — The window's name, read first by a screen reader; required, a dialog without one is mute. - `description`: `string` (default `""`) — One line under the title, read right after it. - `size`: `"sm" | "md"` = `sm | md` (default `"md"`) — Width: `sm` for a question or a short form, `md` for a form with a few rows. - `persistent`: `boolean` (default `false`) — Unsaved input: the backdrop and Escape are refused with a shake; the cross still closes. - `submitting`: `boolean` (default `false`) — A request is out (a save, a delete): every close is refused with a shake, and no second submit goes. - `role`: `"dialog" | "alertdialog"` = `dialog | alertdialog` (default `"dialog"`) — `alertdialog` for a question that needs an answer: the backdrop does not close it, focus starts on the footer's first button. - `padded`: `boolean` (default `true`) — `false` for rows edge to edge (a detail of key and value): the body keeps no padding of its own. - `align`: `"start" | "center"` = `start | center` (default `"start"`) — `center` for a result after an action: a glyph over a centered title, no lines between the parts. - `form`: `boolean` (default `false`) — The body and the footer are a form: its `type="submit"` button, Enter in a field and Cmd+Enter from a text box send `submit`. Off, a question or a detail keeps its buttons plain. - `open`: `boolean` (default `false`) — Whether the window is open (`v-model:open`); every way out sets it false and emits `close`. Attributes are placed by the component itself: its description says where. Events: - `submit` — The form was sent, with `form`: its `type="submit"` button, Enter in a field, Cmd+Enter from a text box. - `update:open` (`value: boolean`) — The new value of `open`, for `v-model:open`. - `close` — The window closed: the cross, the backdrop, Escape, or another window that took its place. A window whose input must not go that way is `persistent`. Slots: - `default` with `Record` — The body. It scrolls when long; the header and the footer stay. - `media` with `Record` — A glyph over the title with `align="center"`: a check in a halo after a sent invoice. The window sizes it. - `header` with `Record` — The window's own header in place of the title, the description and the cross: a search field, as a palette's. The title is still read by a screen reader; Escape and the backdrop close. It stands alone: no `#media`, no `align="center"`. - `footer` with `Record` — Actions, sharing the footer's width in equal parts: the cancel is `secondary` and first, the main action last. #### How it behaves ##### One window seen - When: a window opens while another window is open. - Props: `role="alertdialog"` for a question about the window under it; every UiModal, UiConfirm included. - Guarantee: A window opened from a window takes its place: the first closes, and when the new one closes keyboard focus returns to what opened the first. A question is different: the window underneath is hidden from sight, from the keyboard and from screen readers while it asks, and comes back as it was once it is answered, with keyboard focus on the button that asked. Either way the page keeps one dimmed backdrop instead of two. - Without it: Two windows sit on top of each other over a doubly dimmed page, and it is hard to tell which one is asking. - Code: `… …` - Your part: Give a question about the window `role="alertdialog"` (UiConfirm has it). Bind `open` with `v-model`: the first window closes by its `update:open` and says `close`, as on any close. Mark a window holding input `persistent`, so another window does not take its place. - Not covered: A window with unsaved input (`persistent`) or a request on its way (`submitting`) is not closed for another: it waits hidden under the new one and comes back. - Proof, in the repository's tests (UiModal.spec.ts, not in the package): "a window opened from a window takes its place: the first closes, and the focus goes back to what opened it", "a window opened over a window hides it, out of sight and out of reach; one is seen", "a window that is persistent is not replaced: it waits under the new one and comes back", "a window that is submitting is not replaced: it waits under the new one and comes back", "a question over a window does not take its place: the window comes back once it is answered", "a window opened while a menu of the window is still open takes its place", "three windows: only the top one is seen, and they come back one by one", "closed from outside, the one on top brings the one under back before the focus goes in" - Pattern for all components: https://grani.arseni.work/patterns/closing#one-window ##### Focus on the error - When: a window with `form` is sent, and the app marks a field with an error. - Props: `form`, a field with `error` (`aria-invalid`) set as the submit is checked. - Guarantee: The dialog moves keyboard focus to the first field with an error and scrolls it into view, so you can start correcting immediately. A shake shows that the submission was rejected. Once you start correcting, Save becomes available for another attempt. - Without it: The error appears under its field while keyboard focus stays on Save, and you have to find what went wrong. - Code: ` ` - Your part: Check the form in your `submit` handler and set the field's `error` there, before any `await`: the window looks for the first error right after the submit. An error the server returns after an `await` is focused when `submitting` falls: keep `submitting` true while the request is out and mark the field no later than you lower it. In a custom form, mark the field, wait for the render (`await nextTick()`), then call `focusInvalid(root)` from `grani` with an element of yours around the fields. - Proof, in the repository's tests (UiModal.spec.ts, not in the package): "a submit that leaves a field invalid puts the focus on the first one" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Submit a form with Enter - When: The window has `form`, and you are typing in a single-line field. - Props: `form`, a `type="submit"` button in `#footer`. - Guarantee: Enter in the field submits the form, as a browser does when a form has a submit button. A second Enter emits no second `submit` until the save ends, or until you move in the form or change it. - Without it: You have to leave the field and move to Save, and a quick second Enter sends the form twice. - Code: ` ` - Your part: Make the main action a `type="submit"` button in `#footer`, and set `submitting` while the save runs. - Proof, in the repository's tests (UiModal.spec.ts, not in the package): "Enter in a single-line field submits: the field and Save stand in one form", "a second submit before the host answers emits no second submit" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#enter-saves ##### Cmd+Enter sends - When: The window has `form`, and you are typing in a text box. - Props: `form`, a `type="submit"` button in `#footer`. - Guarantee: Ctrl+Enter or Cmd+Enter in a text field activates the form’s submit button. It does nothing while the button is disabled or loading, or while focus is on Cancel. In a multiline field, Enter alone still inserts a line break. - Without it: You have to leave the text field and move to Save before you can submit the form. - Code: ` ` - Your part: Nothing beyond the `type="submit"` button in `#footer` that Enter needs too: the shortcut presses it. - Proof, in the repository's tests (UiModal.spec.ts, not in the package): "Cmd+Enter in a textarea submits; Enter alone does not" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#meta-enter-saves The patterns: https://grani.arseni.work/patterns #### Built on it - [Confirm](https://grani.arseni.work/components/confirm): Check a decision before an action that cannot be undone, and show progress while the confirmed action runs. Built with what `grani` exports: `useSubmitLock`, `focusInvalid`, `useUiT`. ### No data (`UiNoData`) > Understand why an area is empty and what you can do next. A component of grani 0.1.0. Status, stable, since 0.1.0. Page: https://grani.arseni.work/components/no-data Also called empty state, no results, placeholder message. It works on top of [Card](https://grani.arseni.work/components/card). #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Give every empty list a way forward. Implemented by the app: - Data fetching, saving, permissions and business rules. - Explain the cause and offer a useful action: Create when there are no records, Reset when filters hide them, or Retry with `tone="danger"` when loading fails. UiTable, UiDataTable and UiLog include this message component in their own states. #### Why it works this way An empty place that says why and offers the next step. Take it where a list, a table or a section has nothing to show. Nothing yet: create; nothing matches: change the filters; the load failed: retry (`tone="danger"`, read out as an alert). **Anatomy.** A glyph in `#media` on a round plate in the tone, a short `title`, the `description`, and the next step in `#actions` (UiButton of your own: you choose the weight and `loading`). **Frame.** On a page it has a dashed frame; in a card (UiCard `#empty`), a drawer or a window, the surface frames it. The root is a `
`. ##### Keyboard Not interactive: the block takes no focus; the buttons in `#actions` are the tab stops. ##### Screen reader - Role: a `status`; `tone="danger"` is an `alert`, read at once. The block appears with its words, and a status that appears with its words is often not read out: that a list is empty is said by a region that was on the page before (UiTable has one), and a page of your own keeps one. - Order: the title, then the description. - Glyph: the glyph on its plate is hidden. ##### States Nothing yet, with a `primary` button to create the first one; nothing matches, with a `secondary` button to reset the filters; the load failed, `danger`, a red plate with a warning glyph and Retry. `sm` in a drawer or a card, `md` on a page. A dashed frame on a page, none inside a card, a drawer or a window. A neutral one without a glyph draws no plate. ##### When to use another component - Content on its way: UiSkeleton. - An action in flight: UiSpinner. - A notice over content that is there: UiListItem in a `tone`. - A failed request: a toast through UiToaster. #### Examples ##### Nothing matches ```vue ``` ##### The load failed ```vue ``` ##### Section in a drawer ```vue ``` ##### In a card ```vue ``` ##### Without a glyph ```vue ``` #### API Props: - `title`: `string` (default `""`) — One short line of what happened: "No invoices yet", "The record did not load". - `description`: `string` (required) — Why it is empty and what to do next. - `tone`: `"neutral" | "danger"` = `neutral | danger` (default `"neutral"`) — `danger` for a load that failed: a red plate and an alert; its Retry is a `secondary` UiButton in `#actions`. - `size`: `"sm" | "md"` = `sm | md` (default `"md"`) — `md` for a page, `sm` for a section inside a drawer or a card. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `media` with `Record` — The glyph on the plate, as a row's `#media`; `danger` draws a warning sign when it is empty. - `actions` with `Record` — The next step, a UiButton of your own or two: `primary` to create the first one, `secondary` to reset the filters or retry, with `loading` when it sends. #### How it behaves ##### Way forward - When: a list, a table or a section has nothing to show. - Props: `title` and `description` for the cause, the step in `#actions`; `tone="danger"` for a failed load. - Guarantee: The message explains the cause through `title` and `description`, with the actions from `#actions` below it. With `tone="danger"`, it shows a warning icon on a red background and is announced immediately by a screen reader. - Without it: A blank area or “No data” message gives no explanation or next step. - Code: `` - Your part: Explain the cause and offer a useful action: Create when there are no records, Reset when filters hide them, or Retry with `tone="danger"` when loading fails. UiTable, UiDataTable and UiLog include this message component in their own states. - Not covered: It does not tell the causes apart by itself; only the app knows why the list is empty. - Proof, in the repository's tests (UiNoData.spec.ts, not in the package): "It holds the next step: a button of your own, with its own weight.", "the next step comes after the title and the description", "A failed load is said at once, on a red plate with a warning glyph." - Pattern for all components: https://grani.arseni.work/patterns/finding#empty-next-step The patterns: https://grani.arseni.work/patterns ### Pagination (`UiPagination`) > Move between pages with Previous, Next and page numbers below a list or table. A component of grani 0.1.0. Data, stable, since 0.1.0. Page: https://grani.arseni.work/components/pagination Also called pager, page navigation. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Ignore extra presses while a request runs. - Start a new search on the first page. - Land on a page with records after a delete. - Keep the page arrows under the pointer. - Keep turning pages from the keyboard. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the list’s loading flag as `loading`, from the moment the page is requested until it has arrived. - Set `v-model:page` to 1 when the search or the sort changes; UiDataTable does it for you. - Set the page to the last one when the total drops below it; UiDataTable does this for you. - Set `loading` from the page request until it finishes. Use `disabled` when paging is unavailable altogether; it disables the arrows. #### Why it works this way Page numbers with previous and next arrows. Take it under a list that pages, beside the count. What it owns. It knows the total and the page size and asks for a page, nothing else; the list owns the data and the page size. **Placement.** In the footer of a UiDataTable, in the corner beside the count ("1–25 of 57"). **Count.** The page count is max(1, ceil(total / pageSize)); one page renders nothing. Out of range. A page that is out of range or unreadable is drawn as the nearest one, and a click is clamped to the range. The pager draws; it does not ask: on a list that shrank (a delete, a filter), the app sets the page to the last one, as UiDataTable does. **Loading.** While a page is on its way the app passes `loading`: the arrow that asked shows a spinner and keeps the focus, the other arrow is off, the numbers are held but keep the focus, and a press asks nothing. A number that asked shows the spinner in place of its digits, in a slot of the same width. A click on the current page asks nothing either. `disabled` is a row that cannot page at all, not a load. **Steady row.** The row holds still. Once the pages do not fit, it always shows the same number of slots (a gap never hides a single page), and every number is as wide as the widest, so the arrows stay under the pointer. **Narrow box.** It fills the box it is given, as wide as its parent or the rest of a row, and draws its row at the start of it: `text-center` or `text-end` on it moves the row. Where that box is too narrow for the numbers (20rem at `xs`, 24rem at `sm`, 28rem at `md`, a container query, not the screen; wider as the last page gains digits past two), they give way to "3 / 20" between the arrows. A number that had the focus hands it to "3 / 20". ##### Keyboard - `Tab`: walks the previous arrow, each page number and the next arrow; an arrow at its end is disabled and skipped. - `Enter` `Space`: press the arrow or the number, which are buttons. While `loading` the pressed arrow keeps the focus with its spinner, and the numbers stay in the Tab order (`aria-disabled`), so a press that starts a load keeps the focus in the row. An arrow that turns off at its end once the page has come hands the focus to the current page, or to "3 / 20" in the compact row; so does an arrow under the focus when the row turns `disabled`. ##### Screen reader - Landmark: a navigation landmark named "Pagination", or by the caller's `aria-label`; a second pager on the page takes one of its own, so the two landmarks are told apart. - Arrows: "Previous page" and "Next page". - Numbers: every number is "Page N", the current one is `aria-current="page"`, and held numbers are `aria-disabled`. - Page change: a status region, always there, says "Page N of M" when the page changes, in the wide row and the compact one alike; the numbers are grouped as the app's language does. - Compact label: "3 / 20" is read as "Page 3 of 20" when it takes the focus. - Loading: the pressed arrow or number says busy (`aria-busy`). ##### States Every page while they fit, the ends with a window and gaps, the first and the last page with an arrow inert, `loading` with a spinner on the pressed arrow, `disabled`, `compact` and the narrow row with "N / M", a stale page past the range drawn as the last; three steps of the ladder. One page draws nothing. ##### When to use another component - A list that loads and owns its page: UiDataTable around the table. - Older events at the end of a timeline: UiLog with `hasMore`. - Views of one page: UiTabs. #### Examples ##### Many pages ```vue ``` ##### Compact ```vue ``` ##### Loading ```vue ``` ##### Sizes ```vue ``` ##### Stale page ```vue ``` ##### One page ```vue ``` #### API Props: - `page`: `number` (required) — Current page, from 1 (`v-model:page`). - `pageSize`: `number` (required) — Records per page; the page count is derived from it and `total`. Changing it is not this component's job. - `total`: `number` (required) — Total records. - `compact`: `boolean` (default `false`) — "N / M" instead of page numbers, for a narrow row. - `siblings`: `number` (default `1`) — Pages shown on each side of the current one. - `boundary`: `number` (default `1`) — Pages always shown at each end. - `loading`: `boolean` (default `false`) — A page is on its way: the pressed arrow shows a spinner and keeps the focus, the numbers are held, and a press asks nothing. Pass the list's loading flag. - `disabled`: `boolean` (default `false`) — The row cannot page at all: the arrows are off, the numbers are held. A load is `loading`. - `size`: `ControlSize` = `xs | sm | md` — Control size shared by every button in the row. Unset, the app's size (`createGrani({ size })`), `sm` by default. Anything else lands on the root element: native attributes, aria-*, data-testid. Events: - `update:page` (`page: number`) — Asks the parent for this page, clamped to the range; the parent applies it. #### How it behaves ##### One press, one request - When: a page is loading. - Props: `loading`. - Guarantee: The arrow or page number you pressed shows a spinner and keeps keyboard focus while the page loads. Further presses are ignored until loading finishes, and the page numbers stay where they were. - Without it: Next takes every press: a double press asks twice and skips a page. - Code: `` - Your part: Pass the list’s loading flag as `loading`, from the moment the page is requested until it has arrived. - Not covered: Without `loading`, nothing holds a second press: two presses a moment apart ask for two pages. - Proof, in the repository's tests (UiPagination.spec.ts, not in the package): "disabled holds the whole row: arrows off, numbers held but focusable, nothing asked", "while loading, the pressed arrow keeps the focus with a spinner, the other is off, and a press asks nothing", "a number pressed while its page loads shows a spinner in place of its digits and says busy" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### From page one - When: the list moves the page back to one. - Props: none. - Guarantee: Page one is marked as the current page, Previous is off, and a screen reader hears “Page 1 of 6”. - Without it: The pager still marks page 4 while the list starts over. - Code: `` - Your part: Set `v-model:page` to 1 when the search or the sort changes; UiDataTable does it for you. - Not covered: The pager does not know the search: it shows the page it is given. - Proof, in the repository's tests (UiPagination.spec.ts, not in the package): "a page set back to one from outside is marked current, Previous is off, and the change is said" - Pattern for all components: https://grani.arseni.work/patterns/finding#search-from-page-one ##### Last page lands - When: the list has shrunk below the current page. - Props: none. - Guarantee: The pager shows the last page that exists as the current one, so a page number is always marked and Next is off at the end. - Without it: The pager shows page 3 of 2 as requested: no page number is marked as current, and Next leads nowhere. - Code: `` - Your part: Set the page to the last one when the total drops below it; UiDataTable does this for you. - Not covered: The pager adjusts its display only. It does not change `v-model:page` or load records; the app must update the page value and data. - Proof, in the repository's tests (UiPagination.spec.ts, not in the package): "a stale page beyond the range renders as the last page" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#last-page-lands ##### Arrows hold still - When: there are more pages than fit. - Props: none. - Guarantee: The Previous and Next arrows stay in place as you change pages. The page slots keep a constant count and are wide enough for the largest page number, such as four digits for 1,200 pages. The compact label, such as “3 / 20”, also reserves the width of its widest value. - Without it: Each number is as wide as its digits and the window of pages grows and shrinks, so the Next arrow moves out from under the pointer between clicks. - Code: `` - Your part: Nothing. - Not covered: When the pager’s container is too narrow, the page numbers are replaced by a compact label between the arrows. This depends on the space given to the pager, not the screen size. Larger page numbers need more space. - Proof, in the repository's tests (UiPagination.spec.ts, not in the package): "the row holds the same number of slots on every page", "every number is as wide as the widest, and a four-digit total holds the arrows", "the compact label holds the width of its widest "N / M", and the gap the numbers' text size" - Pattern for all components: https://grani.arseni.work/patterns/finding#pager-holds-still ##### Focus kept - When: you page with the keyboard through a list that loads. - Props: `loading`. - Guarantee: The arrow or page number you pressed keeps keyboard focus while the page loads. When an arrow becomes disabled at the end, focus moves to the current page number or the compact label, such as “3 / 20”; it also moves there when a narrow container replaces the page numbers with that label. If the whole pager becomes `disabled`, focus moves from the disabled arrow to the current page. - Without it: The arrow turns off while the page loads, keyboard focus is lost, and you have to find the page controls again. - Code: `` - Your part: Set `loading` from the page request until it finishes. Use `disabled` when paging is unavailable altogether; it disables the arrows. - Proof, in the repository's tests (UiPagination.spec.ts, not in the package): "keyboard paging on a loading list keeps the focus inside the pager", "while loading, the pressed arrow keeps the focus with a spinner, the other is off, and a press asks nothing", "compact: an arrow that goes inert hands the focus to the label, also while disabled" - Pattern for all components: https://grani.arseni.work/patterns/waiting#pager-keeps-focus The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`, `useUiT`. ### Popover (`UiPopover`) > Open a small panel of options beside a button while keeping the rest of the page available. A component of grani 0.1.0. Overlays, stable, since 0.1.0. Page: https://grani.arseni.work/components/popover Also called dropdown panel, flyout, popup. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Open with keyboard focus where the task begins. Implemented by the app: - Data fetching, saving, permissions and business rules. - Put the field to start on first, or mark another one with the `data-autofocus` attribute. #### Why it works this way A small panel under a button, with the page still live. Take it for a few controls about the page (a note, a currency, a date); a filter is UiFilter and the columns of a table UiTableColumns, both on this panel, as UiMenu is for its list of commands. **Trigger.** The trigger is your own button in `#trigger`; the popover gives it `aria-haspopup`, `aria-expanded` and the toggle. The panel has no heading: the button names it, or `labelledby` does (a field's label). **Dialog.** The panel is a non-modal dialog, and the page stays live: no backdrop, no scroll lock. **Open state.** `v-model:open` lets the host open it, at mount too, and hear every close. When it is closed from outside, a focus that was inside goes back to the button. **Kept pages.** In a page that a `KeepAlive` keeps, a panel left open stays in the body when the page is deactivated, and Escape closes it there. Close it (`open` false) when the route changes. **Footer.** Actions (Apply, Reset) go in `#footer`, under a line at the end, never among the controls; both slots get `close`. Sections are flat UiCard components with a separator between neighbors, as in every overlay. **Nesting.** A select, a menu or a date picker inside works: a press in its list does not count as outside, and anything opened from the panel paints above it. A list at the top or the bottom of the panel (a UiFilterList) reaches that edge, as a select's list does. The panel is never taller than the room beside its button, so it never covers the button; a longer panel scrolls inside itself. **Attributes.** `class` and other attributes land on the panel. Its `id` stays its own, because the button points at it. **Placement.** It sits in a table toolbar (UiFilter, UiDateRange, UiTableColumns), under a date field (UiDatePicker); a menu's panel (UiMenu) is the same panel, holding commands. ##### Keyboard - `Enter` `Space`: on the button, open it, as a click does. The focus goes to the first field (a drawn select, date, switch, scale, drop zone or date range too, or what is marked `data-autofocus`), else to the first control; a panel of buttons opens on its first button. - `Tab`: goes round inside. - `Escape`: closes it and returns the focus to the button, only while the panel is the top layer. `close` from a slot does the same, unless the focus has already moved into a layer opened from the panel (a drawer an item opened), which keeps it. A click outside closes it and leaves the focus where the click went. ##### Screen reader - Button: says it opens a dialog (`aria-haspopup`), whether it is open (`aria-expanded`) and what it controls (`aria-controls`). - Panel: a non-modal `dialog` named by the button (`aria-labelledby`), or by `labelledby` (a field's label), with no heading of its own. ##### States Closed, or open under the button on its start, center or end edge (`align`), above it when there is no room below. It is never taller than the room beside the button; a longer panel scrolls inside itself with the `#footer` in view. While its button is scrolled out of the window, the panel waits hidden, still open, and the focus it held comes back with it. Opened from outside with `v-model:open`, at mount too. A select, a menu or a date picker open inside it. Opened from a drawer, where Escape closes only the panel. ##### When to use another component - Commands: UiMenu. - A task that needs the whole attention: UiModal. - A record to edit: UiCrudDrawer. - A hint of text alone: UiTooltip. #### Examples ##### Nested ```vue ``` ##### Open ```vue ``` ##### Drawer ```vue ``` #### API Props: - `align`: `"start" | "center" | "end"` = `start | center | end` (default `"start"`) — Which edge of the button the panel lines up with; start and end follow the reading direction. - `anchor`: `HTMLElement | null` (default `null`) — The element the panel lines up with, when the button sits inside a wider one (a field's capsule). - `labelledby`: `string` (default `""`) — The id of what names the panel, when the button's text is not its name (a field's label). - `open`: `boolean` (default `false`) — Whether the panel is open (`v-model:open`): the host learns of every close and can open it from outside. Attributes are placed by the component itself: its description says where. Events: - `update:open` (`value: boolean`) — The new value of `open`, for `v-model:open`. Slots: - `trigger` with `Record` — The button that opens the panel: one element or component, it keeps its own look and name. - `default` with `{ close: () => void; }` — The controls. `close` closes the panel and returns focus to the button. - `footer` with `{ close: () => void; }` — The actions under a line at the end of the panel, `xs` buttons 8px in (a gray Reset first with `me-auto`, Apply last); it stays in view while the controls scroll. Gets `close`. One that draws nothing draws no line. #### How it behaves ##### Focus starts inside - When: the panel opens, from the keyboard or a click. - Props: none: every UiPopover; `data-autofocus` names another place. - Guarantee: Start typing or choosing as soon as the panel opens. Focus goes to its first field or list, including a select or switch. If there are none, it goes to the first button. - Without it: The panel opens with keyboard focus left on the button that opened it, so what you type does not reach the panel’s field. - Code: ` ` - Your part: Put the field to start on first, or mark another one with the `data-autofocus` attribute. - Proof, in the repository's tests (UiPopover.spec.ts, not in the package): "focus goes to the first control and Tab goes round inside", "focus goes to the first list, not to a search further down" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-start The patterns: https://grani.arseni.work/patterns #### Built on it - [Menu](https://grani.arseni.work/components/menu): Open a menu of available actions, such as exporting records, changing a row or managing your account. ### Progress (`UiProgress`) > See how much of a task or target is complete when its progress can be measured. A component of grani 0.1.0. Status, stable, since 0.1.0. Page: https://grani.arseni.work/components/progress Also called progress bar, loading bar. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See upload progress and when it ends. - See where the app is working. - Reduce motion without hiding loading. Implemented by the app: - Data fetching, saving, permissions and business rules. - Update `value` with the upload’s measured progress. Inside UiDropzone, its `busy` and `progress` values drive the bar for you. - Update `value` with the measured progress and name the bar with `aria-label`. Use `indeterminate` when the amount completed is unknown. #### Why it works this way How far work or a target has come, `value` of `max`. Take it when the share is known: an upload, a quota, a goal; for work of unknown length set `indeterminate`. **Steps.** With `step` it shows one segment per step, filled by whole steps reached; an empty segment is a step still to go. **Color.** `tone` colors the fill. `variant="thermometer"` colors it by how full it is, from danger to success, and `invert` flips that for "less is better". **Unknown length.** `indeterminate` is work of unknown length: a band runs along the track, with no number. **Size.** The host's classes set the size; without them the bar is 8px tall. **Value.** It is a progressbar with `aria-valuenow`, absent while the value is unknown. `valueText` gives words, and it is what says a value past `max` ("14 of 10, over"), since the number stops at `max`. Name each bar (`aria-label`); unnamed, it is "Progress". **Marks.** A mark already given is this bar with `:step="1"`; a mark to give is UiScaleInput. **Placement.** It sits in a table's progress column, as UiDropzone's upload bar, and in a card, under its figure or as its `#divider`. ##### Keyboard Not interactive: it takes no focus. A mark to give is UiScaleInput. ##### Screen reader - Name: a `progressbar` named by the `aria-label` the host gives it, else "Progress"; a host that names it with `aria-labelledby` gets no second name. - Value: `aria-valuenow` runs from `0` to `max`, and is absent while `indeterminate` or while the value is not a number. - Value text: `valueText` is read in place of the number and carries a value past `max`. - Hidden: the fill and the segments. ##### States Empty, part, full and past `max`, where the number stops at `max`. `step` segments, filled by whole steps reached, and a continuous bar again above `maxSegments` or with no step filled. `tone` on the fill. `thermometer` from danger through warning to success, `invert` reversed. `indeterminate`, a band running along the track, which keeps running, slower, under reduced motion. 8px tall without a height class, else the host's height. In forced colors the track is outlined and the fill takes the highlight. ##### When to use another component - A wait in place: UiSpinner. - Content on its way: UiSkeleton. - A number: UiCount. - A change: UiDelta. - A mark to give: UiScaleInput. #### Examples ##### Segments ```vue ``` ##### Thermometer ```vue ``` ##### Tones ```vue ``` ##### Empty to over ```vue ``` ##### Unknown length ```vue ``` #### API Props: - `value`: `number` (default `Number.NaN`) — The current value, in the units of `max`. Not read while `indeterminate`. - `max`: `number` (default `100`) — The value at which the bar is full. - `indeterminate`: `boolean` (default `false`) — Work of unknown length: a band runs along the track, no value is said. - `step`: `number` — Size of one segment; turns the bar into segments, an empty one reads as one step still to go. - `maxSegments`: `number` (default `24`) — Above this many segments the bar is continuous again: a finger cannot tell them apart. - `tone`: `Tone` = `brand | neutral | success | warning | danger | info` (default `"brand"`) — Color of the fill; `brand` by default. - `variant`: `"plain" | "thermometer"` = `plain | thermometer` (default `"plain"`) — `plain` fills with `tone`; `thermometer` colors the fill by how full it is, danger through warning to success (`utils/scaleRamp`). - `invert`: `boolean` (default `false`) — Reverses the thermometer: less is better (load, pain, cost). - `valueText`: `string` — Words for the value instead of a number: "3 of 10 payments". Past `max` they carry the overage, the number stops at `max`. Anything else lands on the root element: native attributes, aria-*, data-testid. #### How it behaves ##### Upload shown - When: it is the bar of an upload. - Props: `value` of `max` bound to the upload's share. - Guarantee: The bar fills as the file uploads, and its number never goes past the end. The announcement that the upload is done comes from UiDropzone, not from the bar. - Without it: Nothing moves while the file uploads, and you cannot tell how much is left. - Code: `` - Your part: Update `value` with the upload’s measured progress. Inside UiDropzone, its `busy` and `progress` values drive the bar for you. - Proof, in the repository's tests (UiProgress.spec.ts, not in the package): "The bar says how far it is, and its fill follows value of max.", "beyond max the number stays at max and valueText carries the real value" - Pattern for all components: https://grani.arseni.work/patterns/waiting#upload-shown ##### Visible work - When: the amount of work completed is known. - Props: `value` of `max`, moved as the work goes; `indeterminate` when the length is unknown. - Guarantee: The bar fills with the measured progress, and a screen reader can read its current value. When progress cannot be measured, an animated band shows ongoing work without implying a percentage. - Without it: A spinner shows that work is ongoing but cannot tell you how much has finished. - Code: `` - Your part: Update `value` with the measured progress and name the bar with `aria-label`. Use `indeterminate` when the amount completed is unknown. - Not covered: It does not estimate progress: the bar is only as accurate as the `value` it receives. - Proof, in the repository's tests (UiProgress.spec.ts, not in the package): "The bar says how far it is, and its fill follows value of max.", "Work of unknown length runs a band along the track and says no number." - Pattern for all components: https://grani.arseni.work/patterns/waiting#visible-work ##### Calm motion - When: the work has an unknown length and reduced motion is on. - Props: `indeterminate`, and the system setting `prefers-reduced-motion: reduce`. - Guarantee: The band keeps running along the track, once every 1.6 seconds instead of 1.2. - Without it: The band stops partway along the track, and the work looks stuck. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiProgress.spec.ts, not in the package): "The running band keeps running, slower, when the system asks for reduced motion." - Pattern for all components: https://grani.arseni.work/patterns/waiting#calm-motion The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`. ### Radio group (`UiRadioGroup`) > Choose one answer from a short list, cards or a segmented bar; the whole row or card is the click target. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/radio-group Also called radio buttons, segmented control, option cards. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See the value the app actually accepted. - Toggle or pick by clicking the words. - Keep nearby buttons and fields in place. - Pass a group with one press of Tab. - Submit the values shown in the form. - Go straight to the field that needs fixing. - Name each field once for screen readers. - Announce required fields in words. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the answer the app holds as `modelValue`. - Put the words in the slot and the details in `description`, `#media` or `#meta`, not beside the component. - Give each group in a form its own `name`. - Pass the error to its `UiField group`, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Put the name in the slot, and the rest in `description` or `#meta`. - Set `required` on the group, or on the UiField around it, and check for `null` before you save. #### Why it works this way One answer out of a few, shown as rows, cards or a segmented bar. The group owns the value, the name and the states; its `UiRadio` children are thin options drawn from it. **Empty.** No answer is `null`; a pick sends the option's value. A person cannot take an answer back: where none is an answer too, give it a radio of its own. A value no radio stands for shows nothing chosen and sends nothing; in development the console names it once, an empty string excepted: it is read as no answer yet, as in UiSelect. **Required.** It does not force a selection. `required` says one is needed, to a screen reader and to a form. **Variants.** `variant` changes only the look: rows, cards, or a `segmented` bar with one segment pressed. The bar stands on the control ladder (the app's step, `sm` at least), `block` makes it fill the row, and it reddens its frame when invalid. A press on the bar's frame picks the segment beside it. A bar wider than its row scrolls sideways inside its frame, its edges fading where more segments wait: keep its words short, two to four of them. The segmented bar is also the library's state switcher. **Model.** The radios follow the model after a refused pick and after a form reset. **Names.** Two groups in one form need two names: the browser joins radios that share a name. Two Vue apps on one page each need their own `app.config.idPrefix`, or their generated names meet. **Field.** Inside UiField, use `group`. **Attributes.** Attributes land on the root (`data-*`); ours apply where we set one, and the caller's description is joined to ours. **Placement.** In a form column beside UiCheckbox and UiSwitch. A segmented bar switches what a card or a list shows (a period, a view, the kind of event in UiLog), over it or in its toolbar. ##### Keyboard Native radios: the keys are the browser's, and the radios are native inputs under the marks, the cards and the segments, so a bar is walked the same way as rows. - `ArrowUp` `ArrowDown` `ArrowLeft` `ArrowRight`: move the selection. - `Space`: selects. There is one tab stop, on the checked radio, or on the first while none is. A click anywhere on a row, a card or a segment selects it. Safari passes over a group whose checked radio is disabled: turn the whole group off, not its chosen radio. ##### Screen reader - Group: a radiogroup named by the field's legend (`aria-labelledby`) or by the caller's `ariaLabel`. - Description: the field's description and error, and the caller's own, are read with it. - Invalid: `invalid` is `aria-invalid` on the group once, never on a radio. - Required: `required` is `aria-required` on the group and native `required` on the radios for a form; inside a UiField `group` it is silent, since the legend says it once. - Disabled: `disabled` is `aria-disabled`. - Radio: each radio is named by its own text, as UiRadio says. ##### States Nothing chosen, one chosen, hover, focus, disabled (the whole group, or one radio that stays disabled whatever the group says), invalid (red empty marks; a `segmented` bar reddens its frame), required, and the variants `plain`, `card`, `card-check` and `segmented`, with or without `block`. ##### When to use another component - Several answers: UiCheckbox in a UiField `group`. - A mark from 1 to N: UiScaleInput. - A pick from a long list: UiSelect. - On or off: UiSwitch. - Sections of a page, each with its own content: UiTabs. - Actions side by side: UiButtonGroup. #### Examples ##### Card ```vue ``` ##### Check ```vue ``` ##### Segmented ```vue ``` ##### Sizes ```vue ``` ##### Long ```vue ``` ##### States ```vue ``` ##### Refused ```vue ``` ##### Form ```vue ``` #### API Props: - `modelValue`: `SelectValue | null` (required) — The selected value (`v-model`). `null` when nothing is selected. - `name`: `string` — Native name shared by every radio, for forms. Generated when omitted, stable across server and client; two apps on one page need their own `app.config.idPrefix`. - `disabled`: `boolean` (default `false`) — Prevents selecting in every radio. - `invalid`: `boolean` (default `false`) — Marks the group as invalid: red marks. Inside UiField, the error state sets this automatically. - `variant`: `RadioGroupVariant` = `plain | card | card-check | segmented` (default `"plain"`) — `plain` rows with the mark first; `card` a card per radio with the mark at the end; `card-check` cards that show a check and a tint on the selected one only; `segmented` a bar with one segment pressed. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder. A `segmented` bar is 36 or 44 px (`xs` is read as `sm`: a 24px segment is too small to hit); a `plain` mark is 16, 18 or 20 px. Unset, the app's size (`createGrani({ size })`). - `id`: `string` — Own id of the group, for a reference from elsewhere (`aria-controls`, a link); a field names it by its legend. - `ariaLabel`: `string` — Accessible name of the group when there is no visible legend. Inside a UiField group the legend names it. - `required`: `boolean` (default `false`) — One answer is needed: said to a reader on the group, and `required` on the radios for a form. Inside UiField group the legend says it instead. - `block`: `boolean` (default `false`) — A segmented bar as wide as its row, segments sharing the width. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: SelectValue`) — The selected value changed. Slots: - `default` with `Record` — The radios. ##### Radio (`UiRadio`) Props: - `value`: `SelectValue` (required) — The value this radio stands for; the group's model takes it when selected. - `description`: `string` — A second line under the label. Read with the radio as its description. Not drawn in a segment. - `disabled`: `boolean` (default `false`) — Prevents selecting this radio; the group's `disabled` covers all of them. - `id`: `string` — Own id, for a label elsewhere. - `ariaLabel`: `string` — Accessible name when the slot has no text: a glyph segment, a label outside the row. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Slots: - `default` with `Record` — Label text displayed next to the mark. Clicking it selects the radio. - `media` with `Record` — A glyph or a small image before the text. Cards only. - `meta` with `Record` — A value at the end, before the mark: a price, a count. Cards only. #### How it behaves ##### Back to the value - When: the app refuses a choice, or the form is reset. - Props: `v-model`, or `modelValue` with `@update:model-value`. - Guarantee: If the app refuses your choice, the previous answer is selected again. A form reset brings back the app’s answer. - Without it: An answer looks selected that the app did not accept. - Code: `…` - Your part: Pass the answer the app holds as `modelValue`. - Proof, in the repository's tests (UiRadioGroup.spec.ts, not in the package): "a vetoed change puts the DOM back on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Click the label - When: its options are rows or cards. - Props: the default slot of each UiRadio; `variant="card"` or `"card-check"` for cards. - Guarantee: The whole row is the label of its radio: a click on the words picks it, and a long label wraps beside the mark. A card is picked by a click anywhere on it, its words, `#media` and `#meta` included. - Without it: A radio in a corner of the card: a click on the words or the price picks nothing. - Code: `…` - Your part: Put the words in the slot and the details in `description`, `#media` or `#meta`, not beside the component. - Proof, in the repository's tests (UiRadioGroup.spec.ts, not in the package): "the row is the label: a click on the text selects", "a click anywhere on a card picks it, its media and its meta included" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#label-is-target ##### Steady width - When: it is a segmented bar. - Props: `variant="segmented"`. - Guarantee: Each segment keeps the same font weight and width whether selected or not, so choosing one does not push the next option sideways. - Without it: The picked segment turns bold, its word grows wider, and the segments after it shift sideways. - Code: `…` - Your part: Nothing. - Proof, in the repository's tests (UiRadioGroup.spec.ts, not in the package): "segments keep one weight whether chosen or not, so none changes width on a pick" - Pattern for all components: https://grani.arseni.work/patterns/finding#steady-width ##### One Tab per group - When: you choose one answer from a group. - Props: none. - Guarantee: The group takes one press of Tab, on the chosen answer or on the first. The arrow keys change the answer, the same way whether the options are drawn as rows, cards or segments. - Without it: Segments built as separate buttons take a press of Tab each. - Code: `…` - Your part: Nothing. - Proof, in the repository's tests (UiRadioGroup.spec.ts, not in the package): "is a radiogroup whose radios share one name and reflect the value" - Pattern for all components: https://grani.arseni.work/patterns/opening#one-stop ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: The chosen radio sends its value under the group's `name`, and nothing is sent while none is chosen. After a form reset, the radios show the app's value again. - Without it: Radios on screen that the form never sends, or two groups merged under one name. - Code: `…` - Your part: Give each group in a form its own `name`. - Not covered: A disabled group sends nothing, as a disabled radio does: the form leaves it out. - Proof, in the repository's tests (UiRadioGroup.spec.ts, not in the package): "in a form the checked radio sends its value under the group name, and none sends nothing", "a form reset leaves the radios on the model, mark and DOM agreeing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Focus on the error - When: a form is sent with no option chosen. - Props: `invalid`, or the error of its `UiField group`. - Guarantee: When the group is the first field with an error, keyboard focus lands on the chosen radio, or on the first one when none is chosen, so you can move through the options with the arrow keys. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `…` - Your part: Pass the error to its `UiField group`, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiRadioGroup.spec.ts, not in the package): "focusInvalid on an invalid radiogroup focuses the chosen radio, else the first" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### One name - When: its options have descriptions. - Props: `description`, `#meta`. - Guarantee: Each radio is named by its own words alone; its `description` and `#meta` are announced once, after the name. - Without it: A radio whose announced name runs on into its description. - Code: `Team` - Your part: Put the name in the slot, and the rest in `description` or `#meta`. - Proof, in the repository's tests (UiRadioGroup.spec.ts, not in the package): "the name is the label text; the description and meta are read once after it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice ##### Required in words - When: an answer is needed. - Props: `required`, or a required `UiField group`. - Guarantee: The group is announced as required. With its own `required`, each radio carries native `required` too, so a native form checks that an answer was chosen; inside a required `UiField group`, the legend says it once instead. - Without it: An asterisk that a screen reader skips. - Code: `…` - Your part: Set `required` on the group, or on the UiField around it, and check for `null` before you save. - Not covered: A required `UiField group` only says it: the browser checks the group’s own `required`, not the field’s. - Proof, in the repository's tests (UiRadioGroup.spec.ts, not in the package): "required on the group says aria-required and puts required on each input; inside a group field it stays silent" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#required-in-words The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`. ### Reveal (`UiReveal`) > Open details where you need them and collapse them again without leaving the page. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/reveal Also called accordion, collapsible, disclosure, expandable section. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Keep keyboard focus where you are working. - Reduce motion without hiding loading. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass `controls` from the `#trigger` slot to the trigger as `aria-controls`. A control placed elsewhere points at the block through the block's `id`. #### Why it works this way A block that opens and closes in place, under the control that names it. Take it for settings behind a switch or more options behind a button. **Content.** The block goes in the default slot. With `v-model:open`, a `#trigger` (`{ open, toggle, controls }`), such as a UiSwitch in a UiListItem, stands above it; or `:open` alone follows a control elsewhere, which points at the block by its `id`. **Spacing.** In a card of rows, the block continues the row: it sits on the row's inset, 16 below it. On a padded surface, or on no surface, it stands 16 under what is above it. A nested block keeps that gutter. ##### Keyboard Nothing of its own: the control in `#trigger` keeps its keys (a UiSwitch, a button). - `Tab`: reaches the fields of an open block in their order. When the block closes with the focus inside, the focus goes to the control that names the block in `aria-controls`, else to the element above. Opening leaves the focus on the trigger, as a disclosure does; with `autofocus` it goes to the block's first field, for a block opened to be typed in. ##### Screen reader - Trigger: says the block's state: a button takes `open` as `aria-expanded`, and a switch says it through `aria-checked`. `controls` points the trigger at the block only while the block is shown. - Closed block: its fields leave the page and are not submitted. - Height: the change of height is not announced. ##### States Open and closed, with the height following the content: `ui-reveal` animates a grid row from `0fr` to `1fr`, with the opacity, over `--ui-dur-fast`; under reduced motion the change is instant. With a `#trigger` and `open` unset, it starts closed and keeps its own state. ##### When to use another component - A panel floating beside its trigger: UiPopover. - A record inside a record: a step of UiCrudDrawer. - Panes switched among others: UiTabs. #### Examples ##### Behind a switch ```vue ``` ##### Nested ```vue ``` ##### Own state ```vue ``` #### API Props: - `open`: `boolean` — The block is shown (`v-model:open`); the parent owns it, the trigger only asks. Unset, the block starts closed and keeps its own state, which a `#trigger` toggles. - `autofocus`: `boolean` (default `false`) — When the block opens, the focus goes to its first field: a block opened to be typed in, such as a note. A block shown at mount takes no focus. Attributes are placed by the component itself: its description says where. Events: - `update:open` (`open: boolean`) — The trigger asked for the other state. Slots: - `trigger` with `{ open: boolean; toggle: () => void; controls: string | undefined; }` — The control that opens the block: `controls` is its `aria-controls` while the block is shown; a button also takes `open` as `aria-expanded`, a switch does not (its `aria-checked` says it). - `default` with `Record` — The block shown under the trigger; it stands on the gutter the host gives (16 under what is above, or the row's inset in a card of rows). #### How it behaves ##### Focus stays near - When: The block closes while keyboard focus is inside it. - Props: `v-model:open` with a `#trigger`, or `:open` and an `id` a control points at. - Guarantee: Focus returns to the control that opens the section, or to the nearest button or field above it if no linked control is found. The next Tab continues from there. - Without it: Keyboard focus is lost with the closed block, and you have to find the control again. - Code: ` … ` - Your part: Pass `controls` from the `#trigger` slot to the trigger as `aria-controls`. A control placed elsewhere points at the block through the block's `id`. - Not covered: A control that does not name the block in `aria-controls` and does not stand right above it is not found, so focus is not moved there. - Proof, in the repository's tests (UiReveal.spec.ts, not in the package): "returns focus to the trigger when the block collapses with focus inside it", "closing a block no control names, with focus inside, returns focus to the element above" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-stays ##### Calm motion - When: the block opens or closes, with reduced motion on. - Props: the system setting `prefers-reduced-motion: reduce`. - Guarantee: With reduced motion enabled, the section appears or disappears immediately, without sliding. - Without it: Sections keep sliding open and shut although you asked for less movement. - Code: `…` - Your part: Nothing. - Proof, in the repository's tests (UiReveal.spec.ts, not in the package): "It opens and closes at once when the system asks for reduced motion." - Pattern for all components: https://grani.arseni.work/patterns/waiting#calm-motion The patterns: https://grani.arseni.work/patterns ### Scale input (`UiScaleInput`) > Give a rating on a short labeled scale and, where clearing is allowed, take it back by choosing the same value again. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/scale-input Also called rating, nps scale, likert scale, score input. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See the value the app actually accepted. - Submit the values shown in the form. - Go straight to the field that needs fixing. - Name states in words, not color alone. - Take back a scale answer. - Change a number with the arrow keys. - Pass a group with one press of Tab. - Name each field once for screen readers. - Announce required fields in words. - Announce scale steps with their meaning. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the mark the app holds as `modelValue`, and explain why a mark was refused. - Give it a `name`, and check `required` yourself: the hidden input does not. - Pass the error to its `UiField group`, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Keep the numbers or the readout visible. - Treat `null` as no answer. - Put it in a UiField with `group`, or give it `ariaLabel`. - Set `required`, and check for `null` before you save. - Write the end words in `lowLabel` and `highLabel`. #### Why it works this way One mark on a scale of a few steps, 1 to 10 by default. Take it for how hard, how good or how much. **Layout.** A bar of segments is filled up to the mark, with the number in each segment. A readout shows "mark / max" with a unit at its end; the name is the field's, a UiField `group` label over it, or `ariaLabel` alone. Words at the two ends say what low and high mean, and a screen reader hears them with the first and last step. **Empty.** No mark is `null`, as a choice is: a clear sends `null`. **Thermometer.** `variant="thermometer"` colors the fill by the mark in five stops, from danger through warning to success, each with an ink that reads on it. `invert` is for scales where more is worse. **Field.** Standalone it takes `ariaLabel`, `invalid` and `required`. Required is only announced, not enforced, because a hidden input takes no validation. Inside UiField `group` the legend names it, and the description or the error describes it. Invalid tints the empty segments and the frame with the danger tone. **Form.** `name` sends the mark with a form through a hidden input; nothing is sent while there is no answer or the scale is disabled. **Stray values.** A value off the scale shows as no answer. It is dropped only when the scale itself shrinks below it. **Attributes.** Attributes land on the radiogroup, ours where we set one; `class` and `style` on the root. **Steps.** Twelve steps at most, because a narrower segment cannot be hit with a finger. For a free number take UiInput with `numeric`. **Placement.** Beside UiRadioGroup `segmented` and UiInput in a form column. **Loading.** The scale has no loading of its own: while its mark is on its way, its owner draws a UiSkeleton in its shape, a short line where the readout stands over a bar of the control's height for its size (`h-7`, `h-9`, `h-11`), and the field's words at the ends stay, with a `role="status"` of its own that says the load. The demo's Loading tab has it. ##### Keyboard A radiogroup underneath. There is one tab stop: on the mark, or on the first step while there is no answer. - `ArrowLeft` `ArrowRight` `ArrowUp` `ArrowDown`: move the mark from the step in focus; `ArrowUp` at `max` and `ArrowDown` at `min` send nothing. - `Home` `End`: jump to the ends. - `Delete` `Backspace`: clear it; a click on the mark clears it too, and `clearable` off stops both. - `Space` `Enter`: pick the step in focus and never clear. - `Alt` `Ctrl` `Meta`: keys with them go to the browser. After a clear or a refused move, the focus returns to the tab stop. ##### Screen reader - Group: a radiogroup named by the field's legend, or by `ariaLabel`; the field's description or error is read with it. - Step: a radio named by its number out of `max`, the first and the last with their end words; the chosen one is `aria-checked`. - States: `invalid` is `aria-invalid`; `required` is `aria-required` standalone and silent inside a UiField `group`; `disabled` is `aria-disabled`. ##### States No answer (a dash in the readout), a mark with the bar filled to it, hover, focus, disabled (the disabled tokens; it wins over invalid), invalid (the empty segments and the frame in the danger tone, the frame alone on a full bar), required, `thermometer` colored by the mark, inverted, with or without numbers, and a unit in the readout. ##### When to use another component - A free number: UiInput `numeric`. - A count of things: UiStepper. - One answer in words: UiRadioGroup `segmented`. - A mark shown without editing: UiProgress with `:step="1"`, in a table or on a card. #### Examples ##### Thermometer ```vue ``` ##### Unit ```vue ``` ##### Loading ```vue ``` ##### Sizes ```vue ``` ##### States ```vue ``` ##### Refused ```vue ``` ##### Form ```vue ``` #### API Props: - `modelValue`: `number | null` (required) — The mark (`v-model`); `null` while there is no answer. - `min`: `number` (default `1`) — First step of the scale. - `max`: `number` (default `10`) — Last step. Twelve steps at most. - `clearable`: `boolean` (default `true`) — A click on the mark, Delete or Backspace clears the answer; off, an answer once given stays. - `disabled`: `boolean` (default `false`) — Nothing can be picked. - `invalid`: `boolean` (default `false`) — Marks the control invalid: the empty segments and the frame take the danger tone. Inside UiField the error sets this. - `required`: `boolean` (default `false`) — An answer is needed: said to a reader on the group. Not enforced by a form: the hidden input takes no validation. Inside UiField group the legend says it instead. - `unit`: `string` (default `""`) — Shown after the readout: "7 / 10 km". - `readout`: `boolean` (default `true`) — The line "mark / max" above the bar. - `id`: `string` — Own id of the group, for a reference from elsewhere (`aria-controls`, a link); a field names it by its legend. - `name`: `string` — Form field name: a hidden input sends the mark; nothing is sent while there is no answer or the scale is disabled. - `showNumbers`: `boolean` (default `true`) — Numbers in the segments; off for a dense row. - `variant`: `"brand" | "thermometer"` = `brand | thermometer` (default `"brand"`) — `brand` fills with the brand; `thermometer` colors the fill by the mark, danger through warning to success. - `invert`: `boolean` (default `false`) — Reverses the thermometer: more is worse (difficulty, pain, load). - `lowLabel`: `string` (default `""`) — What the low end means: "easy". - `highLabel`: `string` (default `""`) — What the high end means: "hard". - `ariaLabel`: `string` — Accessible name of the scale when there is no UiField around. - `size`: `"sm" | "xs"` = `xs | sm` — Height of the bar on the control ladder: `xs` 28, `sm` 36. Unset, the app's size (`createGrani({ size })`); `md` is read as `sm`: the scale has no `md`. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: number | null`) — The mark changed; `null` after a clear. #### How it behaves ##### Back to the value - When: the app refuses a mark, or the form is reset. - Props: `v-model`, or `modelValue` with `@update:model-value`. - Guarantee: If the app rejects a rating, the scale returns to the accepted value. When you Tab into it again, focus starts on that value. - Without it: The bar is filled to a mark the app did not accept. - Code: `` - Your part: Pass the mark the app holds as `modelValue`, and explain why a mark was refused. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "a refused pick leaves the bar and the tab stop on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: A hidden input sends the chosen step under its `name`; with no answer, or while disabled, nothing is sent. - Without it: A scale on screen that the form never sends. - Code: `` - Your part: Give it a `name`, and check `required` yourself: the hidden input does not. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "in a form the mark sends under the name, none and disabled send nothing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Focus on the error - When: a form is sent with no step chosen. - Props: `invalid`, or the error of its `UiField group`. - Guarantee: When the scale is the first field with an error, keyboard focus lands on the chosen step, or on the first step when none is chosen, so you can pick an answer from the keyboard. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `` - Your part: Pass the error to its `UiField group`, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "focusInvalid on an invalid scale focuses the chosen segment, else the first" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Words, not only color - When: the fill is colored by the answer. - Props: `variant="thermometer"`, `invert`. - Guarantee: The color comes with the number on each segment and with the readout, such as “7 / 10”; each number takes a text color that stays readable on its segment, filled or empty. - Without it: A bar that turns red with no number to say how far. - Code: `` - Your part: Keep the numbers or the readout visible. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "thermometer numerals take the ink that reads on their stop", "empty numerals read in the body ink on their track" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#level-not-by-colour ##### Answer cleared - When: an answer may be withdrawn. - Props: `clearable`, on by default. - Guarantee: A click on the chosen step, Delete or Backspace clears the answer to `null`; Space and Enter never clear. With `clearable` off, an answer once given stays. - Without it: As with native radios, once a step is checked, the answer cannot be taken back. - Code: `` - Your part: Treat `null` as no answer. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "a click picks; a click on the mark clears unless clearable is off", "Delete and Backspace obey clearable=false", "Space on the mark keeps it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#mark-taken-back ##### Arrows step - When: you set the answer from the keyboard. - Props: none. - Guarantee: The arrow keys move the answer from the step that has keyboard focus, Home and End jump to the ends, and an arrow key past an end changes nothing. - Without it: A row of buttons that the arrow keys do not move. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "one tab stop, on the mark; arrows, Home, End move it and Delete clears", "ArrowUp at max and ArrowDown at min emit nothing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#number-by-arrows ##### One Tab per group - When: a question is answered on a scale. - Props: none. - Guarantee: The scale takes one press of Tab, on the chosen step, or on the first step while there is no answer. The arrow keys move the mark, and Home and End take it to the ends. - Without it: Each step is its own button, so a scale of ten steps takes ten presses of Tab to pass one question. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "one tab stop, on the mark; arrows, Home, End move it and Delete clears" - Pattern for all components: https://grani.arseni.work/patterns/opening#one-stop ##### One name - When: it sits in a field. - Props: a `UiField group` around it, or `ariaLabel`. - Guarantee: The field's label names the scale as a whole, and the description or the error is announced with it, once; each step keeps its own short name. - Without it: A scale announced with two names, or with none. - Code: `` - Your part: Put it in a UiField with `group`, or give it `ariaLabel`. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "inside UiField the label names the group and the description describes it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice ##### Required in words - When: an answer is needed. - Props: `required`, or a required `UiField group`. - Guarantee: On its own, the scale is announced as required (`aria-required`); inside a required `UiField group`, the legend says it once instead. The requirement is only announced: the browser does not check it when the form is sent. - Without it: An asterisk that a screen reader skips. - Code: `` - Your part: Set `required`, and check for `null` before you save. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "required on the scale says aria-required standalone and stays silent inside a group field" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#required-in-words ##### Ends named - When: its ends mean something. - Props: `lowLabel`, `highLabel`. - Guarantee: Each step is named by its number out of the total, “7 of 10”, and the first and last add their end words: “1 of 10, easy”, “10 of 10, hard”. - Without it: Steps named by their number alone: the words under the ends are printed, but never announced with the step. - Code: `` - Your part: Write the end words in `lowLabel` and `highLabel`. - Proof, in the repository's tests (UiScaleInput.spec.ts, not in the package): "the first and last radio name their end words" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#mark-says-its-word The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`, `useUiT`. ### Select (`UiSelect`) > Search for and choose one or more options. Enter selects an option without also submitting the form. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/select Also called dropdown, combobox, autocomplete, searchable select. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Results match the current search. - See the value the app actually accepted. - Submit the values shown in the form. - Finish a word in any script before it is sent. - Clear a search in one step. - Keep nearby buttons and fields in place. - Keep nearby content in place while loading. - Open a panel on the side where it fits. - Close only the top layer with Escape. - Close a popup without closing the form behind it. - Go straight to the field that needs fixing. - Close an open picker when its field is disabled. - Read the full text when a label is shortened. - Notice when there are more options below. - Clear a field and enter its replacement immediately. - Search once typing pauses. - Jump to an option by letter. - Pick with Enter without sending the form. - Keep your browser’s keyboard shortcuts. - Remove selected tags with the keyboard. - Open with keyboard focus where the task begins. - Continue from where you opened the panel. - Name each field once for screen readers. Implemented by the app: - Data fetching, saving, permissions and business rules. - Return the options for the query you are given. When you prefill a value, also pass its option in `items`, such as `{ value: 7, label: 'Anna Petrova' }`. - Pass the value the app holds as `modelValue`, and explain why a choice was refused. - Give it a `name`. - Give the field a width where the row needs one; without it, the field is as wide as its longest option. - Pass `loading` while the options load; a `fetcher` reports its own loading. - Pass the error to its UiField, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Treat `null` as no answer. - Return the options for the query you are given. - Put it in a UiField with a `label`, or give it `ariaLabel`. #### Why it works this way A form control for picking one value from a list of options, or several with `multiple`. It sits in the input capsule on the control ladder. **Placement.** In a form, under a UiField label beside UiInput and UiDatePicker on the same step, or joined with a UiInput in a UiButtonGroup (an amount and its currency); as the kind filter of UiLog past three kinds. **Panel.** The trigger is a button with the combobox role. The list is a listbox in a panel anchored under it; the panel flips above when there is no room below and follows scrolling. **Options.** Items are options or groups of options. A disabled option is shown but cannot be picked. While the app's options are on their way it passes `loading`: with none yet the list draws rows in their shape, never "No matches"; with options in view they stay, dimmed, still to be picked, and the highlight keeps to its option as they change. A picked value whose option has not come yet reads as a load in the field and is sent as it is; once the load is over, a value no option knows shows as its own text and is sent, so the field never looks empty while the form holds a value, and in development the console names it once per app. An empty string no option has is nothing picked, as `null` is. With `fetcher` the select says its own load and `loading` does nothing. A list that loads holds the height it reached while open, until a filter narrows options already in. **Types.** The model's shape follows `multiple`: one value or `null` without it, an array with it, and the props reject the other. A `multiple` decided at run time is two branches (`v-if` with the array model, `v-else` with the single one). `update:modelValue` carries either shape: an explicit handler casts what it receives, `v-model` needs nothing. **Search.** `searchable` puts a filter at the top of the list; an option's `keywords` are matched too. The filter leaves the accents of Latin and Cyrillic letters out, so a name typed without them is found ("munchen" finds München) and the Cyrillic yo reads as ie; the Cyrillic short i stays a letter of its own. `fetcher` asks a server for the options as the filter is typed. The picked options are remembered, so their labels survive a new search. While the first answer is on its way the list draws rows of the options' shape, and while it is open it never shrinks, so nothing under the pointer moves as answers come. A failed search offers Try again, which asks once more; a search that matches nothing offers Clear search. **Clear.** `clearable` adds a button that resets the value to `null`, or `[]` with `multiple`: a choice is a value or `null`. A required field never gets it, since there empty is an error, not a state. Its place is kept while there is nothing to clear. **Width.** A single pick from a local list keeps the field's width: the text is sized to the longest labels, counted in letters, so a label of wide letters outside the three longest can still widen it. A chosen label longer than the field is cut, and shows whole in a tooltip on hover while the list is closed. **Multiple.** `multiple` turns the rows into check marks and the model into an array, and a pick keeps the list open. The trigger shows the count of picks, and each pick stands below the field as a tag with a cross. **Picks.** With `multiple`, the `#picks` slot draws the picks under the field the app's way, in place of tags: a row with what the app keeps for each, a quantity, a price. The field becomes a search that adds; it keeps its placeholder, since the picks are in view below. The value stays the list of picked values in the order they were picked; what a row holds besides is the app's. A disabled select takes no pick back, and hands `disabled` to the rows, so the app can hold its own controls too. **Field.** Standalone it takes `invalid` and `ariaLabel`; inside UiField it reads id, error and required from the field. **Attributes.** `class` and `style` land on the select's outer box: the capsule, or with tags or the app's picks the box that holds the capsule and them; every other attribute and listener lands on the trigger, the combobox. ##### Keyboard On the closed field: - `ArrowDown` `ArrowUp` `Enter` `Space` `Home`: open the list, and so do `Alt+ArrowDown` `Alt+ArrowUp`; `Ctrl` or `Meta` with a key stays the browser's. - `End`: opens the list on the last option. - `A–Z`: opens it on the first label that starts with the letter, or starts the filter when there is one. - `Backspace`: with `multiple`, removes the last pick. In the open list: - `ArrowDown` `ArrowUp`: move over the enabled options. - `Home` `End`: jump to the first and the last option; in the filter they move the caret. - `Enter`: picks; never submits the form around it. While the answer to the typed text is on its way it picks nothing. After a failed search it asks again. A pick closes a single list and returns the focus to the field; a `multiple` list stays open. - `Space`: picks, outside the filter; after letters typed over the list it belongs to the word. - `A–Z`: jumps to the first match; letters typed together find a word. - `Escape`: closes the list and returns the focus to the field; inside a window it closes only the list, and in a filter with text it clears the text first. - `Tab`: closes the list and moves on to the next field. A pointer press outside closes it too. On a tag: - `Enter` `Space`: on its cross, removes that pick and moves the focus to the next tag, or to the field. ##### Screen reader - Field: a combobox named by `ariaLabel` or the field's label. It says the chosen label, the count of picks or the placeholder, whether it is expanded, and that it opens a listbox. - List: a listbox with the same name, `aria-multiselectable` with `multiple`. A group's heading is shown but is not an option. - Options: each says selected and disabled. The highlighted one is tracked through `aria-activedescendant` on the field, or on the filter, a named text box with `aria-autocomplete`. - Status: a region that stays in the DOM says what the list is doing: "Searching…" for a `fetcher`, "Loading options…" and "Updating…" for the app's load, then "No matches" to a search, "No options" to none, or the failure, once nothing is on its way. - Errors: `invalid` is `aria-invalid`, `required` is `aria-required`, and the field's description and error are read with the field. - Tags: they stand in a named list, each cross is named with its label, and the clear button has a name. ##### States Empty with the placeholder, a pick, open with the chosen option highlighted, a disabled option, groups, the app's options on their way (`loading`: rows of their shape, then dimmed), `searchable` with its filter, a server search (rows of the options' shape while the first answer is on its way, the previous options dimmed while a new query waits, no matches with Clear search, failed with Try again, the list holding the height it reached while open; a late answer is dropped), `multiple` with check marks and a count, with tags below the field, or with the app's rows below it, `clearable` with the cross and its place kept, disabled (the plate, an inert trigger, nothing sent), invalid (a red border; disabled wins over it), and required, with no cross. ##### When to use another component - A few answers in view: UiRadioGroup. - Several answers in view: UiCheckbox in a UiField `group`. - A time of day: UiTimePicker. - A day: UiDatePicker. - Commands: UiMenu. - A list that adds what is typed: UiListField. #### Examples ##### Search ```vue ``` ##### Multiple ```vue ``` ##### Order ```vue ``` ##### Sizes ```vue ``` ##### Loading ```vue ``` ##### States ```vue ``` ##### Refused ```vue ``` ##### Form ```vue ``` ##### Long ```vue ``` ##### Russian ```vue ``` #### API Props: - `items`: `SelectItem[]` (default `[]`) — Options or groups of options with labels. With `fetcher` they are the known labels: a prefilled value shows its label before the server answers. - `fetcher`: `((query: string) => Promise)` — Asks a server for the options: called with the filter text on open and as it is typed (250 ms after the last key); a late answer to an older query is dropped. Implies `searchable`. - `loading`: `boolean` (default `false`) — The options the app passes in `items` are on their way: with none yet, rows in their shape; with options in view, they stay, dimmed. A `fetcher` says this by itself. - `placeholder`: `string` — Shown when nothing is selected. - `name`: `string` — Native name for forms. The value is submitted through a hidden input. - `id`: `string` — Own id when standalone. Inside UiField, the field's id takes precedence. - `disabled`: `boolean` (default `false`) — Prevents opening and clearing. - `invalid`: `boolean` (default `false`) — Marks the control as invalid. Shows the invalid border and focus ring. Inside UiField, the error state sets this automatically. - `ariaLabel`: `string` — Accessible name when there is no visible label. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder. Unset, the app's size (`createGrani({ size })`), `sm` by default. - `searchable`: `boolean` (default `false`) — Adds a filter to the option list. Typing narrows options by label. - `clearable`: `boolean` (default `false`) — Adds a button that resets the value to `null`, or to `[]` with `multiple`; never shown on a required field. - `required`: `boolean` (default `false`) — Standalone only: empty is an error, said to a reader, and no cross. Inside UiField the field's `required` says so. - `tags`: `boolean` (default `false`) — With `multiple`: every pick as a tag with a cross below the field, the trigger counting them. Without it the picks are listed in the trigger, which fits a narrow place such as a toolbar. - `tagSize`: `"sm" | "md"` = `sm | md` (default `"md"`) — Size of the tags, as a UiBadge's. - `modelValue`: `SelectValue | SelectValue[] | null` (required) — The selected value (`v-model`): `null` when nothing is selected; with `multiple`, the picked values in the order they were picked, `[]` when none is. - `multiple`: `boolean` (default `false`) — Several values: check marks in the rows, an array in the model, the list stays open after a pick. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: SelectValue | SelectValue[] | null`) — The selected value changed; `null` (or `[]` with `multiple`) after clearing. Slots: - `start` with `Record` — Before the value, inside the trigger: a glyph that says what is chosen (a clock, a flag). Decoration. - `picks` with `{ options: SelectOption[]; remove: (option: SelectOption, event?: MouseEvent | undefined) => void; disabled: boolean; }` — With `multiple`: the picks drawn by the app under the field, in place of tags, the field a search that adds to them. `options` are the picked options as given in `items` or by the `fetcher`; `remove` takes one back and puts the focus on the field (pass the click, `remove(option, $event)`, and a double click's second click on the row sliding under the pointer is held back); `disabled` is the field's, and while it holds `remove` does nothing. #### How it behaves ##### Late answer dropped - When: the options come from a server. - Props: `fetcher`, `items` for a value given in advance. - Guarantee: A search response is shown only if it still belongs to the current query; a response to an older one is ignored instead of replacing newer results. The option you chose keeps its name in the field when a new search no longer returns it, and a value set in advance shows the label given in `items` before the first search answers. - Without it: Every answer is shown as it arrives, so a slow answer to an old query covers the newer one, and the choice turns into its raw value once the results change. - Code: `` - Your part: Return the options for the query you are given. When you prefill a value, also pass its option in `items`, such as `{ value: 7, label: 'Anna Petrova' }`. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "a late answer to an older query is dropped", "a picked option keeps its label after the server answers another query", "items seeds the label of a prefilled value before the first fetcher search resolves" - Pattern for all components: https://grani.arseni.work/patterns/finding#late-dropped ##### Back to the value - When: the app refuses a choice, or the form is reset. - Props: `v-model`, or `modelValue` with `@update:model-value`. - Guarantee: If the app refuses your choice, the field shows the previous option again, and the form still sends that option. A form reset brings back the app’s value. - Without it: The field shows an option the app did not accept. - Code: `` - Your part: Pass the value the app holds as `modelValue`, and explain why a choice was refused. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "a change the parent refuses puts the select back on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: The chosen value is included under its `name` through a hidden input. An empty or disabled select sends nothing. After a form reset, the visible choice and the submitted value both match the app’s current value. - Without it: A list on screen that the form never sends. - Code: `` - Your part: Give it a `name`. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "an empty select sends nothing under its name", "a form reset leaves the select on its model, hidden input and trigger agreeing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Words in the making - When: its search is typed through an input method. - Props: `searchable`. - Guarantee: The Enter that picks the word picks no option. - Without it: An option chosen by the key that only meant to finish a word. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "an Enter that confirms an IME composition in the search picks nothing" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#word-as-composed ##### Search clears - When: the filter has text. - Props: `searchable` or `fetcher`. - Guarantee: A cross clears the filter and keeps keyboard focus in it. Escape clears it first and keeps the list open; a second Escape closes the list. - Without it: A filter you can clear only by selecting its text and deleting it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "the search clears its own text on Escape and keeps the panel open; a second Escape closes it" - Pattern for all components: https://grani.arseni.work/patterns/finding#search-clears ##### Steady width - When: one option is picked from a list it holds. - Props: `items`, without `fetcher` or `multiple`. - Guarantee: Picking an option, or clearing it, does not move the fields beside it: the field reserves the width of its longest option or its placeholder, and keeps the place of the clear button while it is hidden. - Without it: A field as wide as its text: a longer option widens it at the pick and pushes the field beside it. - Code: `` - Your part: Give the field a width where the row needs one; without it, the field is as wide as its longest option. - Not covered: Options from a server (`fetcher`) and several picks (`multiple`) are not known in advance, so their field does not reserve the longest one. The longest labels are counted in letters: one of wide letters outside the three longest can still widen the field. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "the field keeps its width: the text stands over the longest labels, the cross keeps its place", "the clear cross pulls to the capsule edge and, at xs, fits inside the capsule; its kept place matches it" - Pattern for all components: https://grani.arseni.work/patterns/finding#steady-width ##### Stable dimensions - When: the app loads the options itself. - Props: `loading`. - Guarantee: While the list has no results, it shows placeholders shaped like options, with checkboxes when `multiple` is enabled. Once open, it keeps the height it has reached as options arrive. A selected value whose option is still loading shows a loading state in the field. - Without it: An empty list says nothing matches, then grows under the pointer when the options arrive. - Code: `` - Your part: Pass `loading` while the options load; a `fetcher` reports its own loading. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "options the app is loading show rows in their shape and say so, never "No matches"" - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt ##### Opens where it fits - When: its field stands near the bottom of the window. - Props: none. - Guarantee: The list opens above the field when the room below is too short for it and the room above is longer, and under it otherwise. It never covers the field and never leaves the window. - Without it: A list under a field at the end of a long form runs off the screen. - Code: `` - Your part: Nothing. - Not covered: The room is measured in the window, not in a scrolling box inside the page. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "near the bottom of the window the list opens above the field; near the top, below it" - Pattern for all components: https://grani.arseni.work/patterns/opening#opens-where-fits ##### Escape, top first - When: its list is open inside a window or a drawer. - Props: none. - Guarantee: Escape closes the list only, and keyboard focus goes back to the field; the window stays open. With text in its search, the first Escape clears the text and the list stays. - Without it: One Escape closes the list and the form behind it at once. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "Escape on the trigger of an open select inside a modal closes the list only", "Home and End jump, Escape closes and returns focus to the trigger", "the search clears its own text on Escape and keeps the panel open; a second Escape closes it" - Pattern for all components: https://grani.arseni.work/patterns/closing#escape-top ##### Outside, top first - When: its list is open inside a window or a drawer. - Props: none. - Guarantee: A press outside the list, on the backdrop too, closes the list alone, and the window stays open; the next press there closes the window. - Without it: A press beside the list closes the window under it as well, with the form in it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "a press outside its list inside a modal closes the list and keeps the modal" - Pattern for all components: https://grani.arseni.work/patterns/closing#press-top ##### Focus on the error - When: a form is sent with no choice made. - Props: `invalid`, or the error of its UiField. - Guarantee: When the select is the first field with an error, keyboard focus lands on its button, so you can open the list and choose straight away. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `` - Your part: Pass the error to its UiField, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "focusInvalid on an invalid select focuses the trigger" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Off closes - When: it is disabled while its list is open. - Props: `disabled`. - Guarantee: The list closes and keyboard focus stays on the field. - Without it: The list stays open over a field that no longer accepts a choice. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "disabling while open closes the list and keeps the focus on the field" - Pattern for all components: https://grani.arseni.work/patterns/closing#off-closes ##### Whole text - When: the chosen option is longer than its field. - Props: none. - Guarantee: The field shows the chosen label on one line, cut with an ellipsis, and its whole text shows in a tooltip on hover while the cut is real. The chevron and the clear button stay in view. - Without it: A long client name cut at the end of a narrow field, with no way to read the rest but opening the list. - Code: `` - Your part: Nothing; give the field the width its row allows. - Not covered: Only the mouse shows it: a tap opens the list, keyboard focus opens no tooltip, and while the list is open there is none, since it would stand over the rows; the rows of the list are cut too. A screen reader hears the whole label. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "a chosen label stands in a UiTruncate, its whole text in the tooltip while cut", "the whole-label tooltip stays away while the list is open, where it would stand over the rows" - Pattern for all components: https://grani.arseni.work/patterns/finding#whole-text ##### Five and a half rows - When: the list is long. - Props: none. - Guarantee: The list is cut at five and a half rows, so the half row shows there is more below. - Without it: A list cut at a row edge that looks finished. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "a long list is cut at five rows and a half" - Pattern for all components: https://grani.arseni.work/patterns/finding#five-rows ##### Cleared in place - When: An answer may be left empty. - Props: `clearable`. - Guarantee: The × sets the value to `null`, or to `[]` with `multiple`, and keyboard focus moves to the field. A required field has no ×. - Without it: The × empties the field and keyboard focus is lost, so you have to find the field again. - Code: `` - Your part: Treat `null` as no answer. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "the clear button keeps the focus on the field" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#cleared-in-place ##### Asks once - When: the options come from a server. - Props: `fetcher`. - Guarantee: Opening the list calls `fetcher` with an empty query. Typing calls it after a 250 ms pause. Reopening after a search requests the empty query again. While results are loading, the visible options remain in place and are dimmed. A list closed within the pause asks nothing, and the closed field shows its value, not a load. - Without it: A request on every key. - Code: `` - Your part: Return the options for the query you are given. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "asks the server on open with an empty query, then 250 ms after the last key", "reopening after a search asks once for the empty query", "a press outside while a search waits asks the server for nothing", "a search dropped before it was asked leaves the closed field showing its value, not a load", "an answer on its way when the list closes changes nothing: the field shows its value" - Pattern for all components: https://grani.arseni.work/patterns/finding#typing-asks-once ##### Letter jumps - When: the list has no filter field. - Props: none. - Guarantee: On the closed field, a letter opens the list with the first option that starts with it highlighted; in the open list, it moves the highlight there. The same letter again goes on to the next option that starts with it. Letters typed together find a word, its spaces too: "new j" reaches New Jersey. - Without it: A long list that you walk one row at a time with the arrow keys. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "a letter jumps to the first label with that prefix", "a letter on the closed field opens on the first label that starts with it, the chosen one too", "a Space after letters typed over the list belongs to the word: New J reaches New Jersey" - Pattern for all components: https://grani.arseni.work/patterns/finding#letter-jumps ##### Enter picks - When: The select is inside a form. - Props: `searchable`, `fetcher`, or none. - Guarantee: On the closed select, Enter opens the list. In the open list or its search box, Enter picks the highlighted option. In both cases the form around it is not submitted. While the answer to the text typed in a server search is on its way, Enter picks nothing: the rows in view answer an older word. - Without it: With a plain text field and a list under it, Enter submits the form before anything is picked. - Code: `
` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "inside a form Enter picks and the form is not submitted", "Enter while the answer to the typed text is on its way picks nothing; once it comes, Enter picks from it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#enter-picks ##### Shortcuts kept - When: the field or its open list has keyboard focus. - Props: none: every UiSelect. - Guarantee: Alt+Down opens the list, as on a native select. With Ctrl or Cmd held an arrow opens nothing and moves no highlight, so the browser’s own shortcut runs. - Without it: Ctrl+Down, meant for the page, opens the list or moves its highlight instead. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "Alt with ArrowDown opens the list, as a select does; Ctrl or Meta leave the key", "in the open list Ctrl or Meta with an arrow moves no highlight and leaves the key" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#shortcuts-kept ##### Tag taken back - When: Selections show as tags. - Props: `multiple`, `tags`. - Guarantee: A tag's × removes that selection and moves keyboard focus to the next tag, or back to the field after the last tag. With the list closed, Backspace on the field removes the last selection shown. A value missing from `items` shows as its own text, so it is one of them. - Without it: A tag’s cross takes the keyboard focus away with the tag: it falls to the page, and a screen reader loses its place. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "the cross of a tag drops that pick and moves the focus to the next tag", "removing the last tag puts the focus back on the field", "Backspace takes back the last pick in view, a value outside items shown as its text too" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#tag-taken-back ##### Focus starts inside - When: its list has a search field. - Props: `searchable` or `fetcher`. - Guarantee: Opening the list puts keyboard focus in its search field, so typing narrows the options at once. When nothing matches, the list says so in words instead of showing an empty box. - Without it: The list opens with keyboard focus left on the field, and you press Tab or click before you can search. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "focuses the filter, narrows by label, and says when nothing matches" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-start ##### Focus comes back - When: the list closes. - Props: none. - Guarantee: After choosing an option, pressing Escape, or pressing Tab from the list’s search field, focus returns to the select field. Clicking outside closes the list and leaves focus where you clicked. - Without it: The list closes and keyboard focus falls to the page: a screen reader loses its place, and you look for the field again. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "Home and End jump, Escape closes and returns focus to the trigger", "Tab from the filter closes and puts focus back on the trigger", "after a pick from a searchable list the focus is back on the field; a press outside leaves it where it was" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-back ##### One name - When: it needs a name. - Props: a UiField around it, or `ariaLabel`. - Guarantee: The select and its open list are announced once, by the field's label or by `ariaLabel`; the description and the error are announced with it. - Without it: A list announced with two names, or a select named only by its placeholder. - Code: `` - Your part: Put it in a UiField with a `label`, or give it `ariaLabel`. - Proof, in the repository's tests (UiSelect.spec.ts, not in the package): "takes id, required, error and description from the field", "a standalone listbox takes the ariaLabel" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`, `useUiT`. ### Separator (`UiSeparator`) > Separate groups in a menu, toolbar or card with a simple dividing line. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/separator Also called divider, horizontal rule. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. Implemented by the app: - Data fetching, saving, permissions and business rules. #### Why it works this way A line between groups of content. Take it between menu items, parts of a toolbar or groups of a UiButtonGroup. **Spacing.** It has no margin of its own: the parent's gap spaces it. On a padded surface, a horizontal line runs through the gutter to the edges by itself; your own `-mx-*` is only for a place with no gutter. A vertical line stretches to the height of its flex row; in a UiButtonGroup it stands across the group. **Border.** It is a border, not a fill, because forced colors keep borders. ##### Keyboard Not interactive: it takes no focus and no tab stop. ##### Screen reader - Line: a `separator` with its `aria-orientation`: a break between groups and nothing more. ##### States None: one line, horizontal or vertical, through a surface's gutter or across a row; a border, so forced colors keep it. ##### When to use another component - The line under a section title: UiHeader `legend`; where a legend opens a section, the legend is the line. - The line between rows of a list: UiList draws it. - Lines under a card's header and above its footer: UiCard draws them. - The lines between the flat sections of an overlay: the overlay places them, as UiDrawer does. #### Examples ##### In a row ```vue ``` ##### In a button group ```vue ``` ##### In a menu ```vue ``` #### API Props: - `orientation`: `"horizontal" | "vertical"` = `horizontal | vertical` — `horizontal` spans the width between stacked blocks; `vertical` stands between items of a row. Default: across the enclosing UiButtonGroup, else horizontal. Anything else lands on the root element: native attributes, aria-*, data-testid. ### Skeleton (`UiSkeleton`) > Show where content will appear while it loads, holding its space so the page around it moves less. A component of grani 0.1.0. Status, stable, since 0.1.0. Page: https://grani.arseni.work/components/skeleton Also called loading placeholder, shimmer, content placeholder. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See where the app is working. - Keep nearby content in place while loading. - Reduce motion without hiding loading. Implemented by the app: - Data fetching, saving, permissions and business rules. - Show it from the start of the request until the answer, and never next to a spinner for the same wait. - Give it the size and shape of the content to come, and remove it when the data arrives. Inside a card, a table, a drawer or a chart card, set their `loading` instead. #### Why it works this way The shape of content on its way. Take it in a block of your own while its first load runs; a card, a table, a drawer and a chart card draw their own under `loading`. **Size.** Size and shape come from the classes the host passes (`h-4 w-32`, `h-64 w-full`, `rounded-pill` for an avatar); without them it has no size. A line of text is an `inline-block h-3 align-middle` inside a block of the text's size, which keeps the line's height. A page skeleton is several of these, mirroring the real layout. **Decoration.** It is decoration, and its shimmer is the sign that the first load runs: under reduced motion it keeps shimmering, slower, since a still gray block reads as a broken page. The host's live region announces the loading state. **By hand.** UiCard, UiTable, UiDataTable, UiCrudDrawer and UiChartCard (with its charts) draw it themselves under `loading`. Place it by hand only in blocks of your own, which bring their own `role="status"` and drop it when the data comes. ##### Keyboard Not interactive: it takes no focus and holds no control. ##### Screen reader - Hidden: `aria-hidden`; the loading state is announced by the host's live region, a `role="status"` the host brings and drops when the data comes. ##### States One: a shimmering block, slower under reduced motion, in the size and shape the host's classes give it; without them it has no size. ##### When to use another component - An action in flight: UiSpinner. - Work measured or of unknown length: UiProgress. - Nothing there or a failed load: UiNoData. - Loading a card, a table, a drawer or a chart card: UiCard, UiTable, UiCrudDrawer and UiChartCard draw it themselves under `loading`. #### Examples ##### By hand ```vue ``` ##### Blocks ```vue ``` #### API No props, events or slots of its own. Anything else lands on the root element: native attributes, aria-*, data-testid. #### How it behaves ##### Visible work - When: on a first load whose layout is known. - Props: shown from the request to its answer. - Guarantee: Placeholders show that content is still loading and where it will appear; their shimmer shows that loading is in progress. - Without it: A blank space that looks empty rather than loading. - Code: `` - Your part: Show it from the start of the request until the answer, and never next to a spinner for the same wait. - Proof, in the repository's tests (UiSkeleton.spec.ts, not in the package): "A hidden block in the shape its host gives it." - Pattern for all components: https://grani.arseni.work/patterns/waiting#visible-work ##### Stable dimensions - When: on a first load. - Props: the host gives it the size and shape of what comes. - Guarantee: The placeholder reserves space in the shape of the expected content, so when the content replaces it nothing nearby moves, as long as the dimensions match. - Without it: The space stays empty, and when the content arrives everything below it jumps down. - Code: `` - Your part: Give it the size and shape of the content to come, and remove it when the data arrives. Inside a card, a table, a drawer or a chart card, set their `loading` instead. - Not covered: It does not announce that the page is loading; the surrounding component’s `role="status"` does. - Proof, in the repository's tests (UiSkeleton.spec.ts, not in the package): "A hidden block in the shape its host gives it." - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt ##### Calm motion - When: reduced motion is on. - Props: the system setting `prefers-reduced-motion: reduce`. - Guarantee: The placeholder animation slows from one pulse every 2 seconds to one every 3 seconds. - Without it: The placeholders stand still and gray, and the page looks stalled rather than loading. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiSkeleton.spec.ts, not in the package): "It keeps shimmering, slower, when the system asks for reduced motion." - Pattern for all components: https://grani.arseni.work/patterns/waiting#calm-motion The patterns: https://grani.arseni.work/patterns ### Sparkline (`UiSparkline`) > See a value's recent trend beside the number, with gaps where data is missing. A component of grani 0.1.0. Charts, stable, since 0.1.0. Page: https://grani.arseni.work/components/sparkline Also called mini chart, trend line, inline chart. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Show missing data as a gap. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass `null` where there is no data, and `0` only for a real zero. #### Why it works this way A small trend with no axes, beside the number it belongs to. Take it in a table cell, in a strip's actions beside a UiDelta, or under a headline in a UiChartCard. **Shapes.** `line` and `area` stretch in one viewBox, with a dot on the newest value kept inside the box; a flat run stands at mid-height. `bar` draws HTML boxes from zero: the newest one (or `highlight`) is full, and the rest are pale with a line at their height, so each still reads. Bars stand 4px apart while there is room; a long run in a narrow host closes the gaps. **Gaps.** A `null` is no data: the line breaks (a single value between two gaps is a dot), and a bar's place stays empty. A zero is a stub on the zero line. Size and tone. The host sets the width, and `height` sets the height. Set `tone` to agree with the verdict beside it. `mark` draws one reference line, an average or a plan, named at its end. In a card. In a UiChartCard it draws its shape pale on the first load and holds it still when there is nothing to draw. ##### Keyboard Not interactive: no tab stop, no keys; the number beside it carries the value. ##### Screen reader - Name: without `ariaLabel` it is hidden (`aria-hidden`), and the number beside it says the value; with one it is an image with that name, followed by the mark's label ("Average receipt, the last 12 months, 12-month average 43.06"). - Values: not read; a chart a reader can walk is UiAreaChart or UiBarChart. - Hidden: on a card's first load, when the card has nothing to draw, and when no value is a number, whatever its name. ##### States `line`, `area` and `bar`, a dot on the newest value or on `highlight`, a gap (`null`) that breaks the line or leaves a bar's place empty, a loss below the zero line, a flat run at mid-height, a `mark` with its name, six tones. In a UiChartCard: its first load as a pale shape, nothing to draw held still, a refresh dimmed by the card. No hover or focus state of its own. ##### When to use another component - A chart with axes, a legend and a tooltip: UiAreaChart or UiBarChart. - A share of a whole: UiProgress. - The verdict beside it: UiDelta. - A single figure with a trend as a card: UiChartCard with `bleed="full"`. #### Examples ##### Tones ```vue ``` ##### Mark ```vue ``` ##### Beside a number ```vue ``` ##### In a table ```vue ``` ##### Gaps ```vue ``` ##### In a card ```vue ``` #### API Props: - `values`: `(number | null)[]` (required) — The values in order, the newest last; `null` is no data, a gap. - `variant`: `"line" | "area" | "bar"` = `line | area | bar` (default `"line"`) — `line` a trend, `area` the same filled below, `bar` one box per value from zero. - `tone`: `Tone` = `brand | neutral | success | warning | danger | info` (default `"brand"`) — What the trend means: `brand` for a figure of the product, `success` or `danger` for a verdict, `neutral` for a quiet one. - `endpoint`: `boolean` (default `true`) — A dot on the newest value of a line or an area. - `height`: `number` (default `28`) — The height in pixels; the host sets the width. - `highlight`: `number` — The index of the value in light: the full bar, or the dot on a line; the newest value when unset. - `mark`: `{ at: number; label: string; }` — A line of reference across the trend, named at its end: `{ at: 43.06, label: '12-month average 43.06' }`. - `ariaLabel`: `string` (default `""`) — What the trend shows, for a screen reader: "Average receipt, the last 12 months". Unset, it is decoration. Anything else lands on the root element: native attributes, aria-*, data-testid. #### How it behaves ##### A gap, not a zero - When: a value is missing. - Props: `values` with `null` where there is no data. - Guarantee: A `null` breaks the line, which resumes at the next available value. An isolated value is shown as a dot so it remains visible, and the endpoint dot marks the latest available value. In a bar sparkline, missing data leaves an empty space. - Without it: A missing day is drawn as a dive to zero: a fall that never happened. - Code: `` - Your part: Pass `null` where there is no data, and `0` only for a real zero. - Proof, in the repository's tests (UiSparkline.spec.ts, not in the package): "a missing value breaks the line, and the dot stands on the newest value there is", "a bar with no value leaves its place empty rather than drawing a stub", "a value alone between two gaps is drawn as a dot, not a speck of line" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#gap-not-zero The patterns: https://grani.arseni.work/patterns ### Spinner (`UiSpinner`) > Show that work is in progress where the surrounding component has no loading state of its own. A component of grani 0.1.0. Status, stable, since 0.1.0. Page: https://grani.arseni.work/components/spinner Also called loader, loading indicator, activity indicator. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See where the app is working. - Reduce motion without hiding loading. Implemented by the app: - Data fetching, saving, permissions and business rules. - Show it when the request starts and remove it when the request ends. A spinner that mounts with its name is often not read out, so say the wait in a live region that was on the page before the request starts (UiCard `loading` has one). Inside a button, a badge, a switch or a row, set their `loading` instead of placing a spinner yourself. #### Why it works this way The sign that work is under way. Take it where a request runs and nothing draws one for you; a button, a badge, a switch and a row draw their own under `loading`. **Size.** It is 16px unless the host sets `h-*` or `size-*`. It has no props. **Placement.** It is the loading state of buttons, badges, switches, rows, selects, list fields, uploads, toasts and chart cards. ##### Keyboard Not interactive: it takes no focus. A button that spins is UiButton `loading`, which handles the keys. ##### Screen reader - Name: with an `aria-label` or `aria-labelledby` it is a `status` with that name; without a name it is decoration, hidden. A `role` or `aria-hidden` you set never overrides this. - Status: one that mounts with its name already in place is often not read out. Say the wait in a live region that was on the page before the request starts (UiCard `loading` has one), and let the spinner be its picture. ##### States One: turning, slower under reduced motion, never still; 16px unless the host sets `h-*` or `size-*`. ##### When to use another component - Content on its way: UiSkeleton. - Work of unknown length on a track: UiProgress `indeterminate`. - A button, a badge, a switch or a row at work: UiButton, UiBadge, UiSwitch and UiListItem draw it themselves under `loading`. #### Examples ##### Named or not ```vue ``` ##### Color ```vue ``` ##### Where it turns ```vue ``` #### API No props, events or slots of its own. Attributes are placed by the component itself: its description says where. #### How it behaves ##### Visible work - When: a request is running. - Props: it is mounted while the request runs; `aria-label` is set when no words beside it say what is loading. - Guarantee: A spinning ring shows that work is in progress. With an accessible name it is exposed as a status with that name; without a name it is hidden from a screen reader. - Without it: Nothing changes after the click, so you click again and the same request goes twice. - Code: `Save` - Your part: Show it when the request starts and remove it when the request ends. A spinner that mounts with its name is often not read out, so say the wait in a live region that was on the page before the request starts (UiCard `loading` has one). Inside a button, a badge, a switch or a row, set their `loading` instead of placing a spinner yourself. - Not covered: It does not stop a second click; the button’s `loading` does. - Proof, in the repository's tests (UiSpinner.spec.ts, not in the package): "The ring turns, at the size its host gives it.", "With a name it is a status with that name; without one it is hidden from a screen reader." - Pattern for all components: https://grani.arseni.work/patterns/waiting#visible-work ##### Calm motion - When: reduced motion is on. - Props: the system setting `prefers-reduced-motion: reduce`. - Guarantee: The spinner continues turning, slowed to one rotation every 1.6 seconds. - Without it: The ring stops with every other animation, and the screen looks frozen. - Code: `` - Your part: Nothing. A loading indicator of your own keeps moving only with the `animate-spin` class or the `data-motion-keep` attribute; otherwise it stops with every other animation. - Proof, in the repository's tests (UiSpinner.spec.ts, not in the package): "It keeps turning, slower, when the system asks for reduced motion." - Pattern for all components: https://grani.arseni.work/patterns/waiting#calm-motion The patterns: https://grani.arseni.work/patterns ### Stack (`UiStack`) > Arrange related content vertically or horizontally with consistent spacing between groups, headings and sections. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/stack Also called vertical stack, vstack, spacing scale. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. Implemented by the app: - Data fetching, saving, permissions and business rules. #### Why it works this way Things one under another or side by side, spaced by what they are to each other. Take it for the blocks of a page or a form; text on the left and a thing on the right is UiListItem. **Gaps.** You name the relation, never a number. `tight` is 8, for the lines of one thing (a label and its control, a name and its count). `group` is 16, the default, for the things of one group (the fields of a form, a search over its list). `title` is 24, for a title and the text or the fields it names. `block` is 48, for the blocks of one page, and `section` is 96, for its sections. Importance is shown by size and color, not distance: a wider gap says "another thing", never "a bigger thing". **Margins.** It is a gap, not a margin, so the children carry no space of their own. A UiSeparator among them keeps the rhythm, and a UiHeader legend after other content adds its own 32 to the gap: in the default stack that lands at `block` (48), in another it is that gap plus 32. The stack pads nothing: in a UiCard it is the body, and the card's gutter goes round it. **Rows.** `row` is for things that share one line and nothing else (a badge beside a name, two buttons); they align on its vertical center. Text on the left and a thing on the right is UiListItem, not a `row`. ##### Keyboard Not interactive: the children keep their own tab stops, in the order they are stacked. ##### Screen reader - Element: nothing of its own; a `div`, or the element `as` names (`section`, `ul`, `form`, `li`), with the children read in order. ##### States None: the gap is one step of the scale and does not change with the content. ##### When to use another component - Text on the left and a thing on the right: UiListItem. - A table: UiTable. - The toolbar row: UiTableToolbar owns it. - Buttons joined into one capsule: UiButtonGroup. - A grid of columns: the page lays it out itself. #### Examples ##### Nested ```vue ``` ##### A row ```vue ``` #### API Props: - `gap`: `"block" | "title" | "group" | "section" | "tight"` = `block | title | group | section | tight` (default `"group"`) — The relation between the children, a step of the scale: `tight` 8, `group` 16, `title` 24, `block` 48, `section` 96 px. - `direction`: `"column" | "row"` = `column | row` (default `"column"`) — `column` stacks the children; `row` lines them up on one line. - `align`: `"start" | "center" | "end" | "stretch"` = `start | center | end | stretch` — Where the children stand across the stack. Unset, a column stretches them to its width and a row centers them on the line. - `as`: `"div" | "form" | "section" | "ul" | "li"` = `div | form | section | ul | li` (default `"div"`) — The element: `div` by default; `section`, `ul`, `form` or `li` when the stack is one. Anything else lands on the root element: native attributes, aria-*, data-testid. Slots: - `default` with `Record` — The things stacked, in order, each without a margin of its own. ### Stepper (`UiStepper`) > Count things with minus and plus, such as pieces in a cart or guests on a booking, or type the number when it is far from the current one. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/stepper Also called quantity input, counter, plus minus input, numeric stepper. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Change a number with the arrow keys. - See the value the app actually accepted. - Submit the values shown in the form. - Enter numbers in the app’s local format. - Keep nearby buttons and fields in place. - Go straight to the field that needs fixing. - Name each field once for screen readers. - Announce required fields in words. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass `min` and `max` where the count has bounds, and `step` if it is not 1. - Pass the count the app holds as `modelValue`, and say why a count was refused. - Listen to `unsure` if the count is saved: a count the locale cannot read for sure, such as 1.200 in Russian, reaches the model as written, and `unsure` is `true` while the count holds it. #### Why it works this way A count of things with minus and plus at its edges, the count between them. Take it for pieces, people, nights: a whole number a person changes by one more often than types. **Count.** It is a number field, the one UiInput draws with `numeric`: the value is text, empty is `null`, a number pasted or typed is read in the app's locale, and one it cannot read for sure is marked and told through `unsure`, as in UiInput; a press on it moves nothing and marks it at once. The step is `1` unless set; the buttons and ArrowUp and ArrowDown move by it and stop at `min` and `max`, which bound the buttons, not the typing. A count of things takes `min` (`0` or `1`): without it minus goes below zero. A fraction typed into it is carried as written, as a count past `max` is: the form checks it (`Number.isInteger`). The app takes or refuses a count in the same tick it is sent; a count confirmed later is a draft of the app's own. A unit stands after the count in `#end`, its short form (`pcs`, `kg`); an addon before it in `#start`. **Width.** The count is 3em wide, room for four digits, and the stepper keeps that width in a field rather than stretching across it; given a width, the count takes the room between the buttons, so they stand at the edges and never move. A longer count is cut at its end. **Field.** Standalone it takes `invalid` and `ariaLabel`; inside UiField it reads id, error and required from the field. **Attributes.** Attributes and listeners land on the input (`@blur`, `data-testid`); `class` and `style` on the capsule. **Placement.** In a form column under a UiField label; in a row of a list or a cart, beside the name of what is counted; joined with other controls in a UiButtonGroup. ##### Keyboard - `Tab`: reaches the count; the buttons are out of the tab order. - `ArrowUp` `ArrowDown`: move the count by the step without moving the caret, and stop at `min` and `max`. The buttons keep the focus and the caret in the count; `disabled` or `readonly` turns them off. ##### Screen reader - Count: a spinbutton named by `ariaLabel` or the field's label; it says its value and its bounds, and no value while the count is unsure. The unit in `#end` is not part of it. - Buttons: named by what they change, "Decrease Guests" and "Increase Guests", disabled at a bound; out of the tab order. - Errors: `invalid` sets `aria-invalid`; inside UiField the description and the error are read with it. ##### States Rest, focus, a button resting at a bound, disabled, readonly, invalid, with a unit, at `xs`, `sm` and `md`. ##### When to use another component - A number with decimals or money: UiInput `numeric`. - A mark from 1 to N: UiScaleInput. #### Examples ##### States ```vue ``` ##### Sizes ```vue ``` ##### Field ```vue ``` ##### Russian ```vue ``` #### API Props: - `modelValue`: `string | null` (required) — The count as text (`v-model`): `null` when emptied. A number is plain text (`12`) parsed by its owner on use. - `step`: `number` (default `1`) — How much a button or an arrow key moves the count. - `min`: `number` — The lowest count the buttons and the arrows reach. Typing is not clamped: the form validates. - `max`: `number` — The highest count the buttons and the arrows reach. - `name`: `string` — Native name for forms. - `id`: `string` — Own id when standalone. Inside UiField the field's id takes precedence. - `placeholder`: `string` — Hint shown while empty. Not a replacement for a label. - `readonly`: `boolean` (default `false`) — Prevents editing while keeping the count focusable: something else sets it. - `disabled`: `boolean` (default `false`) — Prevents input and interaction. - `invalid`: `boolean` (default `false`) — Marks the control invalid: red border and halo. Inside UiField the error sets this. - `required`: `boolean` (default `false`) — Standalone only: a count is needed, said to a reader and to a form. Inside UiField the field's `required` says so. - `ariaLabel`: `string` — Accessible name when there is no visible label. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder. Unset, the app's size (`createGrani({ size })`), `sm` by default. Attributes are placed by the component itself: its description says where. Events: - `update:modelValue` (`value: string | null`) — The count changed: the number as text, `null` when emptied. - `unsure` (`unsure: boolean`) — `true` while the count holds text the locale cannot read for sure (`1.200` in Russian), which the model carries as written; `false` when it does not. Said on mount, then on every change. Slots: - `start` with `Record` — An addon before the count: a currency sign, an icon. - `end` with `Record` — A unit after the count, its short form: `pcs`, `kg`. #### How it behaves ##### Arrows step - When: always: its minus and plus, and ArrowUp and ArrowDown in the count. - Props: `step`, `min`, `max`. - Guarantee: Minus, plus and the arrow keys move the count by the step and stop at `min` and `max`. A press leaves the focus in the count, so the arrow keys go on from there. - Without it: Buttons beside a field that take the focus, so the next arrow key goes nowhere, and a count that runs past its bounds. - Code: `` - Your part: Pass `min` and `max` where the count has bounds, and `step` if it is not 1. - Not covered: Typing is not clamped: a count typed past `max` stays as typed, and the form checks it. - Proof, in the repository's tests (UiStepper.spec.ts, not in the package): "the buttons rest at the bounds and while disabled; the keyboard steps too", "pressing minus or plus with a pointer keeps the focus and the caret in the count", "minus and plus at the edges, the count centered in monospace, step 1 by default" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#number-by-arrows ##### Back to the value - When: the app refuses a count, more than it has, or the form is reset. - Props: `modelValue` with `@update:model-value`. - Guarantee: If the app refuses the count a press or a key asked for, the stepper shows the count the app holds again. A form reset brings it back too. - Without it: The stepper shows 6 while the app, with 5 in stock, still holds 5. - Code: `` - Your part: Pass the count the app holds as `modelValue`, and say why a count was refused. - Not covered: The app takes or refuses a count in the same tick it is sent: a count it answers later is overtaken by the next press or key, so a count confirmed by a server is a draft the app keeps. - Proof, in the repository's tests (UiStepper.spec.ts, not in the package): "a press of plus the parent refuses leaves the count on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: The count is sent under its `name`, and after a form reset it shows the app’s count again. - Without it: A count on screen the form never sends. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiStepper.spec.ts, not in the package): "in a form it sends its count under its name, and a reset puts the app's count back" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Number as written - When: a count is typed or pasted as people write it. - Props: none. - Guarantee: The count is read in the app’s locale: a pasted 1 200 is 1200 in Russian. - Without it: A pasted 1 200 the field cannot read. - Code: `` - Your part: Listen to `unsure` if the count is saved: a count the locale cannot read for sure, such as 1.200 in Russian, reaches the model as written, and `unsure` is `true` while the count holds it. - Proof, in the repository's tests (UiStepper.spec.ts, not in the package): "a count pasted as people write it is read in the app locale", "a count the locale cannot read for sure tells the app, as its number field does" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#number-as-written ##### Steady width - When: the count grows past a digit, 9 to 10. - Props: none. - Guarantee: Minus and plus stand at the edges of a count of one width, so a count that grows from 9 to 10 to 100 moves neither of them: pressing plus again lands on plus. - Without it: A field as wide as its number: at 10 it widens, and the plus moves from under the pointer pressing it. - Code: `` - Your part: Nothing; give the stepper a width where the row needs a wider count. - Not covered: A count longer than its width is cut at its end, not widened: give the stepper a width for counts that large. - Proof, in the repository's tests (UiStepper.spec.ts, not in the package): "the count keeps its 3em whatever its digits, minus and plus at the edges, so neither moves" - Pattern for all components: https://grani.arseni.work/patterns/finding#steady-width ##### Focus on the error - When: a form is sent with this field in error. - Props: `error` on UiField. - Guarantee: When this is the first field with an error, the focus moves into the count. - Without it: Keyboard focus stays on Save, and you look for the count yourself. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiStepper.spec.ts, not in the package): "in a field in error, focusInvalid lands on the count" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### One name - When: it stands in a UiField, or takes `ariaLabel`. - Props: none. - Guarantee: The count is named once, by its label, and each button says what it changes: Decrease Guests. - Without it: Two buttons with no name, and a count read with none. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiStepper.spec.ts, not in the package): "inside UiField the label points at the count, and the error and required reach it", "the buttons are named by what they change: Decrease Guests, Increase Guests" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice ##### Required in words - When: it must be filled. - Props: `required` on UiField. - Guarantee: Inside a required UiField, the count is announced as required, and the field draws the asterisk. - Without it: An asterisk that a screen reader skips. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiStepper.spec.ts, not in the package): "inside UiField the label points at the count, and the error and required reach it" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#required-in-words The patterns: https://grani.arseni.work/patterns ### Switch (`UiSwitch`) > Change an on/off setting immediately, returning to the previous position if the app rejects the change. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/switch Also called toggle, toggle switch, on off switch. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See the value the app actually accepted. - Submit the values shown in the form. - Go straight to the field that needs fixing. - Toggle or pick by clicking the words. - Name each field once for screen readers. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the value the app holds as `modelValue`, set `loading` while the change is saved, and explain why a change was refused. - Give it a `name`, and a `value` where the server expects something other than `on`. - Pass the error to its UiField, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Put the words in the default slot; in a setting row, use UiListItem with `as="label"`. - Give it words in the default slot, or `ariaLabel`; a switch with no name warns in development. #### Why it works this way An on or off setting that applies at once. Take it where a change takes effect without saving a form. **Element.** It is a `button` with `role="switch"`, so a screen reader says "switch, on". **Form.** With a `name`, an on switch sends `value` (`on` by default) under that name with the form, and an off switch sends nothing, as a checkbox does. **Label.** Label text goes in the default slot, toggles the switch on click, and is then its only name; without it, `ariaLabel` names the switch. Inside UiField it takes the field's label and description. The label starts at the middle of the track (2px down beside the 24px `md` track), so a long label wraps below. **Model.** When the parent does not apply a change, the switch stays as the model says. **Loading.** `loading` keeps the focus on the switch while the change is saved. **Invalid.** An invalid switch that is off tints its track with the danger tone. **Attributes.** Attributes land on the button, `class` and `style` on the root. **Size.** Its `size` is a track, not a box, so it stays `sm` by default and does not follow the app's size (`createGrani({ size })`). **Placement.** Beside UiCheckbox and UiRadio in a form column. A setting row is UiListItem `as="label"` with the switch in `#actions`. It also works as a `UiReveal` trigger. ##### Keyboard A native button. - `Tab`: reaches it. - `Enter` `Space`: toggle it. `disabled` blocks it. `loading` blocks it too while the focus stays, the caller's own click included. A label in the slot toggles it on click, except while disabled. ##### Screen reader - Switch: `role="switch"`; it says on or off (`aria-checked`). - Name: the slot text, else `ariaLabel`, else the field's label: one name only. A switch with no name warns in dev. - Loading: `loading` says busy (`aria-busy`, `aria-disabled`). - Errors: `invalid` sets `aria-invalid`, and the field's words are read with it. - Required: `aria-required` says it is required, except in a group, whose legend says it once. ##### States Off, on, hover, focus, disabled (the plate, and the thumb in the disabled ink so on and off still look different), loading (a spinner in the thumb), invalid (the track in the danger tone while off; disabled wins over it), and the two tracks, `sm` and `md`. ##### When to use another component - Saved with the form, not applied at once: UiCheckbox. - One answer out of a few: UiRadioGroup. - A toggle that is a button: UiButton `pressed`. #### Examples ##### Sizes ```vue ``` ##### States ```vue ``` ##### Loading ```vue ``` ##### Setting row ```vue ``` ##### Refused ```vue ``` ##### Form ```vue ``` #### API Props: - `modelValue`: `boolean` (required) — On or off (`v-model`). - `disabled`: `boolean` (default `false`) — Prevents toggling. - `size`: `"sm" | "md"` = `sm | md` (default `"sm"`) — Track size: `sm` 20px, `md` 24px. - `name`: `string` — Native name for forms: an on switch sends `value` under it, an off one nothing. - `value`: `string` (default `"on"`) — Sent under `name` while on. - `ariaLabel`: `string` — Accessible name when the slot is empty and there is no field label; slot text wins over it. - `loading`: `boolean` (default `false`) — The change is being saved: a spinner in the thumb, `aria-busy`, no toggling; focus stays. - `id`: `string` — Own id when standalone. Inside UiField the field's id takes precedence. - `invalid`: `boolean` (default `false`) — Marks the switch invalid: the track takes the danger tone while off. Inside UiField the error sets this. - `required`: `boolean` (default `false`) — Standalone only: the switch must be on, said to a reader, with a star after its text. Inside UiField the field's `required` says so. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: boolean`) — The state changed. Slots: - `default` with `Record` — The visible label, to the right of the track; clicking it toggles. #### How it behaves ##### Back to the value - When: the app saves the change and may refuse it. - Props: `modelValue` with `@update:model-value`, `loading`. - Guarantee: You can see whether a setting changed. The switch follows the app’s value, so a rejected change leaves it in its previous position. While the app marks the update as loading, it shows progress, keeps focus and ignores further presses. - Without it: The switch flips on the press and stays on, while the setting is still off. - Code: `` - Your part: Pass the value the app holds as `modelValue`, set `loading` while the change is saved, and explain why a change was refused. - Proof, in the repository's tests (UiSwitch.spec.ts, not in the package): "a change the parent refuses leaves the switch on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`, `value`. - Guarantee: When on, it sends `value` (`on` by default) under its `name`; when off or disabled, it sends nothing. After a form reset it shows the app's value again. - Without it: A switch on screen that the form never sends. - Code: `Weekly digest` - Your part: Give it a `name`, and a `value` where the server expects something other than `on`. - Proof, in the repository's tests (UiSwitch.spec.ts, not in the package): "in a form an on switch sends its value under its name and an off one sends nothing", "a form reset leaves the switch on its model" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Focus on the error - When: a form is sent with the switch in error. - Props: `invalid`, or the error of its UiField. - Guarantee: When the switch is the first field with an error, keyboard focus lands on it, so you can turn it on from the keyboard straight away. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `I accept the terms` - Your part: Pass the error to its UiField, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiSwitch.spec.ts, not in the package): "a switch marked invalid is what focusInvalid lands on" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Click the label - When: the switch has words. - Props: the default slot. - Guarantee: The words sit inside the label around the switch, so a click on them toggles it, as a click on the track does; while the switch is disabled, they do not. - Without it: Words beside the switch that only name it: a click on them does nothing. - Code: `Weekly summary` - Your part: Put the words in the default slot; in a setting row, use UiListItem with `as="label"`. - Proof, in the repository's tests (UiSwitch.spec.ts, not in the package): "a label in the slot wraps the control, so clicking the text toggles too" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#label-is-target ##### One name - When: it needs a name. - Props: the default slot, `ariaLabel`, or a UiField around it. - Guarantee: A screen reader gets one name: the switch’s text, then `ariaLabel` if there is no text, then the surrounding UiField’s label if neither is provided. The field’s description and error are announced with it. - Without it: A switch announced with two names, or with none. - Code: `Weekly summary` - Your part: Give it words in the default slot, or `ariaLabel`; a switch with no name warns in development. - Proof, in the repository's tests (UiSwitch.spec.ts, not in the package): "with its own text inside UiField it is named by that text, as UiCheckbox", "slot text and ariaLabel together emit one name" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice The patterns: https://grani.arseni.work/patterns ### Table (`UiTable`) > Compare records in columns, sort and select rows and act on a row, with clear states for loading, empty results and errors. A component of grani 0.1.0. Data, stable, since 0.1.0. Page: https://grani.arseni.work/components/table Also called simple table, grid, rows and columns. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Show missing data as a gap. - See that a row goes on past its edge. - Select or clear a range of rows. - Show when only some are selected. - Keep nearby content in place while loading. - Keep keyboard focus where you are working. - See whether an action worked. - Ignore extra presses while a request runs. - Give every empty list a way forward. - See how the rows are sorted. - Compare numbers in aligned columns. - Read the full text when a label is shortened. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass `null` where there is no value, and `0` only for a real zero. - Keep `v-model:selection` to rows that still exist: drop the keys of rows that were deleted. - Set `skeletonRows` to the page size. - Pass the error message in words as `error`, and load again on `@retry`. - Set `loading` from the moment you ask for rows until they arrive. - Use `#empty` to supply a UiNoData message with an action such as Create or Reset. For a failed load, pass an explanation in `error` and load again on `retry`. - Order the rows by `sort` yourself: the table reports the choice and does not reorder the rows. - Format the numbers alike down a column, with the same number of decimals, so their digits line up. - Set `truncate` on a column of long names, and a `width` where it should not take the room left. #### Why it works this way Records as rows under column headers, with sorting, selection and loading states. Take it inside UiDataTable, or alone for a list with no toolbar and no pages. **Data.** It draws the page it is given and owns none of the data. A header click asks for a sort (`v-model:sort`). A checkbox marks a row (`v-model:selection`, a set of row keys); with Shift, by pointer or Shift+Space, it marks the range from the last row marked. The parent loads, filters and orders the rows, and keeps the selection to rows that still exist. **Look.** One presentation: a hairline between rows, space between columns, a tint on the row under the pointer. The table draws no shell; the card around it does (`UiCard :padded="false"`). Its focus rings are drawn inside, since the card clips what sticks out. **Wide tables.** A wide table scrolls sideways in its own box, with `#row-actions` kept at the end. Once it overflows, the box takes focus and the arrow keys scroll it, a side with more columns past it is shaded, and on a desktop pointer an arrow in the header's band pages the table toward it, so a narrow screen shows the table goes on and how to reach the rest. The arrow stands over the edge of the header cell under it, which is pressed beside it. An empty or a failed table shows its plate with neither. Once it scrolls, a column of short values (24 characters or fewer) keeps them on one line: the table has the room to the side. **Loading.** The first load shows skeleton rows, as many as the page will hold; a refresh keeps the rows in view, dimmed and busy, and says it updates. In UiDataTable, a list that searches, the table stands on a floor: its empty and failed states fill it, and a skeleton holds as many rows as were in view, never fewer than fill it. A skeleton cell draws the lines its column holds (`lines`, 2 for a UiMoneyCell or UiDateCell), so the rows arrive at their height. With `#row-actions`, it holds the square of one icon button at the app's size, so the actions column keeps its width and the row its height. The other columns take their width from their content, which a skeleton does not have: those without a `width` settle when the rows arrive. Empty and failed. An empty page is UiNoData: "no records yet", or, with `filtered`, "nothing matches"; `#empty` puts one with its own action in its place. A failed load is UiNoData `danger` with the message and Retry. A failed refresh shows the same plate in place of the rows, and Retry brings the skeleton back. **Rows.** A row without a key keeps its DOM node for the life of the page, so a sort moves the row with its record. The totals row shows only when there are rows. A cell of an object needs `format`, `accessor` or its slot; without one it draws a dash. **Focus.** It stays where the person is when the rows change under it: a sort leaves it on the same control of the same row, a row deleted hands it to the same control of the row now in its place (the row above when it was the last), and a page left without rows gives it to the table's box. A focus that went elsewhere, into a window opened from a row, is not pulled back; that window gives it back to its own trigger. A row's own control turned off for a moment keeps it. A row deleted from its UiMenu is handed on the same way: the menu gives the focus back to its button first. A row removed while a window opened from it holds the focus (a question's `action`) is handed on too: the window gives the focus to the same control of the row now in its place. **Attributes.** They reach the ``, so `aria-label` names it and its page checkbox; `class` and `style` stay on the root. **Placement.** In UiDataTable, under UiTableToolbar and over UiPagination, with UiMoneyCell and UiDateCell as its cells. Alone in a `UiCard :padded="false"` when a list needs no toolbar and no pages. ##### Keyboard - `Tab`: once the table overflows, reaches its scroll box first; then the page checkbox, a sortable header, each row's checkbox and the controls in `#row-actions`, and the Retry of a failed load. - `ArrowLeft` `ArrowRight` `ArrowUp` `ArrowDown`: on the scroll box, scroll it. - `Enter` `Space`: on a sortable header, a button, turn it. - `Space`: on a checkbox, marks the row. - `Shift+Space`: on a checkbox, marks the range from the last row marked. A row is reached through a control in `#row-actions`, never by a click on the row alone. A header pressed while rows load asks for nothing and keeps the focus; Retry is held the same way and leaves the focus in the table. ##### Screen reader - Table: named by the `aria-label` the page gives it, and so are its page checkbox ("Select all on this page of Orders") and its scroll box. - Sortable header: says its direction (`aria-sort`); a sort a press asked for is said once the app sends it back ("Sorted by Name, ascending"). - Row checkbox: "Select row", or "Select Anna" with `rowLabel`, and says its shortcut. - Columns: the selection column and the actions column have hidden names. - Row: says whether it is selected (`aria-selected`). - Status: one region, present from the start and drawn empty, says "Loading…", the empty words, from a moment after the table mounts. The plate of an empty page is no second live region; a UiNoData of your own in `#empty` takes `role="none"` for the same. - Failed load: announced by its plate. - Refresh: `aria-busy` on the rows. ##### States A first load (skeleton rows, as many as the page holds), rows, a refresh (the rows kept, dimmed to 60% and busy), empty with nothing yet, empty because of a filter (`filtered`), a failed load (UiNoData `danger` with Retry), a failed refresh (the same plate in place of the rows), a sort, a selection with the page checkbox mixed, a totals row shown only with rows, and a wide table that scrolls in its box. ##### When to use another component - A list screen: UiDataTable, with its toolbar, pages and selection strip. - Rows of settings or facts: UiListItem rows in a UiList. - A list of names that grows from its last row: UiListField. - The changes of one event: UiLog's own table. #### Examples ##### Sortable ```vue ``` ##### Selectable ```vue ``` ##### Row actions ```vue ``` ##### Totals ```vue ``` ##### States ```vue ``` ##### Wide ```vue ``` ##### Money ```vue ``` ##### Date ```vue ``` #### API Props: - `columns`: `TableColumn[]` (required) — The columns in order; each names its `#cell.` slot. A column is `{ key, label }` and may add `accessor(row)` (the value when it is not `row[key]`), `format(value, row)` (the text), `sortable` with `defaultSortDir` (`asc` or `desc`, the first press), `align: 'end'` (numbers, digit under digit), `width` (one CSS value, `120px` or `calc(…)`), `truncate` (one line, cut with a tooltip; cut columns with no width share the room left evenly). - `items`: `T[]` (required) — Rows of the current page. The table does not load, filter or sort them. - `rowKey`: `RowKey` — What tells rows apart: a field name or a function; `id` when unset, so rows without an `id` need it. A row without a key cannot be selected, and is known by its object: a parent that rebuilds its rows on every render remounts them all. - `loading`: `boolean` (default `false`) — Rows are on their way: skeleton rows on the first load, the rows in view dimmed on a refresh. - `skeletonRows`: `number` (default `5`) — Skeleton rows on the first load: the page size, so nothing moves when the rows arrive. - `rowLabel`: `((row: T) => string)` — Names a row for its checkbox: "Select Anna" instead of "Select row". - `error`: `string | null` (default `null`) — Why the rows could not be loaded, in words for the reader, shown with a retry. A failed refresh shows the same plate in place of the rows. - `selectable`: `boolean` (default `false`) — A checkbox on every row and one for the page in the header. - `selection`: `Set` (default `new Set()`) — Keys of the selected rows (`v-model:selection`); the page checkbox adds or removes the page, Shift with a row's checkbox the rows from the last one marked. The parent drops the keys of rows it deletes. - `isRowSelectable`: `((row: T) => boolean)` — Which rows can be selected; the others show a disabled checkbox and stay out of the page checkbox. - `sort`: `TableSort` (default `null`) — The current sort (`v-model:sort`); a header click cycles the column's first direction, the other one, then none. - `clickable`: `boolean` (default `false`) — Rows answer a click with `row-click` and show a pointer cursor. A pointer shortcut: the keyboard path to a row is a control in it (`#row-actions`), the row itself takes no tab stop. - `filtered`: `boolean` (default `false`) — The page is empty because of a filter or a search: the empty text says so. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `retry` — The retry of a failed load was pressed. - `update:sort` (`sort: TableSort`) — A header was clicked: the column and direction to sort by, `null` to drop the sort. - `update:selection` (`selection: Set`) — The selection changed; a new set, the one passed in is not mutated. - `row-click` (`row: T, event: MouseEvent`) — A row was clicked with `clickable` on. Not for a click on a control inside the row, with a modifier key, or over selected text. Slots: - `row-actions` with `{ row: T; rowIndex: number; }` — Controls of a row, in a column kept at the end when the table scrolls: ghost icon buttons. - `empty` with `{ filtered: boolean; }` — Instead of the empty-page text; `filtered` says which wording it replaces. ##### Date cell (`UiDateCell`) Props: - `value`: `DateValue | null` (required) — An ISO string with a time or a day alone, a Date, or a timestamp. A time with no zone is read in the app's zone. Empty or unreadable prints a dash. - `variant`: `DateCellVariant` = `day | time` (default `"day"`) — Which fact leads: `time` on top for a list within a day, `day` on top for a list across months. - `context`: `DateCellContext` = `weekday | relative` (default `"relative"`) — What the lower line answers: how long ago (`relative`), or which day of the week (`weekday`). Attributes are placed by the component itself: its description says where. ##### Money cell (`UiMoneyCell`) Props: - `value`: `string | number | null` (required) — The amount in major units: a number or the decimal string an API sends. Empty or unreadable prints a dash. - `currency`: `string` — ISO 4217 code for this cell; unset, the app's currency (`createGrani({ currency })`). - `unit`: `string` — What the amount is per, `kg` or `pc`; shown after the currency as "EUR / kg". - `tone`: `MoneyTone` = `success | danger | info` — Meaning of the amount: `success`, `danger`, `info`. Unset is the normal ink. Anything else lands on the root element: native attributes, aria-*, data-testid. #### How it behaves ##### A gap, not a zero - When: a cell has no value. - Props: `items` with `null` where there is no value. - Guarantee: A cell with no value shows a dash, and so does one whose `format` returns an empty or blank string. A real `0` is printed as 0. - Without it: A missing amount is printed as 0 and read as nothing owed, or the cell is left empty and reads as a value still loading. - Code: `` - Your part: Pass `null` where there is no value, and `0` only for a real zero. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "renders headers and values: format, accessor, a dash for an empty value", "a format that returns an empty string still shows the table's dash" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#gap-not-zero ##### More past the edge - When: its columns are wider than its box. - Props: none: every UiTable; `#row-actions` for the actions of a row. - Guarantee: A shade marks the edge with more columns past it: the end at first, at the row actions, where the columns slide under them, and the start once scrolled. On a desktop pointer an arrow in the header’s band pages the table toward that edge; the scroll box takes the focus and the arrow keys scroll it. The actions of each row stay at the end, in view. - Without it: A table cut at the screen’s edge that looks complete, with the actions of its rows out of sight. - Code: `` - Your part: Nothing. Give a column of long names `truncate`, and a `width` where a column should not grow. - Not covered: The arrows are for a desktop pointer: on a touch screen the table is swiped. An arrow stands over the edge of the header cell under it, which is pressed beside it. The header row does not stick to the top of the page as the page scrolls. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "fades at the end while more columns wait there", "fades at the start once scrolled, and not at the end when the end is reached", "a table that fits draws no fade", "the horizontal scroll box is keyboard-focusable and named once the table overflows", "row actions add a sticky last column with a named header; a footer cell adds the footer row", "the end's shade stands at the row actions, where the columns slide under them", "an edge with more columns past it grows an arrow that pages the table, for the pointer", "at the end the arrow stands at the start and pages back", "an empty or a failed wide table draws no shade and no arrow over its plate" - Pattern for all components: https://grani.arseni.work/patterns/finding#edge-says-more ##### Range select - When: a row checkbox is pressed with Shift, with the pointer or with Shift+Space. - Props: `selectable`, `v-model:selection`. - Guarantee: Every selectable row between the checkbox you activate and the previous one takes its new state: checking selects the range, unchecking clears it. - Without it: Ten rows take ten presses. - Code: `` - Your part: Keep `v-model:selection` to rows that still exist: drop the keys of rows that were deleted. - Not covered: A range stays within the page in view and skips rows that cannot be selected. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "Shift with a second checkbox marks every row between", "Shift over marked rows clears the range, as the pressed row goes", "Shift+Space from the keyboard marks the range too" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#range-select ##### Some selected - When: some, but not all, selectable rows of the page are selected. - Props: `selectable`. - Guarantee: The page checkbox shows the dash and is announced as mixed. A press selects every selectable row of the page and leaves out rows that cannot be selected; a press when all of them are selected clears them. - Without it: The page checkbox looks empty while some rows are selected, as if nothing were. - Code: `` - Your part: Nothing. - Not covered: It counts only the page in view: rows selected on other pages do not count toward it. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "the page checkbox selects the selectable rows, shows mixed for a part, clears when all are in" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#mixed-says-some ##### Stable dimensions - When: rows load: the first page, or a refresh with rows in view. - Props: `loading`, `skeletonRows`. - Guarantee: The first load shows placeholder rows sized like text rows, as many as the page size. A refresh keeps the existing rows visible, dimmed and marked busy, so the table does not collapse while waiting. - Without it: The rows disappear for the refresh, and everything below the table jumps up, then down. - Code: `` - Your part: Set `skeletonRows` to the page size. - Not covered: A page that comes back shorter or longer still changes the height once. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "a skeleton row has the height of a text row and its bar follows the column alignment", "a refresh in flight keeps the existing rows instead of skeleton rows, and says it is busy" - Pattern for all components: https://grani.arseni.work/patterns/waiting#no-jolt ##### Focus stays near - When: Rows are deleted or sorted while keyboard focus is on a button in a row. - Props: none: every UiTable. - Guarantee: After you delete a row, keyboard focus moves to the same button in the row that takes its place, or in the previous row if you deleted the last one, so the next delete is one Enter away. A row deleted while a window opened from it holds the focus (a confirmation’s `action`) is handed on the same way when the window closes. If the page becomes empty, focus moves to the table container. After sorting, focus stays on the same button in the same record, wherever that record moves. - Without it: Every delete loses keyboard focus, and you tab through the page again to reach the next row's Delete button. - Code: ` ` - Your part: Nothing. Remove or reorder the rows in `items` as usual; the table moves keyboard focus itself. - Not covered: This applies to buttons in a row and to a row’s UiMenu, which gives the focus back to its button before the command runs, so Delete from the ⋯ menu lands on the ⋯ of the next row. A menu of your own that keeps the focus until the row is gone loses it with the row. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "a row deleted under the focus hands it to the same control of the row now in its place", "the page checkbox turned off with the last row gives the focus to the table box" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-stays ##### Outcome shown - When: loading rows fails. - Props: `error`, `@retry`. - Guarantee: When a load fails, whether first or refresh, a message with Retry replaces the rows, and a screen reader announces it once; no old rows are left on screen. Pressing Retry brings the placeholder rows back. - Without it: An empty table, with no sign of whether there are no records or the request failed. - Code: `` - Your part: Pass the error message in words as `error`, and load again on `@retry`. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "a failed load shows the message with a retry; a failed refresh shows the same plate in place of the rows" - Pattern for all components: https://grani.arseni.work/patterns/waiting#answered ##### One press, one request - When: you press a column header or Retry while rows load. - Props: `loading`. - Guarantee: While rows load, a column heading keeps keyboard focus, but pressing it does not request another sort. When the app starts loading after Retry, placeholder rows replace the Retry button, so it cannot be pressed again while the request runs. - Without it: Two presses send two requests, and the answer that arrives last may be the older one. - Code: `` - Your part: Set `loading` from the moment you ask for rows until they arrive. - Not covered: Row checkboxes and `#row-actions` stay active during a refresh, because the selection holds row keys, not the rows themselves. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "a retry in flight after a failure draws the skeleton: no retry is left to press twice", "a header pressed while rows load asks for no second sort and keeps the focus" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Way forward - When: a page has no rows, or the first load failed. - Props: `filtered`, `error`, `#empty`. - Guarantee: An empty page says why: “No records yet”, or “No matching records” with `filtered`. A failed load says “Could not load records” with the message you pass in `error`, offers Retry, and a screen reader announces it at once. - Without it: A blank table, which reads as a failed load. - Code: ` ` - Your part: Use `#empty` to supply a UiNoData message with an action such as Create or Reset. For a failed load, pass an explanation in `error` and load again on `retry`. - Not covered: Without `#empty`, an empty page says why but offers no button. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "an empty page and a failed load say why; #empty puts the next step of your own in place" - Pattern for all components: https://grani.arseni.work/patterns/finding#empty-next-step ##### Sort said - When: a column is `sortable`. - Props: `sortable` and `defaultSortDir` on a column, `v-model:sort`. - Guarantee: A press on its header asks for the column’s first direction (`defaultSortDir`, ascending unless set), then the other direction, then no sort. An arrow icon shows the direction, and `aria-sort` lets a screen reader announce it. - Without it: Nothing shows how the rows are sorted, so you have to compare values to work out their order. - Code: `` - Your part: Order the rows by `sort` yourself: the table reports the choice and does not reorder the rows. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "a sortable header cycles first direction, the other one, then none; aria-sort follows" - Pattern for all components: https://grani.arseni.work/patterns/finding#sort-says ##### Digits line up - When: a column has `align: 'end'`. - Props: `align: 'end'` on a column. - Guarantee: The column aligns numbers to the end and uses equal-width digits, so with consistent decimal formatting the digits line up and values are easy to compare down the column. - Without it: 1,111 and 8,888 have different widths, so the eye compares lengths, not values. - Code: `` - Your part: Format the numbers alike down a column, with the same number of decimals, so their digits line up. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "an end-aligned column sets tabular figures" - Pattern for all components: https://grani.arseni.work/patterns/finding#digits-line ##### Whole text - When: a column is set to `truncate`. - Props: `columns[].truncate`. - Guarantee: The cell keeps one line, cut with an ellipsis, and the whole text stands in a tooltip while the cut is real. - Without it: A row that grows to three lines for one long name, or a name cut with no way to read the rest. - Code: `` - Your part: Set `truncate` on a column of long names, and a `width` where it should not take the room left. - Proof, in the repository's tests (UiTable.spec.ts, not in the package): "stands in a UiTruncate, not under a title" - Pattern for all components: https://grani.arseni.work/patterns/finding#whole-text The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useUiT`, `announce`, `useControlSize`. ### Table columns (`UiTableColumns`) > Choose which columns to show and keep that choice for later visits in the same browser. A component of grani 0.1.0. Data, stable, since 0.1.0. Page: https://grani.arseni.work/components/table-columns Also called column picker, column visibility, show hide columns. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Keep at least one column visible. - Remember hidden columns in this browser. - Keep one option and clear the rest in one press. - Find what you chose at the top of a long filter. - Clear a search in one step. - Notice when there are more options below. - Jump to an option by letter. - Keep Reset in one place. - Open with keyboard focus where the task begins. - Continue from where you opened the panel. - Pass a group with one press of Tab. Implemented by the app: - Data fetching, saving, permissions and business rules. - Put an identifying column, such as a name or record number, in `required` to keep it visible. The component prevents hiding the last visible column automatically. - Name the kind of table in `key` (for example `invoices`), and pass the signed-in user as `scope` when two people share a browser. #### Why it works this way A button with a panel where a person picks which columns a table shows. The table toolbar draws it from its `columns` prop; it is not placed by hand. **Panel.** The panel is a UiPopover with a checkbox per column, applied at once. A required column is ticked, held and marked "Required". The last column shown cannot be hidden and is marked "Last one". The marks are one word, so the one that comes with the last tick does not widen the panel under the pointer, and the column's own name keeps its room. Reset in the footer returns to the default; it is held while nothing differs, so the list never moves under the pointer. From six columns the shown stand first under "Shown" and the hidden under "Hidden", a line between, so the list says why its order is not the table's. A view, not a filter. The choice is a personal view, so the button shows no count. `useTableColumns` keeps the choice per kind of table; this component is the view of that state, and the page draws the table from the state's `visible`. A stored choice arrives once the page runs in the browser, so a page rendered on the server shows the default columns for a moment. **Placement.** The toolbar draws it first in the right cluster, across the row from UiFilter and UiDateRange. UiDataTable takes the same state as its `columns`. **Button.** The button names itself in a tooltip. Attributes land on the button, not on the panel. ##### Keyboard On the button: - `Tab`: reaches it. - `Enter` `Space`: open the panel; the focus goes to the list, or to its search when there are six columns or more. In the panel, where the list is one tab stop and Reset the next; from six columns up the search takes the list's tab stop, and its cross is one more once it holds text: - `ArrowDown` `ArrowUp`: in the list, move the highlight. - `Home` `End`: jump. - `Enter` `Space`: tick. - `A–Z`: jumps to the column that starts with the letter. - `Escape`: closes the panel and returns the focus to the button. - `Tab`: cycles inside the panel. ##### Screen reader - Button: named "Columns" (`aria-label`, and the tooltip), with `aria-haspopup` and `aria-expanded`. - Panel: a dialog with the same name. - List: a multi-select listbox named "Columns", each column an option that says whether it is ticked. - Held column: says "Required" or "Last one" after its name. - Count: under the list, a status region, read as it changes: "2 hidden" or "All columns shown". ##### States Rest, open, a column ticked, a required column held with "Required", the last column shown held with "Last one", Reset held while nothing differs; the choice kept across a remount, and the default columns for a moment on a page rendered on the server. No count on the button: the choice is a view, not a filter. ##### When to use another component - Narrowing the rows: UiFilter. - A period: UiDateRange. - One choice among a few: UiSelect. - A second menu beside a toolbar: never; the toolbar draws it from `columns`. #### Examples ##### No column required ```vue ``` #### API Props: - `columns`: `TableColumns` (required) — The columns state from `useTableColumns`: every column, the hidden ones, which may be hidden. The table draws its `visible`. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder for the button: the toolbar row's, the app's size (`createGrani({ size })`) when unset. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. #### How it behaves ##### Last column stays - When: no column is required and all but one are hidden. - Props: `useTableColumns(key, columns)` with no `required`. - Guarantee: The last column shown cannot be unticked and is marked “Last one”, so the table always shows at least one column. - Without it: A menu that unticks the last column, leaving empty rows that read as a failed load. - Code: `const cols = useTableColumns('invoices', COLUMNS)` - Your part: Put an identifying column, such as a name or record number, in `required` to keep it visible. The component prevents hiding the last visible column automatically. - Not covered: A stored choice that hides every column, for example one saved for an older set of columns, shows the first column. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "the last column shown is held and marked Last one" - Pattern for all components: https://grani.arseni.work/patterns/finding#last-column-stays ##### Columns remembered - When: the table is opened again in this browser. - Props: `useTableColumns(key, columns)`. - Guarantee: The column choice is stored as soon as it changes and restored on the next visit. With the default browser storage, changes made in another tab are also applied to this one. - Without it: A choice kept only in the page: the hidden columns are back on every visit. - Code: `const cols = useTableColumns('invoices', COLUMNS)` - Your part: Name the kind of table in `key` (for example `invoices`), and pass the signed-in user as `scope` when two people share a browser. - Not covered: Preferences belong to this browser and are not shared through a URL. A server-rendered page may briefly show default columns before the stored choice applies. With `createGrani({ storage })`, automatic updates from other tabs are unavailable; the last tab to save a choice wins. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "a choice made in the menu is there after a remount" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#columns-remembered ##### Only this - When: a table of three columns or more. - Props: none: every UiTableColumns. - Guarantee: The highlighted row offers Only, which shows that column alone and hides the others in one press; a required column stays beside it, and Reset brings the default back. - Without it: Unticking every other column, one by one, to look at a single one. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "Only keeps the highlighted column alone, and a required column stays beside it", "Only on a hidden column leaves that column alone in view" - Pattern for all components: https://grani.arseni.work/patterns/finding#only-this ##### Chosen first - When: a table of six columns or more. - Props: none: every UiTableColumns. - Guarantee: The columns the table shows open at the top of the list under Shown, in the table’s order, and the hidden ones below a line under Hidden, so you see why the list runs in another order than the table; the order holds while the panel is open, so a tick never moves a row under the pointer. - Without it: The columns in view scattered down a long list, some below its fold. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "from six columns the ones shown open at the top, in the order of the table", "from six columns the shown and the hidden stand under their names, a line between" - Pattern for all components: https://grani.arseni.work/patterns/finding#chosen-first ##### Search clears - When: a table of six columns or more. - Props: none: every UiTableColumns. - Guarantee: A search stands over the list of columns and narrows it as you type. Its cross or Escape empties it and keeps the focus in the field; an Escape with no text closes the panel. - Without it: An Escape meant for the text that closes the whole panel, so the search starts over. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "from six columns a search narrows the list, and Escape empties it with the panel still open" - Pattern for all components: https://grani.arseni.work/patterns/finding#search-clears ##### Five and a half rows - When: a table of more than five columns. - Props: none: every UiTableColumns. - Guarantee: The list shows five columns and half of the sixth, so the cut says more wait below; the keys bring a column past the cut into view. - Without it: A list cut on a row’s edge that looks finished while columns wait below it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "a long list of columns is cut at five rows and a half" - Pattern for all components: https://grani.arseni.work/patterns/finding#five-rows ##### Letter jumps - When: a table of five columns or fewer, where no search takes the letters. - Props: none: every UiTableColumns. - Guarantee: A letter goes to the first column that starts with it, and the same letter again to the next. - Without it: Arrowing down the list one column at a time to reach the one you know by name. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "a letter goes to the column that starts with it, and the same letter again to the next" - Pattern for all components: https://grani.arseni.work/patterns/finding#letter-jumps ##### Reset held - When: the panel is open and you hide or show the first column. - Props: none. - Guarantee: Reset and the hidden-column count, such as “2 hidden”, have a fixed place in the footer. Reset becomes available when column visibility differs from the default, without changing the panel’s layout. - Without it: A Reset that appears only once a column is hidden, so the footer changes under the pointer. - Code: `` - Your part: Nothing. - Not covered: Reset returns to the columns hidden by default (`hiddenByDefault`), not to every column shown. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "a tick applies at once; the summary counts the hidden; Reset is held until something differs" - Pattern for all components: https://grani.arseni.work/patterns/finding#reset-held ##### Focus starts inside - When: you open the columns panel. - Props: none. - Guarantee: Use the arrow keys to move between columns and Space to show or hide one. With six or more columns, focus starts in the search field instead, so you can find a column by name. - Without it: The panel opens with keyboard focus left on its button, and you press Tab before you can turn a column on or off. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "opened, the focus goes to the list of columns", "opened on six columns or more, the focus goes to the search" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-start ##### Focus comes back - When: you close the columns panel with Escape. - Props: none. - Guarantee: Focus returns to the columns button at the end of the toolbar, so you can continue through the toolbar with Tab. - Without it: After Escape, keyboard focus drops to the start of the page, and you tab through the page again to reach the table. - Code: `` - Your part: Nothing. - Not covered: If the panel is closed by a click outside it, keyboard focus stays where the click put it. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "Escape closes the panel and returns the focus to the button" - Pattern for all components: https://grani.arseni.work/patterns/opening#focus-back ##### One Tab per group - When: the columns panel is open. - Props: none. - Guarantee: Tab reaches the column list as one group, so you do not have to visit every column. Reset is the next available stop when the view differs from the default. - Without it: Each column is its own checkbox, so a wide table costs a press of Tab per column. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTableColumns.spec.ts, not in the package): "the list is one tab stop, and Reset the next once something differs" - Pattern for all components: https://grani.arseni.work/patterns/opening#one-stop The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useTableColumns`, `useControlSize`, `useUiT`. ### Table toolbar (`UiTableToolbar`) > Keep search, filters and actions together above the table they affect. A component of grani 0.1.0. Data, stable, since 0.1.0. Page: https://grani.arseni.work/components/table-toolbar Also called list toolbar, search and filters bar, table header bar. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Results match the current search. - Keep a reset from restoring the old search. - Search once typing pauses. - Finish a word in any script before it is sent. - Reach frequent actions from the keyboard. - Clear a search in one step. Implemented by the app: - Data fetching, saving, permissions and business rules. - Apply the values in the order they arrive. - Set `search` to the new value; there is no pending send for you to cancel. - Load the list on `update:search`, not on every key. #### Why it works this way The row above a list, with a built-in search, what narrows the list on the left and what acts on it on the right. UiDataTable builds it from its own props and slots. **Clusters.** `#start` holds what narrows the list (a period in UiDateRange, filters). `#end` holds what acts on it: catalog buttons, then the main action last. The columns menu is built in too; when the toolbar is given its state, it stands first on the right. **Search.** The search is built in and always present (`v-model:search`). The field takes every key; the value goes out after a 250 ms pause, and Enter sends it at once. A value set from outside (a reset, the URL) replaces what was being typed. When a host applies the model late, the toolbar's own sent values coming back are not taken for an outside value, so what was typed since stays, and a clear or a step back made while a value is on its way is sent too. A host that drops a value it was sent without a word keeps it counted as on its way, and takes one outside value equal to it for its answer. **Size.** Controls in the row share one step, `size`: the app's size (`createGrani({ size })`), or `sm` (36 px) if unset; `xs` for a dense row, `md` for a comfortable one. The slots get it as `{ size }` to bind on their controls. **Layout.** On a narrow screen the search takes a line of its own and the rest share the line under it. The row is padded 16 px, so it lines up with the table's cells under it and with its footer; the card around them is the shell. **Placement.** In UiDataTable's card, above the table; UiDataTable builds it from its search, `#filters`, `#actions` and `columns`. UiFilter and UiDateRange stand in `#start`, UiTableColumns first in the right cluster. A host may make it `inert` while a selection strip covers it. ##### Keyboard - `Tab`: reaches the search first (its cross next, while it holds text), then the controls in `#start`, the columns button and the controls in `#end`, in the order they stand. - `/`: from anywhere outside a field, goes to the search of the first list in view, in the window open over the page when there is one. An app that takes the key itself calls `preventDefault` on it first. In the search, where every key narrows the list after a 250 ms pause: - `Enter`: sends at once and does not submit a form around it. - `Escape`: empties it and keeps the focus, as the cross does. Leaving the field sends what was typed. The Enter that confirms an IME candidate sends nothing. ##### Screen reader - Search: a landmark (`role="search"`) named like its field, by `searchPlaceholder`, "Search" by default; UiDataTable adds its own name ("Search Invoices"), so two lists on one page keep their searches apart. The field says its shortcut, `/` (`aria-keyshortcuts`). - Row: has no name and no role. - Slots: what they hold is read as the controls they are. ##### States Rest, text in the search (the cross shows), a pending send, a value set from outside that replaces the typing, `inert` under a selection strip; the row at `xs`, `sm` and `md`, and on a narrow screen the search on a line of its own. ##### When to use another component - One block of table, toolbar, pages and states: UiDataTable. - A search outside a list: UiInput `type="search"`. - A second columns menu: never in `#end`; the toolbar draws it from `columns`. #### Examples ##### Status ```vue ``` ##### Columns ```vue ``` ##### Set from outside ```vue ``` ##### Under a selection strip ```vue ``` ##### Sizes ```vue ``` #### API Props: - `search`: `string` (required) — The search text (`v-model:search`); the field is always there. Sent as typed, spaces included, after a 250 ms pause or at Enter. - `searchPlaceholder`: `string` — What the search looks in; the field's placeholder and its name. - `columns`: `TableColumns` — The columns menu: the state from `useTableColumns`. The button stands first in the right cluster; without it the columns are fixed. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder for the row: `xs` for a dense row, `sm` (36 px), `md` for a comfortable one; unset, the app's size (`createGrani({ size })`). The slots get it as `{ size }`. Anything else lands on the root element: native attributes, aria-*, data-testid. Events: - `update:search` (`value: string`) — The search text, after the pause or at Enter. Slots: - `start` with `{ size: ControlSize; }` — What narrows the list, after the search: a period, filters; bind `size` on them. - `end` with `{ size: ControlSize; }` — What acts on the list, after the columns menu: catalog buttons, then the main action last; bind `size`. Not another columns menu. #### How it behaves ##### Late answer dropped - When: the app applies the search late, as a router query does. - Props: `v-model:search`. - Guarantee: When the app applies a search value the toolbar sent earlier, the toolbar recognizes it as a delayed update: it keeps the text you typed since then and sends that newer value when ready, including an empty value if you cleared the search. - Without it: The late value writes the old text back into the field, and a clear you made is undone. - Code: `` - Your part: Apply the values in the order they arrive. - Not covered: The toolbar expects each value it sends to be applied to `search`. If the app drops one, the toolbar still treats it as pending. A later external update with that same value can then be mistaken for the delayed response instead of a new reset. - Proof, in the repository's tests (UiTableToolbar.spec.ts, not in the package): "the host echoing an older sent value does not erase what was typed since", "a clear made while a value is on its way is sent, and the late echo does not undo it" - Pattern for all components: https://grani.arseni.work/patterns/finding#late-dropped ##### Reset wins - When: the app sets the search itself: a reset, a saved view, a step back. - Props: `v-model:search`. - Guarantee: The field shows the search value the app set and discards any earlier text still waiting for the typing delay, so the old search is never sent afterward. - Without it: The list is reset while the field still shows the old text, and the pending send can filter the list by it again. - Code: ` Reset filters` - Your part: Set `search` to the new value; there is no pending send for you to cancel. - Proof, in the repository's tests (UiTableToolbar.spec.ts, not in the package): "a value set from outside replaces the typing and drops the pending send" - Pattern for all components: https://grani.arseni.work/patterns/finding#outside-wins ##### Asks once - When: you type in the search. - Props: none: every UiTableToolbar. - Guarantee: The text is sent once, 250 ms after the last key, with the latest value. Enter sends it at once and does not submit a form around the toolbar. Nothing is sent while an input method, for example for Japanese, is still composing a word. - Without it: A request on every key: five letters, five requests. - Code: `` - Your part: Load the list on `update:search`, not on every key. - Proof, in the repository's tests (UiTableToolbar.spec.ts, not in the package): "sends the text once after the pause, with the last value", "Enter sends at once and does not submit a form around the toolbar", "no emit happens while a word is being composed" - Pattern for all components: https://grani.arseni.work/patterns/finding#typing-asks-once ##### Words in the making - When: a search is typed through an input method. - Props: none: every UiTableToolbar. - Guarantee: Nothing is sent while the word is being composed, and the Enter that picks it sends nothing; the whole word goes out once the typing pauses. - Without it: A search sent for every reading of the word, and one more for the Enter that picks it. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTableToolbar.spec.ts, not in the package): "no emit happens while a word is being composed", "the Enter that confirms an IME candidate sends nothing", "the Enter Safari sends after it ends a composition sends nothing at once" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#word-as-composed ##### Shortcut keys - When: a page with a list on it. - Props: none: every UiTableToolbar, and so every UiDataTable. - Guarantee: Pressing `/` anywhere outside a field moves keyboard focus to the search of the first list in view, or to the list in the window open over the page when there is one. The field names the key to a screen reader. - Without it: A trip to the mouse, or a run of Tab presses, to start a search you meant to type. - Code: `` - Your part: Nothing. An app that takes `/` for itself calls `preventDefault` on it first, and the list leaves it. - Not covered: Only the first list in view takes the key; a list hidden in a closed tab or panel is passed over. - Proof, in the repository's tests (UiTableToolbar.spec.ts, not in the package): "/ from the page goes to the search, and the field says its shortcut", "typed in a field, / stays a slash", "the first list on the page takes /", "a / the app took first, a window open over the list, or a list held inert keep it" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#keyboard-actions ##### Search clears - When: the search has text. - Props: none: every UiTableToolbar. - Guarantee: × or Escape clears the search, and you can type the next term without leaving the field. The empty value reaches the app immediately, bypassing the usual 250 ms delay, even when earlier typing was still waiting to be sent. - Without it: The field looks empty while the list is still filtered by text the field no longer shows. - Code: `` - Your part: Nothing. Do not add a clear button of your own: the field already has one. - Proof, in the repository's tests (UiTableToolbar.spec.ts, not in the package): "the cross and Escape each send the empty value once, at once, and keep the focus" - Pattern for all components: https://grani.arseni.work/patterns/finding#search-clears The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `debounce`, `useTableColumns`, `useControlSize`, `useUiT`. ### Tabs (`UiTabs`) > Switch between sections of the same screen without showing them all at once. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/tabs Also called tab bar, tab strip, tabbed navigation. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See that a row goes on past its edge. - Keep counts short and in place. - Keep your browser’s keyboard shortcuts. - Pass a group with one press of Tab. Implemented by the app: - Data fetching, saving, permissions and business rules. - Give `badgeLabel` the count in words, such as “3 unread”; a bare number triggers a warning in development. - Share a link to the open tab. - Name the strip with `ariaLabel`, and put each pane in the slot named after its tab. #### Why it works this way Panes of one screen, one at a time. Take it when a page or a record splits into parts a person moves between; a choice with no panes is UiRadioGroup `segmented`. **Panes.** The root is a `
`. The active tab's pane is the slot named after its value (`#overview`); a tab without a slot renders no pane. A pane that is not shown is unmounted, so a form in it must keep its values in the parent, not in the DOM. The pane follows `modelValue`, not the tabs: a value whose tab is gone (taken away with a permission) still shows its slot, named by no tab, so set `modelValue` to a tab that stays when the tabs change. **Variants.** `underline` is a strip on its own. `panel` puts the strip inside the card shell as the header of a block, with `#header` above it; inside a UiCard's `#header`, the card is the shell and the panel draws none of its own. A tab stands on the control ladder (`size`, else the app's step). **Focus.** A focused tab rings inward with its underline kept, so no scroll edge or card clips the ring. In forced colors the selected tab is highlighted. **Direction.** The strip follows the reading direction: on a right-to-left page it starts at the right, and its paging arrow and faded edge stand on the side where tabs wait. **Routing.** The active tab is not synced to the URL; the consumer does that around `v-model`. ##### Keyboard The WAI-ARIA tab pattern: the strip has one tab stop, the active tab, and a move activates the tab and focuses it. - `ArrowLeft` `ArrowRight`: the tab to the left and the tab to the right, round the ends; a disabled tab is passed over. On a right-to-left page the strip starts at the right, so `ArrowRight` is the tab before and `ArrowLeft` the tab after. - `Home` `End`: the first and the last tab. - `Tab`: on to the pane's first control, or to the pane when it holds none. - `ArrowUp` `ArrowDown`: stay the page's: the strip is horizontal. - `Alt` `Ctrl` `Meta`: a chord with them stays the browser's. The paging arrows of a wide set are for the pointer, outside the tab order. ##### Screen reader - Strip: a `tablist` named by `ariaLabel`. - Tab: says whether it is selected (`aria-selected`) and controls its pane (`aria-controls`) only while the pane is there; a disabled tab says so (`aria-disabled`). - Pane: a `tabpanel` named by its tab. - Badge: read in the words of `badgeLabel`, with the number hidden; a bare number warns in dev, since the reader would hear the label and the number as one. ##### States Active, with the brand ink and underline; rest; hover, only when enabled; disabled, in the disabled ink, not chosen by a click or a key; focus, ringing inward with the underline kept. `panel` in the card shell, with `#header` and a rule above the strip, and inside a UiCard's `#header` with no shell of its own; `underline` on its own. A set wider than the strip scrolls, with faded edges and arrows on desktop. In forced colors the selected tab is highlighted. `sm` is 36px, `md` 44px. ##### When to use another component - A choice with no panes: UiRadioGroup `segmented`. - Pages of one set: UiPagination. - The places of the app: UiAppShell. - A block shown or hidden in place: UiReveal. #### Examples ##### Underline ```vue ``` ##### Many tabs ```vue ``` ##### Medium ```vue ``` ##### Disabled ```vue ``` #### API Props: - `modelValue`: `TabValue` (required) — Value of the active tab (`v-model`). - `tabs`: `TabItem[]` (required) — Tabs in order: `value`, `label`, `disabled`, and a `badge` with its `badgeTone` and `badgeLabel` (what it means, in words). - `variant`: `"panel" | "underline"` = `panel | underline` (default `"panel"`) — `panel` (the default) is the header of a block inside the card shell; `underline` is a strip on its own. - `ariaLabel`: `string` (required) — Accessible name of the tab set. - `size`: `"sm" | "xs" | "md"` = `xs | sm | md` — Step of a tab on the control ladder: `sm` 36px, `md` 44px. Unset, the app's size (`createGrani({ size })`); `xs` is read as `sm`. Anything else lands on the root element: native attributes, aria-*, data-testid. Events: - `update:modelValue` (`value: TabValue`) — The new value, for `v-model`. Slots: - `header` with `Record` — Above the strip, inside the shell; `panel` only. What the tabs do not switch. #### How it behaves ##### More past the edge - When: its tabs are wider than their strip. - Props: none: every UiTabs. - Guarantee: The strip scrolls sideways without a bar and never wraps; an edge that hides tabs fades, and with a mouse an arrow there pages to the rest. - Without it: Tabs past the edge that nobody knows are there, or a strip that breaks onto a second line. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTabs.spec.ts, not in the package): "a long set scrolls sideways without a bar and never wraps", "edges that hide tabs fade and grow arrows; a set that fits has neither", "the arrows are for a mouse: drawn only on a device whose pointer hovers and is fine, never on touch" - Pattern for all components: https://grani.arseni.work/patterns/finding#edge-says-more ##### Compact counts - When: a tab has a count. - Props: `badge` and `badgeLabel` on the tab. - Guarantee: The count shows only above zero and reads 99+ past its limit. A screen reader announces the words you give, such as “3 unread”, instead of a bare number. - Without it: A 0 sits on every quiet tab, and a screen reader announces “Inbox 3” without saying what the 3 counts. - Code: `` - Your part: Give `badgeLabel` the count in words, such as “3 unread”; a bare number triggers a warning in development. - Proof, in the repository's tests (UiTabs.spec.ts, not in the package): "a count of zero draws no badge", "a count past its limit reads 99+ and keeps the badge short", "a badge with badgeLabel is read in words: the number hidden, the meaning said", "a bare number badge warns in dev" - Pattern for all components: https://grani.arseni.work/patterns/waiting#compact-counts ##### Share a link to the open tab - When: a tab should be shared as a link. - Props: `v-model`. - Guarantee: It shows the tab named by `v-model` and reports every change through it; it does not read or write the page address itself. - Without it: A link that always opens on the first tab, whatever tab was open when it was sent. - Code: `` - Your part: Bind `v-model` to a query parameter of the route and read it back when the page opens. If the address names a tab that no longer exists, fall back to the first one. ##### Shortcuts kept - When: keyboard focus is on a tab. - Props: none: every UiTabs. - Guarantee: The arrows move between tabs; with Alt or Cmd held the tab stays and the browser takes the key, so Alt+Left still goes back a page. - Without it: Going back a page from the row of tabs switches the tab instead. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTabs.spec.ts, not in the package): "a browser chord on a tab is not swallowed: Alt+ArrowLeft and Cmd+ArrowRight emit nothing" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#shortcuts-kept ##### One Tab per group - When: a screen is split into tabs. - Props: `tabs`, `v-model` and `ariaLabel`. - Guarantee: The tab strip takes one press of Tab and enters on the open tab. The arrow keys move to the next or previous tab and open it, wrapping round at the ends; Home and End go to the first and last tab, and the next Tab goes into the open pane. - Without it: Tabs built as a row of buttons take a press of Tab each before you reach the content below them. - Code: `` - Your part: Name the strip with `ariaLabel`, and put each pane in the slot named after its tab. - Not covered: A pane that is not shown is unmounted, so a form inside it keeps what was typed only if the parent holds the values. - Proof, in the repository's tests (UiTabs.spec.ts, not in the package): "marks the active tab and gives only it a tab stop", "arrows wrap round the ends; Home and End jump to the ends", "a pane with a focusable child adds no tab stop of its own; a pane of words does" - Pattern for all components: https://grani.arseni.work/patterns/opening#one-stop The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`. ### Textarea (`UiTextarea`) > Write several lines, such as a note, address or biography, in a field that can grow with the text. A component of grani 0.1.0. Form, stable, since 0.1.0. Page: https://grani.arseni.work/components/textarea Also called multiline input, text area. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See the value the app actually accepted. - Submit the values shown in the form. - Go straight to the field that needs fixing. - Name each field once for screen readers. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the text the app holds as `modelValue`, and explain why a change was refused. - Give it a `name`. - Pass the error to its UiField, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Put it in a UiField with a `label`, or give it `ariaLabel`. #### Why it works this way A multi-line text field in the input capsule. Take it for a note, an address or a bio. **Height.** The height comes from `rows`, from the resize handle, or from `resize="auto"`, which grows from `rows` up to 24rem and then scrolls. One row stands on the control ladder (`size`, else the app's step), so it lines up with UiInput. **Field.** It reads the field context; standalone, the name goes through `ariaLabel`. **Attributes.** Attributes and listeners land on the textarea, `class` and `style` on the capsule. **Model.** The field shows the model: when the owner refuses a change, or a form resets, it goes back to the model and the caret stays where it was. **Empty.** The value is text, and empty is `''`, never `null`. **Length.** `maxlength` cuts off text past the limit and shows no counter; the caller writes one into UiField's `#meta`. **Placement.** In a form column beside UiInput, inside UiField, or in a UiModal or a UiCrudDrawer that sends on Cmd+Enter from it. ##### Keyboard A native textarea. - `Tab`: reaches it. - `Enter`: adds a line. - `Meta+Enter` `Ctrl+Enter`: send from it in a UiModal or a UiCrudDrawer; that key belongs to the surface, not to the textarea. Nothing is sent while an IME composes. `readonly` keeps it focusable and blocks typing; `disabled` blocks the focus too. The handle resizes with the pointer. ##### Screen reader - Field: a text box named by `ariaLabel` or the field's label, never by the placeholder. A textarea with no name warns in dev. - Errors: `invalid` sets `aria-invalid`. Inside UiField the description and the error are read with it once, and `aria-required` says it is required. - Caller attributes: a caller's `aria-invalid` and `aria-required` reach it when the field sets none. ##### States Rest, hover, focus (the capsule draws the ring), disabled (its plate, nothing sent), readonly, invalid (red border and halo; disabled wins over it), with a handle, a fixed height, `auto` growing to 24rem and then scrolling, and one row on each step of the ladder. ##### When to use another component - One line: UiInput. - Names added one by one: UiListField. - A counter under it: the caller's text in UiField `#meta`. #### Examples ##### Field ```vue ``` ##### Resize ```vue ``` ##### Sizes ```vue ``` ##### States ```vue ``` ##### Refused ```vue ``` ##### Form ```vue ``` #### API Props: - `modelValue`: `string` (required) — The text (`v-model`); empty is `''`. - `rows`: `number` (default `3`) — Visible lines before the user resizes the control. - `name`: `string` — Native name for forms. - `id`: `string` — Own id when standalone. Inside UiField the field's id takes precedence. - `placeholder`: `string` — Hint shown while empty. Not a replacement for a label. - `maxlength`: `number` — Maximum number of characters. - `readonly`: `boolean` (default `false`) — Prevents editing while keeping the control focusable. - `disabled`: `boolean` (default `false`) — Prevents input and interaction. - `invalid`: `boolean` (default `false`) — Marks the control invalid: red border and halo. Inside UiField the error sets this. - `required`: `boolean` (default `false`) — Standalone only: a value is needed, said to a reader and to a form (native `required`). Inside UiField the field's `required` says so. - `ariaLabel`: `string` — Accessible name when there is no visible label. A prop, not `aria-label`: the root is the capsule, the name belongs to the control. - `size`: `ControlSize` = `xs | sm | md` — Height of one row on the control ladder; defaults to the app's step. - `resize`: `"none" | "vertical" | "auto"` = `none | vertical | auto` (default `"vertical"`) — `vertical` gives the user a handle; `none` fixes the height at `rows`; `auto` grows with the text. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: string`) — The value changed. #### How it behaves ##### Back to the value - When: the app refuses a change, or the form is reset. - Props: `v-model`, or `modelValue` with `@update:model-value`. - Guarantee: A refused change is undone and the text cursor stays where you were typing, so you can go on. This also holds for text composed with an input method (IME), as for Chinese or Japanese. A form reset brings back the app’s text. - Without it: The refused words stay on the screen, although the app did not accept them. - Code: `` - Your part: Pass the text the app holds as `modelValue`, and explain why a change was refused. - Proof, in the repository's tests (UiTextarea.spec.ts, not in the package): "a change the parent refuses puts the textarea back on the model", "a refused composition goes back to the model too" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: It sends its text under its `name`, nothing while disabled, and after a form reset it shows the app's value again. - Without it: A text field on screen that the form never sends. - Code: `` - Your part: Give it a `name`. - Proof, in the repository's tests (UiTextarea.spec.ts, not in the package): "in a form it sends its value under its name, nothing when disabled", "a form reset leaves the textarea on its model" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Focus on the error - When: a form is sent with the text in error. - Props: `invalid`, or the error of its UiField. - Guarantee: When the textarea is the first field with an error, keyboard focus lands in it, so you can type the fix straight away. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `` - Your part: Pass the error to its UiField, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiTextarea.spec.ts, not in the package): "a textarea marked invalid is what focusInvalid lands on" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### One name - When: it needs a name. - Props: a UiField around it, or `ariaLabel`. - Guarantee: A screen reader announces the field’s label or `ariaLabel`, followed by its description and error, each once. The placeholder is never used as its name, and a missing name triggers a development warning. - Without it: A textarea named only by its placeholder, which disappears as soon as you type. - Code: `` - Your part: Put it in a UiField with a `label`, or give it `ariaLabel`. - Proof, in the repository's tests (UiTextarea.spec.ts, not in the package): "inside UiField with a description it is named once and described once", "warns in dev when it has no name" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`. ### Time picker (`UiTimePicker`) > Choose hours and minutes or type a time such as 2:30p or 1430. A valid time is kept even if it is not among the listed options. A component of grani 0.1.0. Dates, stable, since 0.1.0. Page: https://grani.arseni.work/components/time-picker Also called time input, time field. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See the value the app actually accepted. - Submit the values shown in the form. - Respect date limits without losing a value. - Type a date or a time as your language writes it. - Close only the top layer with Escape. - Go straight to the field that needs fixing. - Clear a field and enter its replacement immediately. - Name each field once for screen readers. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the time the app holds as `modelValue`, and explain why a time was refused. - Give it a `name`. - State the allowed window in the field's description, since the list alone does not say why some times cannot be picked. - Pass the error to its UiField, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Treat `null` as no time. - Put it in a UiField with a `label`, or give it `ariaLabel`. #### Why it works this way A form field for a time of day, in the input capsule on the control ladder. Take it beside a UiDatePicker or a UiCalendar for a day and its hours. **Value.** The value is `HH:mm` (`10:30`); one with seconds (`14:30:00`) is read to the minute, and a change sends `HH:mm`. It is shown as the app's language writes a time: `14:30` in Russian, `2:30 PM` in American English. **Panel.** The button opens a UiPopover with two listboxes side by side, Hour and Minute (a third, Period, in a 12-hour locale); each shows five rows and a half and scrolls the rest (`useVisibleRows`), scrolled on open to the chosen row. A press on an hour sets the value at once, the current minute clamped to the nearest one the window still allows (the first allowed minute with no value yet), keeps the panel open and moves the focus to Minute; a press on a minute sets it and closes the panel, the focus back on the field. A press on a period keeps the hour's number and flips its half of the day, and stays open. **Typing.** Digits build a time without the search row UiSelect has: one or two are the hour (`9` lights 9, `14` lights 14), three are `H:mm` (`930`, `230`) while the minute fits (`183` stays 18:3_), four `HH:mm` (`0930`, `1437`). A mark after the hour (`9:30`, `9.30`, `9 30`) ends the hour where it is, and a lone digit after it is the minute's tens. Digits of other scripts count as theirs. The hour reads on the 24-hour clock unless `a` or `p` sets its half of the day, and a 12-hour field says the half it reads as it is typed (`2:30 AM`); `a` or `p` with nothing typed moves the time held to that half. A time that does not exist (`9:75`, `2400`) is no time, never one nearby. The field shows what was typed until Enter takes it to the minute, off the step too, and closes; a time that is not one, or one outside the window, is not taken: the field is marked invalid until the text changes, and a reader hears why. A press away takes it too when it is written in full: the hour and two digits of the minute, and in a 12-hour language its half of the day or an hour past 12 (where the half cannot be typed as `a` or `p`, Korean or Arabic, the minute is enough); anything else is dropped there, and a reader hears why. While it is typed the columns show it chosen, the minute once both of its digits are in, off the step too. Backspace takes back the last key, the half of the day too; Escape, the cross, a press on a row and an arrow drop it and give the time held back. Typing opens the panel from the closed field too, and the keys typed while it opens go on with the time, so the pointer picks on the step and the keys reach any minute. **Hours.** The app's language decides: a 24-hour one draws 00…23 in two columns and writes `14:30`; a 12-hour one draws 12, 1…11 and a Period column of the language's AM/PM words and writes `2:30 PM`. `hourCycle` sets it for every language. **Window.** `min` and `max` are `HH:mm`. `min` after `max` is read as a window that wraps past midnight (a night shift, 22:00–06:00), not an empty one. An hour with no allowed minute in the window, and a minute outside it, are shown but disabled; a value outside the window is kept and reads invalid. **Step.** Minutes in a column are multiples of `step` (1..60, a bad step falls back to 1); the default is 5. A value off the step is kept and listed in its own place. **Empty.** No time is `null`: the cross sends `null`, and `null` or an empty string reads as none. With `name` a form gets the field's name with an empty value, as a native time input sends it. **Odd values.** `9:30` is read as `09:30`. A value that is not a time (`25:99`) counts as none. **Width.** The field keeps its width when a time is picked or cleared; cut in a narrow column, the whole time shows in a tooltip on hover. A required field shows no cross. **Field.** Inside UiField it reads id, error and required. `focus()` puts the focus on it. ##### Keyboard - `Tab`: reaches the combobox. - `ArrowDown` `Alt+ArrowDown` `Enter` `Space`: open the panel, on the closed field. - `0–9`: a digit opens the panel and starts the typed time; a mark after the hour (`:` `.` `-` `,` or a space) ends it; in a 12-hour language `a`/`p` set its half of the day. - `Alt` `Ctrl` `Meta`: a chord with them stays the browser's, on the field and in the panel; only `Alt+ArrowDown` on the closed field is its own. In the open panel: - `ArrowUp` `ArrowDown`: move within a column, over the enabled rows; `Home`/`End` to its ends. - `ArrowLeft` `ArrowRight`: move to the next or the previous column. - `Enter` `Space`: picks the row, as a press does; on Minute (or Period with no Minute column left to fill) it closes the panel too. With a time typed, Enter takes it and Space is a mark. - `Backspace`: takes back the last typed key. - `Escape`: closes the panel; inside a window it closes only the panel. - `Tab`: moves to the next column and goes round inside, as in UiDatePicker's calendar; `Escape` leaves. The cross clears the value and keeps the focus on the field. ##### Screen reader - Field: a combobox named by `ariaLabel` or the field's label and says the time as the locale prints it, or the placeholder. - Panel: Hour, Minute and, in a 12-hour locale, Period are listboxes named in words, the chosen row marked, the focus moving with the rows (roving, not `aria-activedescendant`: three listboxes side by side read better as one tab stop each). - Icon: the clock icon is hidden from a screen reader. - Errors: a time outside `min` and `max`, a typed time refused, or a field error, sets `aria-invalid`; `required` sets `aria-required`; the field's description and error are read with it once. - Typed time: a time not taken is said in words, "Not a time", "Time outside the allowed range" or "Time not kept: type it in full". ##### States Empty, a time picked, open with the chosen hour and minute (and period) highlighted, a time off the step (listed in its place), a time outside the window (kept, reads invalid), a value that is not a time (shown as empty, nothing sent), disabled (nothing sent, the panel closes), invalid, required (no cross), `clearable` with the cross, and joined in a UiButtonGroup. The width holds in all of them. ##### When to use another component - A day: UiDatePicker. - A duration in minutes: UiInput `numeric`. - Any other pick from a list: UiSelect itself. #### Examples ##### Odd values ```vue ``` ##### States ```vue ``` ##### Sizes ```vue ``` ##### Modal ```vue ``` ##### Refused ```vue ``` ##### Form ```vue ``` ##### Russian ```vue ``` #### API Props: - `modelValue`: `string | null` (default `null`) — The time, `HH:mm` (`v-model`); `null` for none. - `step`: `number` (default `5`) — Minutes between two rows of the Minute column. - `min`: `string` (default `"00:00"`) — The first time the window allows, `HH:mm`. - `max`: `string` (default `"23:59"`) — The last time the window allows, `HH:mm`; a `min` after it wraps the window past midnight. - `clearable`: `boolean` (default `false`) — A cross that empties the field. - `placeholder`: `string` (default `""`) — Shown while empty; defaults to "Pick a time". - `disabled`: `boolean` (default `false`) — The field cannot be changed. - `required`: `boolean` (default `false`) — Standalone only: empty is an error, said to a reader, and no cross. Inside UiField the field's `required` says so. - `invalid`: `boolean` (default `false`) — Standalone only: the value is wrong. Inside UiField the field's error says so. - `name`: `string` — Sends the time with a form, under this name. - `id`: `string` — Id of the trigger, standalone; inside UiField the field's id wins. - `ariaLabel`: `string` — Standalone only: the accessible name. Inside UiField the label names it. - `hourCycle`: `12 | 24` — 12 hours with an AM/PM column, or 24; unset, the app's language decides (`2:30 PM` in American English, `14:30` in Russian). The field writes the time the same way. - `size`: `ControlSize` = `xs | sm | md` — Step of the control ladder. Unset, the app's size (`createGrani({ size })`), `sm` by default. Attributes: `class` and `style` stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it. Events: - `update:modelValue` (`value: string | null`) — A time was picked (`HH:mm`), or `null` when cleared. #### How it behaves ##### Back to the value - When: the app refuses a time. - Props: `modelValue` with `@update:model-value`. - Guarantee: If the app refuses the time you pick, the field shows the previous time again. - Without it: The field shows a time the app is not using. - Code: `` - Your part: Pass the time the app holds as `modelValue`, and explain why a time was refused. - Proof, in the repository's tests (UiTimePicker.spec.ts, not in the package): "a change the parent refuses puts the picker back on the model" - Pattern for all components: https://grani.arseni.work/patterns/waiting#refused-goes-back ##### Sent with the form - When: it sits inside a native form. - Props: `name`. - Guarantee: It sends the time as `HH:mm` under its `name`, the time the field shows, an empty value while it has none, and nothing while disabled. - Without it: A time field on screen that the form never sends. - Code: `` - Your part: Give it a `name`. - Proof, in the repository's tests (UiTimePicker.spec.ts, not in the package): "in a form it sends HH:mm under its name, and an empty value while empty", "a disabled picker sends nothing with a form" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#sends-like-native ##### Limits shown - When: The times are limited by `min` and `max`. - Props: `min`, `max`. - Guarantee: You can see hours and minutes outside the allowed range, but cannot select them. The range can cross midnight. A previously stored time outside it remains visible and is marked invalid, so you can correct it. - Without it: A list of only the allowed times: a saved time outside them shows as an empty field, and saving the form sends no time. - Code: `` - Your part: State the allowed window in the field's description, since the list alone does not say why some times cannot be picked. - Proof, in the repository's tests (UiTimePicker.spec.ts, not in the package): "a value outside min and max is kept and reads invalid" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#limit-shown ##### Typed as written - When: a digit is typed on the field. - Props: none; 12 or 24 hours come from the app’s `locale`, or `hourCycle`. - Guarantee: The field shows what is typed, the Hour column moves to the hour it reads, and Enter takes the time to the minute, off the step too. Three digits are `H:mm` and four `HH:mm`; a mark (`:`, `.`, a space) ends the hour. A 12-hour field says the half of the day it reads (`2:30 AM`) until `a` or `p` sets another. A press away takes the time when it is written in full, and in a 12-hour language with its half of the day; Escape drops it. A time that does not exist, or one outside `min` and `max`, is not taken: the field is marked invalid and a screen reader announces why. - Without it: A time is scrolled to in a list of minutes, or typed into a plain text field that reads `230` as 23:0 and sends a time nobody meant. - Code: `` - Your part: Nothing; `createGrani({ locale })` sets 12 or 24 hours. - Not covered: Words are not read: “noon” or “half past two” is the columns’ to give. Where the half of the day is not written with a and p (Korean, Arabic), a press away takes a time written to the minute on the 24-hour clock. - Proof, in the repository's tests (UiTimePicker.spec.ts, not in the package): "three digits are H:mm: 230 and Enter is 02:30, not 23:00", "a space after the hour ends it, as a colon does: 9 30 is 09:30, and nothing is emitted before", "Enter on a time that does not exist takes nothing, says so and marks the field invalid", "a time that does not exist is not taken: 2400 is not 02:40", "a press away takes a time typed in full, and drops a shorter one, said", "in a 12-hour language a press away wants the period, or an hour only the 24-hour clock has", "in a 12-hour language the typed text names the half of the day it will be read in", "a time outside the window is not taken by Enter nor by a press away, and said", "Escape drops the typed time wherever the focus is in the panel" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#date-as-written ##### Escape, top first - When: its list is open inside a window. - Props: none. - Guarantee: Escape closes the list of times only, and keyboard focus goes back to the field; the window stays open. - Without it: One Escape closes the list and the window behind it at once. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiTimePicker.spec.ts, not in the package): "Escape in a window closes the list only" - Pattern for all components: https://grani.arseni.work/patterns/closing#escape-top ##### Focus on the error - When: a form is sent with no time. - Props: `invalid`, or the error of its UiField. - Guarantee: When the time field is the first one in error, keyboard focus lands on it, so you can enter the time straight away. - Without it: The form does not send, keyboard focus stays on Save, and you have to find the field with an error yourself. - Code: `` - Your part: Pass the error to its UiField, or set `invalid`. UiModal with `form` and UiCrudDrawer move the focus themselves; in a form of your own, call `focusInvalid(form)` after your check. - Proof, in the repository's tests (UiTimePicker.spec.ts, not in the package): "a picker marked invalid is what focusInvalid lands on" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-invalid ##### Cleared in place - When: A time may be left empty. - Props: `clearable`. - Guarantee: The × sets the value to `null`, and keyboard focus stays on the field. A required field has no ×, because empty is not allowed there. - Without it: The × disappears together with the time it cleared, and keyboard focus is lost with it. - Code: `` - Your part: Treat `null` as no time. - Proof, in the repository's tests (UiTimePicker.spec.ts, not in the package): "the cross clears it and the focus stays on the field" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#cleared-in-place ##### One name - When: it needs a name. - Props: a UiField around it, or `ariaLabel`. - Guarantee: A screen reader announces it once, by the field's label or by `ariaLabel`, with the time as the locale writes it; the description and the error are announced with it, once. - Without it: A time field announced only by its placeholder. - Code: `` - Your part: Put it in a UiField with a `label`, or give it `ariaLabel`. - Proof, in the repository's tests (UiTimePicker.spec.ts, not in the package): "inside UiField with a description it is named once and described once" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useControlSize`, `useUiT`, `announce`. ### Toaster (`UiToaster`) > See a short message about an action's progress or result, with an action such as Undo when available. A component of grani 0.1.0. Overlays, stable, since 0.1.0. Page: https://grani.arseni.work/components/toaster Also called toast, notifications, snackbar. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - See whether an action worked. - Ignore extra presses while a request runs. - Confirm only what cannot be undone. - Reach frequent actions from the keyboard. - Keep keyboard focus where you are working. Implemented by the app: - Data fetching, saving, permissions and business rules. - Keep the button in `loading` until the request answers, then report the outcome with `toast.success` or `toast.error`; an error says what to do next. Where the button leaves the screen once pressed, as a menu row does, `toast.promise` shows the wait in a message instead. - Make the change, retain the information needed to reverse it, and connect the toast’s Undo action to that reversal. - List F8 wherever your app lists its keys; the notification region already names it to a screen reader as “Notifications (F8)”. #### Why it works this way The place where toasts appear. Put it once at the app root (its root component or a layout), beside UiConfirm. **Root.** Never put it in a page or a component: every `useToast()` call anywhere lands in this one stack, and a second toaster in the same app warns in dev. Without a toaster, a toast is queued and never seen, and dev says so once. It stands at the end of the `body`, or where `createGrani({ teleport })` says. **Placement.** By default it stands at the bottom start of the screen (`placement`: `bottom-start`, `bottom`, `bottom-end`; start and end follow the reading direction), clear of the safe area. On a phone it stands at the top, where it covers no pinned footer and no Save button. The newest toast is in front, and up to three are shown; older ones peek behind it, smaller and as tall as it, so a short one never hides behind a long one nor a long one stands over a short one as an empty frame, and wait with their timers paused until there is room. Under the pointer or the focus, the pile opens into a column and every timer stops until both have left. Over a drawer. The stack stands above every layer and leaves no room for the one under it. On a phone, at the top, it covers the drawer's header (its cross and title) while it stays; on a screen under 850px wide, at the bottom start, it covers the end of a drawer's footer, its Cancel. It goes by itself, by a swipe or with its close button. In a UiAppShell the stack at the bottom start stands over the sidebar's `#footer` while it shows: with a control there that is used often (a theme switch), set `placement="bottom-end"`. **Toast.** A toast is a row on a card: the glyph of its tone (or its own `icon`; `neutral` has none), a title, a detail line, one action and a close button. It closes when its `duration` runs out (5 s, 7 s for an error, 13 s with an action, time to reach it by `F8`), on the close button, on Escape while focused, or on a swipe away from the screen edge it stands at (down, or up on a phone) or toward its side. Its action runs once. A long line wraps; a long detail is cut at three lines. A toast never holds what the page needs to go on: that is a dialog or inline text. **Again.** The same toast shown again while one stands, shown or waiting (the same title, detail, tone, glyph and `announce`, no action), is that one card: it comes to the front with its whole time again, and its id is returned. Five presses of Copy are one "Copied", not a pile of five. A toast with an action is always new: two Undo buttons undo two things. **The key.** F8 is a function key no browser uses for itself, and the key a toast region is commonly reached by. F6 is the browser's own walk between its panes, and a letter with Alt or Ctrl+Alt types text on some keyboards. The region's name includes the key, so a screen reader user hears how to get in. The toaster stands above every layer, and an open window's focus trap lets the focus into it. ##### Keyboard - `F8`: from anywhere on the page, with a window open or not, moves the focus to the newest toast's action (its close button when it has none); `F8` again goes back. - `Tab`: reaches a toast's action and its close button. - `Escape`: on a focused toast, closes it. When a toast closes with the focus in it (Escape, its close button, its action), the focus goes back to where it came from. Focus in the stack opens the pile and pauses every timer until it leaves; a toast beyond the three shown is `inert` until it moves up. The swipe is for a pointer; the keyboard closes a toast with the button or Escape. ##### Screen reader - Live regions: two stand on the page before any toast arrives. - Error: with `priority: 'high'`, an alert, read at once. - Other toasts: a status, read at a pause. - Content: each is read with its title and detail. - Several: toasts that come together are read one after another in the order they came. - Again: the same toast shown again while it stands is read once more. - Silent: `announce: false` is not read (for a control that has already said it). - Stack: a region named "Notifications (F8)". - Close button: each is named "Close" and the toast's title. - Action: its label, described by the toast's title, so F8 landing on it says what it acts on. - Glyph: hidden. ##### States One toast per tone (`neutral` without a glyph, `success`, `danger`, `warning`, `info`); a glyph of its own; loading from `promise`, then a new toast with its outcome; with an action; open until closed (`persistent`); the same toast again, one card; long text wrapping and a detail cut at three lines; a pile of three with older ones peeking behind, opened under the pointer or the focus; placed at the start, the center or the end of the bottom edge, and at the top on a phone. ##### When to use another component - What the page needs to go on: UiModal or inline text. - A question: UiConfirm. - A field's validation: stays by the field in UiField. - A load that failed: UiNoData `tone="danger"` with Retry, inline. #### Examples ##### Undo ```vue ``` ##### Request ```vue ``` ##### Pile ```vue ``` ##### Persistent ```vue ``` #### API Props: - `placement`: `"bottom" | "bottom-end" | "bottom-start"` = `bottom | bottom-end | bottom-start` (default `"bottom-start"`) — Where the stack stands on the bottom edge; `start` and `end` follow the reading direction. Anything else lands on the root element: native attributes, aria-*, data-testid. ##### `useToast()` The queue behind UiToaster. Components call `toast.success(...)` and the toaster at the app root shows it. Feedback on an action goes here: a save that failed, a copy that worked; a field's validation stays by the field and a load error stays inline with its retry. The same toast again while one stands, with no action, is that one card, back at the front with its id. Returns: - `toasts`: `Readonly>` — Every toast in order of arrival, read-only. - `stack`: `ComputedRef` — The toasts in the stack, newest first; beyond the limit they wait, their clocks held. - `show`: `(options: ToastOptions) => number` — Shows a toast from full options and returns its id. - `update`: `(id: number, patch: Partial) => void` — Changes a toast by id in place; a new tone or duration restarts its clock. - `close`: `(id?: number | undefined) => void` — Closes one toast by id, or all of them with no argument; an id that is `undefined` closes none. - `promise`: `(p: Promise, messages: ToastPromiseMessages) => Promise` — A request as toasts: `loading` while it runs, then a new one with `success` or `error`; a message that throws gives way to "Done" or "Could not complete the action". - `success`: `(title: string, options?: Omit) => number` — `success(title, options?)`: a done action, "Invoice sent". - `error`: `(title: string, options?: Omit) => number` — `error(title, options?)`: a failed action, read out at once, a little longer on screen. - `warning`: `(title: string, options?: Omit) => number` — `warning(title, options?)`: done, with something to look at. - `info`: `(title: string, options?: Omit) => number` — `info(title, options?)`: a fact worth a glance. `ToastAction` - `label`: `string` — The button's text: "Undo", "Open". - `onClick`: `() => void` — Runs once, as the toast closes. `ToastOptions` - `title`: `string` — One line, the fact: "Invoice sent". - `description`: `string` — A second line with the detail, optional. - `tone`: `"neutral" | "success" | "warning" | "danger" | "info"` — Meaning, and with it the glyph and its color: `neutral` (default), `success`, `danger`, `warning`, `info`. - `icon`: `Component` — A Lucide glyph in place of the tone's own; the tone still colors it. - `loading`: `boolean` — A request in flight: a spinner instead of the glyph; `promise` sets it. - `duration`: `number | "persistent"` — How long it stays once shown: 5 s by default, 7 s for `danger`, 13 s with an `action`. - `priority`: `"low" | "high"` — How a screen reader hears it: `high` at once, `low` at a pause; `danger` is `high` by default. - `action`: `ToastAction` — One button in the toast, "Undo" or "Open"; it never holds what the page needs to work. - `announce`: `boolean` — `false` shows it unread: for an outcome the control has already announced where the focus is (a copy button's own live region). `Toast` - `id`: `number` - `duration`: `number | "persistent"` — How long it stays once shown: 5 s by default, 7 s for `danger`, 13 s with an `action`. - `priority`: `"low" | "high"` — How a screen reader hears it: `high` at once, `low` at a pause; `danger` is `high` by default. - `again`: `number` — How many times the same toast came again while it stood; each time it is read again. - `title`: `string` — One line, the fact: "Invoice sent". - `description`: `string` — A second line with the detail, optional. - `tone`: `"neutral" | "success" | "warning" | "danger" | "info"` — Meaning, and with it the glyph and its color: `neutral` (default), `success`, `danger`, `warning`, `info`. - `icon`: `Component` — A Lucide glyph in place of the tone's own; the tone still colors it. - `loading`: `boolean` — A request in flight: a spinner instead of the glyph; `promise` sets it. - `action`: `ToastAction` — One button in the toast, "Undo" or "Open"; it never holds what the page needs to work. - `announce`: `boolean` — `false` shows it unread: for an outcome the control has already announced where the focus is (a copy button's own live region). `ToastPromiseMessages`: The three faces of `promise`: a title or full options, the last two maybe from the outcome. - `loading`: `string | ToastOptions` - `success`: `string | ToastOptions | ((value: T) => string | ToastOptions)` - `error`: `string | ToastOptions | ((error: unknown) => string | ToastOptions)` #### How it behaves ##### Outcome shown - When: a request started by a button, such as a send or a save. - Props: the button's `loading` while the request runs, then `toast.success` or `toast.error`. - Guarantee: While the request runs, its button shows a spinner and ignores a second press. When the request completes, a message states the outcome, and a screen reader announces an error immediately. - Without it: The button is pressed, and nothing says whether the request went or how it ended. - Code: `sending.value = true try { await send(invoice) toast.success('INV-0042 sent') } catch { toast.error('Could not send', { description: 'Check the connection and send it again.' }) } finally { sending.value = false }` - Your part: Keep the button in `loading` until the request answers, then report the outcome with `toast.success` or `toast.error`; an error says what to do next. Where the button leaves the screen once pressed, as a menu row does, `toast.promise` shows the wait in a message instead. - Proof, in the repository's tests (UiToaster.spec.ts, not in the package): "an error is an alert, the rest are status; the glyph follows the tone" - Pattern for all components: https://grani.arseni.work/patterns/waiting#answered ##### One press, one request - When: a message with an action, such as Undo or Open. - Props: `action: { label, onClick }`. - Guarantee: The action closes its notification and runs once for that notification. Another press during the fade-out does not run it again. - Without it: A double press on Undo restores the invoice twice. - Code: `toast.show({ title: 'Invoice deleted', action: { label: 'Undo', onClick: restore } })` - Your part: Nothing: `onClick` is called once. - Proof, in the repository's tests (UiToaster.spec.ts, not in the package): "the action runs once and closes its toast", "a second click on the action during the leave fade does not run it twice" - Pattern for all components: https://grani.arseni.work/patterns/waiting#one-press ##### Undo, not a question - When: a change that can be reversed: a delete, an archive, a move. - Props: `action: { label: 'Undo', onClick }` on the toast. - Guarantee: A notification with Undo stays visible longer than one without an action. Its background fades to show the time remaining, and the timer pauses while the pointer is over the notifications or keyboard focus is inside them. Undo runs once for that notification. - Without it: Either a delete is gone for good, or every delete first asks “Are you sure?”. - Code: `toast.show({ title: 'Invoice deleted', action: { label: 'Undo', onClick: restore }, })` - Your part: Make the change, retain the information needed to reverse it, and connect the toast’s Undo action to that reversal. - Proof, in the repository's tests (UiToaster.spec.ts, not in the package): "a toast with an action stays longer than one without", "A toast with an action shows its time as a haze over its whole stay; a plain one has none.", "the close button and Escape on a focused toast close it; the pointer on the stack holds the clocks", "the action runs once and closes its toast" - Pattern for all components: https://grani.arseni.work/patterns/waiting#ask-what-stays ##### Shortcut keys - When: a notification is shown, with a window open or not. - Props: none: every UiToaster. - Guarantee: F8 from anywhere on the page moves keyboard focus to the newest notification's action, or to its close button when it has none, and F8 again returns focus to where it was. With no notification on screen, the key is left to the page. - Without it: A notification only the mouse can reach, gone before keyboard focus gets there. - Code: `` - Your part: List F8 wherever your app lists its keys; the notification region already names it to a screen reader as “Notifications (F8)”. - Proof, in the repository's tests (UiToaster.spec.ts, not in the package): "F8 moves the focus to the newest toast action; the region names the key", "F8 with no toast does nothing and leaves the key to the page" - Pattern for all components: https://grani.arseni.work/patterns/working-the-list#keyboard-actions ##### Focus stays near - When: A message closes while keyboard focus is in it: by Escape, its close button or its action. - Props: none: every UiToaster. - Guarantee: Keyboard focus goes back to where it was before it moved into the message, for example the field you were typing in, not to the start of the page. - Without it: The message closes with keyboard focus inside it, focus is lost, and you have to find the field you were in again. - Code: `` - Your part: Nothing. - Proof, in the repository's tests (UiToaster.spec.ts, not in the package): "running the action from the keyboard gives the focus back where it came from" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#focus-stays The patterns: https://grani.arseni.work/patterns Built with what `grani` exports: `useToast`, `useUiT`, `announce`. ### Tooltip (`UiTooltip`) > Get a short hint on hover, keyboard focus or tap; anything essential stays on the page outside the tooltip. A component of grani 0.1.0. Overlays, stable, since 0.1.0. Page: https://grani.arseni.work/components/tooltip Also called hint, hover hint. #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Close only the top layer with Escape. - Name each field once for screen readers. Implemented by the app: - Data fetching, saving, permissions and business rules. - Name an icon button with `ariaLabel`, and use the hint to add what the name does not say. #### Why it works this way A short text hint beside its trigger. Take it for detail a dense row has no room for. **Showing.** It shows on hover and on keyboard focus, but not when a script gives the focus back after a click. When the pointer moves from one trigger to the next, the next hint opens at once. A tap shows it on a trigger that only shows something (a cut text, a date); a tap on a button that opens something only opens it. A press outside hides the hint and still reaches the window under it. A long word wraps inside it. **Content.** Text only. It is never the only place for what must be read by a person who cannot reach it: a natively disabled button takes no focus, so its reason is visible text beside it; UiButton and UiIconButton with `disabledReason` keep the focus and say their reason in one. **Trigger.** Attributes and listeners set on the tooltip reach the trigger along with the trigger's own, so it can stand inside a popover's or a menu's `#trigger`. The trigger is one element or a single-root component (not a fragment, not another tooltip) and should be focusable (a static badge takes `tabindex="0"`). When the panel is pushed aside, its tail stays on the trigger; a trigger that goes away takes the hint with it. The pointer may cross onto the tooltip (WCAG 1.4.13). **Placement.** UiTruncate and UiDateCell stand on it; UiButton and UiIconButton draw the same hint for the reason they are off. It names an icon button where a sighted user needs the name (the sidebar's toggle, a table's columns). ##### Keyboard Keyboard focus on the trigger shows it at once, and blur hides it; a focus a script gives back after a click does not show it. - `Escape`: hides it first and goes no further, so a window under it stays until the next `Escape`. The hint itself takes no focus and holds nothing focusable. ##### Screen reader - Role: a `tooltip`. - Description: while shown, it describes the trigger (`aria-describedby`), next to any description you set; with no `content` there is none. - Repeated words: a hint that repeats the trigger's own name (an icon button's `ariaLabel`) or a description it already has (a disabled button's reason) is not wired, so the words are read once. - Tail: hidden. ##### States Hidden; shown after a short delay on hover, at once on keyboard focus, on a tap on a touch screen, and at once when the pointer moves from one trigger to the next. On one of four sides (`side`), flipped when that side has no room, and pushed aside at the screen edge with the tail kept on the trigger; a `left` or `right` hint with room on neither side goes below or above, never over its trigger. Long text and a long word wrap. One hint on the page at a time. ##### When to use another component - A hint with a link or a control: UiPopover. - The reason a button is off: UiButton or UiIconButton `disabledReason`, which keeps the focus and says it; for a button natively `disabled`, visible text beside it. - A cut value in a cell: UiTruncate, built on it. - A date: UiDateCell, built on it. #### Examples ##### Dense ```vue ``` ##### Long ```vue ``` ##### Popover ```vue ``` ##### Disabled ```vue ``` ##### Edge ```vue ``` ##### Window ```vue ``` #### API Props: - `content`: `string` (default `""`) — The hint. Empty or absent means no tooltip. - `side`: `"top" | "bottom" | "left" | "right"` = `top | bottom | left | right` (default `"top"`) — Preferred side; it flips when that side has no room, and a side hint with room on neither side goes below or above. Attributes are placed by the component itself: its description says where. Slots: - `default` with `Record` — The trigger: one element or a single-root component the hint describes. It should be focusable. #### How it behaves ##### Escape, top first - When: a hint is shown inside a window, a drawer or a panel. - Props: none: every UiTooltip. - Guarantee: The first Escape hides the hint and goes no further; the window under it closes only with the next Escape. - Without it: Escape meant for a hint closes the whole window, and what you typed in it is lost. - Code: ` Reset ` - Your part: Nothing. - Proof, in the repository's tests (UiTooltip.spec.ts, not in the package): "Escape on a shown tooltip inside a modal hides the hint and leaves the modal open" - Pattern for all components: https://grani.arseni.work/patterns/closing#escape-top ##### One name - When: its hint repeats the name of its trigger, as on an icon button. - Props: `content` equal to the trigger's `ariaLabel`. - Guarantee: When the hint matches the trigger's `aria-label` letter for letter, it is not connected as a description, so a screen reader announces the name once; a hint that adds something is announced as the trigger's description. - Without it: A screen reader announces “Columns, Columns”: the name, then the same word as its description. - Code: ` ` - Your part: Name an icon button with `ariaLabel`, and use the hint to add what the name does not say. - Proof, in the repository's tests (UiTooltip.spec.ts, not in the package): "a hint equal to the trigger name is not wired as its description" - Pattern for all components: https://grani.arseni.work/patterns/filling-in#one-name-one-voice The patterns: https://grani.arseni.work/patterns #### Built on it - [Truncate](https://grani.arseni.work/components/truncate): Keep a long value on one line with an ellipsis, and reveal the full text on hover or tap when it does not fit. ### Truncate (`UiTruncate`) > Keep a long value on one line with an ellipsis, and reveal the full text on hover or tap when it does not fit. A component of grani 0.1.0. Layout, stable, since 0.1.0. Page: https://grani.arseni.work/components/truncate Also called ellipsis, text overflow, truncated text. It works on top of [Tooltip](https://grani.arseni.work/components/tooltip). #### Use ```vue ``` #### Who does what Provided by grani: - Rendering, keyboard and screen reader behavior, and the states this page names. - Read the full text when a label is shortened. Implemented by the app: - Data fetching, saving, permissions and business rules. - Pass the full text in `text` and give the component the width it has. Do not cut text by hand with the `truncate` class and a `title`. #### Why it works this way One line of text cut with an ellipsis, its whole text in a UiTooltip while it is cut. Take it for a name or a title in a narrow place: a table cell, a list row, a file name. **Tooltip.** While the text is really cut, the whole text shows in the tooltip on hover and tap, as UiDateCell shows the full date; text that fits has no tooltip. The cut is measured, not assumed: when a column widens or the text changes, it is measured again, and the tooltip follows. **Props.** `text` is the only prop; markup in a slot handles its own cut. The root is a block `` with no tab stop, and attributes land on it. ##### Keyboard No tab stop: the span is not focusable, so the tooltip opens on hover and on tap, not from the keyboard; for the keyboard, put a control beside it that leads to the whole text. ##### Screen reader - Text: the whole text, always: the cut is CSS, and the span holds every character. - Tooltip: while shown, it describes the span (`aria-describedby`) with the same words again. ##### States Fits, with no tooltip and no native `title`; cut, with the whole text in the tooltip on hover and tap. The cut is measured on mount, on resize, when a web font arrives and when `text` changes, with one pixel of slack; an empty text is a cut of nothing. ##### When to use another component - Markup in a slot: it handles its own cut (`truncate` on the element). - A date: UiDateCell. - The label of a badge: UiBadge cuts it itself. - Text that may wrap: plain text. #### Examples ##### Fits or cut ```vue ``` #### API Props: - `text`: `string` (required) — The text; the whole of it goes to the tooltip while it is cut. Attributes are placed by the component itself: its description says where. #### How it behaves ##### Whole text - When: one line of text in a narrow place. - Props: `text`. - Guarantee: Hover or tap a shortened label to read its full text. The component measures whether it fits and checks again when the text or available space changes. Text that fits has no tooltip or native `title`. - Without it: An ellipsis and nothing more: the end of the name is gone, and the only way to read it is to widen the window. - Code: `` - Your part: Pass the full text in `text` and give the component the width it has. Do not cut text by hand with the `truncate` class and a `title`. - Not covered: The text is not a Tab stop, so a keyboard cannot open this tooltip. If people need the full value to complete a task, provide a nearby button or link that opens it. - Proof, in the repository's tests (UiTruncate.spec.ts, not in the package): "A cut text keeps its whole in a tooltip, while the cut is real.", "Text that fits gets no tooltip, and no native title either.", "The cut is measured again when the text changes." - Pattern for all components: https://grani.arseni.work/patterns/finding#whole-text The patterns: https://grani.arseni.work/patterns ## How admin screens behave: the patterns of grani > 126 interaction patterns, by what the person is doing, with explanations, who implements them and test references where available (111 of them have tests). Page: https://grani.arseni.work/patterns One chapter alone: - https://grani.arseni.work/patterns/opening.md - https://grani.arseni.work/patterns/closing.md - https://grani.arseni.work/patterns/filling-in.md - https://grani.arseni.work/patterns/finding.md - https://grani.arseni.work/patterns/waiting.md - https://grani.arseni.work/patterns/working-the-list.md ### Focus and navigation Where keyboard focus starts, how it moves, and where it returns. Page: https://grani.arseni.work/patterns/opening #### Mark today in the app’s time zone The calendar marks today in the app’s time zone, or in the browser’s time zone if the app has not set one. On a page rendered by a server, the marker appears only after the calendar runs in the browser. A screen reader identifies it as the current date. Why: Near midnight, the server’s date may differ from the date in the calendar’s time zone. Waiting for the browser avoids marking the wrong day as today, even briefly. Held by the library by itself. Where: [Calendar](https://grani.arseni.work/components/calendar). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/opening#readers-today #### Open a panel on the side where it fits A field near the top of the window opens its calendar under it; a field near the bottom opens it above, so the calendar stays inside the window and the page needs no scrolling. The panel is never taller than the room it has, scrolling inside itself when the room is short, and never covers its field. Why: You pick a day at the end of a long form without the calendar running off the screen, and the field you are filling stays in sight. Held by the library by itself. Where: [Date picker](https://grani.arseni.work/components/date-picker), [Select](https://grani.arseni.work/components/select). Tests: 6, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/opening#opens-where-fits #### Know when a link opens a new tab Before opening a link, you can tell whether it will use a new tab: an arrow follows the text, and a screen reader announces “opens in a new tab”. Links to another site use this behavior by default. Mail and phone links can open another app, so they do not carry the new-tab notice. Why: You know that the original page will remain open and that you can return to it by switching tabs. Held by the library by itself. Where: [Link](https://grani.arseni.work/components/link), [Button](https://grani.arseni.work/components/button), [List item](https://grani.arseni.work/components/list-item). Tests: 7, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/opening#new-tab-said #### Open with keyboard focus where the task begins Open a form and start typing in its first field, or open a list and start choosing with the arrow keys. Searchable lists start in their search field, and a calendar starts on the selected day. In a confirmation dialog, focus starts on Cancel, so pressing Enter by habit does not approve the action. Why: You can begin the task without first clicking a field or pressing Tab to find it. Confirmation dialogs give you time to read the question before approving it. Held by the library by itself. Where: [Filter list](https://grani.arseni.work/components/filter-list), [Popover](https://grani.arseni.work/components/popover), [Confirm](https://grani.arseni.work/components/confirm), [Table columns](https://grani.arseni.work/components/table-columns), [Filter](https://grani.arseni.work/components/filter), [Select](https://grani.arseni.work/components/select), [Date picker](https://grani.arseni.work/components/date-picker), [Calendar](https://grani.arseni.work/components/calendar), [Drawer](https://grani.arseni.work/components/drawer), [Modal](https://grani.arseni.work/components/modal), [Crud drawer](https://grani.arseni.work/components/crud-drawer). Tests: 16, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/opening#focus-start #### Continue from where you opened the panel After closing a dialog or side panel, continue from the button or row that opened it. Closing a popup with Escape or by choosing an option also returns focus to its opener, provided it is still on the page. Clicking outside a popup leaves focus where you clicked. Why: You can open and close one record after another from the keyboard without looking for your place again each time. Held by the library by itself. Where: [Drawer](https://grani.arseni.work/components/drawer), [App shell](https://grani.arseni.work/components/app-shell), [Filter](https://grani.arseni.work/components/filter), [Table columns](https://grani.arseni.work/components/table-columns), [Date range](https://grani.arseni.work/components/date-range), [Select](https://grani.arseni.work/components/select), [Date picker](https://grani.arseni.work/components/date-picker), [Popover](https://grani.arseni.work/components/popover), [Modal](https://grani.arseni.work/components/modal), [Menu](https://grani.arseni.work/components/menu), [Crud drawer](https://grani.arseni.work/components/crud-drawer). Tests: 18, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/opening#focus-back #### Pass a group with one press of Tab Use Tab to reach a group of options, then the arrow keys to move within it. The next Tab leaves the group. This works for tabs, radio options, scale values, filter options and calendar days, so you do not have to Tab through each option separately. Separate controls, such as search and Reset buttons, have their own Tab stops. Why: You can get past a group of twelve options without pressing Tab twelve times, while still reaching every option with the arrow keys. Held by the library by itself. Where: [Tabs](https://grani.arseni.work/components/tabs), [Filter list](https://grani.arseni.work/components/filter-list), [Calendar](https://grani.arseni.work/components/calendar), [Radio group](https://grani.arseni.work/components/radio-group), [Table columns](https://grani.arseni.work/components/table-columns), [Filter](https://grani.arseni.work/components/filter), [Scale input](https://grani.arseni.work/components/scale-input). Tests: 10, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/opening#one-stop ### Closing panels Dismiss nested panels in order, the top one first, and leave the form behind them as it was. Page: https://grani.arseni.work/patterns/closing #### Keep the current period until both days are picked Your current period stays in effect until you select both ends of a new range. If you close the panel before selecting both dates, the unfinished range is discarded, and the panel next opens on its list of periods. If the app sets another period while you are selecting, the unfinished range is discarded too. Why: You can explore dates and cancel without changing the records shown by the current filter. Held by the library by itself. Where: [Date range](https://grani.arseni.work/components/date-range). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/closing#half-pick-dropped #### Close only the top layer with Escape Press Escape to step back from the last thing you opened. A list or calendar closes while the form behind it stays open. In a drawer with nested views, Escape goes back one level before closing the drawer. A search containing text uses the first Escape to clear it; a calendar’s month view uses it to return to the days. Why: You can dismiss a temporary choice without accidentally closing the form you are still filling in. Held by the library by itself. Where: [Date picker](https://grani.arseni.work/components/date-picker), [Select](https://grani.arseni.work/components/select), [Time picker](https://grani.arseni.work/components/time-picker), [Drawer](https://grani.arseni.work/components/drawer), [Tooltip](https://grani.arseni.work/components/tooltip), [Popover](https://grani.arseni.work/components/popover), [Crud drawer](https://grani.arseni.work/components/crud-drawer), [Modal](https://grani.arseni.work/components/modal). Tests: 11, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/closing#escape-top #### Close a popup without closing the form behind it If a select’s list or a calendar is open inside a window or drawer, a click outside it closes only that list or calendar, and the form stays open. In a window this holds even for a click on the backdrop; the next click there closes the window. Why: Clicking away is the usual way to dismiss a list you opened by mistake, and it should not cost you the form you were filling in. Held by the library by itself. Where: [Select](https://grani.arseni.work/components/select), [Date picker](https://grani.arseni.work/components/date-picker), [Crud drawer](https://grani.arseni.work/components/crud-drawer), [Modal](https://grani.arseni.work/components/modal). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/closing#press-top #### See one window at a time If a dialog opens another dialog, the first one closes and the new one takes its place. If an edit dialog asks a question (a confirmation), the edit dialog is hidden until you answer, then returns as you left it, with focus on the button that asked. A side drawer remains visible beneath a dialog. Why: You can tell which question needs an answer, and the keyboard cannot move into a hidden dialog by mistake. Held by the library by itself. Where: [Modal](https://grani.arseni.work/components/modal), [Confirm](https://grani.arseni.work/components/confirm). Tests: 9, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/closing#one-window #### Close an open picker when its field is disabled If a select or date field becomes disabled while its list or calendar is open, the popup closes. Focus returns to the field, so you keep your place in the form even though that field cannot currently accept a choice. Why: An open list should not offer choices the field no longer accepts, and with focus on the field you do not have to find your place again. Held by the library by itself. Where: [Select](https://grani.arseni.work/components/select), [Date picker](https://grani.arseni.work/components/date-picker). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/closing#off-closes ### Forms and input Enter values, correct validation errors and submit a form. Page: https://grani.arseni.work/patterns/filling-in #### Get your text back when adding fails While an entry is being added, the field shows a spinner. You can keep typing, but pressing Enter again sends nothing. If adding fails, your text returns with a message such as “Could not add Meat. Press Enter to try again.” Why: A failed request does not cost you the text you typed; one Enter tries again. Held by the library by itself. Where: [List field](https://grani.arseni.work/components/list-field). Tests: 6, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#text-back #### Know why an action is unavailable A button that cannot be pressed yet says what it waits for: “Add the client’s email first”. It keeps its place in the Tab order and shows the reason when you point at it, reach it with Tab, press it or tap it, and a screen reader reads the reason with it. A press does nothing until the reason is gone. Why: A grey Save with no word sends a person hunting through the form for what is missing, or to support; the reason turns a dead end into the next step. Held by the library by itself. Where: [Button](https://grani.arseni.work/components/button), [Icon button](https://grani.arseni.work/components/icon-button), [Copy button](https://grani.arseni.work/components/copy-button). Tests: 18, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#off-says-why #### Know what was refused and why If something cannot be added, a message names it and says why, and a screen reader announces it at once. A dropzone names up to three files for each reason and counts the rest, for example “a.png, b.png, c.png and 2 more: unsupported file type.”, a sentence per reason. A list field keeps the refused text in the field to be corrected, and adds the valid entries typed or pasted with it. Why: You know what to fix or leave out before you try again, and you correct a refused entry instead of typing it again. Held by the library by itself. Where: [Dropzone](https://grani.arseni.work/components/dropzone), [List field](https://grani.arseni.work/components/list-field). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#refused-by-name #### Submit the values shown in the form The form sends the values you can see and review. Give each control a `name` to include it in a native form submission or `FormData`. Unchecked boxes, off switches, an empty select and disabled controls send nothing. An empty text field, a date picker with no day and a time picker with no time send an empty value. Resetting the form restores the app’s current values, so the app must clear those values to empty the form. Why: The submitted values agree with the visible fields, and developers do not have to maintain a second set of hidden inputs themselves. Held by the library by itself. Where: [Date picker](https://grani.arseni.work/components/date-picker), [Input](https://grani.arseni.work/components/input), [Time picker](https://grani.arseni.work/components/time-picker), [Textarea](https://grani.arseni.work/components/textarea), [Switch](https://grani.arseni.work/components/switch), [Select](https://grani.arseni.work/components/select), [Scale input](https://grani.arseni.work/components/scale-input), [Radio group](https://grani.arseni.work/components/radio-group), [Password field](https://grani.arseni.work/components/password-field), [Dropzone](https://grani.arseni.work/components/dropzone), [Code input](https://grani.arseni.work/components/code-input), [Checkbox](https://grani.arseni.work/components/checkbox), [Stepper](https://grani.arseni.work/components/stepper). Tests: 27, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#sends-like-native #### Finish a word in any script before it is sent Japanese, Chinese and Korean are typed through an input method: the reading first, then the word picked with Enter. Until the word is picked nothing is sent or chosen, and the Enter that picks it does not search, tick a row or add an entry. Why: A search for half a word, or an entry added halfway through typing it, is a wrong answer for anyone who writes these languages every day. Held by the library by itself. Where: [Table toolbar](https://grani.arseni.work/components/table-toolbar), [Input](https://grani.arseni.work/components/input), [Select](https://grani.arseni.work/components/select), [Filter list](https://grani.arseni.work/components/filter-list), [List field](https://grani.arseni.work/components/list-field). Tests: 10, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#word-as-composed #### Avoid adding the same entry twice If you add something already in the list, the list keeps one copy and says so, for example “a.pdf: already in the list.” A file is the same when its name, size and date match; a typed or pasted name when it matches a whole name in the list in any capitalization, repeats within one paste included. Why: Nobody gets a second copy by accident, and you see why nothing new was added instead of wondering or finding it twice later. Held by the library by itself. Where: [Dropzone](https://grani.arseni.work/components/dropzone), [List field](https://grani.arseni.work/components/list-field). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#no-twice #### Show the error where the hint was The hint below a field and its error share the same place: when the value has an error, the explanation replaces the hint, and when the error clears, the hint returns. A screen reader announces the error and identifies the field as invalid. Why: You find the correction where you were already reading the instructions, and the fields below do not move to make room for a second message. A longer error can still increase the field’s height. Held by the library by itself. Where: [Field](https://grani.arseni.work/components/field), [List item](https://grani.arseni.work/components/list-item). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#error-takes-the-hints-place #### Leave invalid date values empty instead of guessing If the app supplies an impossible date, such as 2026-13-40, the field shows its placeholder instead of converting it into another date. It has no clear button, and the form sends an empty value for it. Why: An invalid value never silently becomes a day nobody selected. Held by the library by itself. Where: [Date picker](https://grani.arseni.work/components/date-picker). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#unreadable-is-none #### Respect date limits without losing a value You can see which dates and times are available before choosing: values outside the allowed range stay visible but cannot be picked. A previously saved value outside the range is kept and marked invalid, so you can review and correct it. Calendar navigation respects the allowed range, but still lets you navigate back if the selected date is outside it. Why: A previously saved value is not silently erased or replaced when the range changes: you can see what needs correcting and navigate back to an available date or time. Held by the library by itself. Where: [Time picker](https://grani.arseni.work/components/time-picker), [Calendar](https://grani.arseni.work/components/calendar), [Date picker](https://grani.arseni.work/components/date-picker). Tests: 10, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#limit-shown #### Keep the end of a period after its start Two date fields, from and to: the days before the start cannot be picked as the end. If the start is moved past an end already chosen, the end keeps its day and shows an error instead of changing by itself. Why: A period that ends before it starts never reaches the report, and a day you chose is never changed behind your back. Held by the library by itself. Where: [Date picker](https://grani.arseni.work/components/date-picker). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#end-after-start #### See a date range before you pick its end After the first day of a range, the calendar draws the range to the day under the pointer or the keyboard focus and says how many days it covers, such as “7 days”. Nothing is applied until you pick the last day. Why: A week that came out eight days long, or a period one day short, is caught while you are still choosing it, not after the report has changed. Held by the library by itself. Where: [Calendar](https://grani.arseni.work/components/calendar), [Date range](https://grani.arseni.work/components/date-range). Tests: 6, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#span-seen-first #### See which days are the weekend The calendar names the days of rest in bold in its header, as the app’s language and region count them: Saturday and Sunday in most places, Friday and Saturday in Israel or Saudi Arabia. A due date put on a day off is seen by its column before it is picked. Why: A deadline or a delivery set on a day when nobody works is caught while you choose it, not on the day itself. Held by the library by itself. Where: [Calendar](https://grani.arseni.work/components/calendar), [Date picker](https://grani.arseni.work/components/date-picker), [Date range](https://grani.arseni.work/components/date-range). Tests: 7, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#weekend-shown #### See the day of the week beside a date A date field shows the day of the week with the date, “Thu, Sep 24”, the year added when it is not this one, and a column of dates can carry it under each date. A due date put on a Saturday, or a day picked one off, is seen at a glance. Why: People plan by days of the week: a meeting on the wrong Thursday or a deadline on a Sunday is caught before it is kept. Held by the library by itself. Where: [Date picker](https://grani.arseni.work/components/date-picker), `UiDateCell`. Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#weekday-said #### Save the day you picked, in any time zone A date field holds and sends the day itself, `2026-10-24`, not a moment at midnight. A server or a colleague in another time zone reads the same 24th, so a date of birth or a due date never slips to the day before. Why: A day kept as midnight in Moscow is already the 23rd on a server that runs in UTC: a birthday or a deadline moves by a day, and nobody notices until it matters. Held by the library by itself. Where: [Date picker](https://grani.arseni.work/components/date-picker), [Calendar](https://grani.arseni.work/components/calendar), [Date range](https://grani.arseni.work/components/date-range), `UiDateCell`. Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#day-stays-day #### Make small changes without losing decimal precision The arrows adjust a price or measurement at the precision you entered. With a step of 0.1, 10.44 changes to 10.45 or 10.43, because its last decimal place is finer than the step. Results are rounded to that precision, so 0.2 plus 0.1 becomes 0.3 rather than a long string of digits. Why: A small adjustment preserves the decimal places you entered without adding rounding digits to the result. Held by the library by itself. Where: [Input](https://grani.arseni.work/components/input). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#cent-stays-cent #### Enter numbers in the app’s local format You type or paste a number the way your language writes it: “1 234,50” in Russian and “1,234.50” in English are both 1234.50. If the field cannot interpret a value unambiguously, it keeps the text as written for you to correct, and marks it invalid when you leave the field. Why: You can enter numbers in the format you are used to, without removing separators by hand. An ambiguous number waits for your correction instead of being saved as the wrong amount. Held by the library by itself. Where: [Input](https://grani.arseni.work/components/input), [Stepper](https://grani.arseni.work/components/stepper). Tests: 11, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#number-as-written #### Type a date or a time as your language writes it Type `12.03.1987` in Russian or `03/12/1987` in American English, or the digits alone, and the date field reads the parts in the order your language writes them. Type `14:30`, `1430` or `2:30p`, and the time field reads it the same way. As you type, the calendar or the column jumps to what was read, and Enter takes it. A day or a time that does not exist is not taken, and never guessed. Why: A date from a passport or a time from an invoice goes in at typing speed, instead of twelve years of paging back or scrolling through the hours, and you see how the field read it before it is kept. Held by the library by itself. Where: [Date picker](https://grani.arseni.work/components/date-picker), [Time picker](https://grani.arseni.work/components/time-picker). Tests: 21, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#date-as-written #### Let the app check a code as soon as it is complete When you type, paste or autofill the last character, the field tells the app that the code is complete, so the app can check it without a separate button. A code the app puts in the field itself, such as a restored draft, is not reported as completed, and input that leaves the code unchanged is not reported again. Why: You can finish entering the code and the app can check it immediately, with no separate button to press. A restored draft is not sent for checking automatically. Held by the library by itself. Where: [Code input](https://grani.arseni.work/components/code-input). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#complete-once #### Go straight to the field that needs fixing If a form cannot be submitted because a field has an error, focus moves to the first field you can correct, and the page scrolls enough to show it. UiModal with `form` and UiCrudDrawer do this by themselves when the app marks the field as the submit is checked, and again when `submitting` falls on an answer that marked one, unless you are typing in another field. Any error in a custom form is focused by the app calling `focusInvalid`. Why: You can correct the problem immediately, even in a long form where the error was off screen, instead of pressing Save again or searching for the highlighted field. Held by the library by itself. Where: [Modal](https://grani.arseni.work/components/modal), [Checkbox](https://grani.arseni.work/components/checkbox), [Time picker](https://grani.arseni.work/components/time-picker), [Textarea](https://grani.arseni.work/components/textarea), [Switch](https://grani.arseni.work/components/switch), [Select](https://grani.arseni.work/components/select), [Scale input](https://grani.arseni.work/components/scale-input), [Radio group](https://grani.arseni.work/components/radio-group), [List field](https://grani.arseni.work/components/list-field), [Input](https://grani.arseni.work/components/input), [Field](https://grani.arseni.work/components/field), [Dropzone](https://grani.arseni.work/components/dropzone), [Code input](https://grani.arseni.work/components/code-input), [Stepper](https://grani.arseni.work/components/stepper), [List item](https://grani.arseni.work/components/list-item), [Crud drawer](https://grani.arseni.work/components/crud-drawer). Tests: 20, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#focus-invalid #### Name states in words, not color alone You can identify a status without its color: a badge says “Paid” or “Overdue”, a scale shows its numbers, and a screen reader announces whether a change is better or worse. Color reinforces the meaning. A badge without text triggers a development warning. Why: The state stays readable for people who do not tell colors apart, in grayscale, and with a screen reader. Held by the library by itself. Where: [Badge](https://grani.arseni.work/components/badge), [Delta](https://grani.arseni.work/components/delta), [Scale input](https://grani.arseni.work/components/scale-input), [Link](https://grani.arseni.work/components/link), [List item](https://grani.arseni.work/components/list-item). Tests: 8, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#level-not-by-colour #### Know what fits when a list has a limit When a list allows only so many entries, what fits is added and a message says what was left out, for example “c.pdf: up to 2 files.” A list field that is full replaces its field with a message that explains the limit, and if the field had focus, focus moves to that message. Why: You can tell why adding stopped and how much of a pasted list or a drop was accepted. Held by the library by itself. Where: [List field](https://grani.arseni.work/components/list-field), [Dropzone](https://grani.arseni.work/components/dropzone). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#nearly-full #### Toggle or pick by clicking the words A click on the text of a checkbox, a switch or a radio works as a click on its box does. In a setting row, a click anywhere on the row, its title and description included, toggles its switch. Options shown as cards are picked by a click anywhere on the card: on its words, its image or its details, such as a price. Why: The words give a much larger target than the small box, switch or radio. Held by the library by itself. Where: [Switch](https://grani.arseni.work/components/switch), [List item](https://grani.arseni.work/components/list-item), [Radio group](https://grani.arseni.work/components/radio-group), [Checkbox](https://grani.arseni.work/components/checkbox). Tests: 5, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#label-is-target #### Drop files anywhere on the upload card You can drop files anywhere on the upload card, including over files already listed. While the dropzone is on the page, a file dropped outside any drop target does not open in the browser tab. Other file inputs and drop targets still handle their own drops. Why: A drop that misses by a few pixels does not replace the page and the form you were filling in. Held by the library by itself. Where: [Dropzone](https://grani.arseni.work/components/dropzone). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#whole-zone-is-target #### Clear a field and enter its replacement immediately Press a field’s × and enter the replacement immediately, without clicking back into the field: clearing moves keyboard focus to the field, whether you use the mouse or the keyboard. Why: The × disappears once the field is empty; without this, keyboard focus would be lost with it, and you would have to find the field again. Held by the library by itself. Where: [Time picker](https://grani.arseni.work/components/time-picker), [Select](https://grani.arseni.work/components/select), [List field](https://grani.arseni.work/components/list-field), [Input](https://grani.arseni.work/components/input), [Date picker](https://grani.arseni.work/components/date-picker). Tests: 6, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#cleared-in-place #### See the password you generate Generate a password to see it in plain text and copy it before saving. Clearing the field turns password hiding back on, so the next password you type is hidden. Why: You never save a password you have not seen and could not type again on another device. Held by the library by itself. Where: [Password field](https://grani.arseni.work/components/password-field). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#made-is-seen #### Generate a password instead of inventing one Press Generate to get a random password, 16 characters long by default, with an uppercase letter, a lowercase letter, a digit and a symbol. The app must still check any other requirements of its password policy. Why: You get a ready-made password without inventing one or assembling the required character types yourself. Held by the library by itself. Where: [Password field](https://grani.arseni.work/components/password-field). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#strong-on-offer #### Show a password without leaving the field Click the password visibility button to check what you typed, then continue editing immediately. Showing or hiding the characters keeps focus in the field and preserves the text cursor’s position. Why: You can check what you typed and correct it at once, without clicking back into the field and finding your place. Held by the library by itself. Where: [Input](https://grani.arseni.work/components/input). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#eye-keeps-field #### Take back a scale answer Click the selected value again, or press Delete or Backspace, to clear the answer to `null`. Space and Enter select an answer and never clear it. With `clearable` turned off, an answer cannot be cleared once given. Why: You can withdraw an answer given by mistake, or one that no longer applies, instead of being forced to leave some value. Held by the library by itself. Where: [Scale input](https://grani.arseni.work/components/scale-input). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#mark-taken-back #### Know how to add files from a folder If you drop a folder, a message tells you how to continue: “photos: folders are not accepted; drop the files inside.” Files dropped alongside the folder are processed separately and are added if they meet the file requirements. Why: You get a next step you can take, instead of a refusal with no reason or an empty file in the list. Held by the library by itself. Where: [Dropzone](https://grani.arseni.work/components/dropzone). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#folder-refused #### Type, paste or autofill a code Enter a code whichever way you have it: type it, paste it, or accept the browser’s autofill from a message where available. The cells share one input, so a complete code fills them at once. Pasted text keeps only the allowed characters and is cut to the code’s length. Why: You use the code you already have without copying it one character at a time. Held by the library by itself. Where: [Code input](https://grani.arseni.work/components/code-input). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#code-any-way #### Show where the next code character goes As you type a code, the cell for the next character is highlighted; on a full code, the last cell is. Backspace removes the last character, and the highlight moves back. Why: You see where the next character goes and can enter the whole code without clicking between cells. Held by the library by itself. Where: [Code input](https://grani.arseni.work/components/code-input). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#code-moves-on #### Keep only the characters a code allows Whatever you type or paste, the field keeps the allowed characters, removes the rest and cuts the result to the code’s length. A value supplied by the app, such as a restored draft, follows the same rules. Why: A stray space or letter never remains in a numeric code. The app still needs to check whether the code itself is correct. Held by the library by itself. Where: [Code input](https://grani.arseni.work/components/code-input). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#code-refuses-wrong-kind #### Pick with Enter without sending the form In a select, a filter or a calendar inside a form, Enter chooses the highlighted option or day and does not submit the form. The form is submitted only when you choose to submit it. Why: Choosing a value from the keyboard does not send a half-filled form. Held by the library by itself. Where: [Select](https://grani.arseni.work/components/select), [List field](https://grani.arseni.work/components/list-field), [Filter list](https://grani.arseni.work/components/filter-list), [Filter](https://grani.arseni.work/components/filter), [Calendar](https://grani.arseni.work/components/calendar). Tests: 8, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#enter-picks #### Submit a form with Enter Enter in a single-line field submits a modal or drawer form, when submission is available. A quick second Enter, or one pressed while the app marks the save as running, does not submit again. The app validates and saves the submitted values. Why: You can submit a completed form without moving to Save, and repeated presses while it is busy do not start another submission. Held by the library by itself. Where: [Modal](https://grani.arseni.work/components/modal), [Crud drawer](https://grani.arseni.work/components/crud-drawer). Tests: 5, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#enter-saves #### Submit from a text box with Ctrl or Cmd+Enter Send a modal or drawer form from a multiline text field with Ctrl+Enter or Cmd+Enter. Enter on its own still inserts a line break. The shortcut activates the submit button, so it is unavailable while that button is disabled or loading. Why: You can write several lines and send them without reaching for the mouse. Held by the library by itself. Where: [Modal](https://grani.arseni.work/components/modal), [Crud drawer](https://grani.arseni.work/components/crud-drawer). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#meta-enter-saves #### Change a number with the arrow keys ArrowUp increases a number and ArrowDown decreases it, without retyping and without moving the text cursor. Changes follow the configured step while preserving finer decimal precision when the value has it. The arrows stop at the minimum and maximum, but typed values still need validation. On a scale, the arrow keys change the answer, and Home and End reach the endpoints. Why: You can make small changes without the mouse and without typing the number again. Held by the library by itself. Where: [Input](https://grani.arseni.work/components/input), [Stepper](https://grani.arseni.work/components/stepper), [Scale input](https://grani.arseni.work/components/scale-input). Tests: 10, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#number-by-arrows #### Move by month or year from the keyboard Page Up and Page Down move the calendar by a month, and with Shift by a year, so a distant date is a few key presses away. The day number is preserved where possible: moving back from May 31 goes to April 30, then March 31. Why: A date months or years away is reached in a few key presses instead of one day at a time. Held by the library by itself. Where: [Calendar](https://grani.arseni.work/components/calendar). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#page-by-key #### Jump to a month from the heading Press the calendar’s month heading to show all twelve months, change the year with the header arrows, and choose a month, without paging through every month in between. Escape returns to the day view. Why: You can go directly to a month such as next September instead of pressing the next-month arrow repeatedly. Held by the library by itself. Where: [Calendar](https://grani.arseni.work/components/calendar). Tests: 5, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#months-jump #### Remove selected tags with the keyboard You can remove selected tags without the mouse: Tab reaches each tag’s remove button, and Enter or Space activates it. In a closed multi-select, Backspace removes the last visible tag. After a removal, keyboard focus moves to the next tag, or back to the field when no tag remains. Why: You can correct a selection without switching to the mouse, and Backspace never removes a value you cannot see. Held by the library by itself. Where: [Select](https://grani.arseni.work/components/select), [Badge](https://grani.arseni.work/components/badge). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#tag-taken-back #### Pick a common date in one press Choose a common date without opening the calendar. Buttons below the field can set Today, Yesterday, Tomorrow or another date relative to today, and the field shows the date a press set. Why: When most entries fall on the same few days, you do not have to open the calendar for each one. Held by the library by itself. Where: [Date picker](https://grani.arseni.work/components/date-picker). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#common-days #### Pick range ends in either order Choose the two ends of a date range in either order. The earlier date becomes the start and the later date the end. Your existing filter stays in effect until you have chosen both. Why: You do not have to start over because you happened to press the later date first. Held by the library by itself. Where: [Date range](https://grani.arseni.work/components/date-range). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#either-order #### Paste a list to add several entries Paste a column of values, such as email addresses, to add several entries at once. Each line goes the way a typed entry goes: spaces at the ends removed, the app’s cleanup applied, such as one capitalization for tags, then its check. Accepted lines are added together; duplicates are identified instead of being added again. Why: You can reuse a list prepared elsewhere without entering each value on its own, and typed and pasted entries come out alike, so the list does not collect several spellings of one entry. Held by the library by itself. Where: [List field](https://grani.arseni.work/components/list-field). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#paste-many #### Keep keyboard focus where you are working You can delete several rows without finding the Delete button after each one: when a row is removed, keyboard focus moves to the same button in the row that takes its place, so Enter deletes the next one. When a message, field or panel disappears while you are using it, focus moves to a control that remains. Why: You can continue a sequence of actions from the keyboard without tabbing back into the list each time or switching to the mouse. Held by the library by itself. Where: [App shell](https://grani.arseni.work/components/app-shell), [Dropzone](https://grani.arseni.work/components/dropzone), [Reveal](https://grani.arseni.work/components/reveal), [Table](https://grani.arseni.work/components/table), [Copy button](https://grani.arseni.work/components/copy-button), [Filter list](https://grani.arseni.work/components/filter-list), [Toaster](https://grani.arseni.work/components/toaster), [List field](https://grani.arseni.work/components/list-field), [Input](https://grani.arseni.work/components/input). Tests: 18, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#focus-stays #### Open a field from its label Clicking a field’s label works as it does for a text field, where it puts the cursor in: it presses the control. A select opens its list and a dropzone its file dialog, so the label is as good a target as the field itself. Why: The label is a larger target right above the field. A click on it reaches the field instead of doing nothing. Held by the library by itself. Where: [Field](https://grani.arseni.work/components/field). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#label-opens #### Name each field once for screen readers A screen reader announces each field’s name once, followed by its hint or error. UiField connects those messages to the control they describe. Repeated text, such as a tooltip that matches an icon button’s name, is not announced twice. Why: You can understand what a field asks for and how to correct it without guessing from a placeholder or listening to the same label twice. Held by the library by itself. Where: [Field](https://grani.arseni.work/components/field), [Time picker](https://grani.arseni.work/components/time-picker), [Textarea](https://grani.arseni.work/components/textarea), [Switch](https://grani.arseni.work/components/switch), [Select](https://grani.arseni.work/components/select), [Scale input](https://grani.arseni.work/components/scale-input), [Radio group](https://grani.arseni.work/components/radio-group), [List field](https://grani.arseni.work/components/list-field), [Password field](https://grani.arseni.work/components/password-field), [Button group](https://grani.arseni.work/components/button-group), [Icon button](https://grani.arseni.work/components/icon-button), [Tooltip](https://grani.arseni.work/components/tooltip), [Stepper](https://grani.arseni.work/components/stepper), [List item](https://grani.arseni.work/components/list-item). Tests: 25, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#one-name-one-voice #### Announce required fields in words A required field shows an asterisk and is announced as required, so you can identify it visually and with a screen reader. For a group, such as radio options or a scale, the requirement is announced once with the group’s label. The app still checks that an answer has been given. Why: People who cannot see the asterisk still learn which answers they must give before they send the form. Held by the library by itself. Where: [Field](https://grani.arseni.work/components/field), [Scale input](https://grani.arseni.work/components/scale-input), [Radio group](https://grani.arseni.work/components/radio-group), [Input](https://grani.arseni.work/components/input), [Stepper](https://grani.arseni.work/components/stepper), [List item](https://grani.arseni.work/components/list-item). Tests: 8, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#required-in-words #### Describe calendar marks in words A screen reader announces each day’s full date and the meaning of its marks, such as “overdue” or “unavailable”, as well as which day is today and which days are outside the allowed range. The calendar’s colors and symbols are not the only way to learn a day’s state. Why: Someone who cannot see the dots, the strike-through or the colors still learns that a day is overdue, taken or out of range before choosing it. Held by the library by itself. Where: [Calendar](https://grani.arseni.work/components/calendar). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#state-in-words #### Announce scale steps with their meaning A screen reader announces both the position on a scale and what its endpoints mean. For example, a middle value is “7 of 10”, while the endpoints can be “1 of 10, easy” and “10 of 10, hard”. Why: You can choose an answer knowing which end means easy or hard, even when you cannot see the labels below the scale. Held by the library by itself. Where: [Scale input](https://grani.arseni.work/components/scale-input). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/filling-in#mark-says-its-word ### Search and selection Find options, filter rows and navigate long lists. Page: https://grani.arseni.work/patterns/finding #### Results match the current search In a select, a slow response to an older query cannot replace the results for newer text, and the option you chose keeps its name while the results change. In a table toolbar, a search value the app applies late cannot overwrite newer typing or undo a clear. Either way, an old response never changes what you are currently searching for. Why: You can keep searching without a slow response restoring text or results from a question you have already moved past. Held by the library by itself. Where: [Select](https://grani.arseni.work/components/select), [Table toolbar](https://grani.arseni.work/components/table-toolbar). Tests: 5, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#late-dropped #### Start a new search on the first page When you type a new search, sort by another column or change a filter, the table asks for page one, so the new results are read from their start. The pager marks page one as the current page. Why: Staying on page 4 of a new search shows the middle of its results, or an empty page that reads as no results, while the first matches wait on page one. Held by the library by itself. Where: [Data table](https://grani.arseni.work/components/data-table), [Pagination](https://grani.arseni.work/components/pagination). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#search-from-page-one #### Give every empty list a way forward An empty area explains what happened: there are no records yet, nothing matches the current search or filters, or loading failed, and it never leaves you at a dead end. It offers the next step it can, such as Clear search, Reset filters, Retry or Show all, and beside it a step of the app, such as creating what you searched for or adding the first record. Why: You can decide what to do next instead of guessing whether to wait, change a filter or reload the page. Held by the library by itself. Where: [No data](https://grani.arseni.work/components/no-data), [Data table](https://grani.arseni.work/components/data-table), [List field](https://grani.arseni.work/components/list-field), [Chart card](https://grani.arseni.work/components/chart-card), [Table](https://grani.arseni.work/components/table), [Log](https://grani.arseni.work/components/log). Tests: 14, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#empty-next-step #### Count the last day of a period whole A period from the 1st to the 31st takes in everything on the 31st, up to midnight by the app’s clock. The filter hands the app the moments its period covers, from midnight of the first day to midnight after the last, so a query never stops where the last day begins or at the server’s midnight. Why: A report for October that quietly leaves out the invoices of the 31st, or moves its edges by the server’s time zone, gives a wrong total that nobody checks. Held by the library by itself. Where: [Date range](https://grani.arseni.work/components/date-range). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#last-day-whole #### Keep “Last month” and “Today” up to date A saved period keeps the meaning of its name: opening a Last month link in October shows September. Named periods are recalculated when the page opens and when the day changes. Date-picker presets such as Today update within a minute of midnight. A custom range keeps the exact dates you chose. Why: A saved view or a shared link keeps meaning what its name says, so you do not read an old month’s figures as the latest ones. Held by the library by itself. Where: [Date range](https://grani.arseni.work/components/date-range), [Date picker](https://grani.arseni.work/components/date-picker). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#named-period-rolls #### Keep a reset from restoring the old search After Reset filters, a saved view or Back changes the search, the field shows that new value immediately. Any earlier typing still waiting for the search delay is discarded, so it cannot reapply the old filter a moment later. Why: You can trust that a reset or saved view will stay in effect instead of changing again because of text entered before it. Held by the library by itself. Where: [Table toolbar](https://grani.arseni.work/components/table-toolbar). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#outside-wins #### Keep Reset in one place Reset stays in the footer of filter and column panels. It becomes available when there is something to reset, without appearing as a new row or moving the options you are selecting. Why: You can tick several boxes in a row without a click landing on a row that was pushed into its place. Held by the library by itself. Where: [Filter](https://grani.arseni.work/components/filter), [Table columns](https://grani.arseni.work/components/table-columns). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#reset-held #### Clear a search in one step Clear a search with × or Escape and start typing another term immediately. Focus stays in the field. Inside a panel, the first Escape clears nonempty text without closing the panel; another Escape can then close it. Why: You can try one term after another without selecting and deleting the old text, or reopening a panel that Escape closed. Held by the library by itself. Where: [Filter](https://grani.arseni.work/components/filter), [Table columns](https://grani.arseni.work/components/table-columns), [List field](https://grani.arseni.work/components/list-field), [Table toolbar](https://grani.arseni.work/components/table-toolbar), [Filter list](https://grani.arseni.work/components/filter-list), [Select](https://grani.arseni.work/components/select), [Input](https://grani.arseni.work/components/input). Tests: 13, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#search-clears #### Find what you chose at the top of a long filter From six options, a filter opens with the options you already chose at the top, in their own order. The order stays while the panel is open, so a tick never moves a row under the pointer. Why: In a long list you see and change your choice at once, instead of scrolling to find the ticks. Held by the library by itself. Where: [Filter](https://grani.arseni.work/components/filter), [Table columns](https://grani.arseni.work/components/table-columns), [Filter list](https://grani.arseni.work/components/filter-list). Tests: 5, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#chosen-first #### Keep nearby buttons and fields in place Controls keep their width as their values change, so nearby controls stay in place. Filters, date fields, selects, steppers and period buttons reserve room for their widest content, and the segments of a switcher such as Day, Week, Month keep one width whichever is selected. A custom date range may still widen its button if it is wider than every named period. Why: When you make several choices in a row, the next button stays where you expect it, so a click meant for it does not land on another one. Held by the library by itself. Where: [Filter](https://grani.arseni.work/components/filter), [Date picker](https://grani.arseni.work/components/date-picker), [Radio group](https://grani.arseni.work/components/radio-group), [Stepper](https://grani.arseni.work/components/stepper), [Select](https://grani.arseni.work/components/select), [Date range](https://grani.arseni.work/components/date-range). Tests: 9, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#steady-width #### Keep the page arrows under the pointer You can click Next repeatedly without moving the mouse. When there are more pages than fit, the number of page slots stays constant, and each slot is as wide as the largest page number. The compact label, such as “3 / 20”, also keeps a steady width. Why: Without it, a quick second click can land on a page number that moved into the arrow’s place and open a page you did not ask for. Held by the library by itself. Where: [Pagination](https://grani.arseni.work/components/pagination). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#pager-holds-still #### Keep at least one column visible The column menu keeps at least one column visible. Once only one remains, its checkbox is disabled and marked “Last one”. If an older saved preference would hide every column, the first column is shown instead. Why: You can still identify the records in the table instead of seeing empty rows that look like a loading failure. Held by the library by itself. Where: [Table columns](https://grani.arseni.work/components/table-columns). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#last-column-stays #### See that a row goes on past its edge When columns or tabs do not fit, the side with more past it is marked, and with a mouse an arrow there pages to the rest: a table shades the edge where its columns slide under the row actions, tabs fade theirs. A wide table scrolls from the keyboard too, and keeps the actions of its rows in view at the end. Why: A table cut at the screen’s edge reads as complete, and the column you need, or the actions of a row, stay out of sight. Held by the library by itself. Where: [Table](https://grani.arseni.work/components/table), [Tabs](https://grani.arseni.work/components/tabs). Tests: 12, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#edge-says-more #### See how the rows are sorted Press a sortable column heading to sort by it, press again for the opposite direction, and a third time to clear the sort. An arrow shows the current direction, and a screen reader can announce it. The app applies the requested order to the rows. Why: You know which column orders the rows, and which way, before you read them, instead of comparing rows to guess. Held by the library by itself. Where: [Table](https://grani.arseni.work/components/table). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#sort-says #### Compare numbers in aligned columns You can compare amounts or counts by scanning down the column: the numbers are right-aligned in equal-width digits, so matching decimal places line up when the values share the same format and precision. Why: You can spot the largest amount or an odd one out by scanning the column, without reading each number. Held by the library by itself. Where: [Table](https://grani.arseni.work/components/table), [Data table](https://grani.arseni.work/components/data-table). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#digits-line #### Read old and new values in full See exactly what changed in a record, with the old and new values shown together. Long values wrap onto more lines so the differing characters remain visible. A single change appears on one line; several changes use a table. Why: You can review the change without opening an older version, even when two long values differ only at the end. Held by the library by itself. Where: [Log](https://grani.arseni.work/components/log). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#was-and-became #### See when each event happened Read a record’s history by day, with each event’s time shown beside it. When events use Date values, headings identify Today, Yesterday or a date, including the year for older events. Hover over a time to see the complete date and time in the app’s time zone. Why: You can distinguish recent activity from older events and find out exactly when a change happened. Held by the library by itself. Where: [Log](https://grani.arseni.work/components/log). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#exact-time #### Read the full text when a label is shortened When a name is too long for its place, it is cut to one line with an ellipsis, and hovering over it or tapping it shows the full text in a tooltip. The tooltip appears only while the text is really cut, and a screen reader always reads the full text. A number that must fit, such as the total in the middle of a donut chart, is shortened instead, and its full value stands in the legend and in the table a screen reader reads. Why: You can distinguish names that begin alike without guessing what is hidden at the end. Held by the library by itself. Where: [Truncate](https://grani.arseni.work/components/truncate), [Select](https://grani.arseni.work/components/select), [Table](https://grani.arseni.work/components/table), [Bar chart](https://grani.arseni.work/components/bar-chart), [Donut chart](https://grani.arseni.work/components/donut-chart), [Copy button](https://grani.arseni.work/components/copy-button), [Badge](https://grani.arseni.work/components/badge). Tests: 11, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#whole-text #### Notice when there are more options below A long list shows five options and part of the sixth, so you can see there is more to scroll to. You scroll inside the list to reach the rest. The list is never taller than the available screen space. Why: The partially visible row shows that the list continues, so you are less likely to miss an option below the edge. Held by the library by itself. Where: [Filter list](https://grani.arseni.work/components/filter-list), [Table columns](https://grani.arseni.work/components/table-columns), [Date range](https://grani.arseni.work/components/date-range), [Select](https://grani.arseni.work/components/select). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#five-rows #### Search once typing pauses A search is sent after a 250 ms pause in typing, not on every letter. In the table toolbar, Enter sends it immediately. Text still being composed with an input method, such as Japanese input, is not sent until composition finishes. When the response arrives, a screen reader announces how many results were found. Why: A word typed quickly produces one request instead of a request and a redraw for every letter. Held by the library by itself. Where: [Table toolbar](https://grani.arseni.work/components/table-toolbar), [Data table](https://grani.arseni.work/components/data-table), [Select](https://grani.arseni.work/components/select). Tests: 9, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#typing-asks-once #### Jump to an option by letter Find a known option by typing its first letter while the list has focus: G can take you to Germany. Repeat the letter to move to the next matching option. In a list with a search field, typing searches instead. Why: When you already know the name, you do not have to press the Down arrow once for every option above it. Held by the library by itself. Where: [Menu](https://grani.arseni.work/components/menu), [Filter](https://grani.arseni.work/components/filter), [Filter list](https://grani.arseni.work/components/filter-list), [Table columns](https://grani.arseni.work/components/table-columns), [Select](https://grani.arseni.work/components/select). Tests: 9, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#letter-jumps #### Keep one option and clear the rest in one press The highlighted option of a filter offers Only, which keeps that option chosen and clears the others. From the keyboard, Alt+Enter does the same. Why: Narrowing a list to one value takes one press instead of unticking every other box. Held by the library by itself. Where: [Filter](https://grani.arseni.work/components/filter), [Table columns](https://grani.arseni.work/components/table-columns), [Filter list](https://grani.arseni.work/components/filter-list). Tests: 5, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/finding#only-this ### Loading and feedback Show progress, explain failures and confirm actions when needed. Page: https://grani.arseni.work/patterns/waiting #### See whether an action worked When an action ends, you can tell how it went: the content changes, a short message confirms it, or an error says what went wrong and what to do next. Messages and errors are announced by a screen reader too. Why: You do not have to repeat an action, or check somewhere else, to find out whether it went through. Held by the library by itself. Where: [Toaster](https://grani.arseni.work/components/toaster), [Table](https://grani.arseni.work/components/table), [Log](https://grani.arseni.work/components/log), [List field](https://grani.arseni.work/components/list-field), [Password field](https://grani.arseni.work/components/password-field), [Dropzone](https://grani.arseni.work/components/dropzone), [Copy button](https://grani.arseni.work/components/copy-button), [Confirm](https://grani.arseni.work/components/confirm). Tests: 13, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#answered #### Ignore extra presses while a request runs After you press Send, a loading indicator shows that the request is running and further presses are ignored until it finishes. This prevents another click from starting the same action through that control. The app supplies the loading state unless the component manages the request itself. Why: You do not need to press again to check whether the first click registered, and an impatient second press does not start an extra request through the busy control. Held by the library by itself. Where: [Button](https://grani.arseni.work/components/button), [Icon button](https://grani.arseni.work/components/icon-button), [Pagination](https://grani.arseni.work/components/pagination), [Table](https://grani.arseni.work/components/table), [Log](https://grani.arseni.work/components/log), [List field](https://grani.arseni.work/components/list-field), [Dropzone](https://grani.arseni.work/components/dropzone), [Confirm](https://grani.arseni.work/components/confirm), [Menu](https://grani.arseni.work/components/menu), [Toaster](https://grani.arseni.work/components/toaster), [List item](https://grani.arseni.work/components/list-item), [Badge](https://grani.arseni.work/components/badge). Tests: 24, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#one-press #### Confirm only what cannot be undone Ask for confirmation only before an action that cannot be undone, and name the action on its button. A change the app can reverse happens immediately, with Undo offered for a limited time in a notification. The app handles the reversal; the notification provides the action that triggers it. Why: A question asked rarely gets read, so the one that matters is noticed. A reversible change is corrected with Undo instead of a question before every routine action. Held by the library by itself. Where: [Toaster](https://grani.arseni.work/components/toaster), [Confirm](https://grani.arseni.work/components/confirm). Tests: 9, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#ask-what-stays #### See the value the app actually accepted A field always shows the value the app holds, so you can tell whether an edit took effect. If the app rejects a change, the field returns to that value: a switch cannot stay on while its setting is still off. A form reset also restores the app’s current value, which is not necessarily the value saved on the server. Why: You see at once that a change did not take effect, instead of finding out later that the screen and the app disagree. Held by the library by itself. Where: [Textarea](https://grani.arseni.work/components/textarea), [Switch](https://grani.arseni.work/components/switch), [Checkbox](https://grani.arseni.work/components/checkbox), [Radio group](https://grani.arseni.work/components/radio-group), [Stepper](https://grani.arseni.work/components/stepper), [Time picker](https://grani.arseni.work/components/time-picker), [Select](https://grani.arseni.work/components/select), [Scale input](https://grani.arseni.work/components/scale-input), [List field](https://grani.arseni.work/components/list-field), [Password field](https://grani.arseni.work/components/password-field), [Input](https://grani.arseni.work/components/input), [Dropzone](https://grani.arseni.work/components/dropzone), [Code input](https://grani.arseni.work/components/code-input). Tests: 14, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#refused-goes-back #### Keep the chart when a refresh fails If a chart refresh fails, the previous chart stays on screen, and “Not updated” with Retry makes clear that new data did not arrive. A table behaves differently: a failed load replaces its rows with the error and Retry. Why: The figures you were reading stay available, with a clear sign that they are not current. Held by the library by itself. Where: [Chart card](https://grani.arseni.work/components/chart-card). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#kept-on-fail #### See upload progress and when it ends While files upload, you can see how far the upload has gone and when it is done. The file list cannot be changed during the upload. When the upload ends at 100%, each file gets a check mark and a screen reader announces the result, such as “2 files uploaded”. If it fails, the files left behind say why and offer Retry. The app supplies the progress and any failure; the dropzone shows them. Why: You can tell that the upload has started, which files are still on their way and when you can move on, instead of guessing from a single spinner. Held by the library by itself. Where: [Dropzone](https://grani.arseni.work/components/dropzone), [Progress](https://grani.arseni.work/components/progress). Tests: 8, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#upload-shown #### See where the app is working Progress appears where you expect the result. A busy button shows a spinner. A first load shows placeholders shaped like the coming content. A refresh keeps the existing content visible and dimmed, announces “Updating…”, and in a card adds one spinner over the content. A page whose layout is not yet known may show a single spinner in its center. Why: You can distinguish loading from an empty result or an ignored click, without pressing again to find out. Held by the library by itself. Where: [Spinner](https://grani.arseni.work/components/spinner), [Progress](https://grani.arseni.work/components/progress), [Card](https://grani.arseni.work/components/card), [Filter list](https://grani.arseni.work/components/filter-list), [Filter](https://grani.arseni.work/components/filter), [Badge](https://grani.arseni.work/components/badge), [Skeleton](https://grani.arseni.work/components/skeleton). Tests: 12, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#visible-work #### Keep nearby content in place while loading You can keep reading, or reach for a button, while nearby content loads. Placeholders reserve space for the expected content, media keeps its space before it arrives, and a refresh keeps the existing content in place. The final result can still shift the layout once if its size differs from the reserved space. Why: A button that shifts under the pointer is easy to miss, and a page that keeps changing shape makes you look for your reading position again. Held by the library by itself. Where: [App shell](https://grani.arseni.work/components/app-shell), [Button](https://grani.arseni.work/components/button), [Aspect ratio](https://grani.arseni.work/components/aspect-ratio), [Card](https://grani.arseni.work/components/card), [Skeleton](https://grani.arseni.work/components/skeleton), [Table](https://grani.arseni.work/components/table), [Avatar](https://grani.arseni.work/components/avatar), [Chart card](https://grani.arseni.work/components/chart-card), [Data table](https://grani.arseni.work/components/data-table), [Select](https://grani.arseni.work/components/select), [Log](https://grani.arseni.work/components/log), [Crud drawer](https://grani.arseni.work/components/crud-drawer). Tests: 21, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#no-jolt #### Search while the table stays in place You can keep typing while the table loads your search. Placeholder rows immediately replace the previous results, preserving their space and at least the height of an empty or error message. Sorting or paging within the same search keeps the existing rows visible and dimmed. Keyboard focus and your text stay in the search field throughout. Why: The table does not collapse with every letter, and old results are not mistaken for the answer to the new query. Its height may change once, when the new result arrives. Held by the library by itself. Where: [Data table](https://grani.arseni.work/components/data-table), [Table](https://grani.arseni.work/components/table). Tests: 8, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#still-list #### Keep reading as older events load Older events are appended below the ones already shown, so loading more history does not send you back to the top. If “Load older events” had keyboard focus and disappears because no older events remain, focus moves to the first new event, and a screen reader announces how many arrived. Why: You can continue from the newly loaded events instead of finding where you left off after the loading button disappears. Held by the library by itself. Where: [Log](https://grani.arseni.work/components/log). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#keep-place #### Keep counts short and in place A count stays short: above the configured maximum, it is shortened to a value such as 99+, so it never grows past that. A value that is not a number is hidden, and a negative one counts as 0. An attention indicator disappears at zero; a total beside a label can still show 0. Why: You can tell what needs attention without oversized counts, meaningless “NaN” values or a notification marker showing zero. Held by the library by itself. Where: [Indicator](https://grani.arseni.work/components/indicator), [Count](https://grani.arseni.work/components/count), [Tabs](https://grani.arseni.work/components/tabs). Tests: 12, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#compact-counts #### Keep turning pages from the keyboard You can keep paging from the keyboard without finding Next again after every load: the arrow you pressed keeps keyboard focus while the page loads. When that arrow reaches the end and becomes disabled, focus moves to the current page number or the compact page label. In a calendar, a month arrow that reaches its limit passes focus to the month heading, and a period’s arrow to the period button. Why: You can read a long list, or step through months, without returning to the mouse after every step. Held by the library by itself. Where: [Pagination](https://grani.arseni.work/components/pagination), [Calendar](https://grani.arseni.work/components/calendar), [Date range](https://grani.arseni.work/components/date-range). Tests: 6, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#pager-keeps-focus #### Announce a new password without reading it When a generated password is accepted by the app, a screen reader confirms “New password generated” without reading the password itself. Generate another one and the confirmation is announced again. Why: You get confirmation that generation worked without the password being read out where others can hear it. Held by the library by itself. Where: [Password field](https://grani.arseni.work/components/password-field). Tests: 1, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#made-is-not-spoken #### Reduce motion without hiding loading With the system’s reduced-motion setting enabled, sections open without sliding and attention indicators stop pulsing. Loading indicators keep moving, more slowly: spinners still turn and placeholders still shimmer. Why: Decorative movement is removed, but you can still see that loading is in progress. Held by the library by itself. Where: [Reveal](https://grani.arseni.work/components/reveal), [Spinner](https://grani.arseni.work/components/spinner), [Indicator](https://grani.arseni.work/components/indicator), [Progress](https://grani.arseni.work/components/progress), [Skeleton](https://grani.arseni.work/components/skeleton). Tests: 5, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/waiting#calm-motion ### Working with records Select rows, apply actions and move between records. Page: https://grani.arseni.work/patterns/working-the-list #### Land on a page with records after a delete When you delete the last record on the last page and other pages remain, the table requests the previous page, so you continue with the remaining records instead of an empty page. If the current page no longer exists for another reason, the table waits for loading to finish and then requests the new last page. The app loads the page the table requests. Why: You can go on through the remaining records without meeting an empty page that looks like an empty list. Held by the library by itself. Where: [Data table](https://grani.arseni.work/components/data-table), [Pagination](https://grani.arseni.work/components/pagination). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#last-page-lands #### Show missing data as a gap A missing value appears as a gap in a chart and a dash in a table, never as zero. A line breaks where data is missing and resumes at the next value, a bar leaves a gap or a gray stub, and a cell shows “—”. A real zero keeps its own representation, so the two look different. Why: A day with no data does not read as a day when nothing sold, so the chart does not show a fall that never happened. Held by the library by itself. Where: [Sparkline](https://grani.arseni.work/components/sparkline), [Table](https://grani.arseni.work/components/table), [Data table](https://grani.arseni.work/components/data-table), [Area chart](https://grani.arseni.work/components/area-chart), [Bar chart](https://grani.arseni.work/components/bar-chart). Tests: 11, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#gap-not-zero #### Show when a reporting period is still incomplete A month still in progress is marked as a partial result: its bar is hatched or its line segment dashed, and the chart labels it “in progress”. This lets you tell the figures collected so far from a completed month before comparing them. Why: Half a month’s total looks much lower than a full month’s. The marker keeps that difference from being read as a decline. Held by the library by itself. Where: [Bar chart](https://grani.arseni.work/components/bar-chart), [Area chart](https://grani.arseni.work/components/area-chart). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#partial-not-drop #### Show a shrinking loss as an improvement A balance that goes from −100 to −50 has improved, and it is shown that way: +50%, better. The percentage is calculated against the absolute value of the previous figure, so moving toward zero from a loss is shown as an increase. Why: The sign and the assessment agree: a smaller loss reads as an improvement, not as a decline. Held by the library by itself. Where: [Delta](https://grani.arseni.work/components/delta). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#loss-shrinks #### Act on several selected rows at once Select several records to reveal actions for the whole selection. A strip replaces the toolbar, states the count, such as “2 selected”, and offers the actions supplied by the app plus a way to clear the selection. Why: You can apply one change to many records in one step, instead of repeating it row by row. Held by the library by itself. Where: [Data table](https://grani.arseni.work/components/data-table). Tests: 2, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#bulk-action #### Edit a value in its row Offer an editor within a table row for small changes such as a status or amount. Make Save, Cancel and any correction needed after an error clear, so the task can be completed without opening a separate form. Why: You can correct one value and continue down the list without navigating into a full form and back. Not built yet: until it is, the app does it. #### Show when only some are selected See whether a selection is partial before acting on it. The select-all checkbox shows a dash when some items are selected, and a screen reader announces a mixed state. In a table, activating it selects all selectable rows on the current page; activating it when all are selected clears them. Why: You can tell a partial selection from a full one, or from none, before you act on the selected rows. Held by the library by itself. Where: [Checkbox](https://grani.arseni.work/components/checkbox), [Table](https://grani.arseni.work/components/table). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#mixed-says-some #### Copy a whole value in one press Copy an entire id or link with one press, even when only a shortened version is displayed. A brief check mark confirms a successful copy. Why: You get the exact value without selecting it by hand, so no character is missed and no ellipsis is copied with it. Held by the library by itself. Where: [Copy button](https://grani.arseni.work/components/copy-button). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#copy-value #### Remember hidden columns in this browser Hide unnecessary columns once and return to the same table layout on later visits in this browser. With the default browser storage, a change in another tab is also reflected here. Why: You set a table up for your work once, instead of hiding the same columns on every visit. Held by the library by itself. Where: [Table columns](https://grani.arseni.work/components/table-columns). Tests: 3, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#columns-remembered #### Return to the list where you were Return from a record to the same part of the list. The app should restore the filters, page and scroll position so you can continue reviewing the rows you left. Why: You can go through a list record by record without finding your place again after each one. Held by the app that uses it. Where: in the app's own screens; no component of the library holds it by itself. #### Find records by the details you remember Search using a detail you remember, such as a phone number, email address, invoice number or client’s company. The app should search the relevant fields and related records, and explain which details its search accepts. Why: You can find a record without remembering its exact title, and understand when a different search is needed. Held by the app that uses it. Where: in the app's own screens; no component of the library holds it by itself. #### Reload or share a list as you see it The search, the filters, the sort and the page are written into the page’s address, so a reload, a link sent to a colleague or the browser’s Back opens the list as you saw it. Why: A colleague can open the same query, and you can return to it later without setting the filters again. The matching records may change as the underlying data changes. Held by the library by itself. Where: [Data table](https://grani.arseni.work/components/data-table). Tests: 8, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#list-link #### Share a link to the open tab A link should open the same tab you were viewing and preserve that choice after a reload. The app enables this by storing the active tab in the page address and restoring it when the page opens. Why: Someone who opens your link lands on the section you meant, not on the first tab. Held by the app that uses it. Where: [Tabs](https://grani.arseni.work/components/tabs). #### Open a record from a link Give each record a direct link so someone with access can open it without searching. The app provides the address and loads the corresponding record. Why: A link points to exactly one record, so nobody has to search for it or guess which of two similar records was meant. Held by the app that uses it. Where: in the app's own screens; no component of the library holds it by itself. #### Open the records behind a number Make a count such as “12 overdue invoices” open the records it represents. The app applies the corresponding filters so you can inspect the records or act on them. Why: You can check a number, or act on the records behind it, without rebuilding its filters by hand. Held by the app that uses it. Where: in the app's own screens; no component of the library holds it by itself. #### Open a related record inside the drawer Explore related information without leaving the record you started from. A row with a chevron leads to the related view, which the app shows inside the same drawer as a level of its own. Back names the previous level and returns to it, with focus on the row that opened the related view. Why: You can inspect related records and return to the original task without reopening its drawer or re-entering the draft kept at the previous level. Held by the library by itself. Where: [Crud drawer](https://grani.arseni.work/components/crud-drawer), [List item](https://grani.arseni.work/components/list-item). Tests: 5, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#open-related #### Save filters you use often Save a frequently used set of filters under a name, then choose that name to apply them again. The app stores and restores the view; the records it returns can change over time. Why: A weekly query becomes one choice instead of several filters to rebuild, while preserving the same selection criteria. Held by the app that uses it. Where: in the app's own screens; no component of the library holds it by itself. #### Choose which records to export Before exporting, offer a clear choice between the selected rows and all records matching the current search and filters, including records on other pages. Why: The file holds the set you meant, and you do not have to delete stray records from it afterwards. Not built yet: until it is, the app does it. #### Create a related record from the form If a required related record is missing, offer a way to create it from the current form. For example, add a new client while preparing an invoice, then return to the invoice with its draft intact. Why: You finish the record you started, instead of leaving the form, creating the missing record elsewhere and typing everything again. Not built yet: until it is, the app does it. #### Start a new record with known values When the current task already identifies a value, prefill it in the new record. For example, an invoice created from a client’s page can start with that client selected. The app supplies the known values and lets you review them before saving. Why: You can review information the app already knows instead of re-entering it, reducing typing and accidental mistakes. Held by the app that uses it. Where: in the app's own screens; no component of the library holds it by itself. #### Start a record from a copy For a record similar to an existing one, let the app copy reusable values into a new draft. Review and change that draft before saving it as a separate record. Why: A record much like an existing one takes a few edits instead of every value typed again. Held by the app that uses it. Where: in the app's own screens; no component of the library holds it by itself. #### Keep your browser’s keyboard shortcuts Shortcuts you know from the browser and the system keep working inside the controls. The arrow keys move through a calendar, a list, tabs or a number, but with Ctrl, Cmd or Alt held the key goes to the browser: Alt+Left still goes back a page, and Cmd+Up still moves the text cursor to the start of a field. Alt+Down opens a list, as it does on a native select. Why: People who work from the keyboard use these shortcuts without thinking; a control that takes one over changes a number or a date instead, and the shortcut seems broken in that one place. Held by the library by itself. Where: [Input](https://grani.arseni.work/components/input), [List field](https://grani.arseni.work/components/list-field), [Calendar](https://grani.arseni.work/components/calendar), [Select](https://grani.arseni.work/components/select), [Tabs](https://grani.arseni.work/components/tabs), [Code input](https://grani.arseni.work/components/code-input), [Date picker](https://grani.arseni.work/components/date-picker), [Time picker](https://grani.arseni.work/components/time-picker), [Menu](https://grani.arseni.work/components/menu), [Scale input](https://grani.arseni.work/components/scale-input), [Area chart](https://grani.arseni.work/components/area-chart). Tests: 16, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#shortcuts-kept #### Select or clear a range of rows Select a group of neighboring rows without checking each one: check row 3, then hold Shift and check row 10, and rows 3 through 10 are selected. From the keyboard, Shift+Space on the checkbox does the same. Clearing a checkbox the same way clears the range. Why: Two checkbox actions select a whole range, so preparing a bulk action does not take a click per row. Held by the library by itself. Where: [Table](https://grani.arseni.work/components/table), [Data table](https://grani.arseni.work/components/data-table). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#range-select #### Search by a pasted list of values Provide a way to paste several values, such as order numbers from a spreadsheet, and use the accepted entries together as search or filter criteria. Why: You can work with a prepared set of records without entering and checking each search value separately. Not built yet: until it is, the app does it. #### Filter by a value in a cell Use a value already visible in a table as the next filter. For example, offer an action on a client’s name to show that client’s records without retyping the name. The application must connect the action to its filtering behavior. Why: The value in front of you is often the next thing to search by, and retyping it costs time and invites typos. Not built yet: until it is, the app does it. #### Reach frequent actions from the keyboard Use Ctrl+B or Cmd+B to collapse or expand the sidebar. Use F8 to reach the newest notification’s action, or its close button if it has no action; press F8 again to return. Press / to start a search in the list on the page. The shortcuts do not run while you are typing in a field or editor. Why: You can reach a notification’s Undo before it disappears and use common navigation actions without moving to the mouse. Held by the library by itself. Where: [App shell](https://grani.arseni.work/components/app-shell), [Table toolbar](https://grani.arseni.work/components/table-toolbar), [Toaster](https://grani.arseni.work/components/toaster). Tests: 15, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#keyboard-actions #### Reopen a recent record Keep recently opened records easy to return to. The app should provide a short recent-records list so you can reopen one directly instead of searching again. Why: You can pick up interrupted work without repeating the search that found the record. Held by the app that uses it. Where: in the app's own screens; no component of the library holds it by itself. #### Pin the records you use often Let people pin records they use regularly and reopen them from a persistent list. The app manages the pins and the list they appear in. Why: Records you come back to every day stay one step away, however long the lists around them grow. Held by the app that uses it. Where: in the app's own screens; no component of the library holds it by itself. #### Step through records in one drawer Review successive records without closing the drawer. Previous record and Next record ask for the neighboring record of the current list while the drawer stays open. While there is unsaved input or a save is running, they refuse with a shake instead of leaving your input behind. Why: You can go through a run of records, such as checking each new order, without closing and reopening the drawer for each one. Held by the library by itself. Where: [Crud drawer](https://grani.arseni.work/components/crud-drawer). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#walk-records #### Save and start the next record Choose Save and add another to continue entering records in the same drawer. After a successful save, the app clears the form and keeps the drawer open. Focus moves to the first field, ready for the next entry. A failed save keeps the current input for correction or retry. Why: You can enter a batch without reopening the form after every successful save. Held by the library by itself. Where: [Crud drawer](https://grani.arseni.work/components/crud-drawer). Tests: 4, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#save-and-new #### Read every chart value from the keyboard Tab to a chart, then use the arrow keys, Home and End to explore its values. A screen reader announces the selected point and its data. A separate data table is also available to screen readers, including values that would otherwise appear only in a hover tooltip. Why: Someone on a keyboard or a screen reader gets the same numbers as someone with a mouse, not only the shape of the line. Held by the library by itself. Where: [Area chart](https://grani.arseni.work/components/area-chart), [Bar chart](https://grani.arseni.work/components/bar-chart), [Donut chart](https://grani.arseni.work/components/donut-chart). Tests: 6, named in patterns/tests.md beside this chapter Link: https://grani.arseni.work/patterns/working-the-list#values-by-keys