Installation
Complete project setup with vue/reka, then install these components:
pnpm exec frontfriend download input-group attachment icon dropdown-menu -f vue --primitive reka Enter sends and Shift+Enter adds a line. Input composition does not submit. Empty prompts are disabled unless a file is attached. The file input resets after selection so the same file can be selected again. Files remain local; this example does not upload them.
Usage
Complete example
<script setup lang="ts">
import { ref } from "vue";
import { InputGroup } from "@/components/ui/input-group";
import { InputGroupAddon } from "@/components/ui/input-group";
import { Attachment } from "@/components/ui/attachment";
import { AttachmentMedia } from "@/components/ui/attachment";
import { Icon } from "@/components/ui/icon";
import { AttachmentContent } from "@/components/ui/attachment";
import { AttachmentTitle } from "@/components/ui/attachment";
import { AttachmentActions } from "@/components/ui/attachment";
import { AttachmentAction } from "@/components/ui/attachment";
import { InputGroupTextarea } from "@/components/ui/input-group";
import { InputGroupButton } from "@/components/ui/input-group";
import { DropdownMenu } from "@/components/ui/dropdown-menu";
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { DropdownMenuRadioGroup } from "@/components/ui/dropdown-menu";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { DropdownMenuGroup } from "@/components/ui/dropdown-menu";
import { DropdownMenuLabel } from "@/components/ui/dropdown-menu";
import { DropdownMenuRadioItem } from "@/components/ui/dropdown-menu";
const args = {
"placeholder": "Ask the agent to change your code",
};
const focusedMenu = ref("");
const menuFocusStyle = {
outline: "2px solid var(--border-interactive-mid)",
outlineOffset: "2px",
};
function focusMenu(event: FocusEvent, name: string) {
focusedMenu.value =
event.target instanceof HTMLElement &&
event.target.matches(":focus-visible")
? name
: "";
}
const text = ref("");
const files = ref<
Array<{
id: string;
name: string;
file: File;
}>
>([]);
const formRef = ref<HTMLFormElement | null>(null);
const providers = [
{ name: "Provider A", models: ["Fast", "Balanced"] },
{ name: "Provider B", models: ["Precise"] },
];
const fileInput = ref<HTMLInputElement | null>(null);
const model = ref("Balanced");
const effort = ref("Medium");
const status = ref("");
function selectFiles(event: Event) {
const input = event.target as HTMLInputElement;
files.value.push(
...Array.from(input.files ?? []).map((file) => ({
id: crypto.randomUUID(),
name: file.name,
file,
})),
);
input.value = "";
}
function send() {
if (!text.value.trim() && !files.value.length) return;
status.value = `Sent: ${text.value.trim() || "Files only"} (${files.value.length} ${files.value.length === 1 ? "file" : "files"}).`;
text.value = "";
files.value = [];
}
function keydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
send();
}
}
function removeFile(id: string) {
files.value = files.value.filter((file) => file.id !== id);
formRef.value?.querySelector("textarea")?.focus();
}
</script>
<template>
<div
style="
width: 100%;
max-width: 560px;
min-width: 0;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding-block: 16px;
font-size: var(--text-sm);
line-height: 1.5;
"
>
<form ref="formRef" @submit.prevent="send">
<input
ref="fileInput"
type="file"
multiple
hidden
aria-label="Attach files"
@change="selectFiles"
/>
<InputGroup
><InputGroupAddon v-if="files.length" align="block-start"
><div
style="
display: flex;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
width: 100%;
"
>
<Attachment v-for="file in files" :key="file.id" size="sm"
><AttachmentMedia><Icon name="FileText" /></AttachmentMedia
><AttachmentContent
><AttachmentTitle>{{
file.name
}}</AttachmentTitle></AttachmentContent
><AttachmentActions
><AttachmentAction
type="button"
:aria-label="'Remove ' + file.name"
@click="removeFile(file.id)"
><Icon name="X" /></AttachmentAction></AttachmentActions
></Attachment></div></InputGroupAddon
><InputGroupTextarea
v-model="text"
aria-label="Prompt"
:placeholder="args.placeholder"
@keydown="keydown" />
<InputGroupAddon align="block-end"
><div
style="
display: flex;
gap: 8px;
flex-wrap: wrap;
width: 100%;
align-items: center;
"
>
<InputGroupButton
type="button"
variant="outline"
size="icon-sm"
aria-label="Attach files"
@click="fileInput?.click()"
><Icon name="Plus"
/></InputGroupButton>
<div style="margin-inline-start: auto">
<div style="display: flex; flex-wrap: wrap; gap: 8px">
<DropdownMenu
><DropdownMenuTrigger as-child
><InputGroupButton
size="sm"
variant="outline"
:style="
focusedMenu === 'model' ? menuFocusStyle : undefined
"
@focus="focusMenu($event, 'model')"
@blur="focusedMenu = ''"
:aria-label="'Model: ' + model"
>{{ model
}}<Icon
name="ChevronDown" /></InputGroupButton></DropdownMenuTrigger
><DropdownMenuContent
><DropdownMenuRadioGroup v-model="model"
><template
v-for="(provider, index) in providers"
:key="provider.name"
><DropdownMenuSeparator
v-if="index > 0"
/><DropdownMenuGroup
><DropdownMenuLabel>{{
provider.name
}}</DropdownMenuLabel
><DropdownMenuRadioItem
v-for="value in provider.models"
:key="value"
:value="value"
>{{ value }}</DropdownMenuRadioItem
></DropdownMenuGroup
></template
></DropdownMenuRadioGroup
></DropdownMenuContent
></DropdownMenu
>
<DropdownMenu
><DropdownMenuTrigger as-child
><InputGroupButton
size="sm"
variant="outline"
:style="
focusedMenu === 'effort' ? menuFocusStyle : undefined
"
@focus="focusMenu($event, 'effort')"
@blur="focusedMenu = ''"
:aria-label="'Effort: ' + effort"
>{{ effort
}}<Icon
name="ChevronDown" /></InputGroupButton></DropdownMenuTrigger
><DropdownMenuContent
><DropdownMenuGroup
><DropdownMenuLabel>Reasoning effort</DropdownMenuLabel
><DropdownMenuRadioGroup v-model="effort"
><DropdownMenuRadioItem
v-for="value in ['Low', 'Medium', 'High']"
:key="value"
:value="value"
>{{ value }}</DropdownMenuRadioItem
></DropdownMenuRadioGroup
></DropdownMenuGroup
></DropdownMenuContent
></DropdownMenu
>
</div>
</div>
<InputGroupButton
type="submit"
:variant="text.trim() || files.length ? 'default' : 'secondary'"
size="icon-sm"
aria-label="Send"
:aria-disabled="!text.trim() && !files.length"
><Icon name="ArrowUp"
/></InputGroupButton></div></InputGroupAddon
></InputGroup>
</form>
<p
role="status"
style="
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
"
>
{{ status }}
</p>
</div>
</template> Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
input-group | Reka UI API |
attachment | Reka UI API |
icon | Reka UI API |
dropdown-menu | Reka UI API |