Input Group
Free, copy-and-go Input Group components built on the SevenUI Input Group primitive.Read the primitive docs.
"use client";
import { useId } from "react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
const fields = [
{
key: "seat-price",
label: "Price per seat",
prefix: "$",
suffix: "USD / month",
defaultValue: "12.00",
inputMode: "decimal",
},
{
key: "trial-length",
label: "Free trial",
prefix: null,
suffix: "days",
defaultValue: "14",
inputMode: "numeric",
},
{
key: "annual-discount",
label: "Annual discount",
prefix: null,
suffix: "%",
defaultValue: "20",
inputMode: "numeric",
},
] as const;
export default function InputGroup01() {
const id = useId();
return (
<form
className="flex w-full max-w-sm flex-col gap-5"
onSubmit={(event) => event.preventDefault()}
>
{fields.map((field) => {
const inputId = `${id}-${field.key}`;
return (
<div key={field.key} className="flex flex-col gap-2">
<Label htmlFor={inputId}>{field.label}</Label>
<InputGroup>
{field.prefix ? (
<InputGroupAddon>
<InputGroupText>{field.prefix}</InputGroupText>
</InputGroupAddon>
) : null}
<InputGroupInput
id={inputId}
inputMode={field.inputMode}
defaultValue={field.defaultValue}
className="tabular-nums"
/>
<InputGroupAddon align="inline-end">
<InputGroupText>{field.suffix}</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
);
})}
</form>
);
}
npx shadcn@latest add @sevenui/component/input-group-01pnpm dlx shadcn@latest add @sevenui/component/input-group-01yarn dlx shadcn@latest add @sevenui/component/input-group-01bunx --bun shadcn@latest add @sevenui/component/input-group-01"use client";
import { useId } from "react";
import { SearchIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Kbd } from "@/components/ui/kbd";
import { Label } from "@/components/ui/label";
// One field, three densities: a table toolbar, a form, and a hero search.
const sizes = [
{
key: "compact",
label: "Filter invoices",
size: "28px",
placeholder: "Invoice number or customer",
group: "h-7 rounded-md",
input: "h-full text-xs md:text-xs",
icon: "size-3.5",
shortcut: null,
},
{
key: "default",
label: "Search documentation",
size: "32px",
placeholder: "Guides, primitives, API",
group: "",
input: "h-full",
icon: "size-4",
shortcut: "/",
},
{
key: "comfortable",
label: "Search everything",
size: "44px",
placeholder: "Projects, people, and files",
group: "h-11 rounded-xl",
input: "h-full text-base md:text-base",
icon: "size-5",
shortcut: "⌘K",
},
] as const;
export default function InputGroup02() {
const id = useId();
return (
<div className="flex w-full max-w-md flex-col gap-6">
{sizes.map((size) => {
const inputId = `${id}-${size.key}`;
return (
<div key={size.key} className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<Label htmlFor={inputId}>{size.label}</Label>
<span className="text-xs text-muted-foreground tabular-nums">
{size.size}
</span>
</div>
<InputGroup className={size.group}>
<InputGroupAddon className={size.key === "comfortable" ? "pl-3" : undefined}>
<SearchIcon aria-hidden="true" className={size.icon} />
</InputGroupAddon>
<InputGroupInput
id={inputId}
type="search"
placeholder={size.placeholder}
className={size.input}
/>
{size.shortcut ? (
<InputGroupAddon
align="inline-end"
className={size.key === "comfortable" ? "pr-3" : undefined}
>
<Kbd>{size.shortcut}</Kbd>
</InputGroupAddon>
) : null}
</InputGroup>
</div>
);
})}
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-02pnpm dlx shadcn@latest add @sevenui/component/input-group-02yarn dlx shadcn@latest add @sevenui/component/input-group-02bunx --bun shadcn@latest add @sevenui/component/input-group-02"use client";
import { useId } from "react";
import { AtSignIcon, BuildingIcon, TicketIcon, UserIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
// Four surface treatments over the same anatomy. Only the group's own
// border, fill, radius, and shadow change; the addons stay untouched.
const surfaces = [
{
key: "outline",
style: "Outline",
label: "Full name",
placeholder: "Maya Chen",
icon: UserIcon,
className: "",
},
{
key: "filled",
style: "Filled",
label: "Work email",
placeholder: "maya@northwind.com",
icon: AtSignIcon,
className:
"border-transparent bg-muted dark:bg-muted has-[[data-slot=input-group-control]:focus-visible]:bg-background",
},
{
key: "underline",
style: "Underline",
label: "Company",
placeholder: "Northwind Labs",
icon: BuildingIcon,
className:
"rounded-none border-x-0 border-t-0 bg-transparent dark:bg-transparent has-[[data-slot=input-group-control]:focus-visible]:ring-0 [&>[data-align=inline-start]]:pl-0",
},
{
key: "elevated",
style: "Elevated",
label: "Invite code",
placeholder: "NW-2026-BETA",
icon: TicketIcon,
className: "border-border/60 bg-card shadow-sm dark:bg-card",
},
] as const;
export default function InputGroup03() {
const id = useId();
return (
<div className="grid w-full max-w-md gap-5 sm:grid-cols-2">
{surfaces.map((surface) => {
const inputId = `${id}-${surface.key}`;
const Icon = surface.icon;
return (
<div key={surface.key} className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<Label htmlFor={inputId}>{surface.label}</Label>
<span className="text-xs text-muted-foreground">
{surface.style}
</span>
</div>
<InputGroup className={surface.className}>
<InputGroupAddon>
<Icon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput id={inputId} placeholder={surface.placeholder} />
</InputGroup>
</div>
);
})}
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-03pnpm dlx shadcn@latest add @sevenui/component/input-group-03yarn dlx shadcn@latest add @sevenui/component/input-group-03bunx --bun shadcn@latest add @sevenui/component/input-group-03"use client";
import { useId, useState } from "react";
import {
CircleAlertIcon,
CircleCheckIcon,
LandmarkIcon,
MailIcon,
} from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
// EU VAT IDs: a two-letter country code followed by 8 to 12 characters.
const VAT_PATTERN = /^(AT|BE|DE|DK|ES|FI|FR|IE|IT|NL|PL|PT|SE)[0-9A-Z]{8,12}$/;
type Status = "idle" | "error" | "success";
function getEmailStatus(value: string): Status {
if (!value) return "idle";
return EMAIL_PATTERN.test(value) ? "success" : "error";
}
function getVatStatus(value: string): Status {
// Stay quiet until the ID is long enough to judge.
if (value.length < 10) return "idle";
return VAT_PATTERN.test(value) ? "success" : "error";
}
const successGroup =
"border-success/60 has-[[data-slot=input-group-control]:focus-visible]:border-success has-[[data-slot=input-group-control]:focus-visible]:ring-success/20";
export default function InputGroup04() {
const id = useId();
const [email, setEmail] = useState("maya@northwind");
const [vat, setVat] = useState("DE294817365");
const emailStatus = getEmailStatus(email);
const vatStatus = getVatStatus(vat);
return (
<form
noValidate
className="flex w-full max-w-sm flex-col gap-5"
onSubmit={(event) => event.preventDefault()}
>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-email`}>Billing email</Label>
<InputGroup className={emailStatus === "success" ? successGroup : undefined}>
<InputGroupAddon>
<MailIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id={`${id}-email`}
type="email"
autoComplete="email"
value={email}
aria-invalid={emailStatus === "error"}
aria-describedby={`${id}-email-message`}
onChange={(event) => setEmail(event.target.value.trim())}
/>
{emailStatus !== "idle" ? (
<InputGroupAddon align="inline-end">
{emailStatus === "error" ? (
<CircleAlertIcon aria-hidden="true" className="text-destructive" />
) : (
<CircleCheckIcon aria-hidden="true" className="text-success" />
)}
</InputGroupAddon>
) : null}
</InputGroup>
<p
id={`${id}-email-message`}
aria-live="polite"
className={
emailStatus === "error"
? "text-sm text-destructive"
: "text-sm text-muted-foreground"
}
>
{emailStatus === "error"
? "Add the full domain, like maya@northwind.com."
: "Receipts and failed-payment notices go here."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-vat`}>VAT number</Label>
<InputGroup className={vatStatus === "success" ? successGroup : undefined}>
<InputGroupAddon>
<LandmarkIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id={`${id}-vat`}
value={vat}
placeholder="DE123456789"
autoCapitalize="characters"
autoComplete="off"
spellCheck={false}
aria-invalid={vatStatus === "error"}
aria-describedby={`${id}-vat-message`}
className="tracking-wide tabular-nums"
onChange={(event) =>
setVat(event.target.value.toUpperCase().replace(/[\s.-]/g, ""))
}
/>
{vatStatus === "success" ? (
<InputGroupAddon align="inline-end">
<InputGroupText className="text-success">
<CircleCheckIcon aria-hidden="true" />
Valid
</InputGroupText>
</InputGroupAddon>
) : null}
{vatStatus === "error" ? (
<InputGroupAddon align="inline-end">
<CircleAlertIcon aria-hidden="true" className="text-destructive" />
</InputGroupAddon>
) : null}
</InputGroup>
<p
id={`${id}-vat-message`}
aria-live="polite"
className={
vatStatus === "error"
? "text-sm text-destructive"
: "text-sm text-muted-foreground"
}
>
{vatStatus === "success"
? "Reverse charge applies, so invoices won’t include VAT."
: vatStatus === "error"
? "Start with the country code, like DE294817365."
: "EU businesses only. Leave empty to be charged VAT."}
</p>
</div>
</form>
);
}
npx shadcn@latest add @sevenui/component/input-group-04pnpm dlx shadcn@latest add @sevenui/component/input-group-04yarn dlx shadcn@latest add @sevenui/component/input-group-04bunx --bun shadcn@latest add @sevenui/component/input-group-04Renews on March 4, 2027 for $1,152.
The region is fixed once a project is created.
"use client";
import { useEffect, useId, useState } from "react";
import {
CheckIcon,
CopyIcon,
CreditCardIcon,
GlobeIcon,
LockIcon,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
const PROJECT_ID = "prj_8kd2Lq41ZxVt";
const plans = [
{
key: "team-yearly",
label: "Team, billed yearly",
note: "Renews on March 4, 2027 for $1,152.",
},
{
key: "team-monthly",
label: "Team, billed monthly",
note: "Renews on October 4, 2026 for $120.",
},
{
key: "business-yearly",
label: "Business, billed yearly",
note: "Renews on March 4, 2027 for $2,880. The difference is prorated.",
},
] as const;
type PlanKey = (typeof plans)[number]["key"];
// Read-only fields stay focusable and selectable, so they get a quiet fill
// instead of the dimmed disabled treatment.
const readOnlyGroup = "bg-muted/50 dark:bg-muted/40";
export default function InputGroup05() {
const id = useId();
const [copied, setCopied] = useState(false);
const [planKey, setPlanKey] = useState<PlanKey>("team-yearly");
const plan = plans.find((option) => option.key === planKey) ?? plans[0];
useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), 1600);
return () => window.clearTimeout(timeout);
}, [copied]);
const copyProjectId = () => {
navigator.clipboard?.writeText(PROJECT_ID).catch(() => {});
setCopied(true);
};
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-project`}>Project ID</Label>
<InputGroup className={readOnlyGroup}>
<InputGroupInput
id={`${id}-project`}
readOnly
value={PROJECT_ID}
className="font-mono text-xs md:text-xs"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={copied ? "Copied" : "Copy project ID"}
onClick={copyProjectId}
>
{copied ? (
<CheckIcon aria-hidden="true" className="text-success" />
) : (
<CopyIcon aria-hidden="true" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-plan`}>Current plan</Label>
<InputGroup className={readOnlyGroup}>
<InputGroupAddon>
<CreditCardIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id={`${id}-plan`}
readOnly
value={plan.label}
aria-describedby={`${id}-plan-note`}
/>
<InputGroupAddon align="inline-end">
<DropdownMenu>
<DropdownMenuTrigger
render={
<InputGroupButton
variant="outline"
aria-label={`Change plan, currently ${plan.label}`}
/>
}
>
Change
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>Switch plan</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={planKey}
onValueChange={(value) => setPlanKey(value as PlanKey)}
>
{plans.map((option) => (
<DropdownMenuRadioItem
key={option.key}
value={option.key}
closeOnClick
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</InputGroupAddon>
</InputGroup>
<p id={`${id}-plan-note`} className="text-sm text-muted-foreground">
{plan.note}
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`${id}-region`}>Data region</Label>
<InputGroup data-disabled="true">
<InputGroupAddon>
<GlobeIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id={`${id}-region`}
disabled
defaultValue="eu-central-1 (Frankfurt)"
aria-describedby={`${id}-region-note`}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>
<LockIcon aria-hidden="true" />
Locked
</InputGroupText>
</InputGroupAddon>
</InputGroup>
<p id={`${id}-region-note`} className="text-sm text-muted-foreground">
The region is fixed once a project is created.
</p>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-05pnpm dlx shadcn@latest add @sevenui/component/input-group-05yarn dlx shadcn@latest add @sevenui/component/input-group-05bunx --bun shadcn@latest add @sevenui/component/input-group-05Try docs.relay.so/guides/webhooks, or any other address.
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { GlobeIcon, LinkIcon, XIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
type Preview = { title: string; site: string };
// Simulated unfurl results, keyed by host and path.
const knownPages: Record<string, Preview> = {
"northwind.dev/blog/pricing-page-teardown": {
title: "Pricing page teardown: 12 patterns that lift trial starts",
site: "Northwind Engineering Blog",
},
"docs.relay.so/guides/webhooks": {
title: "Receiving webhooks: retries, signatures, and idempotency",
site: "Relay Docs",
},
};
const URL_PATTERN = /^(?:https?:\/\/)?([a-z0-9-]+(?:\.[a-z0-9-]+)+)(\/\S*)?$/i;
type State =
| { status: "empty" }
| { status: "invalid" }
| { status: "loading" }
| { status: "ready"; preview: Preview }
| { status: "fallback"; host: string };
function describe(url: string): Preview | { host: string } {
const match = url.match(URL_PATTERN);
const host = match?.[1]?.toLowerCase() ?? "";
const key = `${host}${(match?.[2] ?? "").replace(/\/$/, "")}`;
return knownPages[key] ?? { host };
}
export default function InputGroup06() {
const id = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [url, setUrl] = useState("northwind.dev/blog/pricing-page-teardown");
const [state, setState] = useState<State>({ status: "loading" });
// Wait for typing to pause, then "fetch" the page. Every keystroke cancels
// the pending request.
useEffect(() => {
const value = url.trim();
if (!value) {
setState({ status: "empty" });
return;
}
if (!URL_PATTERN.test(value)) {
setState({ status: "invalid" });
return;
}
setState({ status: "loading" });
const timeout = window.setTimeout(() => {
const result = describe(value);
setState(
"title" in result
? { status: "ready", preview: result }
: { status: "fallback", host: result.host },
);
}, 900);
return () => window.clearTimeout(timeout);
}, [url]);
return (
<div className="flex w-full max-w-md flex-col gap-2">
<Label htmlFor={`${id}-url`}>Reading list link</Label>
<InputGroup aria-busy={state.status === "loading"}>
<InputGroupInput
ref={inputRef}
id={`${id}-url`}
type="url"
inputMode="url"
value={url}
placeholder="Paste an article or docs link"
autoComplete="off"
spellCheck={false}
aria-invalid={state.status === "invalid"}
aria-describedby={`${id}-preview`}
onChange={(event) => setUrl(event.target.value)}
/>
<InputGroupAddon
align="block-end"
id={`${id}-preview`}
aria-live="polite"
className="min-h-11 border-t"
>
{state.status === "empty" ? (
<InputGroupText className="text-xs">
<LinkIcon aria-hidden="true" className="size-3.5" />
A title and site name appear here once the link loads.
</InputGroupText>
) : null}
{state.status === "invalid" ? (
<InputGroupText className="text-xs text-destructive">
That doesn’t look like a web address yet.
</InputGroupText>
) : null}
{state.status === "loading" ? (
<InputGroupText className="text-xs">
<Spinner aria-hidden="true" role="presentation" className="size-3.5" />
Fetching preview…
</InputGroupText>
) : null}
{state.status === "ready" || state.status === "fallback" ? (
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<GlobeIcon aria-hidden="true" className="size-3.5" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">
{state.status === "ready"
? state.preview.title
: "No preview available"}
</span>
<span className="truncate text-xs font-normal text-muted-foreground">
{state.status === "ready"
? state.preview.site
: `${state.host} · the link is still saved`}
</span>
</span>
<InputGroupButton
size="icon-xs"
aria-label="Clear link"
onClick={() => {
setUrl("");
// The button unmounts with the preview, so hand focus back
// to the field instead of dropping it on the page.
inputRef.current?.focus();
}}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
</div>
) : null}
</InputGroupAddon>
</InputGroup>
<p className="text-xs text-muted-foreground">
Try docs.relay.so/guides/webhooks, or any other address.
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-06pnpm dlx shadcn@latest add @sevenui/component/input-group-06yarn dlx shadcn@latest add @sevenui/component/input-group-06bunx --bun shadcn@latest add @sevenui/component/input-group-06Starts on a Monday. Type a date or open the calendar.
"use client";
import { useId, useState } from "react";
import { CalendarIcon } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const formatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "long" });
// Accepts "Oct 12, 2026", "10/12/2026", or "2026-10-12".
function parseDate(value: string): Date | null {
const text = value.trim();
if (!text) return null;
const iso = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
const us = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
const date = iso
? new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]))
: us
? new Date(Number(us[3]), Number(us[1]) - 1, Number(us[2]))
: new Date(text);
return Number.isNaN(date.getTime()) ? null : date;
}
const earliest = new Date(2026, 8, 28);
export default function InputGroup07() {
const id = useId();
const [text, setText] = useState("Oct 12, 2026");
const [date, setDate] = useState<Date | undefined>(new Date(2026, 9, 12));
const [open, setOpen] = useState(false);
const parsed = parseDate(text);
const tooEarly = parsed !== null && parsed < earliest;
const invalid = text.trim() !== "" && (parsed === null || tooEarly);
function commit() {
if (parsed && !tooEarly) {
setDate(parsed);
setText(formatter.format(parsed));
}
}
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor={`${id}-date`}>Contract start date</Label>
<InputGroup>
<InputGroupInput
id={`${id}-date`}
value={text}
placeholder="Oct 12, 2026"
autoComplete="off"
aria-invalid={invalid}
aria-describedby={`${id}-date-hint`}
onChange={(event) => setText(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
}
if (event.key === "ArrowDown" && event.altKey) {
event.preventDefault();
setOpen(true);
}
}}
/>
<InputGroupAddon align="inline-end">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<InputGroupButton
size="icon-xs"
aria-label={
date
? `Choose date, selected ${formatter.format(date)}`
: "Choose date"
}
/>
}
>
<CalendarIcon aria-hidden="true" />
</PopoverTrigger>
<PopoverContent align="end" alignOffset={-8} className="w-auto p-0">
<Calendar
mode="single"
selected={date}
defaultMonth={date}
disabled={{ before: earliest }}
onSelect={(next) => {
setDate(next);
if (next) setText(formatter.format(next));
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</InputGroupAddon>
</InputGroup>
<p
id={`${id}-date-hint`}
aria-live="polite"
className={
invalid ? "text-sm text-destructive" : "text-sm text-muted-foreground"
}
>
{tooEarly
? `Pick ${formatter.format(earliest)} or later. Earlier dates are already invoiced.`
: invalid
? "Type a date like Oct 12, 2026 or 10/12/2026."
: parsed
? `Starts on a ${weekday.format(parsed)}. Type a date or open the calendar.`
: "Type a date or open the calendar."}
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-07pnpm dlx shadcn@latest add @sevenui/component/input-group-07yarn dlx shadcn@latest add @sevenui/component/input-group-07bunx --bun shadcn@latest add @sevenui/component/input-group-07"use client";
import { type ComponentProps, useId, useState } from "react";
import { ArrowUpDownIcon, SearchIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
// Labels sit inside the border in a block-start addon, so each group reads
// as one tall tap target. Useful on dense search and mobile forms.
function InsetField({
id,
label,
className,
...props
}: ComponentProps<typeof InputGroupInput> & {
id: string;
label: string;
}) {
return (
<InputGroup className={className}>
<InputGroupAddon align="block-start" className="pb-0">
<label
htmlFor={id}
className="text-xs font-medium text-muted-foreground"
>
{label}
</label>
</InputGroupAddon>
<InputGroupInput id={id} className="h-8 pt-0" {...props} />
</InputGroup>
);
}
export default function InputGroup08() {
const id = useId();
const [from, setFrom] = useState("San Francisco (SFO)");
const [to, setTo] = useState("Lisbon (LIS)");
const [depart, setDepart] = useState("Thu, Oct 15");
const [returning, setReturning] = useState("");
const [status, setStatus] = useState<{ error: boolean; text: string } | null>(
null,
);
function search() {
if (!from.trim() || !to.trim()) {
setStatus({ error: true, text: "Add both an origin and a destination." });
return;
}
if (from.trim().toLowerCase() === to.trim().toLowerCase()) {
setStatus({ error: true, text: "Origin and destination can't match." });
return;
}
const dates = [depart.trim() || "any date", returning.trim() || "one way"];
setStatus({
error: false,
text: `Searching ${from.trim()} to ${to.trim()} · ${dates.join(" · ")}`,
});
}
return (
<form
aria-label="Search flights"
className="flex w-full max-w-sm flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
search();
}}
>
<div className="relative flex flex-col gap-3">
<InsetField
id={`${id}-from`}
label="From"
value={from}
placeholder="City or airport"
className="pr-10"
onChange={(event) => {
setFrom(event.target.value);
setStatus(null);
}}
/>
<InsetField
id={`${id}-to`}
label="To"
value={to}
placeholder="City or airport"
className="pr-10"
onChange={(event) => {
setTo(event.target.value);
setStatus(null);
}}
/>
<Button
type="button"
variant="outline"
size="icon-sm"
aria-label="Swap origin and destination"
className="absolute inset-y-0 right-3 my-auto rounded-full bg-background dark:bg-background"
onClick={() => {
setFrom(to);
setTo(from);
setStatus(null);
}}
>
<ArrowUpDownIcon aria-hidden="true" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<InsetField
id={`${id}-depart`}
label="Depart"
value={depart}
onChange={(event) => {
setDepart(event.target.value);
setStatus(null);
}}
/>
<InsetField
id={`${id}-return`}
label="Return"
value={returning}
placeholder="One way"
onChange={(event) => {
setReturning(event.target.value);
setStatus(null);
}}
/>
</div>
<Button type="submit" size="lg" className="mt-1 w-full">
<SearchIcon aria-hidden="true" />
Search flights
</Button>
<p
role="status"
className={
status?.error
? "min-h-5 text-center text-sm text-destructive"
: "min-h-5 text-center text-sm text-muted-foreground"
}
>
{status?.text}
</p>
</form>
);
}
npx shadcn@latest add @sevenui/component/input-group-08pnpm dlx shadcn@latest add @sevenui/component/input-group-08yarn dlx shadcn@latest add @sevenui/component/input-group-08bunx --bun shadcn@latest add @sevenui/component/input-group-08- statusopen
- labelbug
Type status: (open, in-review, closed), assignee: (maya, diego, priya), label: (bug, feature) then a value and a space. Backspace removes the last filter.
2 of 5 issues
- NW-412Invoice PDF cuts off the tax line
- NW-391Export button ignores date range
"use client";
import { useId, useState } from "react";
import { ListFilterIcon, XIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
type Filter = { key: FilterKey; value: string };
type FilterKey = "status" | "assignee" | "label";
const issues = [
{ id: "NW-412", title: "Invoice PDF cuts off the tax line", status: "open", assignee: "maya", label: "bug" },
{ id: "NW-409", title: "Add SSO for the billing portal", status: "open", assignee: "diego", label: "feature" },
{ id: "NW-398", title: "Retry failed card charges nightly", status: "in-review", assignee: "maya", label: "feature" },
{ id: "NW-391", title: "Export button ignores date range", status: "open", assignee: "priya", label: "bug" },
{ id: "NW-377", title: "Dunning emails go out twice", status: "closed", assignee: "diego", label: "bug" },
];
const keys: { key: FilterKey; hint: string }[] = [
{ key: "status", hint: "open, in-review, closed" },
{ key: "assignee", hint: "maya, diego, priya" },
{ key: "label", hint: "bug, feature" },
];
const TOKEN_PATTERN = /^(status|assignee|label):(\S+)$/i;
export default function InputGroup09() {
const id = useId();
const [filters, setFilters] = useState<Filter[]>([
{ key: "status", value: "open" },
{ key: "label", value: "bug" },
]);
const [draft, setDraft] = useState("");
const words = draft.trim().toLowerCase();
const results = issues.filter(
(issue) =>
filters.every((filter) => issue[filter.key] === filter.value) &&
(!words || issue.title.toLowerCase().includes(words)),
);
// "status:open" followed by a space turns into a token.
function handleChange(value: string) {
const parts = value.split(" ");
const last = parts.length > 1 ? parts[parts.length - 2] : "";
const match = last.match(TOKEN_PATTERN);
if (value.endsWith(" ") && match) {
const key = match[1].toLowerCase() as FilterKey;
const next = { key, value: match[2].toLowerCase() };
setFilters((current) => [
...current.filter((filter) => filter.key !== key),
next,
]);
setDraft(parts.slice(0, -2).join(" "));
return;
}
setDraft(value);
}
function removeFilter(key: FilterKey) {
setFilters((current) => current.filter((filter) => filter.key !== key));
}
return (
<div className="flex w-full max-w-md flex-col gap-2">
<Label htmlFor={`${id}-query`}>Filter issues</Label>
<InputGroup className="h-auto min-h-8 flex-wrap gap-1 py-1 pr-1">
<InputGroupAddon className="py-0">
<ListFilterIcon aria-hidden="true" />
</InputGroupAddon>
<ul aria-label="Active filters" className="contents">
{filters.map((filter) => (
<li
key={filter.key}
className="flex h-6 max-w-full items-center overflow-hidden rounded-md border border-border text-xs"
>
<span className="bg-muted px-1.5 py-0.5 text-muted-foreground">
{filter.key}
</span>
<span className="truncate px-1.5 font-medium">{filter.value}</span>
<InputGroupButton
size="icon-xs"
className="size-6 rounded-none"
aria-label={`Remove ${filter.key} filter`}
onClick={() => removeFilter(filter.key)}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
</li>
))}
</ul>
<InputGroupInput
id={`${id}-query`}
value={draft}
placeholder={filters.length ? "Search titles" : "Try label:bug"}
autoComplete="off"
spellCheck={false}
aria-describedby={`${id}-query-hint ${id}-query-count`}
className="h-6 min-w-28 px-1.5"
onChange={(event) => handleChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Backspace" && !draft && filters.length) {
removeFilter(filters[filters.length - 1].key);
}
}}
/>
</InputGroup>
<p id={`${id}-query-hint`} className="text-xs text-muted-foreground">
Type{" "}
{keys.map((item, index) => (
<span key={item.key}>
<code className="font-mono text-foreground">{item.key}:</code>
<span className="sr-only"> ({item.hint})</span>
{index < keys.length - 1 ? ", " : ""}
</span>
))}{" "}
then a value and a space. Backspace removes the last filter.
</p>
<div className="mt-1 rounded-lg border border-border">
<p
id={`${id}-query-count`}
aria-live="polite"
className="border-b border-border px-3 py-2 text-xs text-muted-foreground tabular-nums"
>
{results.length} of {issues.length} issues
</p>
{results.length ? (
<ul className="divide-y divide-border">
{results.map((issue) => (
<li key={issue.id} className="flex items-baseline gap-3 px-3 py-2">
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.id}
</span>
<span className="min-w-0 truncate text-sm">{issue.title}</span>
</li>
))}
</ul>
) : (
<p className="px-3 py-4 text-sm text-muted-foreground">
No issues match. Remove a filter to widen the search.
</p>
)}
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-09pnpm dlx shadcn@latest add @sevenui/component/input-group-09yarn dlx shadcn@latest add @sevenui/component/input-group-09bunx --bun shadcn@latest add @sevenui/component/input-group-09About $2,725.00 at today’s sample rate
"use client";
import { useId, useState } from "react";
import { ArrowRightLeftIcon, ChevronDownIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
// Static sample rates, USD per 1 unit of each currency.
const currencies = [
{ code: "USD", name: "US dollar", symbol: "$", rate: 1 },
{ code: "EUR", name: "Euro", symbol: "€", rate: 1.09 },
{ code: "GBP", name: "British pound", symbol: "£", rate: 1.27 },
{ code: "JPY", name: "Japanese yen", symbol: "¥", rate: 0.0067 },
] as const;
type CurrencyCode = (typeof currencies)[number]["code"];
function getCurrency(code: CurrencyCode) {
return currencies.find((currency) => currency.code === code) ?? currencies[0];
}
export default function InputGroup10() {
const id = useId();
const [code, setCode] = useState<CurrencyCode>("EUR");
const [amount, setAmount] = useState("2,500.00");
const currency = getCurrency(code);
const numeric = Number.parseFloat(amount.replace(/,/g, ""));
const inUsd = Number.isFinite(numeric)
? new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(numeric * currency.rate)
: "—";
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor={`${id}-amount`}>Invoice amount</Label>
<InputGroup>
<InputGroupAddon>
<DropdownMenu>
<DropdownMenuTrigger
render={
<InputGroupButton
variant="ghost"
aria-label={`${currency.code}, change currency`}
className="font-medium"
/>
}
>
{currency.code}
<ChevronDownIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>Billing currency</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={code}
onValueChange={(value) => setCode(value as CurrencyCode)}
>
{currencies.map((option) => (
<DropdownMenuRadioItem
key={option.code}
value={option.code}
closeOnClick
>
<span className="w-4 text-muted-foreground">
{option.symbol}
</span>
{option.name}
<span className="ml-auto text-xs text-muted-foreground">
{option.code}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<InputGroupText aria-hidden="true" className="-mr-1 border-l pl-2">
{currency.symbol}
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id={`${id}-amount`}
inputMode="decimal"
value={amount}
aria-describedby={`${id}-amount-converted`}
className="tabular-nums"
onChange={(event) =>
setAmount(event.target.value.replace(/[^\d.,]/g, ""))
}
/>
</InputGroup>
<p
id={`${id}-amount-converted`}
aria-live="polite"
className="flex items-center gap-1.5 text-sm text-muted-foreground"
>
<ArrowRightLeftIcon aria-hidden="true" className="size-3.5" />
<span>
About <span className="font-medium text-foreground tabular-nums">{inUsd}</span>{" "}
at today’s sample rate
</span>
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-10pnpm dlx shadcn@latest add @sevenui/component/input-group-10yarn dlx shadcn@latest add @sevenui/component/input-group-10bunx --bun shadcn@latest add @sevenui/component/input-group-10Order summary
- $128.00
Waxed canvas tote
Olive · 1
- $42.00
Leather shoulder strap
Tan · 1
- Subtotal
- $170.00
- Shipping
- $8.00
- Total
- $178.00
"use client";
import { useId, useState } from "react";
import { TagIcon, XIcon } from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { Separator } from "@/components/ui/separator";
const lines = [
{ id: "tote", name: "Waxed canvas tote", detail: "Olive · 1", price: 128 },
{ id: "strap", name: "Leather shoulder strap", detail: "Tan · 1", price: 42 },
];
const codes: Record<string, { label: string; percent: number }> = {
WELCOME10: { label: "10% off your first order", percent: 10 },
FALL25: { label: "25% off fall collection", percent: 25 },
};
const SHIPPING = 8;
function formatMoney(value: number) {
return `$${value.toFixed(2)}`;
}
export default function InputGroup11() {
const inputId = useId();
const messageId = useId();
const [draft, setDraft] = useState("");
const [applied, setApplied] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const subtotal = lines.reduce((sum, line) => sum + line.price, 0);
const discount = applied
? Math.round(subtotal * codes[applied].percent) / 100
: 0;
const total = subtotal - discount + SHIPPING;
function apply() {
const code = draft.trim().toUpperCase();
if (!code) {
setError("Enter a promo code first.");
return;
}
if (!codes[code]) {
setError(`"${code}" isn't a valid code or has expired.`);
return;
}
setApplied(code);
setDraft("");
setError(null);
}
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-5">
<h3 className="font-semibold">Order summary</h3>
<ul className="mt-4 flex flex-col gap-3">
{lines.map((line) => (
<li key={line.id} className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{line.name}</p>
<p className="text-xs text-muted-foreground">{line.detail}</p>
</div>
<span className="text-sm tabular-nums">
{formatMoney(line.price)}
</span>
</li>
))}
</ul>
<Separator className="my-4" />
<form
className="flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
apply();
}}
>
<label htmlFor={inputId} className="text-sm font-medium">
Promo code
</label>
<InputGroup>
<InputGroupAddon>
<TagIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id={inputId}
value={draft}
placeholder="e.g. WELCOME10"
autoComplete="off"
spellCheck={false}
className="uppercase placeholder:normal-case"
aria-invalid={error ? true : undefined}
aria-describedby={messageId}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError(null);
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton type="submit" variant="secondary">
Apply
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<div id={messageId} aria-live="polite">
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : applied ? (
<div className="flex items-center justify-between gap-2 rounded-md bg-muted/60 py-1 pr-1 pl-2.5 text-sm">
<span className="min-w-0 truncate">
<span className="font-medium">{applied}</span>
<span className="text-muted-foreground">
{" "}
· {codes[applied].label}
</span>
</span>
<button
type="button"
aria-label={`Remove code ${applied}`}
onClick={() => setApplied(null)}
className="grid size-6 shrink-0 place-items-center rounded-sm text-muted-foreground outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<XIcon aria-hidden="true" className="size-3.5" />
</button>
</div>
) : (
<p className="text-xs text-muted-foreground">
Try WELCOME10 or FALL25. One code per order.
</p>
)}
</div>
</form>
<Separator className="my-4" />
<dl className="flex flex-col gap-2 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{formatMoney(subtotal)}</dd>
</div>
{applied ? (
<div className="flex justify-between">
<dt className="text-muted-foreground">Discount</dt>
<dd className="text-success tabular-nums">
−{formatMoney(discount)}
</dd>
</div>
) : null}
<div className="flex justify-between">
<dt className="text-muted-foreground">Shipping</dt>
<dd className="tabular-nums">{formatMoney(SHIPPING)}</dd>
</div>
<div className="mt-1 flex justify-between border-t border-border pt-3 font-semibold">
<dt>Total</dt>
<dd className="tabular-nums">{formatMoney(total)}</dd>
</div>
</dl>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-11pnpm dlx shadcn@latest add @sevenui/component/input-group-11yarn dlx shadcn@latest add @sevenui/component/input-group-11bunx --bun shadcn@latest add @sevenui/component/input-group-11"use client";
import { useEffect, useId, useRef, useState } from "react";
import { CheckIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
const takenSlugs = ["acme", "northwind", "studio", "design", "team"];
type Step = 1 | 2 | 3;
type Status = "idle" | "checking" | "available" | "taken" | "invalid";
function toSlug(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-{2,}/g, "-")
.slice(0, 32);
}
export default function InputGroup12() {
const inputId = useId();
const hintId = useId();
const nameId = useId();
const headingRef = useRef<HTMLHeadingElement>(null);
const [step, setStep] = useState<Step>(2);
const [name, setName] = useState("Northwind Labs");
const [slug, setSlug] = useState("northwind-labs");
const [status, setStatus] = useState<Status>("idle");
useEffect(() => {
const value = slug.replace(/^-|-$/g, "");
if (!value) {
setStatus("idle");
return;
}
if (value.length < 3) {
setStatus("invalid");
return;
}
setStatus("checking");
// Simulated availability lookup; cleared whenever the slug changes.
const timer = setTimeout(() => {
setStatus(takenSlugs.includes(value) ? "taken" : "available");
}, 450);
return () => clearTimeout(timer);
}, [slug]);
const hint: Record<Status, string> = {
idle: "Lowercase letters, numbers, and hyphens.",
checking: "Checking availability…",
available: `relay.so/${slug.replace(/^-|-$/g, "")} is yours to claim.`,
taken: "That URL is already taken. Try adding your city or team.",
invalid: "Use at least 3 characters.",
};
const isError = status === "taken" || status === "invalid";
const clean = slug.replace(/^-|-$/g, "");
function goTo(next: Step) {
setStep(next);
// Move focus to the new step's heading so keyboard and screen reader
// users land on the content that just replaced the old step.
requestAnimationFrame(() => headingRef.current?.focus());
}
return (
<form
className="w-full max-w-md rounded-xl border border-border bg-card p-6"
onSubmit={(event) => {
event.preventDefault();
if (step === 1 && name.trim()) {
setSlug(toSlug(name.trim()));
goTo(2);
}
if (step === 2 && status === "available") goTo(3);
}}
>
<div
role="img"
aria-label={`Step ${step} of 3`}
className="flex gap-1.5"
>
{[1, 2, 3].map((item) => (
<span
key={item}
className={
item <= step
? "h-1 flex-1 rounded-full bg-primary"
: "h-1 flex-1 rounded-full bg-muted"
}
/>
))}
</div>
<h3
ref={headingRef}
tabIndex={-1}
className="mt-5 text-lg font-semibold tracking-tight outline-none"
>
{step === 1
? "Name your workspace"
: step === 2
? "Pick your workspace URL"
: "Your workspace is ready"}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{step === 1
? "Use your company or team name. Teammates see it on every invite."
: step === 2
? "Teammates use this link to sign in. You can change it later in settings."
: `${name.trim()} lives at relay.so/${clean}. Share the link to invite your team.`}
</p>
{step === 1 ? (
<div className="mt-5 flex flex-col gap-2">
<label htmlFor={nameId} className="text-sm font-medium">
Workspace name
</label>
<InputGroup>
<InputGroupInput
id={nameId}
value={name}
autoComplete="organization"
onChange={(event) => setName(event.target.value)}
/>
</InputGroup>
</div>
) : null}
{step === 2 ? (
<div className="mt-5 flex flex-col gap-2">
<label htmlFor={inputId} className="text-sm font-medium">
Workspace URL
</label>
<InputGroup>
<InputGroupAddon>
<InputGroupText>relay.so/</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id={inputId}
value={slug}
autoComplete="off"
spellCheck={false}
className="pl-0.5!"
aria-invalid={isError ? true : undefined}
aria-describedby={hintId}
onChange={(event) => setSlug(toSlug(event.target.value))}
/>
<InputGroupAddon align="inline-end">
{status === "checking" ? (
<Spinner aria-hidden="true" role="presentation" />
) : null}
{status === "available" ? (
<CheckIcon aria-hidden="true" className="text-success" />
) : null}
{isError ? (
<XIcon aria-hidden="true" className="text-destructive" />
) : null}
</InputGroupAddon>
</InputGroup>
<p
id={hintId}
aria-live="polite"
className={
isError
? "text-sm text-destructive"
: "text-sm text-muted-foreground"
}
>
{hint[status]}
</p>
</div>
) : null}
<div className="mt-6 flex items-center justify-between gap-3">
{step > 1 ? (
<Button
type="button"
variant="ghost"
onClick={() => goTo(step === 3 ? 2 : 1)}
>
Back
</Button>
) : (
<span />
)}
{/* Distinct keys keep React from reusing one <button> for both: a
reused element would flip to type="submit" mid-click and submit. */}
{step === 3 ? (
<Button
key="start-over"
type="button"
variant="outline"
onClick={() => {
setName("Northwind Labs");
setSlug("northwind-labs");
goTo(1);
}}
>
Start over
</Button>
) : (
<Button
key="continue"
type="submit"
disabled={step === 1 ? !name.trim() : status !== "available"}
>
Continue
</Button>
)}
</div>
</form>
);
}
npx shadcn@latest add @sevenui/component/input-group-12pnpm dlx shadcn@latest add @sevenui/component/input-group-12yarn dlx shadcn@latest add @sevenui/component/input-group-12bunx --bun shadcn@latest add @sevenui/component/input-group-12Members 6
- OMOwner
Olivia Martin
olivia@northwind.io
- JLAdmin
Jackson Lee
jackson@northwind.io
- INMember
Isabella Nguyen
isabella@northwind.io
- WKMember
William Kim
will@northwind.io
- SDBilling
Sofia Davis
sofia@northwind.io
- EBGuest
Ethan Brooks
ethan@contractors.dev
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { MailIcon, SearchIcon, UserPlusIcon, XIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { Kbd } from "@/components/ui/kbd";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const initialMembers = [
{ name: "Olivia Martin", email: "olivia@northwind.io", role: "Owner" },
{ name: "Jackson Lee", email: "jackson@northwind.io", role: "Admin" },
{ name: "Isabella Nguyen", email: "isabella@northwind.io", role: "Member" },
{ name: "William Kim", email: "will@northwind.io", role: "Member" },
{ name: "Sofia Davis", email: "sofia@northwind.io", role: "Billing" },
{ name: "Ethan Brooks", email: "ethan@contractors.dev", role: "Guest" },
];
function initials(name: string) {
return name
.split(" ")
.map((part) => part[0])
.join("");
}
export default function InputGroup13() {
const inputId = useId();
const countId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const inviteId = useId();
const inviteErrorId = useId();
const [query, setQuery] = useState("");
const [members, setMembers] = useState(initialMembers);
const [inviteOpen, setInviteOpen] = useState(false);
const [invite, setInvite] = useState("");
const [inviteError, setInviteError] = useState<string | null>(null);
function sendInvite() {
const email = invite.trim().toLowerCase();
if (!EMAIL_PATTERN.test(email)) {
setInviteError("Enter an email like alex@northwind.io.");
return;
}
if (members.some((member) => member.email === email)) {
setInviteError("That person is already in the workspace.");
return;
}
const name = email
.split("@")[0]
.split(/[._-]+/)
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ");
setMembers((current) => [...current, { name, email, role: "Invited" }]);
setInvite("");
setInviteError(null);
setInviteOpen(false);
}
// Press "/" anywhere outside a text field to jump to search. If the host
// app already claimed "/" (a site-wide search, say), step aside instead of
// firing both shortcuts at once.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (
event.key !== "/" ||
event.defaultPrevented ||
target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
) {
return;
}
event.preventDefault();
inputRef.current?.focus();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
const needle = query.trim().toLowerCase();
const results = members.filter(
(member) =>
member.name.toLowerCase().includes(needle) ||
member.email.toLowerCase().includes(needle) ||
member.role.toLowerCase().includes(needle),
);
return (
<div className="w-full max-w-md rounded-xl border border-border bg-card">
<div className="flex items-center justify-between gap-3 px-4 pt-4">
<h3 className="font-semibold">
Members{" "}
<span className="font-normal text-muted-foreground tabular-nums">
{members.length}
</span>
</h3>
<Popover
open={inviteOpen}
onOpenChange={(open) => {
setInviteOpen(open);
if (!open) setInviteError(null);
}}
>
<PopoverTrigger render={<Button size="sm" variant="outline" />}>
<UserPlusIcon aria-hidden="true" />
Invite
</PopoverTrigger>
<PopoverContent align="end" className="w-72">
<PopoverHeader>
<PopoverTitle>Invite a teammate</PopoverTitle>
<PopoverDescription>
They join as a Member once they accept.
</PopoverDescription>
</PopoverHeader>
<form
noValidate
className="flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
sendInvite();
}}
>
<label htmlFor={inviteId} className="sr-only">
Email address
</label>
<InputGroup>
<InputGroupAddon>
<MailIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id={inviteId}
type="email"
value={invite}
placeholder="name@company.com"
autoComplete="off"
aria-invalid={inviteError ? true : undefined}
aria-describedby={inviteError ? inviteErrorId : undefined}
onChange={(event) => {
setInvite(event.target.value);
if (inviteError) setInviteError(null);
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton type="submit" variant="secondary">
Send
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{inviteError ? (
<p id={inviteErrorId} role="alert" className="text-xs text-destructive">
{inviteError}
</p>
) : null}
</form>
</PopoverContent>
</Popover>
</div>
<div className="px-4 pt-3 pb-2">
<label htmlFor={inputId} className="sr-only">
Search members
</label>
<InputGroup>
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
ref={inputRef}
id={inputId}
type="search"
value={query}
placeholder="Name, email, or role"
aria-describedby={countId}
className="[&::-webkit-search-cancel-button]:hidden"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setQuery("");
}}
/>
<InputGroupAddon align="inline-end">
{needle ? (
<>
<InputGroupText id={countId} className="text-xs tabular-nums">
{results.length} of {members.length}
</InputGroupText>
<InputGroupButton
size="icon-xs"
aria-label="Clear search"
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
</>
) : (
<Kbd aria-label="Press slash to search">/</Kbd>
)}
</InputGroupAddon>
</InputGroup>
</div>
<ul aria-label="Team members" className="max-h-72 overflow-y-auto px-2 pb-2">
{results.map((member) => (
<li
key={member.email}
className="flex items-center gap-3 rounded-lg px-2 py-2 hover:bg-muted/60"
>
<Avatar className="size-8">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback className="text-xs">
{initials(member.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{member.name}</p>
<p className="truncate text-xs text-muted-foreground">
{member.email}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{member.role}
</span>
</li>
))}
</ul>
{results.length === 0 ? (
<div className="px-4 pt-2 pb-8 text-center" aria-live="polite">
<p className="text-sm font-medium">No one matches "{query.trim()}"</p>
<p className="mt-1 text-sm text-muted-foreground">
Check the spelling, or invite them to the workspace.
</p>
</div>
) : null}
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-13pnpm dlx shadcn@latest add @sevenui/component/input-group-13yarn dlx shadcn@latest add @sevenui/component/input-group-13bunx --bun shadcn@latest add @sevenui/component/input-group-13Operations / Planning
3 filesQ3 board deck.pdf
4.2 MB · 2h ago
hiring-plan-2027.xlsx
86 KB · Yesterday
office-floorplan.png
1.1 MB · Sep 18
"use client";
import { useId, useState } from "react";
import {
CheckIcon,
FileTextIcon,
ImageIcon,
PencilIcon,
SheetIcon,
XIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
type FileEntry = {
id: string;
base: string;
ext: string;
size: string;
edited: string;
};
const initialFiles: FileEntry[] = [
{ id: "f1", base: "Q3 board deck", ext: "pdf", size: "4.2 MB", edited: "2h ago" },
{ id: "f2", base: "hiring-plan-2027", ext: "xlsx", size: "86 KB", edited: "Yesterday" },
{ id: "f3", base: "office-floorplan", ext: "png", size: "1.1 MB", edited: "Sep 18" },
];
const icons: Record<string, typeof FileTextIcon> = {
pdf: FileTextIcon,
xlsx: SheetIcon,
png: ImageIcon,
};
const ILLEGAL = /[\\/:*?"<>|]/;
export default function InputGroup14() {
const inputId = useId();
const errorId = useId();
const [files, setFiles] = useState(initialFiles);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
// Saving or cancelling unmounts the field, so send focus back to the row's
// rename button instead of dropping it on the page.
function stopEditing(file: FileEntry) {
setEditingId(null);
setError(null);
requestAnimationFrame(() =>
document.getElementById(`${inputId}-rename-${file.id}`)?.focus(),
);
}
function startEditing(file: FileEntry) {
setEditingId(file.id);
setDraft(file.base);
setError(null);
}
function save(file: FileEntry) {
const name = draft.trim();
if (!name) {
setError("File name can't be empty.");
return;
}
if (ILLEGAL.test(name)) {
setError('Names can\'t contain \\ / : * ? " < > |');
return;
}
const clash = files.some(
(other) =>
other.id !== file.id &&
other.ext === file.ext &&
other.base.toLowerCase() === name.toLowerCase(),
);
if (clash) {
setError(`${name}.${file.ext} already exists in this folder.`);
return;
}
setFiles((current) =>
current.map((item) =>
item.id === file.id ? { ...item, base: name, edited: "Just now" } : item,
),
);
stopEditing(file);
}
return (
<div className="w-full max-w-lg rounded-xl border border-border bg-card">
<div className="flex items-baseline justify-between gap-3 border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">Operations / Planning</h3>
<span className="text-xs text-muted-foreground tabular-nums">
{files.length} files
</span>
</div>
<ul className="divide-y divide-border">
{files.map((file) => {
const Icon = icons[file.ext] ?? FileTextIcon;
const editing = editingId === file.id;
return (
<li key={file.id} className="px-4 py-2.5">
{editing ? (
<form
className="flex flex-col gap-1.5"
onSubmit={(event) => {
event.preventDefault();
save(file);
}}
>
<label htmlFor={inputId} className="sr-only">
New name for {file.base}.{file.ext}
</label>
<InputGroup>
<InputGroupAddon>
<Icon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id={inputId}
value={draft}
autoFocus
onFocus={(event) => event.currentTarget.select()}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError(null);
}}
onKeyDown={(event) => {
if (event.key === "Escape") stopEditing(file);
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupText className="font-mono text-xs">
.{file.ext}
</InputGroupText>
<InputGroupButton
type="submit"
size="icon-xs"
aria-label="Save name"
>
<CheckIcon aria-hidden="true" />
</InputGroupButton>
<InputGroupButton
size="icon-xs"
aria-label="Cancel rename"
onClick={() => stopEditing(file)}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{error ? (
<p id={errorId} role="alert" className="text-xs text-destructive">
{error}
</p>
) : (
<p className="text-xs text-muted-foreground">
Enter to save, Esc to cancel. The extension stays{" "}
<span className="font-mono">.{file.ext}</span>.
</p>
)}
</form>
) : (
<div className="flex items-center gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground">
<Icon aria-hidden="true" className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{file.base}
<span className="text-muted-foreground">.{file.ext}</span>
</p>
<p className="text-xs text-muted-foreground">
{file.size} · {file.edited}
</p>
</div>
<Button
id={`${inputId}-rename-${file.id}`}
size="icon-sm"
variant="ghost"
aria-label={`Rename ${file.base}.${file.ext}`}
onClick={() => startEditing(file)}
>
<PencilIcon aria-hidden="true" />
</Button>
</div>
)}
</li>
);
})}
</ul>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-14pnpm dlx shadcn@latest add @sevenui/component/input-group-14yarn dlx shadcn@latest add @sevenui/component/input-group-14bunx --bun shadcn@latest add @sevenui/component/input-group-14Billing webhook
EnabledWe POST a signed JSON payload to this endpoint for every subscribed event.
Test sends a sample invoice.paid event.
Verify the Northwind-Signature header with this secret.
Events
- invoice.paid
- invoice.payment_failed
- customer.updated
"use client";
import { useEffect, useId, useRef, useState } from "react";
import {
CheckIcon,
CopyIcon,
EyeIcon,
EyeOffIcon,
RotateCwIcon,
SendIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
const events = ["invoice.paid", "invoice.payment_failed", "customer.updated"];
function makeSecret() {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
let out = "whsec_";
for (let i = 0; i < 28; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
type TestResult =
| { state: "idle" }
| { state: "sending" }
| { state: "done"; ok: boolean; message: string };
export default function InputGroup15() {
const urlId = useId();
const secretId = useId();
const urlHintId = useId();
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const [url, setUrl] = useState("api.northwind.io/hooks/billing");
const [secret, setSecret] = useState("whsec_8f2kq9x7m1c4v6b3n5z0p2r8t1y");
const [revealed, setRevealed] = useState(false);
const [copied, setCopied] = useState(false);
const [confirmRoll, setConfirmRoll] = useState(false);
const [test, setTest] = useState<TestResult>({ state: "idle" });
useEffect(() => {
const pending = timers.current;
return () => {
for (const timer of pending) clearTimeout(timer);
};
}, []);
function later(fn: () => void, ms: number) {
timers.current.push(setTimeout(fn, ms));
}
const urlValid = /^[a-z0-9.-]+\.[a-z]{2,}(\/\S*)?$/i.test(url.trim());
function sendTest() {
if (!urlValid) return;
setTest({ state: "sending" });
later(() => {
const ok = !url.includes("localhost");
setTest({
state: "done",
ok,
message: ok
? "200 OK · responded in 142 ms"
: "Connection refused · endpoint must be publicly reachable",
});
}, 900);
}
const masked = `${secret.slice(0, 6)}${"•".repeat(20)}${secret.slice(-4)}`;
return (
<div className="w-full max-w-lg rounded-xl border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">Billing webhook</h3>
<Badge variant="outline" className="gap-1.5">
<span aria-hidden="true" className="size-1.5 rounded-full bg-success" />
Enabled
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
We POST a signed JSON payload to this endpoint for every subscribed
event.
</p>
<div className="mt-5 flex flex-col gap-2">
<label htmlFor={urlId} className="text-sm font-medium">
Endpoint URL
</label>
<InputGroup>
<InputGroupAddon>
<InputGroupText>https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id={urlId}
value={url}
spellCheck={false}
className="pl-0.5! font-mono text-xs md:text-xs"
aria-invalid={url && !urlValid ? true : undefined}
aria-describedby={urlHintId}
onChange={(event) => {
setUrl(event.target.value.replace(/^https?:\/\//, ""));
setTest({ state: "idle" });
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
variant="secondary"
disabled={!urlValid || test.state === "sending"}
onClick={sendTest}
>
{test.state === "sending" ? (
<Spinner className="size-3.5" />
) : (
<SendIcon aria-hidden="true" />
)}
<span className="sr-only sm:not-sr-only">Send test</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<p
id={urlHintId}
aria-live="polite"
className={
(test.state === "done" && !test.ok) || (url && !urlValid)
? "text-xs text-destructive"
: "text-xs text-muted-foreground"
}
>
{url && !urlValid
? "Enter a full host and path, like api.example.com/webhooks."
: test.state === "done"
? test.message
: `Test sends a sample ${events[0]} event.`}
</p>
</div>
<div className="mt-5 flex flex-col gap-2">
<label htmlFor={secretId} className="text-sm font-medium">
Signing secret
</label>
<InputGroup>
<InputGroupInput
id={secretId}
readOnly
value={revealed ? secret : masked}
spellCheck={false}
className="font-mono text-xs md:text-xs"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={revealed ? "Hide secret" : "Reveal secret"}
aria-pressed={revealed}
onClick={() => setRevealed((value) => !value)}
>
{revealed ? (
<EyeOffIcon aria-hidden="true" />
) : (
<EyeIcon aria-hidden="true" />
)}
</InputGroupButton>
<InputGroupButton
size="icon-xs"
aria-label={copied ? "Copied" : "Copy secret"}
onClick={() => {
navigator.clipboard?.writeText(secret).catch(() => {});
setCopied(true);
later(() => setCopied(false), 1500);
}}
>
{copied ? (
<CheckIcon aria-hidden="true" />
) : (
<CopyIcon aria-hidden="true" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<div className="flex min-h-7 flex-wrap items-center justify-between gap-2">
{confirmRoll ? (
<>
<p className="text-xs text-muted-foreground">
The current secret stops working in 24 hours.
</p>
<div className="flex gap-1.5">
<Button
size="xs"
variant="ghost"
onClick={() => setConfirmRoll(false)}
>
Keep current
</Button>
<Button
size="xs"
variant="destructive"
onClick={() => {
setSecret(makeSecret());
setRevealed(true);
setConfirmRoll(false);
}}
>
Roll secret
</Button>
</div>
</>
) : (
<>
<p className="text-xs text-muted-foreground">
Verify the Northwind-Signature header with this secret.
</p>
<Button
size="xs"
variant="ghost"
className="text-muted-foreground"
onClick={() => setConfirmRoll(true)}
>
<RotateCwIcon aria-hidden="true" />
Roll secret
</Button>
</>
)}
</div>
</div>
<div className="mt-4 border-t border-border pt-4">
<p className="text-xs font-medium text-muted-foreground">Events</p>
<ul className="mt-2 flex flex-wrap gap-1.5">
{events.map((event) => (
<li key={event}>
<Badge variant="secondary" className="font-mono font-normal">
{event}
</Badge>
</li>
))}
</ul>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-15pnpm dlx shadcn@latest add @sevenui/component/input-group-15yarn dlx shadcn@latest add @sevenui/component/input-group-15bunx --bun shadcn@latest add @sevenui/component/input-group-15Upcoming
- 11:00 AM
Design review
Today · 45 min · with Priya, Marcus
"use client";
import { useId, useState } from "react";
import {
CalendarDaysIcon,
ClockIcon,
CornerDownLeftIcon,
TimerIcon,
UsersIcon,
} from "lucide-react";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
type Parsed = {
title: string;
day: string | null;
time: string | null;
duration: string | null;
guests: string[];
};
type EventEntry = {
id: number;
title: string;
day: string;
time: string;
duration: string;
guests: string[];
};
const DAY_PATTERN =
/\b(today|tomorrow|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?)\b/i;
const TIME_PATTERN = /\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i;
const DURATION_PATTERN = /\bfor\s+(\d+)\s*(m|min|mins|minutes|h|hr|hrs|hours?)\b/i;
const GUEST_PATTERN = /@([a-z]+)/gi;
const fullDays: Record<string, string> = {
mon: "Monday",
tue: "Tuesday",
wed: "Wednesday",
thu: "Thursday",
fri: "Friday",
sat: "Saturday",
sun: "Sunday",
};
function capitalize(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
function parse(input: string): Parsed {
let rest = input;
const dayMatch = rest.match(DAY_PATTERN);
const timeMatch = rest.match(TIME_PATTERN);
const durationMatch = rest.match(DURATION_PATTERN);
const guests = [...rest.matchAll(GUEST_PATTERN)].map((m) => capitalize(m[1]));
for (const match of [dayMatch, timeMatch, durationMatch]) {
if (match) rest = rest.replace(match[0], " ");
}
rest = rest
.replace(GUEST_PATTERN, " ")
.replace(/\b(with|on)\s*(?=\s|$)/gi, " ")
.replace(/\s{2,}/g, " ")
.trim();
let day: string | null = null;
if (dayMatch) {
const word = dayMatch[1].toLowerCase();
day =
word === "today" || word === "tomorrow"
? capitalize(word)
: fullDays[word.slice(0, 3)];
}
let time: string | null = null;
if (timeMatch) {
const hour = Number(timeMatch[1]);
if (hour >= 1 && hour <= 12) {
time = `${hour}:${timeMatch[2] ?? "00"} ${timeMatch[3].toUpperCase()}`;
}
}
let duration: string | null = null;
if (durationMatch) {
const amount = Number(durationMatch[1]);
duration = durationMatch[2].toLowerCase().startsWith("h")
? `${amount} h`
: `${amount} min`;
}
return { title: rest, day, time, duration, guests };
}
const initialEvents: EventEntry[] = [
{
id: 1,
title: "Design review",
day: "Today",
time: "11:00 AM",
duration: "45 min",
guests: ["Priya", "Marcus"],
},
];
export default function InputGroup16() {
const inputId = useId();
const previewId = useId();
const [value, setValue] = useState("");
const [events, setEvents] = useState(initialEvents);
const parsed = parse(value);
const canAdd = parsed.title.length > 0;
const tokens = [
{ key: "day", icon: CalendarDaysIcon, label: parsed.day ?? "Today", found: !!parsed.day },
{ key: "time", icon: ClockIcon, label: parsed.time ?? "Next free slot", found: !!parsed.time },
{ key: "duration", icon: TimerIcon, label: parsed.duration ?? "30 min", found: !!parsed.duration },
{
key: "guests",
icon: UsersIcon,
label: parsed.guests.length ? parsed.guests.join(", ") : "Just you",
found: parsed.guests.length > 0,
},
];
return (
<div className="w-full max-w-md rounded-xl border border-border bg-card p-4">
<form
onSubmit={(event) => {
event.preventDefault();
if (!canAdd) return;
setEvents((current) => [
...current,
{
id: Date.now(),
title: capitalize(parsed.title),
day: parsed.day ?? "Today",
time: parsed.time ?? "3:30 PM",
duration: parsed.duration ?? "30 min",
guests: parsed.guests,
},
]);
setValue("");
}}
>
<label htmlFor={inputId} className="text-sm font-medium">
Quick add
</label>
<InputGroup className="mt-2">
<InputGroupInput
id={inputId}
value={value}
autoComplete="off"
placeholder="Lunch with @sam tomorrow 1pm for 1h"
className="h-10"
aria-describedby={previewId}
onChange={(event) => setValue(event.target.value)}
/>
<InputGroupAddon
align="block-end"
className="items-end gap-2 border-t border-border"
>
<div
id={previewId}
aria-live="polite"
className="flex min-w-0 flex-1 flex-wrap gap-1"
>
<span className="sr-only">Detected details:</span>
{tokens.map((token) => (
<span
key={token.key}
className={
token.found
? "inline-flex items-center gap-1 rounded-md bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-foreground"
: "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-normal text-muted-foreground"
}
>
<token.icon aria-hidden="true" className="size-3" />
{token.label}
</span>
))}
</div>
<InputGroupButton
type="submit"
size="icon-xs"
variant="default"
disabled={!canAdd}
aria-label="Add event"
>
<CornerDownLeftIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
<h4 className="mt-5 text-xs font-medium text-muted-foreground">
Upcoming
</h4>
<ol className="mt-2 flex flex-col gap-1">
{events.map((event) => (
<li
key={event.id}
className="grid grid-cols-[4.5rem_1fr] items-baseline gap-3 rounded-lg px-2 py-2 hover:bg-muted/50"
>
<span className="text-xs text-muted-foreground tabular-nums">
{event.time}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{event.title}</p>
<p className="truncate text-xs text-muted-foreground">
{event.day} · {event.duration}
{event.guests.length ? ` · with ${event.guests.join(", ")}` : ""}
</p>
</div>
</li>
))}
</ol>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-16pnpm dlx shadcn@latest add @sevenui/component/input-group-16yarn dlx shadcn@latest add @sevenui/component/input-group-16bunx --bun shadcn@latest add @sevenui/component/input-group-16Daniel Ortiz
Billing support · replies in about 2 min
- Daniel said:
Hi Maya, I'm Daniel from billing. I can see the duplicate charge on September 14. Could you share the invoice so I can start the refund?
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { ArrowUpIcon, FileTextIcon, PaperclipIcon, XIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group";
type Message = {
id: number;
from: "agent" | "customer";
text: string;
attachment?: string;
};
const MAX_LENGTH = 500;
const initialMessages: Message[] = [
{
id: 1,
from: "agent",
text: "Hi Maya, I'm Daniel from billing. I can see the duplicate charge on September 14. Could you share the invoice so I can start the refund?",
},
];
export default function InputGroup17() {
const inputId = useId();
const counterId = useId();
const listRef = useRef<HTMLOListElement>(null);
const replyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState("");
const [attachment, setAttachment] = useState<string | null>(null);
const [agentTyping, setAgentTyping] = useState(false);
useEffect(() => {
return () => {
if (replyTimer.current) clearTimeout(replyTimer.current);
};
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever the thread grows
useEffect(() => {
const list = listRef.current;
if (list) list.scrollTop = list.scrollHeight;
}, [messages.length, agentTyping]);
const remaining = MAX_LENGTH - draft.length;
const canSend = draft.trim().length > 0 || attachment !== null;
function send() {
if (!canSend) return;
setMessages((current) => [
...current,
{
id: Date.now(),
from: "customer",
text: draft.trim(),
attachment: attachment ?? undefined,
},
]);
setDraft("");
setAttachment(null);
setAgentTyping(true);
if (replyTimer.current) clearTimeout(replyTimer.current);
replyTimer.current = setTimeout(() => {
setAgentTyping(false);
setMessages((current) => [
...current,
{
id: Date.now(),
from: "agent",
text: "Thanks, got it. The $49.00 refund is on its way and should reach your card in 3–5 business days.",
},
]);
}, 1400);
}
return (
<div className="flex w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-card">
<div className="flex items-center gap-3 border-b border-border px-4 py-3">
<Avatar className="size-8">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback className="text-xs">DO</AvatarFallback>
</Avatar>
<div className="min-w-0">
<h3 className="text-sm font-semibold">Daniel Ortiz</h3>
<p className="text-xs text-muted-foreground">
Billing support · replies in about 2 min
</p>
</div>
</div>
<ol
ref={listRef}
aria-label="Conversation"
className="flex max-h-72 flex-col gap-3 overflow-y-auto px-4 py-4"
>
{messages.map((message) => (
<li
key={message.id}
className={
message.from === "customer"
? "ml-auto flex max-w-[85%] flex-col items-end gap-1.5"
: "mr-auto flex max-w-[85%] flex-col items-start gap-1.5"
}
>
<span className="sr-only">
{message.from === "customer" ? "You said:" : "Daniel said:"}
</span>
{message.text ? (
<p
className={
message.from === "customer"
? "rounded-2xl rounded-br-md bg-primary px-3 py-2 text-sm text-primary-foreground"
: "rounded-2xl rounded-bl-md bg-muted px-3 py-2 text-sm"
}
>
{message.text}
</p>
) : null}
{message.attachment ? (
<span className="inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs">
<FileTextIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
{message.attachment}
</span>
) : null}
</li>
))}
{agentTyping ? (
<li className="mr-auto rounded-2xl rounded-bl-md bg-muted px-3 py-2.5">
<span className="sr-only">Daniel is typing</span>
<span aria-hidden="true" className="flex gap-1">
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground" />
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground [animation-delay:150ms]" />
<span className="size-1.5 animate-pulse rounded-full bg-muted-foreground [animation-delay:300ms]" />
</span>
</li>
) : null}
</ol>
<form
className="border-t border-border p-3"
onSubmit={(event) => {
event.preventDefault();
send();
}}
>
<label htmlFor={inputId} className="sr-only">
Message Daniel
</label>
<InputGroup>
<InputGroupTextarea
id={inputId}
value={draft}
rows={2}
maxLength={MAX_LENGTH}
placeholder="Write a reply…"
aria-describedby={counterId}
className="max-h-32 min-h-14"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
send();
}
}}
/>
<InputGroupAddon align="block-end" className="gap-1">
{attachment ? (
<span className="inline-flex h-6 min-w-0 items-center gap-1 rounded-md bg-muted pr-0.5 pl-2 text-xs text-foreground">
<FileTextIcon aria-hidden="true" className="size-3 shrink-0" />
<span className="truncate">{attachment}</span>
<InputGroupButton
size="icon-xs"
className="size-5"
aria-label={`Remove ${attachment}`}
onClick={() => setAttachment(null)}
>
<XIcon aria-hidden="true" />
</InputGroupButton>
</span>
) : (
<InputGroupButton
size="icon-xs"
aria-label="Attach invoice"
onClick={() => setAttachment("invoice-NW-2291.pdf")}
>
<PaperclipIcon aria-hidden="true" />
</InputGroupButton>
)}
<InputGroupText
id={counterId}
className={
remaining < 50
? "ml-auto text-xs text-destructive tabular-nums"
: "ml-auto text-xs tabular-nums"
}
>
<span className="sr-only">Characters left: </span>
{remaining}
</InputGroupText>
<InputGroupButton
type="submit"
size="icon-xs"
variant="default"
className="rounded-full"
disabled={!canSend}
aria-label="Send message"
>
<ArrowUpIcon aria-hidden="true" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<p className="mt-1.5 text-xs text-muted-foreground">
Enter to send, Shift + Enter for a new line.
</p>
</form>
</div>
);
}
npx shadcn@latest add @sevenui/component/input-group-17pnpm dlx shadcn@latest add @sevenui/component/input-group-17yarn dlx shadcn@latest add @sevenui/component/input-group-17bunx --bun shadcn@latest add @sevenui/component/input-group-17"use client";
import { useId, useState } from "react";
import {
AtSignIcon,
BadgeCheckIcon,
ChevronLeftIcon,
CircleCheckIcon,
MessageSquareTextIcon,
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
} from "@/components/ui/input-group";
const contacts = [
{ handle: "lena.fischer", name: "Lena Fischer", initials: "LF" },
{ handle: "omar.haddad", name: "Omar Haddad", initials: "OH" },
{ handle: "grace.okafor", name: "Grace Okafor", initials: "GO" },
];
const quickAmounts = [20, 50, 100];
const BALANCE = 842.15;
const NOTE_LIMIT = 60;
function formatMoney(value: number) {
return value.toLocaleString("en-US", {
style: "currency",
currency: "USD",
});
}
export default function InputGroup18() {
const recipientId = useId();
const amountId = useId();
const noteId = useId();
const amountHintId = useId();
const [handle, setHandle] = useState("");
const [amount, setAmount] = useState("");
const [note, setNote] = useState("");
const [sent, setSent] = useState(false);
const needle = handle.trim().toLowerCase();
const recipient = contacts.find((contact) => contact.handle === needle);
const suggestions = needle
? contacts.filter(
(contact) =>
contact.handle !== needle &&
(contact.handle.includes(needle) ||
contact.name.toLowerCase().includes(needle)),
)
: contacts;
const value = Number.parseFloat(amount) || 0;
const overBalance = value > BALANCE;
const canSend = !!recipient && value > 0 && !overBalance;
if (sent && recipient) {
return (
<div className="flex w-full max-w-xs flex-col items-center rounded-3xl border border-border bg-card px-6 py-10 text-center">
<CircleCheckIcon aria-hidden="true" className="size-10 text-success" />
<p className="mt-4 text-2xl font-semibold tracking-tight tabular-nums">
{formatMoney(value)}
</p>
<p role="status" className="mt-1 text-sm text-muted-foreground">
Sent to {recipient.name}. It usually arrives in seconds.
</p>
{note ? (
<p className="mt-4 rounded-lg bg-muted px-3 py-2 text-sm">“{note}”</p>
) : null}
<Button
variant="outline"
className="mt-6 w-full"
onClick={() => {
setSent(false);
setAmount("");
setNote("");
setHandle("");
}}
>
Send another
</Button>
</div>
);
}
return (
<form
className="flex w-full max-w-xs flex-col rounded-3xl border border-border bg-card p-4"
onSubmit={(event) => {
event.preventDefault();
if (canSend) setSent(true);
}}
>
<div className="grid grid-cols-[2rem_1fr_2rem] items-center">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Back, clear this transfer"
disabled={!handle && !amount && !note}
onClick={() => {
setHandle("");
setAmount("");
setNote("");
}}
>
<ChevronLeftIcon aria-hidden="true" />
</Button>
<h3 className="text-center text-sm font-semibold">Send money</h3>
</div>
<div className="mt-4 flex flex-col gap-2">
<label htmlFor={recipientId} className="text-xs font-medium text-muted-foreground">
To
</label>
<InputGroup className="h-10 rounded-xl">
<InputGroupAddon>
<AtSignIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id={recipientId}
value={handle}
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
placeholder="username"
className="pl-0.5!"
onChange={(event) => setHandle(event.target.value.replace(/^@/, ""))}
/>
{recipient ? (
<InputGroupAddon align="inline-end">
<InputGroupText className="text-foreground">
<span className="max-w-24 truncate">{recipient.name}</span>
<BadgeCheckIcon
role="img"
aria-label="Verified"
className="text-primary"
/>
</InputGroupText>
</InputGroupAddon>
) : null}
</InputGroup>
{!recipient && suggestions.length > 0 ? (
<ul aria-label="Suggested contacts" className="flex gap-3 overflow-x-auto pb-1">
{suggestions.map((contact) => (
<li key={contact.handle}>
<button
type="button"
onClick={() => setHandle(contact.handle)}
className="flex w-16 flex-col items-center gap-1 rounded-lg p-1 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<Avatar className="size-9">
<AvatarImage src="/placeholder.svg" alt="" />
<AvatarFallback className="text-xs">
{contact.initials}
</AvatarFallback>
</Avatar>
<span className="w-full truncate text-xs">
{contact.name.split(" ")[0]}
</span>
</button>
</li>
))}
</ul>
) : null}
</div>
<div className="mt-5 flex flex-col gap-2">
<label htmlFor={amountId} className="text-xs font-medium text-muted-foreground">
Amount
</label>
<InputGroup className="h-16 rounded-2xl">
<InputGroupAddon className="pl-4">
<InputGroupText className="text-2xl font-medium">$</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id={amountId}
inputMode="decimal"
value={amount}
placeholder="0.00"
className="h-full pl-1! text-3xl font-semibold tracking-tight tabular-nums md:text-3xl"
aria-invalid={overBalance ? true : undefined}
aria-describedby={amountHintId}
onChange={(event) => {
const next = event.target.value.replace(/[^\d.]/g, "");
if (/^\d{0,5}(\.\d{0,2})?$/.test(next)) setAmount(next);
}}
/>
<InputGroupAddon align="inline-end" className="pr-3">
<InputGroupButton
variant="secondary"
onClick={() => setAmount(BALANCE.toFixed(2))}
>
Max
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<p
id={amountHintId}
aria-live="polite"
className={overBalance ? "text-xs text-destructive" : "text-xs text-muted-foreground"}
>
{overBalance
? `That's more than your ${formatMoney(BALANCE)} balance.`
: `Balance ${formatMoney(BALANCE)} · no fee for friends`}
</p>
<div className="grid grid-cols-3 gap-2">
{quickAmounts.map((quick) => (
<Button
key={quick}
type="button"
variant="outline"
size="sm"
className="rounded-full tabular-nums"
aria-pressed={value === quick}
onClick={() => setAmount(String(quick))}
>
${quick}
</Button>
))}
</div>
</div>
<div className="mt-5 flex flex-col gap-2">
<label htmlFor={noteId} className="text-xs font-medium text-muted-foreground">
Note
</label>
<InputGroup className="h-10 rounded-xl">
<InputGroupAddon>
<MessageSquareTextIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id={noteId}
value={note}
maxLength={NOTE_LIMIT}
placeholder="Concert tickets"
onChange={(event) => setNote(event.target.value)}
/>
<InputGroupAddon align="inline-end">
<InputGroupText className="text-xs tabular-nums">
<span className="sr-only">Characters used: </span>
{note.length}/{NOTE_LIMIT}
</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
<Button type="submit" size="lg" className="mt-6 w-full rounded-xl" disabled={!canSend}>
{canSend && recipient
? `Send ${formatMoney(value)} to ${recipient.name.split(" ")[0]}`
: "Send"}
</Button>
</form>
);
}
npx shadcn@latest add @sevenui/component/input-group-18pnpm dlx shadcn@latest add @sevenui/component/input-group-18yarn dlx shadcn@latest add @sevenui/component/input-group-18bunx --bun shadcn@latest add @sevenui/component/input-group-18