Data display
Containers and readouts: the card every panel is built from, and the components that put numbers, rows and progress in front of someone who has to make a decision from them.
Card
The container everything else sits in. Give it a title and it grows a header; fill the footer slot and it grows a footer. The header slot wins over the title prop, so a custom header never means clearing a prop first. Set padding="none" for content that should reach the edges, like a table.
Header, body, footer
Sales
This month
Rp 42.500.000 across 318 tickets.
A bare card — no header, no footer, raises on hover.
<UiCard title="Sales" subtitle="This month">| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | undefined | Heading in the card header. |
| subtitle | string | undefined | Muted line below the title. |
| padding | 'none' | 'sm' | 'md' | 'lg' | 'md' | Body padding. Use none for tables and charts that reach the edge. |
| bordered | boolean | true | Outer border. |
| hoverable | boolean | false | Raises the shadow on hover — for cards that are clickable. |
DataTable
A toolbar layer on top of Table: a search box, select filters, and CSV export. Sort/page/selection state still lives in Table — DataTable doesn't copy it. Two deliberate decisions: export always takes the rows currently visible after filtering, not the whole dataset — silently exporting the entire database is a leak, not a feature — and for every filter, the value '' means "all", not "the empty one".
Search, filter, export, and select rows
| Code | Passenger | Route | Status | |
|---|---|---|---|---|
| AB-1201 | Budi Santoso | Jakarta — Surabaya | paid | |
| AB-1202 | Ani Rahmawati | Bandung — Yogyakarta | pending | |
| AB-1203 | Citra Dewi | Jakarta — Semarang | cancelled | |
| AB-1204 | Dedi Kurniawan | Surabaya — Malang | paid | |
| AB-1205 | Eka Putri | Jakarta — Bandung | pending |
- Code
- AB-1201
- Passenger
- Budi Santoso
- Status
- paid
- Code
- AB-1202
- Passenger
- Ani Rahmawati
- Status
- pending
- Code
- AB-1203
- Passenger
- Citra Dewi
- Status
- cancelled
- Code
- AB-1204
- Passenger
- Dedi Kurniawan
- Status
- paid
- Code
- AB-1205
- Passenger
- Eka Putri
- Status
- pending
Selected: none yet. Filter to "Cancelled" then press Export — only the visible rows are sent to @export and to the CSV.
<UiDataTable :columns :rows row-key="id" :filters :search-keys="['code', 'passenger']" exportable selectable />Manual mode — server does the filtering
| Code | Passenger | Route | Status |
|---|---|---|---|
| AB-1201 | Budi Santoso | Jakarta — Surabaya | paid |
| AB-1202 | Ani Rahmawati | Bandung — Yogyakarta | pending |
| AB-1203 | Citra Dewi | Jakarta — Semarang | cancelled |
| AB-1204 | Dedi Kurniawan | Surabaya — Malang | paid |
| AB-1205 | Eka Putri | Jakarta — Bandung | pending |
- Code
- AB-1201
- Passenger
- Budi Santoso
- Status
- paid
- Code
- AB-1202
- Passenger
- Ani Rahmawati
- Status
- pending
- Code
- AB-1203
- Passenger
- Citra Dewi
- Status
- cancelled
- Code
- AB-1204
- Passenger
- Dedi Kurniawan
- Status
- paid
- Code
- AB-1205
- Passenger
- Eka Putri
- Status
- pending
In manual mode, DataTable doesn't filter anything itself — it only emits v-model:search and @filter-change so the caller can send them to the API and fill :rows/:total from the server's response. Search box above: "(empty)".
<UiDataTable manual :total="total" @update:search @filter-change />| Prop | Type | Default | Description |
|---|---|---|---|
| columns / rows / rowKey | TableColumn[] / T[] / string | fn | — | Same as Table — passed through as-is. |
| filters | DataTableFilter[] | undefined | { key, label, options, allLabel? }. One native <select> per filter. |
| searchKeys | string[] | [] | Columns included in Table's free-text search. |
| searchPlaceholder / searchLabel | string | 'Search…' / 'Search table' | searchLabel becomes the aria-label of the search box. |
| exportable / exportFileName | boolean / string | false / 'export.csv' | Shows the Export button and its download file name. |
| @export | (rows: T[]) => void | — | The rows CURRENTLY VISIBLE after filtering — not the entire props.rows. |
| manual + total | boolean + number | false | The component stops filtering on its own; it only emits v-model:search & @filter-change for the caller to send to the API. |
| @filter-change | (values: FilterValues) => void | — | Emitted every time a filter changes, including back to "all". |
| selectable / expandable / paginate / … | boolean | see Table | Passed straight through to the Table underneath, along with its five v-models. |
List
A ranked list for dashboard widgets: best-selling routes, agents with the highest commission, anything that needs sorting and quick comparison. The proportion bar is measured against the largest entry, not against the total of all entries — if measured against the total, ten balanced entries would each show a tiny 10% bar that's arithmetically correct but useless to look at. The top row is always full.
Best-selling routes
- 1
Jakarta — Surabaya
214 - 2
Bandung — Yogyakarta
156 - 3
Jakarta — Semarang
98 - 4
Surabaya — Malang
12
<UiList :items="rutes" show-rank />Agents with avatar and formatted value
<UiList :items="agen" show-avatar :format-value="formatMoney" clickable @select="..." />Empty
No sales yet this month
<UiList :items="[]" empty-text="No data yet" />| Prop | Type | Default | Description |
|---|---|---|---|
| items | ListItem[] | — | Required. { id, label, value, sublabel?, src?, icon?, tone? } |
| showBar | boolean | true | Proportion bar under the label. |
| showRank | boolean | false | Rank number on the left of each row. |
| showAvatar | boolean | false | Avatar from name/src replaces the icon. |
| clickable | boolean | false | Wraps the row in a <button>, emits @select. |
| dense | boolean | false | Tighter spacing between rows. |
| emptyText | string | 'Nothing to show' | Text shown when items is empty. |
| formatValue | (value: number) => string | undefined | Without this, value is rendered as-is — it's not the component's job to guess currency or unit. |
| @select | (item: ListItem) => void | — | Only emitted when clickable. |
Rating
Stars that are either read-only (supports half stars) or fillable (whole values only). Half stars are deliberately limited to readonly mode — a mouse can't honestly click half a star, so interactive mode only offers whole choices from one up to max.
Fillable
<UiRating v-model="nilai" show-value clearable />Readonly summary
<UiRating :model-value="4.5" readonly :count="212" />Disabled
<UiRating :model-value="2" disabled />| Prop | Type | Default | Description |
|---|---|---|---|
| modelValue | number | 0 | v-model. Whole number for interactive mode, decimals allowed (e.g. 3.5) for readonly. |
| max | number | 5 | Number of stars. |
| readonly | boolean | false | Display only, supports half stars. Not a control. |
| disabled | boolean | false | Still a control, but ignores clicks/keyboard. |
| clearable | boolean | false | Clicking an already-active star clears the value to 0. |
| size | number | 20 | Star icon size (px). |
| count | number | undefined | Review count, shown next to the stars when set. |
| label | string | 'Rating' | aria-label for the radio group in interactive mode. |
| showValue | boolean | false | Shows the numeric value next to the stars. |
StatCard
One number, its label, and how it moved. Two details it refuses to guess: a delta of 0 renders flat rather than green, and whether up is good is declared with invertDelta — cancellations rising 12% is not good news, and no component can infer that from a metric's name.
A dashboard row
Tickets sold
318
+12%vs last week
Revenue
Rp 42,5jt
+4%vs last week
Cancellations
7
+12%vs last week
Occupancy
86%
0%unchanged
Loading
Buses on the road
12
<UiStatCard label="Tickets sold" :value="318" :delta="12" />| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Required. What the number counts. |
| value | string | number | undefined | The number itself, already formatted. |
| delta | number | undefined | Percent change. 0 renders as flat, not up. |
| deltaLabel | string | undefined | What the change is measured against. |
| icon | string | undefined | Decorative icon in the tinted square. |
| tone | 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Icon square colour. |
| invertDelta | boolean | false | Up is bad. Use for cancellations, refunds, complaints. |
| loading | boolean | false | Shows a placeholder instead of the value. |
Table
All of the state lives in useDataTable, so the rules that are easy to get wrong are settled once and tested without a DOM: empty cells sink to the bottom in both sort directions, select-all touches only the rows that survived the filter, selection is stored by row key rather than index, and the page clamps itself when a filter shrinks the data instead of leaving you staring at an empty table.
Sortable, selectable, expandable
| Route | Status | |||||
|---|---|---|---|---|---|---|
| AB-1201 | Budi Santoso | Jakarta — Surabaya | 2 | Paid | ||
| AB-1202 | Ani Rahmawati | Bandung — Yogyakarta | 10 | Pending | ||
| AB-1203 | Citra Dewi | Jakarta — Semarang | Cancelled |
- Code
- AB-1201
- Passenger
- Budi Santoso
- Seats
- 2
- Status
- Paid
- Code
- AB-1202
- Passenger
- Ani Rahmawati
- Seats
- 10
- Status
- Pending
- Code
- AB-1203
- Passenger
- Citra Dewi
- Seats
- Status
- Cancelled
Selected: nothing yet. Sort the Seats column twice — the cancelled booking with no seat stays at the bottom either way.
<UiTable :columns="columns" :rows="rows" row-key="id" selectable expandable paginate />Sticky header
| Route | Status | |||
|---|---|---|---|---|
| AB-1201 | Budi Santoso | Jakarta — Surabaya | 2 | Paid |
| AB-1202 | Ani Rahmawati | Bandung — Yogyakarta | 10 | Pending |
| AB-1203 | Citra Dewi | Jakarta — Semarang | Cancelled | |
| AB-1204 | dedi Kurniawan | Surabaya — Malang | 1 | Paid |
| AB-1205 | Eka Putri | Jakarta — Bandung | 4 | Paid |
| AB-1201 | Budi Santoso | Jakarta — Surabaya | 2 | Paid |
| AB-1202 | Ani Rahmawati | Bandung — Yogyakarta | 10 | Pending |
| AB-1203 | Citra Dewi | Jakarta — Semarang | Cancelled | |
| AB-1204 | dedi Kurniawan | Surabaya — Malang | 1 | Paid |
| AB-1205 | Eka Putri | Jakarta — Bandung | 4 | Paid |
| AB-1201 | Budi Santoso | Jakarta — Surabaya | 2 | Paid |
| AB-1202 | Ani Rahmawati | Bandung — Yogyakarta | 10 | Pending |
| AB-1203 | Citra Dewi | Jakarta — Semarang | Cancelled | |
| AB-1204 | dedi Kurniawan | Surabaya — Malang | 1 | Paid |
- Code
- AB-1201
- Passenger
- Budi Santoso
- Seats
- 2
- Status
- Paid
- Code
- AB-1202
- Passenger
- Ani Rahmawati
- Seats
- 10
- Status
- Pending
- Code
- AB-1203
- Passenger
- Citra Dewi
- Seats
- Status
- Cancelled
- Code
- AB-1204
- Passenger
- dedi Kurniawan
- Seats
- 1
- Status
- Paid
- Code
- AB-1205
- Passenger
- Eka Putri
- Seats
- 4
- Status
- Paid
- Code
- AB-1201
- Passenger
- Budi Santoso
- Seats
- 2
- Status
- Paid
- Code
- AB-1202
- Passenger
- Ani Rahmawati
- Seats
- 10
- Status
- Pending
- Code
- AB-1203
- Passenger
- Citra Dewi
- Seats
- Status
- Cancelled
- Code
- AB-1204
- Passenger
- dedi Kurniawan
- Seats
- 1
- Status
- Paid
- Code
- AB-1205
- Passenger
- Eka Putri
- Seats
- 4
- Status
- Paid
- Code
- AB-1201
- Passenger
- Budi Santoso
- Seats
- 2
- Status
- Paid
- Code
- AB-1202
- Passenger
- Ani Rahmawati
- Seats
- 10
- Status
- Pending
- Code
- AB-1203
- Passenger
- Citra Dewi
- Seats
- Status
- Cancelled
- Code
- AB-1204
- Passenger
- dedi Kurniawan
- Seats
- 1
- Status
- Paid
14 rows in a box capped at max-h-96 (the maxHeight prop) — scroll it and the header stays put. Without that height limit, stickyHeader cannot do anything: the table's own horizontal-scroll wrapper would become the nearest scroll container instead, and it never scrolls on its own.
<UiTable :columns="columns" :rows="rows" row-key="id" sticky-header />Empty and loading
| Route | Status | |||
|---|---|---|---|---|
No bookings yetSales made by your agents will show up here. | ||||
| Route | Status | |||
|---|---|---|---|---|
<UiTable :rows="[]" empty-title="No bookings yet" />One deliberate limit:stickyFirstColumn freezes the first column and only the first. Arbitrary sticky columns need a left-offset engine that is fragile and easy to get a pixel wrong, and the real need is almost always a single identity column on the left. On narrow screens the table becomes a card list instead — the same rows, drawn as label and value pairs, chosen by a CSS breakpoint rather than by measuring the window, because measurement has no answer while the page is rendering on the server.
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | TableColumn[] | — | Required. { key, label, sortable?, align?, width?, hideOnMobile? } |
| rows | T[] | — | Required. The data. |
| rowKey | string | ((row) => string | number) | — | Required. Identity of a row — indexes shift the moment you sort. |
| sort | SortState | null | null | Two-way with v-model:sort. Click cycles asc → desc → none. |
| selected | (string | number)[] | [] | Two-way with v-model:selected. Holds row keys. |
| expanded | (string | number)[] | [] | Two-way with v-model:expanded. |
| search | string | '' | Two-way. Table stores it; DataTable draws the search box. |
| searchKeys | string[] | [] | Columns the free-text search looks at. |
| selectable / expandable | boolean | false | Adds the checkbox column / the expand column. |
| paginate + perPage | boolean + number | false + 10 | Slices rows and shows the pager. |
| stickyHeader | boolean | false | Freezes the header row while the body scrolls. Needs maxHeight — see below. |
| maxHeight | string | 'max-h-96' | Tailwind max-h-* class for the scroll wrapper. Only applied when stickyHeader is on — a sticky header needs a bounded height to scroll against. |
| stickyFirstColumn | boolean | false | Freezes the first column only — see the note below. |
| dense / loading | boolean | false | Tighter rows / spinner instead of the empty state. |
| manual + total | boolean + number | false | Server-side mode: the table stops sorting and slicing, and reports state instead. |
| mobileCards | boolean | true | Renders a card list below the sm breakpoint. |
| @row-click | (row: T) => void | — | Checkbox and expand clicks do not trigger it. |
Timeline
A vertical history of events — connecting line, tone-colored markers, time, and free-form content per event. The last event deliberately doesn't grow a line downward; a line dangling at the end makes the list look cut off, as if there's still more to load.
Ticket booking history
Order created
08:10By Bandung agent
Paid
08:12QRIS · Rp 185,000
Checked in
19:40Seat 4A, Leuwipanjang Terminal
Departed
20:00
<UiTimeline :items="pemesanan" />Dense
Order created
08:10By Bandung agent
Paid
08:12QRIS · Rp 185,000
Checked in
19:40Seat 4A, Leuwipanjang Terminal
Departed
20:00
<UiTimeline :items="pemesanan" dense />No history yet
No history yet
<UiTimeline :items="[]" empty-text="No history yet" />| Prop | Type | Default | Description |
|---|---|---|---|
| items | TimelineItem[] | — | Required. Ordered from oldest to newest event. |
| dense | boolean | false | Tightens the spacing between events. |
| emptyText | string | 'Nothing recorded yet' | Text shown when items is empty. |
Tree
The tree is rendered flat, not nested — each row is a sibling <li role="treeitem"> carrying its own aria-level, the second valid form per the ARIA Authoring Practices. The alternative, recursively nested components, forces focus tracking to jump across component boundaries — two sources of bugs for zero benefit. Focus uses roving tabindex like Tabs: exactly one row has tabindex="0", the rest -1", and the arrow keys actually move it with .focus(), not just a visual highlight.
Province → city → terminal region
- Jawa Barat
- Bandung
- Terminal Leuwipanjang
- Terminal Cicaheum
- Bogor
- Jawa Tengah
<UiTree :nodes="wilayah" v-model:expanded="dibuka" aria-label="Operational region" />Controlled via v-model:selected
- Jawa Barat
- Jawa Tengah
Selected: none yet
"Terminal Tirtonadi" is marked disabled — visible but cannot be selected or receive keyboard focus.
<UiTree :nodes="wilayah" v-model:selected="terpilih" />| Key | Action |
|---|---|
| ↓ | Move to the next visible row. |
| ↑ | Move to the previous visible row. |
| → | Open a closed node; if already open, move to the first child. |
| ← | Close an open node; if already closed, move to the parent. |
| Home | Jump to the first visible row. |
| End | Jump to the last visible row. |
| Enter | Select the currently focused node. |
| Prop | Type | Default | Description |
|---|---|---|---|
| nodes | TreeNode[] | — | Required. { id, label, icon?, disabled?, children? }. |
| ariaLabel | string | undefined | Accessible name for the tree — set it when there's more than one tree on the page. |
| expanded | string[] | [] | Two-way via v-model:expanded. List of currently open node ids. |
| selected | string | null | null | Two-way via v-model:selected. Id of the selected node. |
| @select | (node: TreeNode) => void | — | Emitted when Enter or a click selects a node that is not disabled. |
| #item | { node, level, expanded } | — | Replaces the content of a single row; the open/close chevron stays default. |