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.

Dark previewReplay previewOpen in new tab

Usage

Import the parts you need from the component's folder.

ts
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.

Dark previewReplay previewOpen in new tab

Paste a screenshot, drag a file onto the box, or use the + menu. A .txt is refused, because the default list is images and PDF.

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.

typescript
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.

Dark previewReplay previewOpen in new tab

The whole card is the drop zone, not just the box. Drag a file anywhere onto it.

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.

Dark previewReplay previewOpen in new tab

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.

Dark previewReplay previewOpen in new tab

Default (auto): Enter sends on a pointer, breaks the line on touch.

`enterToSend=false`: Enter always breaks the line, Cmd/Ctrl+Enter or the button sends.

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.

Dark previewReplay previewOpen in new tab

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.

Dark previewReplay previewOpen in new tab

typing fired 0 time(s). Hold a key down: it stays at one every two seconds.

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.

Dark previewReplay previewOpen in new tab

120 characters, two files, 1 MB each, images only. The counter appears in the last tenth; the file rules answer before a round trip rather than after.

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.

vue
<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.

vue
<script setup>
import {
  ChatComposer,
} from "@/components/ui/chat";
</script>

<template>
  <ChatComposer>
    <template #menu />
    <template #actions />
  </ChatComposer>
</template>

API Reference

ChatComposer

PropTypeDefaultDescription
modelValuestring""The draft. Supports v-model.
attachmentsFile[][]Queued files. Supports v-model:attachments.
busybooleanfalseA reply is arriving: Send becomes Stop and Enter no longer sends.
disabledbooleanfalseLocks the textarea, the + menu and Send.
placeholderstring"Ask anything"Also where you explain a disabled box.
maxLengthnumber10000Character cap; a counter appears near it.
compactbooleanfalseA shorter box, for a floating panel rather than a page.
attachablebooleantrueOffer the + menu, paste and drop at all.
dropZonebooleantrueOwn the drop zone. Turn it off when the host wraps a larger container in useFileDrop, so one drop does not fire two zones.
acceptstring[]CHAT_ATTACHMENT_MIMESMIME types the receiver can read. Pass CHAT_DOCUMENT_MIMES on a person-to-person surface.
maxFilesnumberMAX_CHAT_ATTACHMENTSHow many files one message may carry. The constant is 5.
maxFileBytesnumberMAX_CHAT_ATTACHMENT_BYTESPer-file ceiling in bytes. The constant is 10 MB.
enterToSendboolean | "auto""auto"Whether a bare Enter sends. auto: yes on a pointer, no on touch. Cmd/Ctrl+Enter always sends.
classstring—Extra classes, merged with cn().
EventDescription
update:modelValueThe draft changed, and emptied on submit. Payload: the new string.
update:attachmentsA file was added or removed. Payload: the whole File[].
submitThe message is ready. Payload: the trimmed text; the files are in the array you own.
stopThe stop button, only reachable while busy. No payload.
rejectA file was refused. Payload: the reason, already written for a reader. Show it however your app shows problems.
typingThe draft changed. Throttled to once every two seconds, and silent while the box is empty or locked.
SlotDescription
menuAppended to the + dropdown, under Add photos & files. Renders inside DropdownMenuContent. No slot props.
actionsHost controls between the + menu and Send. Receives { disabled }, true while the box is busy or read-only.

ChatComposer (exposed)

PropTypeDefaultDescription
focus()() => void—Puts the cursor in the textarea. Called on the component ref, so a panel can open straight into the box.
acceptFiles(files)(files: File[]) => void—Push files in from outside, which is how a host-owned drop zone hands over what it caught. They go through the same type, size and duplicate checks as the + menu, and a refusal comes back through reject.

ChatAttachments

PropTypeDefaultDescription
filesFile[]—Rendered as a scrolling row of cards; images get a thumbnail.
disabledbooleanfalseLocks the remove button.
EventDescription
removeThe remove button on one card. Payload: its index in files.

useFileDrop(target, options)

PropTypeDefaultDescription
targetMaybeRefOrGetter<HTMLElement | null>—The element that counts as the zone. A template ref is the usual form.
onDrop(files: File[]) => void—Files that passed the browser's own drag check, in the order they were dropped. Nothing else is validated here: forward them to acceptFiles, or check them yourself.
disabledMaybeRefOrGetter<boolean>falseWhile true the zone ignores every drag. A getter keeps it live, e.g. () => busy || readOnly.

useFileDrop() returns

PropTypeDefaultDescription
isDraggingFilesComputedRef<boolean>—True while a drag carrying files is over the zone. Drive the overlay with it, and keep the overlay pointer-events-none so it cannot eat the drop.

useChatAlerts(options)

PropTypeDefaultDescription
soundSrcstring"/sfx/chat-notification.mp3?v=2"Served from public/. Any short, quiet notification. The query string versions the file so a replacement is not served from a browser or edge cache under the old bytes.
storageKeystring"pmone:chat-muted"localStorage key for the mute. Per device and per browser: nothing about it belongs on the account.
vibrationnumber[][15]navigator.vibrate pattern. Keep it under a tick.

useChatAlerts() returns

PropTypeDefaultDescription
ping()() => void—Announce one new message: the sound, then a short buzz.
mutedReadonly<Ref<boolean>>—The reader's mute. Read from localStorage once on mount, not on every ping.
setMuted(value)(value: boolean) => void—Set it and write it back to localStorage.
toggleMuted()() => void—The form a toolbar button wants.

Accessibility

Keyboard shortcuts and ARIA behavior.

Shortcut Description
EnterSends when Enter-to-send is on, otherwise inserts a newline. Ignored while an IME candidate window is open.
Shift + EnterAlways inserts a newline.
Ctrl + EnterAlways sends, on a keyboard and on a touch screen. Cmd+Enter does the same.
TabMoves from the textarea to the + button, then any host controls, then Send.
  • 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.