Utilities
None of these show up on every page, but they decide whether the app feels finished: a print layout that actually produces a filled page instead of a blank one, an import dialog that never lies about a clean import, and a chat widget that survives being closed mid-typing.
ChatWidget
A floating conversation bubble, not a screen-centered overlay like Modal. Still a dialog: ESC and clicking outside the panel close it the same as other overlays, but it stays pinned to the corner so users can keep browsing the page while the chat is open. Enter sends a message, Shift+Enter inserts a new line.
Conversation widget
Same as Drawer and Toast, this component is pinned to the page's real viewport — look for its bubble in the bottom-right corner of the window, not inside this example box.
<UiChatWidget v-model:open="open" v-model:draft="draft" :messages="messages" :typing="typing" :unread="unread" @send="onSend" @retry="onRetry" />| Prop | Type | Default | Description |
|---|---|---|---|
| messages | ChatMessage[] | [] | Required. Conversation history, ordered oldest to newest. |
| title | string | 'Support' | Panel header title and the dialog's accessible name. |
| typing | boolean | false | Shows a typing indicator with aria-live text. |
| unread | number | 0 | Badge on the floating button. Only visible while the panel is closed. |
| placeholder | string | 'Type a message…' | Placeholder for the draft box. |
| agentName | string | undefined | Name used by the avatar and the "typing" text. |
| agentAvatar | string | undefined | URL of the agent's avatar photo. |
| v-model:open | boolean | false | Panel open/closed state. |
| v-model:draft | string | '' | The text the user is currently typing. |
Import dialog
Four steps — upload → preview → map → summary — over one parsing engine in utils/importer.ts. The parser is a character-by-character state machine, not split: real export files are full of quoted cells that contain the delimiter, and split corrupts them silently. The summary step below never lies about a clean import — it always shows how many rows failed and exactly why.
Upload, map, and see what would fail
<UiImportDialog v-model:open="open" :fields="fields" @import="onImport" />| Prop | Type | Default | Description |
|---|---|---|---|
| fields | ImportField[] | — | Required. Destination columns: { key, label, required?, type? }. |
| open | boolean | false | Two-way, written as v-model:open. Closing clears every derived step. |
| title | string | 'Import data' | Modal heading. |
| accept | string | '.csv,.txt' | Forwarded to the file picker. |
| initialText | string | undefined | Testing/docs escape hatch only — skips File entirely. |
| @import | (rows: Record<string, string>[]) => void | — | Fires on the summary step. Carries ONLY the rows that passed validation. |
| @cancel | () => void | — | Fires when the dialog is dismissed without importing. |
PrintLayout
A skeleton for print documents. assets/css/print.css hides the app chrome via visibility, not display: none on the parent — a display-none parent takes its children with it, and the app chrome's h-screen overflow-hidden pattern cuts the print document down to one blank page. That failure has already happened twice in ERPs using the same pattern; the :has() selector as a patch produced yet another blank page. PrintLayout only marks its root via data-print-root — the rest of the app chrome hides itself automatically, without touching each piece one by one.
A4/Letter ticket
Bus e-ticket
TKT-2026-00042
Route: Bandung — Jakarta
Departure 05:30 · Seat 12A · Passenger Budi Santoso
Aresa Executive fleet · Leuwipanjang pool
Printed via the operator panel, not an official proof of payment.
<UiPrintLayout paper="a4" title="Bus e-ticket" document-number="TKT-2026-00042">
<template #header>Route: Bandung — Jakarta</template>
<p>Departure 05:30 · Seat 12A · Passenger Budi Santoso</p>
<template #footer>Printed via the operator panel, not an official proof of payment.</template>
</UiPrintLayout>80mm thermal receipt
ARESA BUS
TKT-2026-00042
Bandung → Jakarta · 05:30
Seat 12A
<UiPrintLayout paper="thermal" :show-print-button="false">…</UiPrintLayout>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| paper | 'a4' | 'letter' | 'thermal' | 'a4' | Print page size via a named @page — not size: auto. |
| title | string | undefined | Document title, shown once at the top. |
| documentNumber | string | undefined | Document number, e.g. a ticket or invoice number. |
| showPrintButton | boolean | true | The built-in "Print" button; always print-hidden. |
| repeatHeader | boolean | false | Places the #header slot in the content table's <thead>, when the content is actually a table. |
Print classes
| Prop | Type | Default | Description |
|---|---|---|---|
| print-hidden | class | — | Hide an element ONLY when printing (app chrome, action buttons). |
| print-only | class | — | The opposite: hidden on screen, shown only when printing. |
| print-break-before | class | — | Start a new page before this element. |
| print-break-after | class | — | Start a new page after this element. |
| print-avoid-break | class | — | Prevent an element from being cut at a page boundary (e.g. one table row). |