Message Composer
The channel input bar: attachment control, text field with combobox hooks, and send.
The channel input bar: attachment control, text field with combobox hooks, and send. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.
"use client"
import * as React from "react"
import { MessageComposer } from "@/registry/bases/newt/ui/message-composer"
export default function MessageComposerDemo() {
const [value, setValue] = React.useState("")
return (
<MessageComposer
channelName="general"
value={value}
onValueChange={setValue}
onSubmit={() => setValue("")}
className="w-full max-w-md"
/>
)
}<script setup lang="ts">
import { ref } from "vue"
import { MessageComposer } from "~~/registry/bases/newt/ui/message-composer"
const value = ref("")
</script>
<template>
<MessageComposer
v-model="value"
channel-name="general"
class="w-full max-w-md"
@submit="value = ''"
/>
</template>Installation
pnpm dlx newtui@latest add message-composernpx newtui@latest add message-composeryarn dlx newtui@latest add message-composerbunx newtui@latest add message-composerCopy the source into your project
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const AddIcon = () => (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5">
<path
fill="currentColor"
d="M11 5a1 1 0 1 1 2 0v6h6a1 1 0 1 1 0 2h-6v6a1 1 0 1 1-2 0v-6H5a1 1 0 1 1 0-2h6z"
/>
</svg>
)
const SendIcon = () => (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5">
<path fill="currentColor" d="M3 20.5v-6l8-2.5-8-2.5v-6l19 8.5z" />
</svg>
)
const composerButton =
"inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-inherit hover:bg-newt-bg-hover hover:text-newt-text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link disabled:cursor-not-allowed disabled:opacity-50"
export interface MessageComposerProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"onSubmit" | "onChange" | "children"
> {
/** Channel the message goes to; drives the default placeholder and labels. */
channelName: string
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
placeholder?: string
/** Sent on Enter and on the send control. */
onSubmit?: (value: string) => void
onEscape?: () => void
/** Arrow keys, for driving a suggestion list above the composer. */
onNavigate?: (direction: "up" | "down") => void
/** Wire the input up as a combobox for slash-command autocomplete. */
autocomplete?: boolean
ariaControls?: string
ariaExpanded?: boolean
ariaActiveDescendant?: string
/** Extra controls rendered between the field and the send button. */
actions?: React.ReactNode
onAdd?: () => void
}
const MessageComposer = React.forwardRef<HTMLDivElement, MessageComposerProps>(
(
{
className,
channelName,
value,
defaultValue = "",
onValueChange,
placeholder,
onSubmit,
onEscape,
onNavigate,
autocomplete = false,
ariaControls,
ariaExpanded,
ariaActiveDescendant,
actions,
onAdd,
...props
},
ref
) => {
const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
const text = value ?? uncontrolled
const hasValue = text.trim().length > 0
const label = placeholder ?? `Message #${channelName}`
const change = (next: string) => {
if (value === undefined) setUncontrolled(next)
onValueChange?.(next)
}
const submit = () => {
if (!hasValue) return
onSubmit?.(text)
}
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case "Enter":
event.preventDefault()
submit()
return
case "Escape":
event.preventDefault()
onEscape?.()
return
case "ArrowUp":
case "ArrowDown":
if (!onNavigate) return
event.preventDefault()
onNavigate(event.key === "ArrowUp" ? "up" : "down")
}
}
return (
<div
ref={ref}
role="group"
aria-label={`Message composer for #${channelName}`}
className={cn(
"flex min-h-11 min-w-0 items-center gap-2 rounded-md border border-newt-border bg-newt-bg-input-elevated px-4 text-newt-text-muted",
className
)}
{...props}
>
<button
type="button"
aria-label="Add attachment"
className={composerButton}
onClick={onAdd}
>
<AddIcon />
</button>
<div className="flex min-w-0 flex-1 items-center gap-2">
<input
type="text"
value={text}
placeholder={label}
aria-label={label}
spellCheck={false}
role={autocomplete ? "combobox" : undefined}
autoComplete={autocomplete ? "off" : undefined}
aria-controls={autocomplete ? ariaControls : undefined}
aria-expanded={autocomplete ? ariaExpanded : undefined}
aria-activedescendant={
autocomplete ? ariaActiveDescendant : undefined
}
onChange={(event) => change(event.target.value)}
onKeyDown={onKeyDown}
className="w-full border-0 bg-transparent font-sans text-[15px] text-newt-text-primary outline-none placeholder:text-newt-text-muted"
/>
</div>
{actions}
<button
type="button"
aria-label="Send message"
disabled={!hasValue}
onClick={submit}
/* The send control only lights up once there is something to send. */
className={cn(composerButton, hasValue && "text-newt-brand")}
>
<SendIcon />
</button>
</div>
)
}
)
MessageComposer.displayName = "MessageComposer"
export { MessageComposer }<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
/** Channel the message goes to; drives the placeholder and the labels. */
channelName: string
placeholder?: string
/** Wire the input up as a combobox for slash-command autocomplete. */
autocomplete?: boolean
ariaControls?: string
ariaExpanded?: boolean
ariaActivedescendant?: string
}>(),
{ autocomplete: false }
)
const emit = defineEmits<{
submit: [value: string]
escape: []
/** Arrow keys, for driving a suggestion list above the composer. */
navigate: [direction: "up" | "down"]
add: []
}>()
const text = defineModel<string>({ default: "" })
const label = computed(
() => props.placeholder ?? `Message #${props.channelName}`
)
const hasValue = computed(() => text.value.trim().length > 0)
const composerButton =
"inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-inherit hover:bg-newt-bg-hover hover:text-newt-text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-newt-text-link disabled:cursor-not-allowed disabled:opacity-50"
function submit() {
if (!hasValue.value) return
emit("submit", text.value)
}
function onKeydown(event: KeyboardEvent) {
switch (event.key) {
case "Enter":
event.preventDefault()
submit()
return
case "Escape":
event.preventDefault()
emit("escape")
return
case "ArrowUp":
case "ArrowDown":
event.preventDefault()
emit("navigate", event.key === "ArrowUp" ? "up" : "down")
}
}
</script>
<template>
<div
role="group"
:aria-label="`Message composer for #${props.channelName}`"
:class="
cn(
'flex min-h-11 min-w-0 items-center gap-2 rounded-md border border-newt-border bg-newt-bg-input-elevated px-4 text-newt-text-muted',
props.class
)
"
>
<button
type="button"
aria-label="Add attachment"
:class="composerButton"
@click="emit('add')"
>
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-5 w-5">
<path
fill="currentColor"
d="M11 5a1 1 0 1 1 2 0v6h6a1 1 0 1 1 0 2h-6v6a1 1 0 1 1-2 0v-6H5a1 1 0 1 1 0-2h6z"
/>
</svg>
</button>
<div class="flex min-w-0 flex-1 items-center gap-2">
<input
v-model="text"
type="text"
:placeholder="label"
:aria-label="label"
:spellcheck="false"
:role="props.autocomplete ? 'combobox' : undefined"
:autocomplete="props.autocomplete ? 'off' : undefined"
:aria-controls="props.autocomplete ? props.ariaControls : undefined"
:aria-expanded="props.autocomplete ? props.ariaExpanded : undefined"
:aria-activedescendant="
props.autocomplete ? props.ariaActivedescendant : undefined
"
class="w-full border-0 bg-transparent font-sans text-[15px] text-newt-text-primary outline-none placeholder:text-newt-text-muted"
@keydown="onKeydown"
/>
</div>
<slot name="actions" />
<button
type="button"
aria-label="Send message"
:disabled="!hasValue"
:class="cn(composerButton, hasValue && 'text-newt-brand')"
@click="submit"
>
<!-- The send control only lights up once there is something to send. -->
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-5 w-5">
<path fill="currentColor" d="M3 20.5v-6l8-2.5-8-2.5v-6l19 8.5z" />
</svg>
</button>
</div>
</template>export { default as MessageComposer } from "./MessageComposer.vue"Update the import paths
The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.
Usage
import { MessageComposer } from "@/components/ui/message-composer"
export function Example() {
return <MessageComposer />
}<script setup lang="ts">
import { MessageComposer } from "@/components/ui/message-composer"
</script>
<template>
<MessageComposer />
</template>API reference
MessageComposer
channelName?string
Channel the message goes to; drives the default placeholder and labels.
stringvalue?string | undefined
Optional value value.
string | undefineddefaultValue?string | undefined
Optional defaultValue value.
string | undefinedonValueChange?(value: string) => void | undefined
Optional onValueChange value.
(value: string) => void | undefinedplaceholder?string | undefined
Optional placeholder value.
string | undefinedonSubmit?(value: string) => void | undefined
Sent on Enter and on the send control.
(value: string) => void | undefinedonEscape?() => void | undefined
Optional onEscape value.
() => void | undefinedonNavigate?(direction: "up" | "down") => void | undefined
Arrow keys, for driving a suggestion list above the composer.
(direction: "up" | "down") => void | undefinedautocomplete?boolean | undefined
Wire the input up as a combobox for slash-command autocomplete.
boolean | undefinedariaControls?string | undefined
Optional ariaControls value.
string | undefinedariaExpanded?boolean | undefined
Optional ariaExpanded value.
boolean | undefinedariaActiveDescendant?string | undefined
Optional ariaActiveDescendant value.
string | undefinedactions?React.ReactNode | undefined
Extra controls rendered between the field and the send button.
React.ReactNode | undefinedonAdd?() => void | undefined
Optional onAdd value.
() => void | undefinedThe registry exports MessageComposer. Parts without a table accept the native attributes for the element they render; React uses className, while Vue uses class and fallthrough attributes.
Accessibility
Keep the textarea labelled with the channel context, let Enter and Shift+Enter retain their documented meanings, and give every icon-only action an accessible name.
Tokens
Message Composer reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.