Textarea
Free, copy-and-go Textarea components built on the SevenUI Textarea primitive.Read the primitive docs.
Compact
Default
Spacious
"use client";
import { useId } from "react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const sizes = [
{
key: "compact",
size: "Compact",
label: "Commit summary",
placeholder: "fix(billing): round prorated seat charges to the cent",
className: "min-h-10 rounded-md px-2 py-1 md:text-xs",
},
{
key: "default",
size: "Default",
label: "Release note",
placeholder: "Invoices now show prorated seat charges line by line.",
className: "",
},
{
key: "spacious",
size: "Spacious",
label: "Incident summary",
placeholder:
"Between 14:02 and 14:37 UTC, 3% of invoice exports failed because the PDF worker ran out of memory.",
className: "min-h-28 rounded-xl px-3.5 py-3 md:text-base",
},
] as const;
export default function Textarea01() {
const id = useId();
return (
<div className="flex w-full max-w-md flex-col gap-6">
{sizes.map((item) => {
const inputId = `${id}-${item.key}`;
return (
<div key={item.key} className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<Label htmlFor={inputId}>{item.label}</Label>
<span className="text-xs text-muted-foreground">{item.size}</span>
</div>
<Textarea
id={inputId}
placeholder={item.placeholder}
className={item.className}
/>
</div>
);
})}
</div>
);
}
npx shadcn@latest add @sevenui/component/textarea-01pnpm dlx shadcn@latest add @sevenui/component/textarea-01yarn dlx shadcn@latest add @sevenui/component/textarea-01bunx --bun shadcn@latest add @sevenui/component/textarea-01Tinted fill, no border. Reads well inside dense forms.
"use client";
import { useId, useState } from "react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const surfaces = {
outline: {
label: "Outline",
hint: "Default border. Use on plain page backgrounds.",
className: "",
},
filled: {
label: "Filled",
hint: "Tinted fill, no border. Reads well inside dense forms.",
className:
"border-transparent bg-muted focus-visible:bg-background dark:bg-muted dark:focus-visible:bg-input/30",
},
ghost: {
label: "Ghost",
hint: "Invisible until hovered or focused. Good for inline editing.",
className:
"border-transparent px-2 hover:bg-muted/60 focus-visible:bg-background dark:bg-transparent dark:focus-visible:bg-input/30",
},
} as const;
type Surface = keyof typeof surfaces;
export default function Textarea02() {
const id = useId();
const [surface, setSurface] = useState<Surface>("filled");
const current = surfaces[surface];
return (
<div className="flex w-full max-w-md flex-col gap-4">
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
aria-label="Textarea surface style"
value={[surface]}
onValueChange={(value) => {
const next = value[0] as Surface | undefined;
if (next) setSurface(next);
}}
>
{(Object.keys(surfaces) as Surface[]).map((key) => (
<ToggleGroupItem key={key} value={key} className="px-3">
{surfaces[key].label}
</ToggleGroupItem>
))}
</ToggleGroup>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-notes`}>Account notes</Label>
<Textarea
id={`${id}-notes`}
aria-describedby={`${id}-hint`}
defaultValue={
"Acme Corp, 42 seats on Team\n- Wants SSO before the October renewal\n- Finance contact is Priya Shah, invoices by PO only"
}
className={current.className}
/>
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
{current.hint}
</p>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/textarea-02pnpm dlx shadcn@latest add @sevenui/component/textarea-02yarn dlx shadcn@latest add @sevenui/component/textarea-02bunx --bun shadcn@latest add @sevenui/component/textarea-02Read-only
Each code signs you in once if you lose your authenticator. Store them somewhere safe.
"use client";
import { CheckIcon, CopyIcon, LockIcon } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const recoveryCodes = [
"7KQ2-M9XD",
"PL4H-83ZR",
"W2NC-6TFJ",
"BY8V-Q5LA",
"3RDG-H7KE",
"N6XU-2CPW",
].join("\n");
export default function Textarea03() {
const id = useId();
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
function handleCopy() {
navigator.clipboard?.writeText(recoveryCodes).catch(() => {});
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2000);
}
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={`${id}-codes`}>Recovery codes</Label>
<InputGroup className="bg-muted/40 dark:bg-muted/30">
<InputGroupAddon align="block-start" className="border-b">
<InputGroupText className="text-xs">
<LockIcon aria-hidden="true" className="size-3.5" />
Read-only
</InputGroupText>
<InputGroupButton
size="xs"
className="ml-auto"
onClick={handleCopy}
aria-label={copied ? "Recovery codes copied" : "Copy recovery codes"}
>
{copied ? (
<CheckIcon aria-hidden="true" className="text-success" />
) : (
<CopyIcon aria-hidden="true" />
)}
{copied ? "Copied" : "Copy"}
</InputGroupButton>
</InputGroupAddon>
<Textarea
data-slot="input-group-control"
id={`${id}-codes`}
readOnly
rows={6}
value={recoveryCodes}
aria-describedby={`${id}-hint`}
className="flex-1 cursor-default resize-none rounded-none border-0 bg-transparent px-3 py-3 shadow-none focus-visible:ring-0 dark:bg-transparent font-mono text-sm leading-6 tracking-wider tabular-nums"
/>
</InputGroup>
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
Each code signs you in once if you lose your authenticator. Store them
somewhere safe.
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/textarea-03pnpm dlx shadcn@latest add @sevenui/component/textarea-03yarn dlx shadcn@latest add @sevenui/component/textarea-03bunx --bun shadcn@latest add @sevenui/component/textarea-03"use client";
import { CircleAlertIcon, CircleCheckIcon } from "lucide-react";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const MIN_LENGTH = 30;
export default function Textarea04() {
const id = useId();
const [value, setValue] = useState("Upgraded by mistake");
const [touched, setTouched] = useState(true);
const length = value.trim().length;
const isValid = length >= MIN_LENGTH;
const showError = touched && !isValid;
const showSuccess = touched && isValid;
return (
<form
className="flex w-full max-w-sm flex-col gap-2"
noValidate
onSubmit={(event) => {
event.preventDefault();
setTouched(true);
}}
>
<Label htmlFor={`${id}-reason`}>Reason for refund</Label>
<Textarea
id={`${id}-reason`}
value={value}
required
minLength={MIN_LENGTH}
aria-invalid={showError || undefined}
aria-describedby={`${id}-status`}
onChange={(event) => setValue(event.target.value)}
onBlur={() => setTouched(true)}
className={
showSuccess
? "border-success focus-visible:border-success focus-visible:ring-success/25"
: undefined
}
/>
<p
id={`${id}-status`}
aria-live="polite"
className={
showError
? "flex items-start gap-1.5 text-sm text-destructive"
: showSuccess
? "flex items-start gap-1.5 text-sm text-success"
: "text-sm text-muted-foreground"
}
>
{showError ? (
<>
<CircleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
Add {MIN_LENGTH - length} more characters so billing can locate the
charge. Include the date and last four card digits.
</>
) : showSuccess ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
Looks good. Billing replies within one business day.
</>
) : (
`At least ${MIN_LENGTH} characters.`
)}
</p>
<Button type="submit" className="mt-2 self-end">
Request refund
</Button>
</form>
);
}
npx shadcn@latest add @sevenui/component/textarea-04pnpm dlx shadcn@latest add @sevenui/component/textarea-04yarn dlx shadcn@latest add @sevenui/component/textarea-04bunx --bun shadcn@latest add @sevenui/component/textarea-0433 left in 1 text
Sent by SMS 24 hours before each appointment.
"use client";
import { CheckIcon } from "lucide-react";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const LIMIT = 160;
const INITIAL_TEMPLATE =
"Hi {first_name}, reminder: your cleaning at Brightside Dental is Tue, Oct 14 at 9:30 AM. Reply C to confirm or R to reschedule.";
export default function Textarea05() {
const id = useId();
const [message, setMessage] = useState(INITIAL_TEMPLATE);
const [savedMessage, setSavedMessage] = useState(INITIAL_TEMPLATE);
const count = message.length;
const remaining = LIMIT - count;
const ratio = Math.min(count / LIMIT, 1);
const over = remaining < 0;
const near = !over && remaining <= 20;
const dirty = message !== savedMessage;
const barColor = over ? "bg-destructive" : near ? "bg-warning" : "bg-primary";
const countColor = over
? "text-destructive"
: near
? "text-warning"
: "text-muted-foreground";
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<Label htmlFor={`${id}-sms`}>Reminder text</Label>
<span
id={`${id}-count`}
aria-live="polite"
className={`text-xs tabular-nums transition-colors ${countColor}`}
>
{over
? `${-remaining} over, splits into 2 texts`
: `${remaining} left in 1 text`}
</span>
</div>
<div className="flex flex-col overflow-hidden rounded-lg">
<Textarea
id={`${id}-sms`}
value={message}
rows={4}
aria-invalid={over || undefined}
aria-describedby={`${id}-count`}
onChange={(event) => setMessage(event.target.value)}
className="resize-none rounded-b-none"
/>
<div aria-hidden="true" className="h-1 w-full bg-muted">
<div
className={`h-full origin-left transition-[transform,background-color] duration-300 ease-out motion-reduce:transition-none ${barColor}`}
style={{ transform: `scaleX(${ratio})` }}
/>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Sent by SMS 24 hours before each appointment.
</p>
<Button
size="sm"
className="shrink-0"
disabled={over || !dirty}
onClick={() => setSavedMessage(message)}
>
{dirty ? null : <CheckIcon aria-hidden="true" />}
{dirty ? "Save template" : "Saved"}
</Button>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/textarea-05pnpm dlx shadcn@latest add @sevenui/component/textarea-05yarn dlx shadcn@latest add @sevenui/component/textarea-05bunx --bun shadcn@latest add @sevenui/component/textarea-05Saved at 9:41 AM
42 words · about 1 min readDrafts are kept for 30 days.
"use client";
import { CloudCheckIcon } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
const WORDS_PER_MINUTE = 200;
const initialDraft = `This week we shipped usage-based billing to the first 40 workspaces. Invoices now itemize API calls per project, and nobody has opened a billing ticket about it yet.
Next week: move the remaining Team plans over and retire the old seat calculator.`;
type SaveState = "saved" | "saving";
function countWords(text: string) {
return text.trim() ? text.trim().split(/\s+/).length : 0;
}
export default function Textarea06() {
const id = useId();
const [draft, setDraft] = useState(initialDraft);
const [saveState, setSaveState] = useState<SaveState>("saved");
const [savedAt, setSavedAt] = useState("9:41 AM");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
function handleChange(value: string) {
setDraft(value);
setSaveState("saving");
if (timer.current) clearTimeout(timer.current);
// Debounce: save once typing pauses for 800ms.
timer.current = setTimeout(() => {
setSaveState("saved");
setSavedAt(
new Date().toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
}),
);
}, 800);
}
const words = countWords(draft);
const minutes = Math.max(1, Math.round(words / WORDS_PER_MINUTE));
return (
<div className="flex w-full max-w-lg flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={`${id}-draft`}>Weekly update to #billing-team</Label>
<p
aria-live="polite"
className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
>
{saveState === "saving" ? (
<>
<Spinner aria-hidden="true" className="size-3.5" />
Saving
</>
) : (
<>
<CloudCheckIcon aria-hidden="true" className="size-3.5" />
Saved at {savedAt}
</>
)}
</p>
</div>
<Textarea
id={`${id}-draft`}
value={draft}
onChange={(event) => handleChange(event.target.value)}
aria-describedby={`${id}-meta`}
placeholder="What shipped, what slipped, and what is next?"
className="min-h-40 leading-relaxed"
/>
<div
id={`${id}-meta`}
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground"
>
<span className="tabular-nums">
{words} {words === 1 ? "word" : "words"} · about {minutes} min read
</span>
<span>Drafts are kept for 30 days.</span>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/textarea-06pnpm dlx shadcn@latest add @sevenui/component/textarea-06yarn dlx shadcn@latest add @sevenui/component/textarea-06bunx --bun shadcn@latest add @sevenui/component/textarea-06"use client";
import { CircleCheckIcon } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
type Status = "idle" | "sending" | "sent";
export default function Textarea07() {
const id = useId();
const [message, setMessage] = useState(
"The CSV export drops the timezone from the created_at column, so our finance sheet shifts every row by two hours.",
);
const [status, setStatus] = useState<Status>("idle");
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const sending = status === "sending";
const sent = status === "sent";
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!message.trim()) return;
setStatus("sending");
timer.current = setTimeout(() => setStatus("sent"), 1600);
}
return (
<form className="flex w-full max-w-sm flex-col gap-2" onSubmit={handleSubmit}>
<Label htmlFor={`${id}-feedback`}>Report a problem</Label>
<div className="relative">
<Textarea
id={`${id}-feedback`}
value={message}
rows={4}
disabled={sending}
readOnly={sent}
aria-busy={sending || undefined}
aria-describedby={`${id}-status`}
onChange={(event) => setMessage(event.target.value)}
className={
sent
? "resize-none border-success/40 bg-success/5 text-muted-foreground dark:bg-success/10"
: "resize-none"
}
/>
{sending ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-lg"
>
<div className="h-full w-1/3 animate-pulse bg-primary motion-reduce:animate-none" />
</div>
) : null}
</div>
<div className="flex min-h-8 items-center justify-between gap-3">
<p
id={`${id}-status`}
aria-live="polite"
className={
sent
? "flex items-center gap-1.5 text-sm text-success"
: "text-sm text-muted-foreground"
}
>
{sent ? (
<>
<CircleCheckIcon aria-hidden="true" className="size-4" />
Sent. Ticket 7310 is open.
</>
) : sending ? (
"Sending to support..."
) : (
"Goes to support with your workspace ID."
)}
</p>
{sent ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setMessage("");
setStatus("idle");
}}
>
New report
</Button>
) : (
<Button type="submit" size="sm" disabled={sending || !message.trim()}>
{sending ? <Spinner aria-hidden="true" /> : null}
{sending ? "Sending" : "Send report"}
</Button>
)}
</div>
</form>
);
}
npx shadcn@latest add @sevenui/component/textarea-07pnpm dlx shadcn@latest add @sevenui/component/textarea-07yarn dlx shadcn@latest add @sevenui/component/textarea-07bunx --bun shadcn@latest add @sevenui/component/textarea-07"use client";
import {
AtSignIcon,
BoldIcon,
ItalicIcon,
LinkIcon,
ListIcon,
} from "lucide-react";
import { useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
const tools = [
{ key: "bold", label: "Bold", icon: BoldIcon },
{ key: "italic", label: "Italic", icon: ItalicIcon },
{ key: "link", label: "Insert link", icon: LinkIcon },
{ key: "list", label: "Bulleted list", icon: ListIcon },
{ key: "mention", label: "Mention a teammate", icon: AtSignIcon },
] as const;
type ToolKey = (typeof tools)[number]["key"];
// Markdown each tool writes around (or in place of) the current selection.
function format(tool: ToolKey, selected: string) {
switch (tool) {
case "bold":
return { before: "**", text: selected || "bold text", after: "**" };
case "italic":
return { before: "_", text: selected || "italic text", after: "_" };
case "link":
return { before: "[", text: selected || "link text", after: "](https://)" };
case "list":
return { before: "- ", text: selected || "list item", after: "" };
case "mention":
return { before: "@", text: selected || "priya", after: " " };
}
}
export default function Textarea08() {
const id = useId();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState("");
const [focused, setFocused] = useState(false);
const [notes, setNotes] = useState<string[]>([]);
const expanded = focused || value.length > 0;
function applyTool(tool: ToolKey) {
const textarea = textareaRef.current;
const start = textarea?.selectionStart ?? value.length;
const end = textarea?.selectionEnd ?? value.length;
const { before, text, after } = format(tool, value.slice(start, end));
// Lists start on their own line.
const prefix =
tool === "list" && start > 0 && value[start - 1] !== "\n" ? "\n" : "";
const inserted = prefix + before + text + after;
setValue(value.slice(0, start) + inserted + value.slice(end));
const selectionStart = start + prefix.length + before.length;
requestAnimationFrame(() => {
textarea?.focus();
textarea?.setSelectionRange(selectionStart, selectionStart + text.length);
});
}
function saveNote() {
const note = value.trim();
if (!note) return;
setNotes((current) => [note, ...current]);
setValue("");
textareaRef.current?.focus();
}
return (
<div className="flex w-full min-w-0 max-w-md flex-col gap-3">
<fieldset
aria-label="Deal note composer"
data-expanded={expanded || undefined}
className="group/composer w-full min-w-0 max-w-md rounded-xl border border-input bg-card shadow-xs transition-[border-color,box-shadow] duration-200 focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 data-expanded:shadow-md"
onFocus={() => setFocused(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setFocused(false);
}
}}
>
<label htmlFor={`${id}-note`} className="sr-only">
Add a note to this deal
</label>
<Textarea
ref={textareaRef}
id={`${id}-note`}
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Add a note to Acme Corp renewal..."
className="min-h-10 resize-none rounded-xl border-0 bg-transparent px-3 py-2.5 shadow-none transition-[min-height] duration-300 ease-out group-data-expanded/composer:min-h-28 focus-visible:ring-0 motion-reduce:transition-none dark:bg-transparent"
/>
<div className="grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out group-data-expanded/composer:grid-rows-[1fr] motion-reduce:transition-none">
<div className="overflow-hidden">
<div
inert={!expanded}
className="flex items-center justify-between gap-2 border-t border-border px-2 py-2 opacity-0 transition-opacity duration-200 group-data-expanded/composer:opacity-100"
>
<div role="toolbar" aria-label="Formatting" className="flex items-center gap-0.5">
{tools.map((tool) => (
<Button
key={tool.key}
type="button"
variant="ghost"
size="icon-sm"
aria-label={tool.label}
onClick={() => applyTool(tool.key)}
className="text-muted-foreground"
>
<tool.icon aria-hidden="true" />
</Button>
))}
</div>
<Button size="sm" disabled={!value.trim()} onClick={saveNote}>
Save note
</Button>
</div>
</div>
</div>
</fieldset>
{notes.length > 0 ? (
<ul aria-label="Saved notes" aria-live="polite" className="flex flex-col gap-2">
{notes.map((note, index) => (
<li
// biome-ignore lint/suspicious/noArrayIndexKey: notes are append-only
key={notes.length - index}
className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm break-words whitespace-pre-wrap"
>
{note}
</li>
))}
</ul>
) : null}
</div>
);
}
npx shadcn@latest add @sevenui/component/textarea-08pnpm dlx shadcn@latest add @sevenui/component/textarea-08yarn dlx shadcn@latest add @sevenui/component/textarea-08bunx --bun shadcn@latest add @sevenui/component/textarea-08Shipping to Maya Chen
1482 Alder Street, Apt 3B, Portland, OR 97205
Optional
Shared with the courier only. 180 characters left.
"use client";
import { MapPinIcon, PlusIcon } from "lucide-react";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const MAX_LENGTH = 180;
const addresses = [
{ name: "Maya Chen", line: "1482 Alder Street, Apt 3B, Portland, OR 97205" },
{ name: "Maya Chen (work)", line: "700 SW Main Street, Floor 4, Portland, OR 97204" },
];
const suggestions = [
"Leave at the front door",
"Ring the bell twice",
"Gate code 4471",
"Hand to the concierge",
];
export default function Textarea09() {
const id = useId();
const [note, setNote] = useState("");
const [addressIndex, setAddressIndex] = useState(0);
const address = addresses[addressIndex];
const remaining = MAX_LENGTH - note.length;
const addSuggestion = (text: string) => {
setNote((current) => {
const trimmed = current.trimEnd();
const next = trimmed ? `${trimmed.replace(/[.,]$/, "")}. ${text}.` : `${text}.`;
return next.slice(0, MAX_LENGTH);
});
};
return (
<section
aria-labelledby={`${id}-heading`}
className="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-border bg-card p-4 text-card-foreground"
>
<div className="flex items-start gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<MapPinIcon aria-hidden="true" className="size-4" />
</span>
<div className="min-w-0 flex-1">
<h3 id={`${id}-heading`} className="text-sm font-medium">
Shipping to {address.name}
</h3>
<p aria-live="polite" className="text-sm text-muted-foreground">
{address.line}
</p>
</div>
<Button
variant="link"
size="sm"
className="h-auto px-0"
onClick={() => setAddressIndex((index) => (index + 1) % addresses.length)}
>
Change
</Button>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<Label htmlFor={`${id}-note`}>Delivery instructions</Label>
<span className="text-xs text-muted-foreground">Optional</span>
</div>
<Textarea
id={`${id}-note`}
value={note}
maxLength={MAX_LENGTH}
onChange={(event) => setNote(event.target.value)}
placeholder="Anything the courier should know to get your parcel to you?"
aria-describedby={`${id}-count`}
className="min-h-20 resize-none"
/>
<fieldset className="flex min-w-0 flex-wrap gap-1.5">
<legend className="sr-only">Quick instructions</legend>
{suggestions.map((suggestion) => {
const used = note.includes(suggestion);
return (
<Button
key={suggestion}
type="button"
variant="outline"
size="xs"
disabled={used || remaining < suggestion.length + 2}
onClick={() => addSuggestion(suggestion)}
className="rounded-full"
>
<PlusIcon aria-hidden="true" />
{suggestion}
</Button>
);
})}
</fieldset>
<p
id={`${id}-count`}
aria-live="polite"
className="text-xs text-muted-foreground tabular-nums"
>
Shared with the courier only. {remaining} characters left.
</p>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/textarea-09pnpm dlx shadcn@latest add @sevenui/component/textarea-09yarn dlx shadcn@latest add @sevenui/component/textarea-09bunx --bun shadcn@latest add @sevenui/component/textarea-09"use client";
import { useId, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const MAX_LENGTH = 160;
const SAVED_BIO =
"Product designer at Northwind. Building calm tools for busy support teams. Based in Lisbon.";
export default function Textarea10() {
const id = useId();
const [savedBio, setSavedBio] = useState(SAVED_BIO);
const [bio, setBio] = useState(SAVED_BIO);
const isDirty = bio.trim() !== savedBio.trim();
const remaining = MAX_LENGTH - bio.length;
return (
<form
aria-labelledby={`${id}-heading`}
className="w-full max-w-lg overflow-hidden rounded-xl border border-border bg-card text-card-foreground"
onSubmit={(event) => {
event.preventDefault();
setSavedBio(bio.trim());
setBio(bio.trim());
}}
>
<div className="grid gap-4 p-4 sm:grid-cols-[10rem_1fr] sm:gap-6 sm:p-6">
<div className="flex flex-col gap-1">
<h3 id={`${id}-heading`} className="text-sm font-medium">
Public bio
</h3>
<p className="text-sm text-muted-foreground">
Shown on your profile and next to your comments.
</p>
</div>
<div className="flex min-w-0 flex-col gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-bio`} className="sr-only">
Bio
</Label>
<Textarea
id={`${id}-bio`}
value={bio}
maxLength={MAX_LENGTH}
onChange={(event) => setBio(event.target.value)}
aria-describedby={`${id}-count`}
className="min-h-24"
/>
<p
id={`${id}-count`}
className="text-right text-xs text-muted-foreground tabular-nums"
>
{remaining} / {MAX_LENGTH}
</p>
</div>
<div className="flex items-start gap-3 rounded-lg bg-muted/60 p-3">
<Avatar size="sm">
<AvatarFallback>IR</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Inês Rocha</p>
<p className="text-sm break-words text-muted-foreground">
{bio.trim() || "No bio yet."}
</p>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-border bg-muted/40 px-4 py-3 sm:px-6">
<p aria-live="polite" className="text-xs text-muted-foreground">
{isDirty ? "You have unsaved changes." : "All changes saved."}
</p>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={!isDirty}
onClick={() => setBio(savedBio)}
>
Discard
</Button>
<Button type="submit" size="sm" disabled={!isDirty}>
Save
</Button>
</div>
</div>
</form>
);
}
npx shadcn@latest add @sevenui/component/textarea-10pnpm dlx shadcn@latest add @sevenui/component/textarea-10yarn dlx shadcn@latest add @sevenui/component/textarea-10bunx --bun shadcn@latest add @sevenui/component/textarea-10Duplicate charge on March invoice
Ticket 4821 · Jordan Ellis
- JE
I was charged twice for my March subscription. Can you refund the duplicate payment?
"use client";
import { SendIcon, ZapIcon } from "lucide-react";
import { useId, useRef, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const macros = [
{
label: "Refund issued",
text: "Hi Jordan, I've issued a full refund of $29.00 to your Visa ending in 4242. It usually shows up within 5 to 7 business days.",
},
{
label: "Need order number",
text: "Hi Jordan, thanks for reaching out. Could you share the order number from your confirmation email so I can look into this?",
},
{
label: "Escalated",
text: "Hi Jordan, I've passed this to our billing team and marked it urgent. You'll hear back from us within one business day.",
},
];
type Message = { id: number; from: "customer" | "agent"; body: string };
const initialThread: Message[] = [
{
id: 1,
from: "customer",
body: "I was charged twice for my March subscription. Can you refund the duplicate payment?",
},
];
export default function Textarea11() {
const id = useId();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [thread, setThread] = useState(initialThread);
const [reply, setReply] = useState("");
const canSend = reply.trim().length > 0;
const send = () => {
if (!canSend) return;
setThread((current) => [
...current,
{ id: current.length + 1, from: "agent", body: reply.trim() },
]);
setReply("");
};
const applyMacro = (text: string) => {
setReply(text);
textareaRef.current?.focus();
};
return (
<div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground">
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">Duplicate charge on March invoice</p>
<p className="text-xs text-muted-foreground">Ticket 4821 · Jordan Ellis</p>
</div>
<Badge variant="secondary">Open</Badge>
</div>
<ol aria-label="Conversation" className="flex flex-col gap-3 px-4 py-4">
{thread.map((message) => (
<li
key={message.id}
className={
message.from === "agent"
? "flex flex-row-reverse items-end gap-2"
: "flex items-end gap-2"
}
>
<Avatar size="sm">
<AvatarFallback>
{message.from === "agent" ? "You" : "JE"}
</AvatarFallback>
</Avatar>
<p
className={
message.from === "agent"
? "max-w-[80%] rounded-2xl rounded-br-sm bg-primary px-3 py-2 text-sm text-primary-foreground"
: "max-w-[80%] rounded-2xl rounded-bl-sm bg-muted px-3 py-2 text-sm"
}
>
{message.body}
</p>
</li>
))}
</ol>
<form
className="flex flex-col gap-2 border-t border-border bg-muted/30 p-3"
onSubmit={(event) => {
event.preventDefault();
send();
}}
>
<fieldset className="flex min-w-0 gap-1.5 overflow-x-auto pb-1">
<legend className="sr-only">Saved replies</legend>
{macros.map((macro) => (
<Button
key={macro.label}
type="button"
variant="outline"
size="xs"
className="shrink-0"
onClick={() => applyMacro(macro.text)}
>
<ZapIcon aria-hidden="true" />
{macro.label}
</Button>
))}
</fieldset>
<Label htmlFor={`${id}-reply`} className="sr-only">
Reply to Jordan
</Label>
<Textarea
ref={textareaRef}
id={`${id}-reply`}
value={reply}
onChange={(event) => setReply(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
send();
}
}}
placeholder="Reply to Jordan…"
aria-describedby={`${id}-hint`}
className="max-h-40 min-h-20 bg-background dark:bg-input/30"
/>
<div className="flex items-center justify-between gap-2">
<p
id={`${id}-hint`}
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<KbdGroup>
<Kbd>Ctrl</Kbd>
<Kbd>Enter</Kbd>
</KbdGroup>
to send
</p>
<Button type="submit" size="sm" disabled={!canSend}>
Send reply
<SendIcon aria-hidden="true" data-icon="inline-end" />
</Button>
</div>
</form>
</div>
);
}
npx shadcn@latest add @sevenui/component/textarea-11pnpm dlx shadcn@latest add @sevenui/component/textarea-11yarn dlx shadcn@latest add @sevenui/component/textarea-11bunx --bun shadcn@latest add @sevenui/component/textarea-11"use client";
import { CircleAlertIcon, CircleCheckIcon } from "lucide-react";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const initialUrls = `https://app.northwind.dev/auth/callback
https://staging.northwind.dev/auth/callback
http://localhost:3000/auth/callback
http://northwind.dev/auth/callback
https://*.preview.northwind.dev/auth/callback`;
type LineIssue = { line: number; message: string };
function validate(raw: string) {
const lines = raw.split("\n");
const seen = new Set<string>();
const issues: LineIssue[] = [];
let valid = 0;
lines.forEach((text, index) => {
const value = text.trim();
if (!value) return;
const line = index + 1;
if (value.includes("*")) {
issues.push({ line, message: "Wildcards are not supported." });
return;
}
let url: URL;
try {
url = new URL(value);
} catch {
issues.push({ line, message: "Not a valid URL." });
return;
}
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
if (url.protocol !== "https:" && !(isLocal && url.protocol === "http:")) {
issues.push({ line, message: "Use https outside localhost." });
return;
}
if (seen.has(value)) {
issues.push({ line, message: "Duplicate of an earlier line." });
return;
}
seen.add(value);
valid += 1;
});
return { lineCount: lines.length, issues, valid };
}
export default function Textarea12() {
const id = useId();
const [urls, setUrls] = useState(initialUrls);
const [saved, setSaved] = useState(false);
const { lineCount, issues, valid } = validate(urls);
const badLines = new Set(issues.map((issue) => issue.line));
const hasIssues = issues.length > 0;
return (
<form
className="flex w-full max-w-lg flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
if (!hasIssues) setSaved(true);
}}
>
<Label htmlFor={`${id}-urls`}>Allowed redirect URLs</Label>
<p id={`${id}-hint`} className="text-sm text-muted-foreground">
One URL per line. Sign-in only returns users to an exact match.
</p>
<div className="flex overflow-hidden rounded-lg border border-input transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-destructive/20 dark:bg-input/30">
<div
aria-hidden="true"
className="shrink-0 border-r border-border bg-muted/50 py-2 font-mono text-xs leading-6 tabular-nums select-none"
>
{Array.from({ length: lineCount }, (_, index) => {
const line = index + 1;
return (
<div
key={line}
className={
badLines.has(line)
? "bg-destructive/10 px-2.5 text-right text-destructive"
: "px-2.5 text-right text-muted-foreground"
}
>
{line}
</div>
);
})}
</div>
<Textarea
id={`${id}-urls`}
value={urls}
wrap="off"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
aria-invalid={hasIssues || undefined}
aria-describedby={`${id}-hint ${id}-issues`}
onChange={(event) => {
setUrls(event.target.value);
setSaved(false);
}}
className="min-h-32 resize-none rounded-none border-0 bg-transparent font-mono text-xs leading-6 shadow-none focus-visible:ring-0 aria-invalid:ring-0 md:text-xs dark:bg-transparent"
/>
</div>
<div id={`${id}-issues`} aria-live="polite">
{hasIssues ? (
<ul className="flex flex-col gap-1 text-sm text-destructive">
{issues.map((issue) => (
<li key={issue.line} className="flex items-start gap-1.5">
<CircleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<span>
<span className="font-medium tabular-nums">Line {issue.line}:</span>{" "}
{issue.message}
</span>
</li>
))}
</ul>
) : (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<CircleCheckIcon aria-hidden="true" className="size-4 shrink-0 text-success" />
{saved
? `Saved. ${valid} redirect ${valid === 1 ? "URL is" : "URLs are"} live.`
: `${valid} ${valid === 1 ? "URL" : "URLs"} ready to save.`}
</p>
)}
</div>
<Button type="submit" className="mt-2 self-end" disabled={hasIssues || saved}>
Save URLs
</Button>
</form>
);
}
npx shadcn@latest add @sevenui/component/textarea-12pnpm dlx shadcn@latest add @sevenui/component/textarea-12yarn dlx shadcn@latest add @sevenui/component/textarea-12bunx --bun shadcn@latest add @sevenui/component/textarea-12src/lib/retry.ts
41
export async function retry(fn, opts) {42-
const attempts = 3;42+
const attempts = opts.attempts ?? 5;43+
const delay = opts.delay ?? 200;44
for (let i = 0; i < attempts; i++) {"use client";
import { MessageSquarePlusIcon } from "lucide-react";
import { useId, useState } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
type Line = { number: number; kind: "context" | "add" | "remove"; code: string };
const diff: Line[] = [
{ number: 41, kind: "context", code: "export async function retry(fn, opts) {" },
{ number: 42, kind: "remove", code: " const attempts = 3;" },
{ number: 42, kind: "add", code: " const attempts = opts.attempts ?? 5;" },
{ number: 43, kind: "add", code: " const delay = opts.delay ?? 200;" },
{ number: 44, kind: "context", code: " for (let i = 0; i < attempts; i++) {" },
];
const markers = { context: " ", add: "+", remove: "-" } as const;
type Comment = { id: number; line: number; body: string };
export default function Textarea13() {
const id = useId();
const [openLine, setOpenLine] = useState<number | null>(43);
const [draft, setDraft] = useState(
"Should `delay` back off exponentially? A flat 200ms will hammer the API when it's already struggling.",
);
const [comments, setComments] = useState<Comment[]>([]);
const submit = () => {
if (openLine === null || !draft.trim()) return;
setComments((current) => [
...current,
{ id: current.length + 1, line: openLine, body: draft.trim() },
]);
setDraft("");
setOpenLine(null);
};
return (
<div className="w-full max-w-xl overflow-hidden rounded-xl border border-border bg-card text-card-foreground">
<div className="border-b border-border bg-muted/40 px-3 py-2 font-mono text-xs text-muted-foreground">
src/lib/retry.ts
</div>
<div className="font-mono text-xs">
{diff.map((line, index) => {
const key = `${line.kind}-${line.number}-${index}`;
const lineComments = comments.filter(
(comment) => line.kind !== "remove" && comment.line === line.number,
);
const isOpen = line.kind !== "remove" && openLine === line.number;
return (
<div key={key}>
<div
className={
line.kind === "add"
? "group flex items-stretch bg-success/10"
: line.kind === "remove"
? "group flex items-stretch bg-destructive/10"
: "group flex items-stretch"
}
>
<span className="w-10 shrink-0 py-1 pr-2 text-right text-muted-foreground tabular-nums select-none">
{line.number}
</span>
<span className="w-4 shrink-0 py-1 text-muted-foreground select-none">
{markers[line.kind]}
</span>
<code className="min-w-0 flex-1 overflow-x-auto py-1 pr-2 whitespace-pre">
{line.code}
</code>
{line.kind !== "remove" ? (
<Button
variant="ghost"
size="icon-xs"
aria-label={`Comment on line ${line.number}`}
aria-expanded={isOpen}
onClick={() => setOpenLine(isOpen ? null : line.number)}
className="my-0.5 mr-1 sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 sm:aria-expanded:opacity-100"
>
<MessageSquarePlusIcon aria-hidden="true" />
</Button>
) : null}
</div>
{lineComments.map((comment) => (
<div
key={comment.id}
className="flex gap-2 border-y border-border bg-background px-3 py-3 font-sans"
>
<Avatar size="sm">
<AvatarFallback>SK</AvatarFallback>
</Avatar>
<div className="min-w-0 text-sm">
<p className="font-medium">Sam Kowalski</p>
<p className="break-words whitespace-pre-wrap text-muted-foreground">
{comment.body}
</p>
</div>
</div>
))}
{isOpen ? (
<div className="border-y border-border bg-background p-3 font-sans">
<Tabs defaultValue="write" className="gap-2">
<TabsList>
<TabsTrigger value="write">Write</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="write" className="flex flex-col gap-2">
<Label htmlFor={`${id}-comment`} className="sr-only">
Comment on line {line.number}
</Label>
<Textarea
id={`${id}-comment`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Leave a comment. Wrap code in backticks."
className="min-h-20 text-sm md:text-sm"
/>
</TabsContent>
<TabsContent value="preview">
<p className="min-h-20 rounded-lg border border-dashed border-border px-2.5 py-2 text-sm break-words whitespace-pre-wrap">
{draft.trim()
? draft.split(/(`[^`]+`)/g).map((part, partIndex) =>
part.startsWith("`") && part.endsWith("`") ? (
<code
// biome-ignore lint/suspicious/noArrayIndexKey: parts are positional
key={partIndex}
className="rounded bg-muted px-1 py-0.5 font-mono text-xs"
>
{part.slice(1, -1)}
</code>
) : (
part
),
)
: "Nothing to preview."}
</p>
</TabsContent>
</Tabs>
<div className="mt-2 flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setOpenLine(null)}
>
Cancel
</Button>
<Button size="sm" disabled={!draft.trim()} onClick={submit}>
Add comment
</Button>
</div>
</div>
) : null}
</div>
);
})}
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/textarea-13pnpm dlx shadcn@latest add @sevenui/component/textarea-13yarn dlx shadcn@latest add @sevenui/component/textarea-13bunx --bun shadcn@latest add @sevenui/component/textarea-13"use client";
import { CheckIcon, CircleAlertIcon, CopyIcon, PlusIcon } from "lucide-react";
import { useId, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const source =
"Hi {name}, invoice {invoiceId} for {amount} is due on {dueDate}. Pay online to keep your workspace active.";
const initialTranslation =
"Hallo {name}, die Rechnung {invoiceId} über {betrag} ist am {dueDate} fällig. Bezahle online, damit dein Workspace aktiv bleibt.";
const PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
function placeholdersIn(text: string) {
return Array.from(text.matchAll(PLACEHOLDER_PATTERN), (match) => match[1]);
}
const required = placeholdersIn(source);
export default function Textarea14() {
const id = useId();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [translation, setTranslation] = useState(initialTranslation);
const [approved, setApproved] = useState(false);
const found = placeholdersIn(translation);
const missing = required.filter((name) => !found.includes(name));
const unknown = [...new Set(found.filter((name) => !required.includes(name)))];
const hasIssues = missing.length > 0 || unknown.length > 0 || !translation.trim();
const lengthDelta = Math.round(
((translation.length - source.length) / source.length) * 100,
);
function update(value: string) {
setTranslation(value);
setApproved(false);
}
function insertPlaceholder(name: string) {
const textarea = textareaRef.current;
const token = `{${name}}`;
const start = textarea?.selectionStart ?? translation.length;
const end = textarea?.selectionEnd ?? translation.length;
update(translation.slice(0, start) + token + translation.slice(end));
// Restore the caret after React commits the new value.
requestAnimationFrame(() => {
textarea?.focus();
textarea?.setSelectionRange(start + token.length, start + token.length);
});
}
return (
<form
aria-labelledby={`${id}-key`}
className="flex w-full max-w-lg flex-col gap-4 rounded-xl border border-border bg-card p-4 text-card-foreground sm:p-5"
onSubmit={(event) => {
event.preventDefault();
if (!hasIssues) setApproved(true);
}}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 id={`${id}-key`} className="min-w-0 truncate font-mono text-sm font-medium">
billing.invoice.due_reminder
</h3>
<Badge variant="outline">English to German</Badge>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground">Source (en-US)</p>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => update(source)}
>
<CopyIcon aria-hidden="true" />
Copy source
</Button>
</div>
<p className="rounded-lg bg-muted px-3 py-2 text-sm leading-relaxed">{source}</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-translation`}>Translation (de-DE)</Label>
<Textarea
ref={textareaRef}
id={`${id}-translation`}
lang="de"
value={translation}
onChange={(event) => update(event.target.value)}
aria-invalid={hasIssues || undefined}
aria-describedby={`${id}-check`}
className="min-h-24 leading-relaxed"
/>
<fieldset className="flex min-w-0 flex-wrap items-center gap-1.5">
<legend className="sr-only">Placeholders from the source</legend>
{required.map((name) => {
const present = found.includes(name);
return (
<Button
key={name}
type="button"
variant="outline"
size="xs"
onClick={() => insertPlaceholder(name)}
aria-label={
present
? `Insert {${name}} again, already used`
: `Insert missing {${name}}`
}
className={
present
? "font-mono text-muted-foreground"
: "border-destructive/50 font-mono text-destructive"
}
>
{present ? (
<CheckIcon aria-hidden="true" className="text-success" />
) : (
<PlusIcon aria-hidden="true" />
)}
{`{${name}}`}
</Button>
);
})}
</fieldset>
</div>
<div
id={`${id}-check`}
aria-live="polite"
className="flex flex-col gap-1 text-sm"
>
{missing.length > 0 ? (
<p className="flex items-start gap-1.5 text-destructive">
<CircleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
Missing {missing.map((name) => `{${name}}`).join(", ")}. Click a
placeholder to insert it at the cursor.
</p>
) : null}
{unknown.length > 0 ? (
<p className="flex items-start gap-1.5 text-destructive">
<CircleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
{unknown.map((name) => `{${name}}`).join(", ")} is not in the source.
Placeholder names must stay in English.
</p>
) : null}
{!hasIssues ? (
<p className="text-muted-foreground">
{approved
? "Approved. Ships with the next release."
: "All placeholders match the source."}
</p>
) : null}
<p className="text-xs text-muted-foreground tabular-nums">
{lengthDelta > 0
? `${lengthDelta}% longer than the source. Check it still fits the email preview.`
: "Same length or shorter than the source."}
</p>
</div>
<Button type="submit" className="self-end" disabled={hasIssues || approved}>
Approve translation
</Button>
</form>
);
}
npx shadcn@latest add @sevenui/component/textarea-14pnpm dlx shadcn@latest add @sevenui/component/textarea-14yarn dlx shadcn@latest add @sevenui/component/textarea-14bunx --bun shadcn@latest add @sevenui/component/textarea-14Launch syncWed, Jul 9 · 10:00
DWOHLB
Start a line with TODO @name to turn it into an action item. Attendees: @dana, @omar, @lena.
Action items
0/3- Omar Haddad
- Lena Brandt
- Dana Whitfield
"use client";
import { CalendarIcon, ListTodoIcon } from "lucide-react";
import { useId, useMemo, useState } from "react";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
const attendees = [
{ handle: "dana", name: "Dana Whitfield", initials: "DW" },
{ handle: "omar", name: "Omar Haddad", initials: "OH" },
{ handle: "lena", name: "Lena Brandt", initials: "LB" },
];
const initialNotes = `Q3 launch sync
Pricing page copy is approved, legal signed off Tuesday.
TODO @omar publish the pricing page behind the launch flag
Beta feedback: onboarding checklist feels long for solo users.
TODO @lena cut the checklist to four steps for solo workspaces
TODO @dana confirm the press embargo time with Northstar PR
Next sync moves to Thursday.`;
const TODO_PATTERN = /^\s*TODO\s+(?:@(\w+)\s+)?(.+)$/i;
function extractActions(notes: string) {
return notes.split("\n").flatMap((line, index) => {
const match = line.match(TODO_PATTERN);
if (!match) return [];
const owner = attendees.find(
(person) => person.handle === match[1]?.toLowerCase(),
);
return [{ key: `${index}-${match[2].trim()}`, owner, task: match[2].trim() }];
});
}
export default function Textarea15() {
const id = useId();
const [notes, setNotes] = useState(initialNotes);
const [done, setDone] = useState<Set<string>>(new Set());
const actions = useMemo(() => extractActions(notes), [notes]);
const doneCount = actions.filter((action) => done.has(action.key)).length;
const toggle = (key: string, checked: boolean) => {
setDone((current) => {
const next = new Set(current);
if (checked) next.add(key);
else next.delete(key);
return next;
});
};
return (
<div className="flex w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3">
<div className="flex min-w-0 items-center gap-2 text-sm">
<CalendarIcon aria-hidden="true" className="size-4 text-muted-foreground" />
<span className="font-medium">Launch sync</span>
<span className="text-muted-foreground">Wed, Jul 9 · 10:00</span>
</div>
<AvatarGroup aria-label="Attendees" className="-space-x-1">
{attendees.map((person) => (
<Avatar key={person.handle} title={person.name}>
<AvatarFallback className="text-xs">{person.initials}</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
</div>
<div className="grid md:grid-cols-[1fr_16rem]">
<div className="flex flex-col gap-2 p-4">
<Label htmlFor={`${id}-notes`}>Meeting notes</Label>
<Textarea
id={`${id}-notes`}
value={notes}
onChange={(event) => setNotes(event.target.value)}
aria-describedby={`${id}-syntax`}
className="min-h-64 resize-y text-sm leading-relaxed md:text-sm"
/>
<p id={`${id}-syntax`} className="text-xs text-muted-foreground">
Start a line with{" "}
<code className="rounded bg-muted px-1 font-mono">TODO @name</code>{" "}
to turn it into an action item. Attendees: @dana, @omar, @lena.
</p>
</div>
<Separator className="md:hidden" />
<section
aria-labelledby={`${id}-actions`}
className="flex flex-col gap-3 bg-muted/40 p-4 md:border-l md:border-border"
>
<div className="flex items-center justify-between gap-2">
<h3
id={`${id}-actions`}
className="flex items-center gap-1.5 text-sm font-medium"
>
<ListTodoIcon aria-hidden="true" className="size-4" />
Action items
</h3>
<Badge variant="secondary" className="tabular-nums">
{doneCount}/{actions.length}
</Badge>
</div>
{actions.length === 0 ? (
<p className="text-sm text-muted-foreground">
No action items yet. Add a TODO line to your notes.
</p>
) : (
<ul aria-live="polite" className="flex flex-col gap-3">
{actions.map((action) => {
const checkboxId = `${id}-${action.key}`;
const checked = done.has(action.key);
return (
<li key={action.key} className="flex items-start gap-2.5">
<Checkbox
id={checkboxId}
checked={checked}
onCheckedChange={(value) => toggle(action.key, value)}
className="mt-0.5"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Label
htmlFor={checkboxId}
className={
checked
? "leading-snug font-normal text-muted-foreground line-through"
: "leading-snug font-normal"
}
>
{action.task}
</Label>
<span className="text-xs text-muted-foreground">
{action.owner ? action.owner.name : "Unassigned"}
</span>
</div>
</li>
);
})}
</ul>
)}
</section>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/textarea-15pnpm dlx shadcn@latest add @sevenui/component/textarea-15yarn dlx shadcn@latest add @sevenui/component/textarea-15bunx --bun shadcn@latest add @sevenui/component/textarea-15