Search for pages, actions, and quick links.
Filter bars, settings rows, secrets and inline creation. 9 blocks.
Search plus removable active filters, and a Clear that only appears when needed.
src/components/blocks/forms/form-filter-bar.tsx"use client";
import * as React from "react";
import { Search, SlidersHorizontal, X } from "lucide-react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
/**
* Search, active filters, and a way out.
*
* The Clear button appears only when something is filtered. A permanently visible Reset invites
* the reader to wonder what state they are in — its absence is itself the signal that nothing is
* applied.
*
* Each active filter is removable individually, which is the difference between a filter bar and
* a filter *display*: narrowing by four things and then wanting to undo one of them is the normal
* case, and an all-or-nothing Clear forces re-applying the other three.
*/
export function FormFilterBar() {
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(["Status: Active", "Region: EMEA"]);
const isFiltered = query.length > 0 || active.length > 0;
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute top-1/2 size-4 -translate-y-1/2 text-muted-foreground ltr:left-3 rtl:right-3" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search customers…"
aria-label="Search customers"
className="ps-9"
/>
</div>
<Button variant="outline" size="sm" className="gap-1.5">
<SlidersHorizontal className="size-4" />
Filters
</Button>
{isFiltered && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setQuery("");
setActive([]);
}}
>
Clear
<X className="ms-1 size-3.5" />
</Button>
)}
</div>
{active.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{active.map((filter) => (
<Badge key={filter} variant="secondary" className="gap-1 ps-2 text-[11px] font-normal">
{filter}
<button
type="button"
onClick={() => setActive(active.filter((entry) => entry !== filter))}
aria-label={`Remove filter ${filter}`}
className="rounded p-0.5 hover:bg-background/60"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}Switches with room to explain what each one actually promises.
src/components/blocks/forms/form-settings-rows.tsx"use client";
import { Switch } from "@dashboardpack/core/components/ui/switch";
const SETTINGS = [
{
id: "weekly-digest",
label: "Weekly digest",
detail: "A summary of activity every Monday at 09:00 in your timezone.",
on: true,
},
{
id: "mentions",
label: "Mentions",
detail: "Email me when someone @-mentions me in a comment.",
on: true,
},
{
id: "product-news",
label: "Product news",
detail: "Occasional notes about new features. No more than monthly.",
on: false,
},
];
/**
* A list of toggles with room to explain each one.
*
* The label is a real `<label htmlFor>` rather than adjacent text, so clicking the words toggles
* the switch — and the detail line is joined by `aria-describedby` rather than being another
* label, because a screen reader should announce the setting's *name* and then its explanation,
* not one run-on string.
*
* The detail is where the promise lives: "Weekly digest" alone leaves the reader guessing when
* and how often, which is the question that stops people enabling notifications.
*/
export function FormSettingsRows() {
return (
<ul className="divide-y divide-border">
{SETTINGS.map((setting) => (
<li key={setting.id} className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<label htmlFor={setting.id} className="text-sm font-medium">
{setting.label}
</label>
<p id={`${setting.id}-detail`} className="mt-0.5 text-sm text-muted-foreground">
{setting.detail}
</p>
</div>
<Switch
id={setting.id}
defaultChecked={setting.on}
aria-describedby={`${setting.id}-detail`}
className="mt-0.5 shrink-0"
/>
</li>
))}
</ul>
);
}A secret masked to its prefix, copyable without revealing it.
src/components/blocks/forms/form-api-key-row.tsx"use client";
import * as React from "react";
import { Check, Copy, Eye, EyeOff, Trash2 } from "lucide-react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
const KEY = "sk_live_9f2b7c41a8e04d63b5170ce9d24af8b1";
/**
* A secret that is hidden by default and revealed on request.
*
* Masked to a prefix plus dots rather than replaced entirely: the prefix is how someone
* identifies *which* key this is without revealing it, which is the whole reason to show
* anything at all. Copy works while the key is still hidden — needing to reveal a secret in order
* to copy it defeats the masking on a shared screen.
*
* The visible characters are the *start*, not the end. A trailing fragment is the part that
* would let someone reconstruct a guessed key; a prefix is a label.
*/
export function FormApiKeyRow() {
const [revealed, setRevealed] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(KEY);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
/* clipboard denied — the key is still selectable when revealed */
}
};
return (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-border p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">Production key</p>
<Badge variant="success" className="text-[10px]">Active</Badge>
</div>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">
{revealed ? KEY : `${KEY.slice(0, 11)}${"•".repeat(24)}`}
</p>
<p className="mt-1 text-[11px] text-muted-foreground">
Created 12 Mar 2026 · last used 4 hours ago
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setRevealed(!revealed)}
aria-label={revealed ? "Hide key" : "Reveal key"}
>
{revealed ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={copy}
aria-label={copied ? "Key copied" : "Copy key"}
>
{copied ? <Check className="size-4 text-success" /> : <Copy className="size-4" />}
</Button>
<Button variant="ghost" size="icon" className="size-8" aria-label="Revoke key">
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
);
}Adding an item without leaving the list or opening a dialog.
src/components/blocks/forms/form-inline-create.tsx"use client";
import * as React from "react";
import { Plus } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
/**
* Adding an item without leaving the list.
*
* The form replaces the button in place rather than opening a dialog, because a single-field
* create does not warrant losing sight of what you are adding to. Escape closes it and Enter
* submits, which are the two keys anyone will try.
*
* The input is focused when it appears — an inline form that requires a second click to type in
* is slower than the dialog it was meant to replace.
*/
export function FormInlineCreate() {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState("");
const [items, setItems] = React.useState(["Design review", "Q3 roadmap", "Vendor security audit"]);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
const submit = () => {
const trimmed = value.trim();
if (!trimmed) return;
setItems([...items, trimmed]);
setValue("");
// Left open, so adding several in a row does not need a click between each.
inputRef.current?.focus();
};
return (
<div className="space-y-2">
<ul className="divide-y divide-border">
{items.map((item) => (
<li key={item} className="py-2 text-sm">
{item}
</li>
))}
</ul>
{open ? (
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
className="flex items-center gap-2"
>
<Input
ref={inputRef}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false);
}}
placeholder="What needs doing?"
aria-label="New item"
className="h-8 text-sm"
/>
<Button type="submit" size="sm" className="h-8 shrink-0" disabled={!value.trim()}>
Add
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 shrink-0"
onClick={() => setOpen(false)}
>
Cancel
</Button>
</form>
) : (
<Button variant="ghost" size="sm" className="gap-1.5" onClick={() => setOpen(true)}>
<Plus className="size-3.5" />
Add item
</Button>
)}
</div>
);
}aria-invalid, aria-describedby and a summary of links to the failures.
src/components/blocks/forms/field-errors.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { AlertCircle } from "lucide-react";
/**
* Validation errors wired up the way screen readers need.
*
* Three attributes do the work, and all three are usually missing.
*
* `aria-invalid` marks the field as failed, so it is announced as invalid on focus rather than
* looking merely red. `aria-describedby` points at the message, so the *reason* is read out with the
* field instead of being an orphaned red string nearby. And the message is `role="alert"`, so it is
* announced when it appears without the user having to go looking.
*
* The summary at the top is a list of links to the failing fields. On a long form this is the
* difference between a keyboard user finding the third error and tabbing through twenty fields
* hunting for it — and it is why the fields need `id`s that the summary can target.
*
* Messages say what to do, not what went wrong: "Use the format [email protected]" beats "Invalid".
*/
export function FieldErrors() {
const [submitted, setSubmitted] = useState(true);
const errors = submitted
? [
{ id: "invoice-email", label: "Billing email", message: "Use the format [email protected]" },
{ id: "invoice-vat", label: "VAT number", message: "Must start with a two-letter country code" },
]
: [];
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
setSubmitted(true);
}}
>
{/*
The summary panel is border-only, with no `bg-destructive/5`.
That tint looks harmless and costs the summary its WCAG AA contrast: `--destructive`
measures 4.77:1 on the card but 4.36:1 on the tinted panel, because the wash lowers the
background's luminance just enough to drop under 4.5:1. The red is doing real work here, so
the background gives way rather than the text colour.
*/}
{errors.length > 0 && (
<div role="alert" className="space-y-2 rounded-lg border border-destructive/40 p-3">
<p className="flex items-center gap-2 text-sm font-medium text-destructive">
<AlertCircle className="h-4 w-4" aria-hidden="true" />
{errors.length} fields need attention
</p>
<ul className="list-disc space-y-1 ps-6">
{errors.map((error) => (
<li key={error.id} className="text-sm">
{/* A link, so the summary is navigable rather than merely informative. */}
<a href={`#${error.id}`} className="text-destructive hover:underline">
{error.label}
</a>
</li>
))}
</ul>
</div>
)}
<div className="space-y-2">
<Label htmlFor="invoice-email">Billing email</Label>
<Input
id="invoice-email"
type="email"
defaultValue="ana@company"
aria-invalid={submitted}
aria-describedby={submitted ? "invoice-email-error" : undefined}
className="scroll-mt-4"
/>
{submitted && (
<p id="invoice-email-error" role="alert" className="text-xs text-destructive">
Use the format name@company.com
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="invoice-vat">VAT number</Label>
<Input
id="invoice-vat"
defaultValue="99887766"
aria-invalid={submitted}
aria-describedby={submitted ? "invoice-vat-error" : undefined}
className="scroll-mt-4"
/>
{submitted && (
<p id="invoice-vat-error" role="alert" className="text-xs text-destructive">
Must start with a two-letter country code
</p>
)}
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">
Save
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setSubmitted(false)}>
Clear errors
</Button>
</div>
</form>
);
}Compares against what loaded, so undoing an edit hides it again.
src/components/blocks/forms/unsaved-changes-bar.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
/**
* A settings form whose save bar appears only when something actually changed.
*
* "Dirty" is computed by comparing the current values against the values the form loaded with, not
* by setting a flag in the change handler. That distinction matters: with a flag, typing a character
* and deleting it again leaves the form permanently dirty, so the user is warned about discarding
* changes they no longer have.
*
* The initial values live in a `useState` initialiser rather than a module constant, so the baseline
* is captured per mount — which is what a real form does after a successful save.
*
* The bar is `role="region"` with a name, and its buttons state the count, so "Discard 2 changes" is
* unambiguous about scope. A bare "Discard" next to a form is the button people fear most.
*/
export function UnsavedChangesBar() {
const [initial] = useState({ name: "Acme Corporation", email: "[email protected]" });
const [values, setValues] = useState(initial);
const changed = (Object.keys(initial) as (keyof typeof initial)[]).filter(
(key) => values[key] !== initial[key],
);
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="org-name">Organisation name</Label>
<Input
id="org-name"
value={values.name}
onChange={(event) => setValues((prev) => ({ ...prev, name: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="org-email">Billing email</Label>
<Input
id="org-email"
type="email"
value={values.email}
onChange={(event) => setValues((prev) => ({ ...prev, email: event.target.value }))}
/>
</div>
{changed.length > 0 ? (
<div
role="region"
aria-label="Unsaved changes"
className="flex flex-wrap items-center gap-3 rounded-lg border bg-card p-3 shadow-sm"
>
<p className="text-sm text-muted-foreground">
{changed.length} unsaved {changed.length === 1 ? "change" : "changes"}
</p>
<div className="flex gap-2 ms-auto">
<Button variant="outline" size="sm" onClick={() => setValues(initial)}>
Discard {changed.length === 1 ? "change" : `${changed.length} changes`}
</Button>
<Button size="sm">Save</Button>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">
Edit a field above — the save bar appears only once a value differs from what loaded, and
disappears again if you undo it.
</p>
)}
</div>
);
}Copy reads state, so the key never has to be shown.
src/components/blocks/forms/api-key-reveal.tsx"use client";
import { useState } from "react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Check, Copy, Eye, EyeOff } from "lucide-react";
/**
* A secret shown masked, with a copy that works without revealing it.
*
* Copy reads from the full value in state, never from the visible text — so a user can hand the key
* to a terminal without ever putting it on screen, which is the point of masking it.
*
* The masked form keeps the prefix and last four characters. A row of identical dots makes it
* impossible to tell two keys apart, so people reveal both just to identify one.
*
* `navigator.clipboard` is guarded: it is undefined on insecure origins, so an unguarded call
* throws in exactly the local-HTTP setup a customer develops against.
*/
export function ApiKeyReveal() {
const keys = [
{ name: "Production", value: "sk_live_9Qv2mK7pRtY4wXz8BnC3dEfG", created: "12 Mar 2026", used: "2 minutes ago" },
{ name: "Staging", value: "sk_test_4Hj8nP2qLvB6yTr9WsX5mKdA", created: "4 Jan 2026", used: "6 days ago" },
];
const [revealed, setRevealed] = useState<string[]>([]);
const [copied, setCopied] = useState<string | null>(null);
const mask = (value: string) => `${value.slice(0, 8)}${"•".repeat(12)}${value.slice(-4)}`;
async function copy(key: { name: string; value: string }) {
// Undefined on insecure origins — an unguarded call throws under plain HTTP.
if (!navigator.clipboard) return;
await navigator.clipboard.writeText(key.value);
setCopied(key.name);
setTimeout(() => setCopied(null), 1600);
}
return (
<ul className="divide-y rounded-lg border">
{keys.map((key) => {
const shown = revealed.includes(key.name);
return (
<li key={key.name} className="space-y-2 p-3">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium">{key.name}</p>
<Badge variant="secondary" className="text-[10px]">
Created {key.created}
</Badge>
<span className="text-xs text-muted-foreground ms-auto">Used {key.used}</span>
</div>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{shown ? key.value : mask(key.value)}
</code>
<Button
variant="outline"
size="sm"
aria-label={`${shown ? "Hide" : "Reveal"} the ${key.name} key`}
aria-pressed={shown}
onClick={() =>
setRevealed((previous) =>
shown ? previous.filter((name) => name !== key.name) : [...previous, key.name],
)
}
>
{shown ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</Button>
{/* Copies from state, not from the rendered text, so masking never blocks it. */}
<Button
variant="outline"
size="sm"
aria-label={`Copy the ${key.name} key`}
onClick={() => copy(key)}
>
{copied === key.name ? (
<Check className="h-4 w-4 text-success" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
<span role="status" className="sr-only">
{copied === key.name ? `${key.name} key copied` : ""}
</span>
</li>
);
})}
</ul>
);
}Backspace removes the last tag, which is what makes it feel native.
src/components/blocks/forms/tag-input.tsx"use client";
import { useState } from "react";
import { Label } from "@dashboardpack/core/components/ui/label";
import { X } from "lucide-react";
/**
* A tag field where Backspace on an empty input removes the last tag.
*
* That behaviour is what makes a tag input feel native rather than like a list with a text box
* above it, and it is the part most implementations skip. Enter and comma both commit, since people
* type both.
*
* Duplicates are rejected case-insensitively but the original casing is kept — adding "React" after
* "react" should be a no-op, not a second tag, and not a silent rewrite of the first.
*
* The live region announces additions and removals. Without it a screen-reader user gets no
* confirmation that Enter did anything, because the input clears either way.
*/
export function TagInput() {
const [tags, setTags] = useState(["nextjs", "typescript", "tailwind"]);
const [draft, setDraft] = useState("");
const [announcement, setAnnouncement] = useState("");
function add(raw: string) {
const value = raw.trim();
if (!value) return;
// Case-insensitive dedupe that preserves the existing casing.
if (tags.some((tag) => tag.toLowerCase() === value.toLowerCase())) {
setAnnouncement(`${value} is already added`);
setDraft("");
return;
}
setTags((previous) => [...previous, value]);
setAnnouncement(`${value} added`);
setDraft("");
}
function remove(tag: string) {
setTags((previous) => previous.filter((entry) => entry !== tag));
setAnnouncement(`${tag} removed`);
}
return (
<div className="space-y-2">
<Label htmlFor="tag-field">Topics</Label>
<div className="flex flex-wrap items-center gap-1.5 rounded-md border p-2 focus-within:border-primary/50">
{tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
>
{tag}
<button
type="button"
onClick={() => remove(tag)}
aria-label={`Remove ${tag}`}
className="rounded-full p-0.5 transition-colors hover:bg-background/60"
>
<X className="h-3 w-3" aria-hidden="true" />
</button>
</span>
))}
<input
id="tag-field"
value={draft}
placeholder={tags.length ? "" : "Add a topic…"}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
add(draft);
return;
}
// The behaviour that makes this feel like a real tag field.
if (event.key === "Backspace" && draft === "" && tags.length > 0) {
remove(tags[tags.length - 1]!);
}
}}
className="min-w-[120px] flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<p className="text-xs text-muted-foreground">
Enter or comma to add. Backspace on an empty field removes the last tag.
</p>
<span role="status" aria-live="polite" className="sr-only">
{announcement}
</span>
</div>
);
}Descriptions tied to the switch, and dependants explain themselves.
src/components/blocks/forms/settings-toggle-rows.tsx"use client";
import { useState } from "react";
import { Switch } from "@dashboardpack/core/components/ui/switch";
import { Label } from "@dashboardpack/core/components/ui/label";
/**
* Settings rows where the description is tied to the switch, not just near it.
*
* `aria-describedby` on the switch pointing at the description's id is what makes the explanation
* part of the control. Otherwise a screen-reader user hears "Weekly digest, switch, on" and never
* the sentence explaining what it sends — the text is visually adjacent and programmatically
* unrelated.
*
* A dependent row is disabled *and* explains why, rather than being hidden. A control that vanishes
* when its parent is off leaves the user unable to discover the option exists.
*/
export function SettingsToggleRows() {
const [settings, setSettings] = useState({
email: true,
digest: true,
mentions: false,
marketing: false,
});
const rows = [
{
id: "email",
label: "Email notifications",
description: "The master switch. Turning this off silences every email below.",
dependsOn: null,
},
{
id: "digest",
label: "Weekly digest",
description: "A Monday summary of orders, revenue and open tickets.",
dependsOn: "email" as const,
},
{
id: "mentions",
label: "Mentions",
description: "When someone @-mentions you in a comment or a ticket.",
dependsOn: "email" as const,
},
{
id: "marketing",
label: "Product updates",
description: "New features and release notes. At most once a month.",
dependsOn: "email" as const,
},
] as const;
return (
<ul className="divide-y rounded-lg border">
{rows.map((row) => {
const blocked = row.dependsOn ? !settings[row.dependsOn] : false;
return (
<li key={row.id} className="flex items-start justify-between gap-4 p-3">
<div className="min-w-0 space-y-0.5">
<Label htmlFor={`toggle-${row.id}`} className="text-sm font-medium">
{row.label}
</Label>
<p id={`toggle-${row.id}-hint`} className="text-xs text-muted-foreground">
{blocked ? "Turn on email notifications to enable this." : row.description}
</p>
</div>
<Switch
id={`toggle-${row.id}`}
checked={settings[row.id] && !blocked}
disabled={blocked}
// The description becomes part of the control's accessible name chain.
aria-describedby={`toggle-${row.id}-hint`}
onCheckedChange={(next) =>
setSettings((previous) => ({ ...previous, [row.id]: next }))
}
/>
</li>
);
})}
</ul>
);
}