Chat Composer
The box someone types a message into: text, files, and the two keys that send it. Paste a screenshot, drop a file, Enter to send and Shift+Enter for a newline. It knows nothing about where a message goes or what a reply looks like.
Default
Two v-models: the draft and the queued files. `submit` gives you the trimmed text; the files are already in the array you own.
Usage
Import the parts you need from the component's folder.
import {
ChatComposer,
ChatAttachments,
useFileDrop,
useChatAlerts,
} from "@/components/ui/chat";Paste and drop
Every route to a file ends in the same place. Paste a screenshot from the clipboard, drag one onto the box, or use the + menu. A file the receiver cannot read is refused through `reject` rather than disappearing, and the drop overlay inherits the box's own radius so it fits whichever Style is active.
Accepted types
Two lists ship with the component. `CHAT_ATTACHMENT_MIMES` is the conservative default: images and PDF, the intersection every model provider and every human reader opens without a converter. `CHAT_DOCUMENT_MIMES` is the wider set for a person-to-person thread, where someone opens a spreadsheet or a deck without thinking about it. Nothing executable or scriptable is in either, because the receiver downloads these from a private bucket and a filename is not a safe signal.
`EXTENSION_MIMES` is the fallback for the file the browser hands over with an empty `type`, which is how several Windows and Linux file managers report a dragged file. Office formats are the worst case: they are ZIP containers, so even a type that does arrive is often application/zip. The extension settles it here; the server still judges the bytes, so nothing in this map widens what is accepted.
The constants, as exported from the chat module.
import {
CHAT_ATTACHMENT_MIMES,
CHAT_DOCUMENT_MIMES,
EXTENSION_MIMES,
MAX_CHAT_ATTACHMENTS,
MAX_CHAT_ATTACHMENT_BYTES,
} from "@/components/ui/chat";
// Images and PDF. The default for `accept`.
CHAT_ATTACHMENT_MIMES; // ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"]
// Everything a human reader opens: images (incl. avif/heic/heif), pdf,
// xlsx, xls, docx, doc, pptx, ppt, csv, txt, zip.
CHAT_DOCUMENT_MIMES;
// Extension -> MIME, for files that arrive with no type at all.
EXTENSION_MIMES.xlsx; // "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
MAX_CHAT_ATTACHMENTS; // 5, the default for `maxFiles`
MAX_CHAT_ATTACHMENT_BYTES; // 10 * 1024 * 1024, the default for `maxFileBytes`
// A person-to-person surface opts into the wider set:
// <ChatComposer :accept="CHAT_DOCUMENT_MIMES" />Drop zone
By default the composer is its own drop target, which makes the drop zone exactly as tall as the box: a three-line strip at the bottom of a conversation. A pointer carrying a file is usually over the transcript instead, where a drop did nothing. Set `dropZone` to false, call `useFileDrop` on whatever container should catch the file, and forward what it catches to the composer's exposed `acceptFiles`. The composer keeps owning what a file has to satisfy; the host only decides where a drop counts. Leave both on and one drop fires two zones.
The counter is the part worth reusing. `dragenter` and `dragleave` fire for every child the pointer crosses, so a boolean flickers as the cursor moves over a textarea or a button inside the zone, and a depth counter does not. A drag that leaves the window, or ends over something else, never sends `dragleave` to the elements it crossed on the way in, so `useFileDrop` also resets on window `dragend` and document `drop`. Without that the overlay sits there until the next drag.
Busy and read-only
`busy` turns Send into Stop and keeps Enter from sending on top of an answer that is still arriving. `disabled` locks the whole box for a surface someone may read but not write to; say why in the placeholder rather than leaving them guessing.
Enter to send
On a keyboard, Enter sends and Shift+Enter breaks the line. On a touch screen Enter is the only way to get a new line and the button is the send key, so `enterToSend="auto"` (the default) inserts a newline there instead. Cmd/Ctrl+Enter always sends. Pass `true` or `false` to pin either behaviour.
Actions slot
Controls the host owns sit in the `actions` slot, between the + menu and Send: a mode toggle, saved replies, a template picker. The slot receives `disabled`, which is true while the box is busy or read-only.
Typing
`typing` fires at most once every two seconds while the draft changes, so a host can forward it as a presence whisper without adding its own timer. Nothing fires for an empty or locked box.
Limits
Mirror whatever the receiving endpoint enforces. The counter appears in the last tenth of `maxLength`, and the file rules are checked here so the reader learns about a refusal before a round trip rather than after one.
Sound and haptics
`useChatAlerts` is the other half of a live surface: one call to make when a message arrives, and a mute the reader controls. The audio element is created once per source at module scope, so several panels sharing one conversation play one sound rather than three overlapping copies of it.
Mute silences the sound only. The buzz is the discreet half and is what someone who muted the tab still wants, so it is kept. If the file is missing or autoplay is blocked, a short synthesised two-tone plays instead: a silent failure is indistinguishable from a chat that never answered.
<script setup>
import { useChatAlerts } from "@/components/ui/chat";
const { ping, muted, toggleMuted } = useChatAlerts();
// Wherever a message actually lands: a socket, a poll, a store watcher.
socket.on("message", () => ping());
</script>
<template>
<Button variant="ghost" size="icon-sm" @click="toggleMuted()">
<Icon :name="muted ? 'hugeicons:volume-mute-02' : 'hugeicons:volume-high'" />
<span class="sr-only">{{ muted ? "Unmute" : "Mute" }}</span>
</Button>
</template>Anatomy
Import all parts and piece them together.
<script setup>
import {
ChatComposer,
} from "@/components/ui/chat";
</script>
<template>
<ChatComposer>
<template #menu />
<template #actions />
</ChatComposer>
</template>API Reference
ChatComposer
ChatComposer (exposed)
ChatAttachments
useFileDrop(target, options)
useFileDrop() returns
useChatAlerts(options)
useChatAlerts() returns
Accessibility
Keyboard shortcuts and ARIA behavior.
- Both icon-only buttons carry their own label, so they are announced as Add attachment and as Send or Stop rather than as the icon inside them.
- Enter while busy does nothing at all. Stopping a reply is the button's job: a bare Enter used to stop the reply and leave whatever had been typed sitting in the box, unsent.
- A refused file is reported through reject and nowhere else. The component renders no message of its own, so announce it where your app announces problems: a toast that is not inside a live region is never read out.
- The drop overlay is decorative and has no text alternative. Every file it accepts is also reachable through the + menu, which is a real button in the tab order.
- Each queued file's remove button is labelled with that file's name, so a row of them is not four buttons called Remove.