Modal dialogs
A focused task over the page: confirm, edit, invite, share or pay.
Delete confirmation that counts the loss
modal-dialogs-01A project's danger zone and the alert it opens: what goes with the project — its tasks, the open ones, the estimates, the people who lose it, its timeline — counted before anything is removed, with Undo after.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DeleteProject } from "./modal-dialogs-01.client"
import { projectLoss } from "./modal-dialogs-01.data"
export type ModalDialogs01Props = {
/** Opens the confirmation on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A project's danger zone: one row that deletes the project, and the alert
* it opens, which counts what goes with it before anything is removed.
*
* A server component: the counts come from `./modal-dialogs-01.data`.
*/
export function ModalDialogs01({ defaultOpen = false }: ModalDialogs01Props) {
const titleId = React.useId()
const project = projectLoss()
return (
<section
data-section="modal-dialogs-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Danger zone</h2>
</CardTitle>
<CardDescription>
Settings for {project.name} ({project.code}), led by {project.lead.name}.
</CardDescription>
</CardHeader>
<CardContent>
<DeleteProject project={project} defaultOpen={defaultOpen} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ModalDialogs01"use client"
import * as React from "react"
import { Trash2Icon, Undo2Icon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { Button } from "@/components/ui/button"
import type { ProjectLoss } from "./modal-dialogs-01.data"
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const count = (value: number, one: string, many = `${one}s`) => `${formatNumber(value)} ${value === 1 ? one : many}`
/**
* The danger-zone row and the confirmation it opens. The alert says what goes
* with the project, counted — its tasks and how many are still open, the
* points estimated on them, the people who lose it from their boards, its
* timeline — so the decision is made on what will be lost, not on a name.
* Focus opens on Keep project, the safe answer; Escape and Keep close it and
* hand focus back to the button that opened it. Deleting says so in the
* row's status line and puts Undo where the button was, with focus on it.
*/
export function DeleteProject({ project, defaultOpen }: { project: ProjectLoss; defaultOpen: boolean }) {
const [open, setOpen] = React.useState(defaultOpen)
const [deleted, setDeleted] = React.useState(false)
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const keep = React.useRef<HTMLButtonElement>(null)
const undo = React.useRef<HTMLButtonElement>(null)
// Read when the dialog finishes closing, which is after the render that swapped the buttons.
const deletedNow = React.useRef(false)
const refocus = React.useRef(false)
React.useEffect(() => {
if (deleted || !refocus.current) return
refocus.current = false
trigger.current?.focus()
}, [deleted])
function remove() {
deletedNow.current = true
setDeleted(true)
setOpen(false)
setNotice(`${project.name} was deleted with its ${count(project.tasks, "task")}.`)
}
function restore() {
deletedNow.current = false
refocus.current = true
setDeleted(false)
setNotice(`${project.name} is back, with everything in it.`)
}
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-sm font-medium">{deleted ? `${project.name} was deleted` : `Delete ${project.name}`}</p>
<p className="text-sm text-pretty text-muted-foreground">
{deleted
? "Its tasks, estimates and board are gone for everyone. Undo brings them back."
: `Removes the project and everything in it for everyone: ${count(project.tasks, "task")}, ${formatNumber(project.points)} points of estimates and its board.`}
</p>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</div>
<AlertDialog open={open} onOpenChange={setOpen}>
{deleted ? (
<Button ref={undo} variant="outline" onClick={restore} className="self-start sm:self-center">
<Undo2Icon data-icon="inline-start" aria-hidden="true" />
Undo
</Button>
) : (
<AlertDialogTrigger ref={trigger} render={<Button variant="destructive" className="self-start sm:self-center" />}>
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
Delete project
</AlertDialogTrigger>
)}
<AlertDialogContent
initialFocus={keep}
finalFocus={() => (deletedNow.current ? undo.current : trigger.current)}
className="data-[size=default]:sm:max-w-md"
>
<AlertDialogHeader>
<AlertDialogMedia className="bg-danger-muted text-danger">
<Trash2Icon aria-hidden="true" />
</AlertDialogMedia>
<AlertDialogTitle>Delete {project.name}?</AlertDialogTitle>
<AlertDialogDescription>Everyone loses it at once, with everything listed here.</AlertDialogDescription>
</AlertDialogHeader>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2.5 rounded-lg bg-surface p-3 text-sm ring-1 ring-border">
<dt className="text-muted-foreground">Tasks</dt>
<dd className="tabular-nums">
{count(project.tasks, "task")}, {formatNumber(project.open)} still open
</dd>
<dt className="text-muted-foreground">Estimates</dt>
<dd className="tabular-nums">{formatNumber(project.points)} points</dd>
<dt className="text-muted-foreground">People</dt>
<dd className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<AvatarGroup size="sm" max={3} users={project.people.map((person) => ({ name: person.name, src: person.avatarUrl }))} />
<span className="tabular-nums">{count(project.people.length, "person", "people")} lose it</span>
</dd>
<dt className="text-muted-foreground">Timeline</dt>
<dd className="tabular-nums">
<time dateTime={project.startAt.toISOString()}>{DAY.format(project.startAt)}</time> to{" "}
<time dateTime={project.endAt.toISOString()}>{DAY.format(project.endAt)}</time>
</dd>
</dl>
<AlertDialogFooter>
<AlertDialogCancel ref={keep}>Keep project</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={remove}>
Delete project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}/**
* What `modal-dialogs-01` reads: one project in `db.projects` and everything
* deleting it would take with it — its tasks in `db.tasks`, the estimates on
* them, and the people (`db.members`) who would lose it from their boards.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the summary as props; it never imports this module.
*/
import { db } from "@/lib/sample-data"
/** The project the band offers to delete. */
export const PROJECT = "Edge caching"
export type Person = { name: string; avatarUrl?: string }
export type ProjectLoss = {
name: string
code: string
startAt: Date
endAt: Date
tasks: number
/** Tasks not yet done. */
open: number
/** The story points estimated across its tasks. */
points: number
lead: Person
/** Everyone with a task on it, the lead first. */
people: Person[]
}
/** What deleting the project would remove, counted from the store. */
export function projectLoss(): ProjectLoss {
const project = db.projects.all().find((entry) => entry.name === PROJECT)
if (!project) throw new Error(`modal-dialogs-01 deletes "${PROJECT}", which is not a project`)
const members = new Map(db.members.all().map((member) => [member.id, member]))
const tasks = db.tasks.all().filter((task) => task.projectId === project.id)
const ids = [project.lead, ...tasks.map((task) => task.assignee).filter((id) => id !== project.lead)]
const people = [...new Set(ids)]
.map((id) => members.get(id))
.filter((member) => member !== undefined)
.map((member) => ({ name: member.name, avatarUrl: member.avatarUrl }))
return {
name: project.name,
code: project.code,
startAt: project.startAt,
endAt: project.endAt,
tasks: tasks.length,
open: tasks.filter((task) => task.status !== "done").length,
points: tasks.reduce((total, task) => total + task.points, 0),
lead: people[0],
people,
}
}npx shadcn@latest add @vibra/modal-dialogs-01Invite people, checked against the seats
modal-dialogs-02The members card and its Invite people dialog: addresses, a role as choice cards, and the seats the plan has left — sent through a server action that invites every address or none.
import * as React from "react"
import { InviteMembers } from "./modal-dialogs-02.client"
import { inviteContext, inviteMembers, type InviteOutcome, type InviteRequest, type Result } from "./modal-dialogs-02.data"
export type ModalDialogs02Props = {
/** Opens the invitation on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The members card with an Invite people dialog: addresses, a role, and the
* seats the plan has left.
*
* A server component. It reads the seats and roles through
* `./modal-dialogs-02.data` and hands the island `sendInvites`, a server
* function defined here, which checks every address against the store and
* the plan before anyone is invited.
*/
export function ModalDialogs02({ defaultOpen = false }: ModalDialogs02Props) {
const titleId = React.useId()
async function sendInvites(request: InviteRequest): Promise<Result<InviteOutcome>> {
"use server"
return inviteMembers(request)
}
return (
<section
data-section="modal-dialogs-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<InviteMembers context={inviteContext()} onInvite={sendInvites} titleId={titleId} defaultOpen={defaultOpen} />
</div>
</section>
)
}
export default ModalDialogs02"use client"
import * as React from "react"
import { UserPlusIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldLegend, FieldSet, FieldTitle } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Spinner } from "@/components/ui/spinner"
import { StatusBadge } from "@/components/ui/status-badge"
import { Textarea } from "@/components/ui/textarea"
import { UsageMeter } from "@/components/ui/usage-meter"
import { UserCell } from "@/components/ui/user-cell"
import type { InviteContext, InviteOutcome, InviteRequest, InviteRole, Invitee, Result } from "./modal-dialogs-02.data"
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const people = (count: number) => `${formatNumber(count)} ${count === 1 ? "person" : "people"}`
const ROLE_NOUNS: Record<InviteRole, [string, string]> = { admin: ["an admin", "admins"], member: ["a member", "members"], viewer: ["a viewer", "viewers"] }
/** The addresses in what was typed: separated by commas, semicolons, spaces or new lines. */
export function addressesIn(text: string): string[] {
return [...new Set(text.split(/[\s,;]+/).map((part) => part.trim().toLowerCase()).filter(Boolean))]
}
/**
* The members card and its Invite people dialog. The dialog opens on the
* address box; a role is a choice card; the seat meter shows what the plan
* has left. Send goes to the section's server action, which invites every
* address or none: a refusal stays in the dialog as a danger callout that
* names each address and why, and the field keeps what was typed. A sent
* invitation closes the dialog, returns focus to Invite people, and the
* card's status line says who was invited and until when; the newcomers join
* the list.
*/
export function InviteMembers({
context,
onInvite,
titleId,
defaultOpen,
}: {
context: InviteContext
onInvite: (request: InviteRequest) => Promise<Result<InviteOutcome>>
titleId: string
defaultOpen: boolean
}) {
const [open, setOpen] = React.useState(defaultOpen)
const [text, setText] = React.useState("")
const [role, setRole] = React.useState<InviteRole>("member")
const [refusal, setRefusal] = React.useState("")
const [pending, setPending] = React.useState(false)
const [notice, setNotice] = React.useState("")
const [taken, setTaken] = React.useState(context.taken)
const [recent, setRecent] = React.useState<Invitee[]>(context.recent)
const field = React.useRef<HTMLTextAreaElement>(null)
const trigger = React.useRef<HTMLButtonElement>(null)
const ids = { emails: React.useId(), hint: React.useId(), refusal: React.useId(), role: React.useId() }
const addresses = addressesIn(text)
const left = context.seats - taken
function change(next: boolean) {
setOpen(next)
if (next) return
setRefusal("")
}
async function send(event: React.FormEvent) {
event.preventDefault()
if (pending) return
setPending(true)
setRefusal("")
let result: Result<InviteOutcome>
try {
result = await onInvite({ emails: addresses, role })
} catch {
result = { ok: false, error: { code: "network", message: "The invitations did not reach the server. Try again." } }
}
setPending(false)
if (!result.ok) {
setRefusal(result.error.message)
field.current?.focus()
return
}
const { invited, expiresAt } = result.data
const [one, many] = ROLE_NOUNS[role]
setTaken(context.seats - result.data.seatsLeft)
setRecent((current) => [...invited, ...current].slice(0, 5))
setNotice(
`Invited ${invited.length === 1 ? invited[0].email : people(invited.length)} as ${invited.length === 1 ? one : many}. The ${invited.length === 1 ? "link works" : "links work"} until ${DAY.format(expiresAt)}.`
)
setText("")
setOpen(false)
}
return (
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Members</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{taken} of {context.seats} seats on the {context.plan} plan are taken.
</CardDescription>
<CardAction>
<Dialog open={open} onOpenChange={change}>
<DialogTrigger ref={trigger} render={<Button size="sm" />}>
<UserPlusIcon data-icon="inline-start" aria-hidden="true" />
Invite people
</DialogTrigger>
<DialogContent initialFocus={field} finalFocus={trigger} className="sm:max-w-lg">
<form onSubmit={send} className="flex flex-col gap-4" noValidate>
<DialogHeader>
<DialogTitle>Invite people</DialogTitle>
<DialogDescription>They join Northwind when they accept. Each link works for a week.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<label htmlFor={ids.emails} className="text-sm font-medium">
Email addresses
</label>
<Textarea
ref={field}
id={ids.emails}
value={text}
onChange={(event) => {
setText(event.target.value)
setRefusal("")
}}
rows={3}
placeholder="ana.lopez@example.com, kofi@example.com"
aria-invalid={refusal ? true : undefined}
aria-describedby={refusal ? `${ids.hint} ${ids.refusal}` : ids.hint}
className="resize-none"
/>
<p id={ids.hint} className="text-xs text-muted-foreground">
Separate addresses with commas or new lines.
</p>
</div>
<FieldSet className="gap-2">
<FieldLegend id={ids.role} variant="label">
Role
</FieldLegend>
<RadioGroup aria-labelledby={ids.role} value={role} onValueChange={(value) => setRole(value as InviteRole)} className="gap-2">
{context.roles.map((option) => (
<FieldLabel key={option.value} htmlFor={`${ids.role}-${option.value}`}>
<Field orientation="horizontal">
<RadioGroupItem id={`${ids.role}-${option.value}`} value={option.value} aria-describedby={`${ids.role}-${option.value}-hint`} />
<FieldContent>
<FieldTitle>{option.label}</FieldTitle>
<FieldDescription id={`${ids.role}-${option.value}-hint`}>{option.description}</FieldDescription>
</FieldContent>
</Field>
</FieldLabel>
))}
</RadioGroup>
</FieldSet>
<UsageMeter
size="sm"
label={`Seats on the ${context.plan} plan`}
used={taken}
limit={context.seats}
resetsAt={
addresses.length > 0
? `${people(addresses.length)} to invite, ${Math.max(0, left - addresses.length)} ${left - addresses.length === 1 ? "seat" : "seats"} left after`
: `${left} ${left === 1 ? "seat" : "seats"} left`
}
/>
{refusal ? (
<Callout id={ids.refusal} variant="danger" role="alert" title="No invitations were sent">
{refusal}
</Callout>
) : null}
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>Cancel</DialogClose>
<Button type="submit" aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" data-icon="inline-start" /> : null}
{addresses.length > 1 ? `Send ${addresses.length} invitations` : "Send invitation"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ul aria-label="Newest members" className="divide-y divide-border">
{recent.map((person) => (
<li key={person.email} className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
<UserCell name={person.name} email={person.email} src={person.avatarUrl} className="min-w-0" />
<span className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
<span className="hidden sm:inline">{person.role.charAt(0).toUpperCase() + person.role.slice(1)}</span>
{person.status === "invited" ? <StatusBadge size="sm" status="invited" variant="info" label="Invited" /> : null}
</span>
</li>
))}
</ul>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</CardContent>
</Card>
)
}/**
* What `modal-dialogs-02` reads and the one thing it writes: the workspace's
* seats — `seatUsage()`, the count every page shows — the
* roles a newcomer can take from `db.roles`, and `inviteMembers`, which the
* section's server action calls to add invited members.
*
* Every read happens inside a function and runs on the server. The island
* takes the context as props and calls the action it is handed; it never
* imports this module.
*/
import { avatarFor } from "@/lib/avatars"
import { REFERENCE_DATE, WORKSPACE_PLAN, db, fields, seatUsage, type Member, type Result } from "@/lib/sample-data"
import { isEmail } from "@/lib/validation"
export type { Result }
/** The plan the workspace is on. */
export const PLAN = WORKSPACE_PLAN
/** How long an invitation link works, in days. */
export const LINK_DAYS = 7
/** The most addresses one request carries: a team's worth. */
const MAX_INVITES = 50
export type InviteRole = Exclude<Member["role"], "owner">
export type Invitee = { name: string; email: string; avatarUrl?: string; role: Member["role"]; status: Member["status"] }
export type InviteContext = {
plan: string
seats: number
/** Seats held: members who are in or yet to join, and open invitations. */
taken: number
roles: { value: InviteRole; label: string; description: string }[]
/** The people who joined or were invited most recently. */
recent: Invitee[]
}
export type InviteRequest = { emails: string[]; role: InviteRole }
export type InviteOutcome = { invited: Invitee[]; expiresAt: Date; seatsLeft: number }
const ROLES: InviteRole[] = ["admin", "member", "viewer"]
/** The plan's seats and how many are held, as every page counts them. */
function seatsHeld(): { seats: number; taken: number } {
const { limit, used } = seatUsage()
// A plan whose seats have no limit is never full.
return { seats: limit ?? Number.POSITIVE_INFINITY, taken: used }
}
/** The plan's seats, who holds them, the roles on offer and the newest arrivals. */
export function inviteContext(): InviteContext {
const members = db.members.all()
const descriptions = new Map(db.roles.all().map((role) => [role.name.toLowerCase(), role.description]))
const { seats, taken } = seatsHeld()
return {
plan: PLAN,
seats,
taken,
roles: ROLES.map((value) => ({
value,
label: value.charAt(0).toUpperCase() + value.slice(1),
description: descriptions.get(value) ?? "",
})),
recent: [...members]
.sort((a, b) => b.joinedAt.getTime() - a.joinedAt.getTime())
.slice(0, 4)
.map(({ name, email, avatarUrl, role, status }) => ({ name, email, avatarUrl, role, status })),
}
}
/** "ana.lopez@example.com" → "Ana Lopez": the name an invitation shows until its owner sets one. */
function nameFrom(email: string): string {
return email
.split("@")[0]
.split(/[._-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ")
}
/**
* Invites every address or none. Each is checked against the store — not a
* member already, not someone with an invitation waiting — and together they
* must fit the seats the plan has left. The role is one a newcomer can take,
* never the owner's. A refusal names each address it turned down and why.
*/
export async function inviteMembers(sent: InviteRequest): Promise<Result<InviteOutcome>> {
const request = fields(sent)
const refuse = (message: string): Result<never> => ({ ok: false, error: { code: "refused", message, field: "emails" } })
// A list of addresses, no longer than a team: anything else is not a request the dialog sends.
if (!Array.isArray(request.emails) || request.emails.length > MAX_INVITES || request.emails.some((email) => typeof email !== "string")) {
return refuse(`Invite up to ${MAX_INVITES} people by email address.`)
}
const emails = [...new Set(request.emails.map((email) => email.trim().toLowerCase()).filter(Boolean))]
if (emails.length === 0) return refuse("Add at least one email address.")
const role = request.role
if (!role || !ROLES.includes(role)) return refuse("Choose Admin, Member or Viewer for the people you invite.")
const malformed = emails.filter((email) => !isEmail(email))
if (malformed.length > 0) return refuse(`${malformed.join(", ")} ${malformed.length === 1 ? "is not an email address" : "are not email addresses"}.`)
const members = db.members.all()
const waiting = new Set(db.invitations.all().filter((invitation) => invitation.status === "pending").map((invitation) => invitation.email))
const problems: string[] = []
for (const email of emails) {
const member = members.find((entry) => entry.email.toLowerCase() === email)
if (member?.status === "deactivated") problems.push(`${email} was deactivated; reactivate them from People instead`)
else if (member?.status === "invited") problems.push(`${email} is already invited`)
else if (member) problems.push(`${email} is already in the workspace`)
else if (waiting.has(email)) problems.push(`${email} already has an invitation waiting`)
}
if (problems.length > 0) return refuse(`Nothing was sent. ${problems.join("; ")}.`)
const { seats, taken } = seatsHeld()
const left = seats - taken
if (emails.length > left)
return refuse(
`The ${PLAN} plan has ${seats} seats and ${taken} are taken, so ${left === 0 ? "no one else" : `${left} more at most`} can be invited. Remove ${emails.length - left} ${emails.length - left === 1 ? "address" : "addresses"} or add seats.`
)
const invited: Invitee[] = []
for (const email of emails) {
const name = nameFrom(email)
const created = await db.members.create({
name,
email,
role,
status: "invited",
avatarUrl: avatarFor(name),
joinedAt: REFERENCE_DATE,
lastActiveAt: REFERENCE_DATE,
twoFactor: false,
})
if (created.ok) invited.push({ name, email, avatarUrl: created.data.avatarUrl, role, status: "invited" })
}
return {
ok: true,
data: { invited, expiresAt: new Date(REFERENCE_DATE.getTime() + LINK_DAYS * 86_400_000), seatsLeft: left - invited.length },
}
}npx shadcn@latest add @vibra/modal-dialogs-02Wizard dialog with a stepper
modal-dialogs-03New project in four steps — name and code, lead and team, dates, review — with a stepper, each step checked before the next, and a server action that checks it all again before creating.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { NewProjectWizard } from "./modal-dialogs-03.client"
import { addProject, wizardContext, type CreatedProject, type ProjectDraft, type Result } from "./modal-dialogs-03.data"
export type ModalDialogs03Props = {
/** Opens the wizard on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The projects card and its New project wizard: four steps with a stepper,
* each checked before the next, and a review before anything is created.
*
* A server component. It reads the people and the projects already running
* through `./modal-dialogs-03.data` and hands the island `createProject`, a
* server function defined here, which checks the draft again against the
* store before it adds the project.
*/
export function ModalDialogs03({ defaultOpen = false }: ModalDialogs03Props) {
const titleId = React.useId()
async function createProject(draft: ProjectDraft): Promise<Result<CreatedProject>> {
"use server"
return addProject(draft)
}
return (
<section
data-section="modal-dialogs-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Projects</h2>
</CardTitle>
<CardDescription>The newest first. A new project starts in planning.</CardDescription>
</CardHeader>
<CardContent>
<NewProjectWizard context={wizardContext()} onCreate={createProject} defaultOpen={defaultOpen} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ModalDialogs03"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Stepper } from "@/components/ui/stepper"
import { UserCell } from "@/components/ui/user-cell"
import type { CreatedProject, ProjectDraft, Result, WizardContext } from "./modal-dialogs-03.data"
export const STEPS = [
{ id: "basics", title: "Basics" },
{ id: "people", title: "People" },
{ id: "dates", title: "Dates" },
{ id: "review", title: "Review" },
]
type Errors = Partial<Record<"name" | "code" | "team" | "dates", string>>
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const dayLabel = (day: string) => DAY.format(new Date(`${day}T00:00:00Z`))
const weeksBetween = (from: string, to: string) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / (7 * 86_400_000))
/** "Orbit launch" → "ORL": three letters from the name until the reader writes their own. */
export function codeFor(name: string): string {
const words = name.match(/[A-Za-z]+/g) ?? []
const code =
words.length >= 3 ? words.slice(0, 3).map((word) => word[0]).join("") : words.length === 2 ? words[0].slice(0, 2) + words[1][0] : (words[0] ?? "").slice(0, 3)
return code.toUpperCase()
}
/**
* New project, as a four-step dialog: what it is called, who is on it, when
* it runs, and a review before anything is created. The stepper and a line
* read out on every move say where the reader is; each step checks its own
* answers before Next lets them on, beside the field that is wrong, and focus
* lands on each step's first field. Create goes to the section's server
* action, which checks everything again against the store; a refusal comes
* back as a danger callout on the review. A created project closes the
* dialog, joins the list and is announced in the card's status line.
*/
export function NewProjectWizard({
context,
onCreate,
defaultOpen,
}: {
context: WizardContext
onCreate: (draft: ProjectDraft) => Promise<Result<CreatedProject>>
defaultOpen: boolean
}) {
const blank = (): ProjectDraft => ({ name: "", code: "", leadId: context.people[0]?.id ?? "", teamIds: [], startDay: context.startDay, dueDay: context.dueDay })
const [open, setOpen] = React.useState(defaultOpen)
const [step, setStep] = React.useState(0)
const [draft, setDraft] = React.useState<ProjectDraft>(blank)
const [codeTouched, setCodeTouched] = React.useState(false)
const [errors, setErrors] = React.useState<Errors>({})
const [refusal, setRefusal] = React.useState("")
const [pending, setPending] = React.useState(false)
const [projects, setProjects] = React.useState(context.projects)
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
// The first thing on each step, where focus lands when the step opens.
const nameField = React.useRef<HTMLInputElement>(null)
const leadField = React.useRef<HTMLSelectElement>(null)
const startField = React.useRef<HTMLInputElement>(null)
const reviewHeading = React.useRef<HTMLHeadingElement>(null)
const moved = React.useRef(false)
const id = React.useId()
React.useEffect(() => {
if (!moved.current) return
moved.current = false
;[nameField, leadField, startField, reviewHeading][step]?.current?.focus()
}, [step])
const people = new Map(context.people.map((person) => [person.id, person]))
const team = [draft.leadId, ...draft.teamIds.filter((entry) => entry !== draft.leadId)]
const update = (patch: Partial<ProjectDraft>) => {
setDraft((current) => ({ ...current, ...patch }))
setRefusal("")
}
function check(at: number): Errors {
const found: Errors = {}
if (at === 0) {
const name = draft.name.trim()
const taken = projects.find((project) => project.name.toLowerCase() === name.toLowerCase())
if (!name) found.name = "Give the project a name."
else if (taken) found.name = `${taken.name} already exists.`
const owner = projects.find((project) => project.code === draft.code)
if (!/^[A-Z]{3}$/.test(draft.code)) found.code = "A code is three letters."
else if (owner) found.code = `${draft.code} belongs to ${owner.name}.`
}
if (at === 1 && team.length < 2) found.team = "Add at least one person besides the lead."
if (at === 2) {
if (!draft.startDay || !draft.dueDay) found.dates = "Both dates are needed."
else if (draft.startDay < context.today) found.dates = "A project cannot start in the past."
else if (draft.dueDay <= draft.startDay) found.dates = "The due date has to come after the start."
}
return found
}
function go(to: number) {
if (to > step) {
const found = check(step)
setErrors(found)
if (Object.keys(found).length > 0) return
}
moved.current = true
setStep(to)
}
function reset(next: boolean) {
setOpen(next)
if (next) return
setStep(0)
setDraft(blank())
setCodeTouched(false)
setErrors({})
setRefusal("")
}
async function create() {
if (pending) return
setPending(true)
let result: Result<CreatedProject>
try {
result = await onCreate({ ...draft, teamIds: team })
} catch {
result = { ok: false, error: { code: "network", message: "The project did not reach the server. Try again." } }
}
setPending(false)
if (!result.ok) return setRefusal(result.error.message)
const made = result.data
setProjects((current) => [made, ...current])
setNotice(`Created ${made.name} (${made.code}) with ${formatNumber(made.people)} people, due ${DAY.format(made.endAt)}.`)
reset(false)
}
const invalid = (key: keyof Errors) => (errors[key] ? { "aria-invalid": true, "aria-describedby": `${id}-${key}-error` } : {})
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground tabular-nums">{projects.length} projects</p>
<Dialog open={open} onOpenChange={reset}>
<DialogTrigger ref={trigger} render={<Button size="sm" />}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
New project
</DialogTrigger>
<DialogContent initialFocus={nameField} finalFocus={trigger} className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New project</DialogTitle>
<DialogDescription>Four short steps. Nothing is created until you confirm on the last one.</DialogDescription>
</DialogHeader>
<Stepper steps={STEPS} activeStep={step} size="sm" aria-hidden="true" />
<p aria-live="polite" className="sr-only">
Step {step + 1} of {STEPS.length}, {STEPS[step].title}
</p>
{step === 0 ? (
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_7rem]">
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-name`} className="text-sm font-medium">
Name
</label>
<Input
ref={nameField}
id={`${id}-name`}
value={draft.name}
onChange={(event) => update({ name: event.target.value, ...(codeTouched ? {} : { code: codeFor(event.target.value) }) })}
placeholder="Orbit launch"
{...invalid("name")}
/>
<FieldError id={`${id}-name-error`}>{errors.name}</FieldError>
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-code`} className="text-sm font-medium">
Code
</label>
<Input
id={`${id}-code`}
value={draft.code}
maxLength={3}
onChange={(event) => {
setCodeTouched(true)
update({ code: event.target.value.toUpperCase().replace(/[^A-Z]/g, "") })
}}
className="font-mono uppercase"
{...invalid("code")}
/>
<FieldError id={`${id}-code-error`}>{errors.code}</FieldError>
</div>
</div>
) : null}
{step === 1 ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-lead`} className="text-sm font-medium">
Lead
</label>
<NativeSelect ref={leadField} id={`${id}-lead`} value={draft.leadId} onChange={(event) => update({ leadId: event.target.value })} className="w-full">
{context.people.map((person) => (
<NativeSelectOption key={person.id} value={person.id}>
{person.name}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<fieldset className="flex flex-col gap-2" aria-describedby={errors.team ? `${id}-team-error` : undefined}>
<legend className="mb-2 text-sm font-medium">
Team <span className="font-normal text-muted-foreground tabular-nums">· {team.length} with the lead</span>
</legend>
<ul className="flex max-h-52 flex-col gap-1 overflow-y-auto rounded-lg p-1 ring-1 ring-border">
{context.people.map((person) => {
const isLead = person.id === draft.leadId
return (
<li key={person.id}>
<label className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50 has-[[data-checked]]:bg-brand-muted">
<Checkbox
checked={isLead || draft.teamIds.includes(person.id)}
disabled={isLead}
onCheckedChange={(checked) =>
update({ teamIds: checked ? [...draft.teamIds, person.id] : draft.teamIds.filter((entry) => entry !== person.id) })
}
/>
<UserCell size="sm" name={person.name} description={isLead ? "Lead" : person.email} src={person.avatarUrl} />
</label>
</li>
)
})}
</ul>
<FieldError id={`${id}-team-error`}>{errors.team}</FieldError>
</fieldset>
</div>
) : null}
{step === 2 ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-start`} className="text-sm font-medium">
Starts
</label>
<Input ref={startField} id={`${id}-start`} type="date" min={context.today} value={draft.startDay} onChange={(event) => update({ startDay: event.target.value })} {...invalid("dates")} />
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-due`} className="text-sm font-medium">
Due
</label>
<Input id={`${id}-due`} type="date" min={draft.startDay} value={draft.dueDay} onChange={(event) => update({ dueDay: event.target.value })} {...invalid("dates")} />
</div>
<FieldError id={`${id}-dates-error`} className="sm:col-span-2">
{errors.dates}
</FieldError>
{!errors.dates && draft.startDay && draft.dueDay > draft.startDay ? (
<p className="text-sm text-muted-foreground tabular-nums sm:col-span-2">{weeksBetween(draft.startDay, draft.dueDay)} weeks from start to finish.</p>
) : null}
</div>
) : null}
{step === 3 ? (
<div className="flex flex-col gap-3">
<h3 ref={reviewHeading} tabIndex={-1} className="text-sm font-semibold outline-none">
Check before you create
</h3>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 rounded-lg bg-surface p-3 text-sm ring-1 ring-border">
<dt className="text-muted-foreground">Project</dt>
<dd>
{draft.name.trim()} <span className="font-mono text-xs text-muted-foreground">{draft.code}</span>
</dd>
<dt className="text-muted-foreground">Lead</dt>
<dd>{people.get(draft.leadId)?.name}</dd>
<dt className="text-muted-foreground">Team</dt>
<dd>{team.map((entry) => people.get(entry)?.name).join(", ")}</dd>
<dt className="text-muted-foreground">Runs</dt>
<dd className="tabular-nums">
{dayLabel(draft.startDay)} to {dayLabel(draft.dueDay)}
</dd>
</dl>
{refusal ? (
<Callout variant="danger" role="alert" title="The project was not created">
{refusal}
</Callout>
) : null}
</div>
) : null}
<DialogFooter>
<Button variant="outline" disabled={step === 0} focusableWhenDisabled onClick={() => go(step - 1)}>
Back
</Button>
{step < STEPS.length - 1 ? (
<Button onClick={() => go(step + 1)}>Next: {STEPS[step + 1].title}</Button>
) : (
<Button onClick={() => void create()} aria-busy={pending || undefined}>
Create project
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<ul aria-label="Projects" className="divide-y divide-border rounded-lg ring-1 ring-border">
{projects.slice(0, 5).map((project) => (
<li key={project.code} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<span className="flex min-w-0 items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{project.code}</span>
<span className="truncate font-medium">{project.name}</span>
</span>
<span className="shrink-0 text-muted-foreground tabular-nums">
{project.lead} · due {DAY.format(project.endAt)}
</span>
</li>
))}
</ul>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</div>
)
}/**
* What `modal-dialogs-03` reads and the one thing it writes: the projects
* already in `db.projects` — their names and codes are taken — the active
* people in `db.members` who can lead or join one, and `addProject`, which
* the section's server action calls to create a project from the wizard's
* answers. "Now" is `REFERENCE_DATE`.
*
* Every read happens inside a function and runs on the server. The island
* takes the context as props and calls the action it is handed; it never
* imports this module.
*/
import { REFERENCE_DATE, asString, calendarDay, db, fields, type Project, type Result } from "@/lib/sample-data"
export type { Result }
const DAY_MS = 86_400_000
export type Teammate = { id: string; name: string; email: string; avatarUrl?: string }
export type ProjectSummary = { name: string; code: string; status: Project["status"]; lead: string; endAt: Date }
export type WizardContext = {
/** Projects already on the books; their names and codes are taken. */
projects: ProjectSummary[]
people: Teammate[]
/** "YYYY-MM-DD" days the date fields open on: the Monday after now, and eight weeks on. */
startDay: string
dueDay: string
today: string
}
export type ProjectDraft = { name: string; code: string; leadId: string; teamIds: string[]; startDay: string; dueDay: string }
export type CreatedProject = ProjectSummary & { people: number }
const dayOf = (date: Date) => date.toISOString().slice(0, 10)
/** Who can be asked, the projects already running, and the days the dates open on. */
export function wizardContext(): WizardContext {
const members = new Map(db.members.all().map((member) => [member.id, member.name]))
const monday = new Date(REFERENCE_DATE.getTime() + (((8 - REFERENCE_DATE.getUTCDay()) % 7 || 7) * DAY_MS))
return {
projects: db.projects
.all()
.sort((a, b) => b.startAt.getTime() - a.startAt.getTime())
.map((project) => ({
name: project.name,
code: project.code,
status: project.status,
lead: members.get(project.lead) ?? "Unassigned",
endAt: project.endAt,
})),
people: db.members
.all()
.filter((member) => member.status === "active")
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ id, name, email, avatarUrl }) => ({ id, name, email, avatarUrl })),
startDay: dayOf(monday),
dueDay: dayOf(new Date(monday.getTime() + 8 * 7 * DAY_MS)),
today: dayOf(REFERENCE_DATE),
}
}
/** A project's name, as a board's column and a report's row print it. */
const MAX_NAME = 80
/**
* Creates the project, or says why not. Everything is checked again here,
* against the store as it is now rather than as the wizard saw it: the name
* and the code are free, the lead and the team are active people, the dates
* run forwards and do not start in the past.
*/
export async function addProject(sent: ProjectDraft): Promise<Result<CreatedProject>> {
const refuse = (message: string, field: string): Result<never> => ({ ok: false, error: { code: "refused", message, field } })
const draft = fields(sent)
const name = asString(draft.name).trim()
const code = asString(draft.code).trim().toUpperCase()
if (!name) return refuse("Give the project a name.", "name")
if (name.length > MAX_NAME) return refuse(`Keep the name under ${MAX_NAME} characters.`, "name")
if (!/^[A-Z]{3}$/.test(code)) return refuse("A code is three letters.", "code")
const projects = db.projects.all()
const sameName = projects.find((project) => project.name.toLowerCase() === name.toLowerCase())
if (sameName) return refuse(`${sameName.name} already exists. Choose another name.`, "name")
const sameCode = projects.find((project) => project.code === code)
if (sameCode) return refuse(`${code} was taken by ${sameCode.name} while you were filling this in. Choose another code.`, "code")
const active = new Map(db.members.all().filter((member) => member.status === "active").map((member) => [member.id, member]))
const lead = typeof draft.leadId === "string" ? active.get(draft.leadId) : undefined
if (!lead) return refuse("The lead has to be someone active in the workspace.", "lead")
const teamIds: unknown[] = Array.isArray(draft.teamIds) ? draft.teamIds : [null]
const team = [...new Set([lead.id, ...teamIds])]
if (team.some((id) => typeof id !== "string" || !active.has(id))) return refuse("Everyone on the team has to be active in the workspace.", "team")
// Days that exist, within the next five years: "2026-02-31" is not one, and nor is 9999.
const within = { to: new Date(REFERENCE_DATE.getTime() + 5 * 365 * 86_400_000) }
const startAt = calendarDay(draft.startDay, within)
const endAt = calendarDay(draft.dueDay, within)
if (!startAt || !endAt) return refuse("Both dates are needed, each a day within the next five years.", "dates")
if (startAt.getTime() < new Date(`${dayOf(REFERENCE_DATE)}T00:00:00Z`).getTime()) return refuse("A project cannot start in the past.", "dates")
if (endAt <= startAt) return refuse("The due date has to come after the start.", "dates")
const created = await db.projects.create({ name, code, status: "planning", lead: lead.id, startAt, endAt, progress: 0 })
if (!created.ok) return created
return { ok: true, data: { name, code, status: "planning", lead: lead.name, endAt, people: team.length } }
}npx shadcn@latest add @vibra/modal-dialogs-03Share with access roles
modal-dialogs-04A file and its Share dialog: add people from the workspace at an access level, change or remove what each can do within what their role allows, and copy a link whose reach you choose.
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ShareFile } from "./modal-dialogs-04.client"
import { sharedFile } from "./modal-dialogs-04.data"
export type ModalDialogs04Props = {
/** Opens the Share dialog on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A file and its Share dialog: who can open it and what each can do, who
* else in the workspace could, and what its link opens for.
*
* A server component: the file and its people come from
* `./modal-dialogs-04.data`.
*/
export function ModalDialogs04({ defaultOpen = false }: ModalDialogs04Props) {
const titleId = React.useId()
const file = sharedFile()
return (
<section
data-section="modal-dialogs-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Sharing</h2>
</CardTitle>
<CardDescription className="tabular-nums">
In {file.folder}, open to {formatNumber(file.grants.length)} people.
</CardDescription>
</CardHeader>
<CardContent>
<ShareFile file={file} defaultOpen={defaultOpen} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ModalDialogs04"use client"
import * as React from "react"
import { CheckIcon, FileTextIcon, LinkIcon, Share2Icon } from "lucide-react"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { formatBytes, formatNumber } from "@/lib/format"
import { Autocomplete } from "@/components/ui/autocomplete"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { UserCell } from "@/components/ui/user-cell"
import type { Access, Grant, SharedFile } from "./modal-dialogs-04.data"
type Level = Exclude<Access, "owner">
export const LEVELS: { value: Level; label: string }[] = [
{ value: "edit", label: "Can edit" },
{ value: "comment", label: "Can comment" },
{ value: "view", label: "Can view" },
]
const RANK: Record<Level, number> = { view: 0, comment: 1, edit: 2 }
const LINK = {
restricted: "Only people with access",
workspace: "Anyone at Northwind with the link",
} as const
const people = (count: number) => `${formatNumber(count)} ${count === 1 ? "person" : "people"}`
/**
* The file row and its Share dialog. The dialog opens on the Add people box,
* which suggests the workspace and takes a name or an address; the access
* picked beside it goes with them. Someone outside the workspace, or a
* workspace viewer asked to edit, is refused in a danger callout under the
* box that says why; a person already listed is told so under the box. Each
* person's access is a select that offers only what their workspace role
* allows, and removes them; the owner stays. Copy link says so in a status
* line inside the dialog; Done closes it and the row says who has the file.
*/
export function ShareFile({ file, defaultOpen }: { file: SharedFile; defaultOpen: boolean }) {
const [open, setOpen] = React.useState(defaultOpen)
const [grants, setGrants] = React.useState<Grant[]>(file.grants)
const [query, setQuery] = React.useState("")
const [level, setLevel] = React.useState<Level>("view")
const [invalid, setInvalid] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const [link, setLink] = React.useState<keyof typeof LINK>("restricted")
const [copied, setCopied] = React.useState("")
const [notice, setNotice] = React.useState("")
const { copy } = useCopyToClipboard()
const trigger = React.useRef<HTMLButtonElement>(null)
const field = React.useRef<HTMLInputElement>(null)
const ids = { add: React.useId(), list: React.useId(), error: React.useId(), refusal: React.useId(), link: React.useId() }
const everyone = [...grants, ...file.others]
function add(event: React.FormEvent) {
event.preventDefault()
setInvalid("")
setRefusal("")
const needle = query.trim().toLowerCase()
if (!needle) return setInvalid("Type a name or an email address.")
const person = everyone.find((entry) => entry.email.toLowerCase() === needle || entry.name.toLowerCase() === needle)
if (!person) return setRefusal(`${query.trim()} is not in the Northwind workspace, so they cannot open files here. Invite them to the workspace first.`)
if (grants.some((grant) => grant.id === person.id)) return setInvalid(`${person.name} already has access.`)
if (RANK[level] > RANK[person.ceiling])
return setRefusal(`${person.name} is a viewer in the workspace, so they can only view files. Add them with Can view, or change their workspace role first.`)
setGrants((current) => [...current, { ...person, access: level }])
setQuery("")
}
function change(id: string, value: string) {
setRefusal("")
if (value === "remove") setGrants((current) => current.filter((grant) => grant.id !== id))
else setGrants((current) => current.map((grant) => (grant.id === id ? { ...grant, access: value as Level } : grant)))
}
async function copyLink() {
const ok = await copy(`https://northwind.example/files/${file.id}`)
setCopied(ok ? `Link copied. ${LINK[link]} can open it.` : "The browser blocked the clipboard, so the link was not copied.")
}
function finish(next: boolean) {
setOpen(next)
if (next) return
setNotice(`${file.name} is shared with ${people(grants.length)}. ${LINK[link]} can open it.`)
setInvalid("")
setRefusal("")
setCopied("")
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<FileTextIcon aria-hidden="true" className="size-5" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{formatBytes(file.sizeBytes, 1)} · changed {file.modifiedDaysAgo === 0 ? "today" : `${file.modifiedDaysAgo} days ago`}
</span>
</span>
<AvatarGroup size="sm" max={4} users={grants.map((grant) => ({ name: grant.name, src: grant.avatarUrl }))} />
<Dialog open={open} onOpenChange={finish}>
<DialogTrigger ref={trigger} render={<Button variant="outline" size="sm" />}>
<Share2Icon data-icon="inline-start" aria-hidden="true" />
Share
</DialogTrigger>
<DialogContent initialFocus={field} finalFocus={trigger} className="gap-5 sm:max-w-lg">
<DialogHeader>
<DialogTitle>Share {file.name}</DialogTitle>
<DialogDescription>
People with access open it from {file.folder}. Add someone from the workspace, or change what each can do.
</DialogDescription>
</DialogHeader>
<form onSubmit={add} noValidate className="flex flex-col gap-2">
<label htmlFor={ids.add} className="text-sm font-medium">
Add people
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<Autocomplete
ref={field}
id={ids.add}
value={query}
onValueChange={(next) => {
setQuery(next)
setInvalid("")
setRefusal("")
}}
getSuggestions={(typed) => {
const needle = typed.trim().toLowerCase()
return file.others
.filter((person) => person.name.toLowerCase().includes(needle) || person.email.includes(needle))
.slice(0, 6)
.map((person) => ({ value: person.email, label: person.name, description: person.email }))
}}
emptyText="Nobody in the workspace matches"
placeholder="Name or email"
aria-invalid={invalid || refusal ? true : undefined}
aria-describedby={invalid ? ids.error : refusal ? ids.refusal : undefined}
className="flex-1"
/>
<div className="flex gap-2">
<NativeSelect aria-label="Access for who you add" value={level} onChange={(event) => setLevel(event.target.value as Level)} className="flex-1 sm:w-36">
{LEVELS.map((option) => (
<NativeSelectOption key={option.value} value={option.value}>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
<Button type="submit">Add</Button>
</div>
</div>
<FieldError id={ids.error}>{invalid}</FieldError>
{refusal ? (
<Callout id={ids.refusal} variant="danger" role="alert" title="Not added">
{refusal}
</Callout>
) : null}
</form>
<div className="flex flex-col gap-2">
<h3 id={`${ids.list}-heading`} className="text-sm font-medium">
{people(grants.length)} with access
</h3>
<ul aria-labelledby={`${ids.list}-heading`} className="flex max-h-60 flex-col divide-y divide-border overflow-y-auto">
{grants.map((grant) => (
<li key={grant.id} className="flex items-center justify-between gap-3 py-2">
<UserCell size="sm" name={grant.name} email={grant.email} src={grant.avatarUrl} className="min-w-0" />
{grant.access === "owner" ? (
<span className="px-2.5 text-sm text-muted-foreground">Owner</span>
) : (
<NativeSelect
size="sm"
aria-label={`Access for ${grant.name}`}
value={grant.access}
onChange={(event) => change(grant.id, event.target.value)}
className="w-36 shrink-0"
>
{LEVELS.map((option) => (
<NativeSelectOption key={option.value} value={option.value} disabled={RANK[option.value] > RANK[grant.ceiling]}>
{option.label}
</NativeSelectOption>
))}
<NativeSelectOption value="remove">Remove access</NativeSelectOption>
</NativeSelect>
)}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-2 rounded-lg bg-surface p-3 ring-1 ring-border">
<label htmlFor={ids.link} className="text-sm font-medium">
Link
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<NativeSelect id={ids.link} value={link} onChange={(event) => setLink(event.target.value as keyof typeof LINK)} className="w-full">
{(Object.keys(LINK) as (keyof typeof LINK)[]).map((key) => (
<NativeSelectOption key={key} value={key}>
{LINK[key]}
</NativeSelectOption>
))}
</NativeSelect>
<Button variant="outline" onClick={() => void copyLink()} className="shrink-0">
{copied.startsWith("Link copied") ? <CheckIcon data-icon="inline-start" aria-hidden="true" /> : <LinkIcon data-icon="inline-start" aria-hidden="true" />}
Copy link
</Button>
</div>
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{copied}
</p>
</div>
<DialogFooter>
<DialogClose render={<Button />}>Done</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</div>
)
}/**
* What `modal-dialogs-04` reads: one file in `db.files`, the folder it sits
* in, and who can open it. The store records owners, not shares, so access
* is a rule over what it does record: the file's owner, and everyone who
* keeps a file in the same folder — they work there — at the most their
* workspace role in `db.members` allows. The rest of the workspace can be
* added.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the file as props; it never imports this module.
*/
import { REFERENCE_DATE, db, type Member } from "@/lib/sample-data"
/** The file the band shares. */
export const FILE = "q3-budget.pdf"
export type Access = "owner" | "edit" | "comment" | "view"
export type Person = {
id: string
name: string
email: string
avatarUrl?: string
role: Member["role"]
/** The most they can be given on a file: a workspace viewer only views. */
ceiling: Exclude<Access, "owner">
}
export type Grant = Person & { access: Access }
export type SharedFile = {
id: string
name: string
folder: string
sizeBytes: number
modifiedAt: Date
/** Days between the last change and `REFERENCE_DATE`. */
modifiedDaysAgo: number
grants: Grant[]
/** Everyone else active in the workspace, who could be added. */
others: Person[]
}
const ceilingOf = (role: Member["role"]): Person["ceiling"] => (role === "viewer" ? "view" : "edit")
/** The file, its folder, who has it and who else could. */
export function sharedFile(): SharedFile {
const file = db.files.all().find((entry) => entry.name === FILE)
if (!file) throw new Error(`modal-dialogs-04 shares "${FILE}", which is not in db.files`)
const folder = db.files.all().find((entry) => entry.id === file.parentId)
const members = db.members.all().filter((member) => member.status === "active")
const person = (member: Member): Person => ({
id: member.id,
name: member.name,
email: member.email,
avatarUrl: member.avatarUrl,
role: member.role,
ceiling: ceilingOf(member.role),
})
const colleagues = new Set(db.files.all().filter((entry) => entry.parentId === file.parentId && entry.kind !== "folder").map((entry) => entry.ownerId))
const owner = members.find((member) => member.id === file.ownerId)
const grants: Grant[] = [
...(owner ? [{ ...person(owner), access: "owner" as const }] : []),
...members
.filter((member) => member.id !== file.ownerId && colleagues.has(member.id))
.sort((a, b) => a.name.localeCompare(b.name))
.map((member) => ({ ...person(member), access: ceilingOf(member.role) })),
]
const granted = new Set(grants.map((grant) => grant.id))
return {
id: file.id,
name: file.name,
folder: folder?.name ?? "Files",
sizeBytes: file.sizeBytes,
modifiedAt: file.modifiedAt,
modifiedDaysAgo: Math.max(0, Math.floor((REFERENCE_DATE.getTime() - file.modifiedAt.getTime()) / 86_400_000)),
grants,
others: members.filter((member) => !granted.has(member.id)).sort((a, b) => a.name.localeCompare(b.name)).map(person),
}
}npx shadcn@latest add @vibra/modal-dialogs-04Command search dialog
modal-dialogs-05Workspace search as a command dialog: a search box on the band or ⌘K from anywhere opens it onto customers, projects, people and actions that filter as the reader types.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { WorkspaceSearch } from "./modal-dialogs-05.client"
import { searchIndex } from "./modal-dialogs-05.data"
export type ModalDialogs05Props = {
/** Opens the search on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* Workspace search as a command dialog: a search box on the band that opens
* it, ⌘K or Ctrl+K from anywhere, and the customers, projects, people and
* actions it filters as the reader types.
*
* A server component: the index comes from `./modal-dialogs-05.data`.
*/
export function ModalDialogs05({ defaultOpen = false }: ModalDialogs05Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Search</h2>
</CardTitle>
<CardDescription>Jump to any customer, project or person, from anywhere in the app.</CardDescription>
</CardHeader>
<CardContent>
<WorkspaceSearch index={searchIndex()} defaultOpen={defaultOpen} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ModalDialogs05"use client"
import * as React from "react"
import { ArrowDownIcon, ArrowUpIcon, CornerDownLeftIcon, FolderKanbanIcon, PlusIcon, SearchIcon, SettingsIcon, UserPlusIcon } from "lucide-react"
import { firstDelivery, matchesHotkey } from "@/lib/hotkeys"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
useCommandState,
} from "@/components/ui/command"
import { Kbd } from "@/components/ui/kbd"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import type { SearchEntry, SearchIndex } from "./modal-dialogs-05.data"
/** Things to do rather than things to open: vocabulary, not data. */
export const ACTIONS = [
{ id: "invite", label: "Invite people", icon: UserPlusIcon, shortcut: "I" },
{ id: "project", label: "New project", icon: PlusIcon, shortcut: "P" },
{ id: "settings", label: "Workspace settings", icon: SettingsIcon, shortcut: "," },
]
const initials = (name: string) =>
name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0])
.join("")
function Face({ entry }: { entry: SearchEntry }) {
return (
<Avatar className="size-5">
{entry.avatarUrl ? <AvatarImage src={entry.avatarUrl} alt="" /> : null}
<AvatarFallback className="text-avatar">{initials(entry.label)}</AvatarFallback>
</Avatar>
)
}
/**
* Every word typed has to appear in the line or its keywords; a line that
* starts with what was typed ranks first. cmdk's own fuzzy match lets a long
* email address answer to almost anything, which is noise in a workspace.
*/
export function score(value: string, search: string, keywords: string[] = []): number {
const words = search.toLowerCase().split(/\s+/).filter(Boolean)
const haystack = [value, ...keywords].join(" ").toLowerCase()
if (!words.every((word) => haystack.includes(word))) return 0
return value.toLowerCase().startsWith(search.trim().toLowerCase()) ? 1 : 0.5
}
/** How many lines the search leaves, said once the reader has typed something. */
function ResultCount() {
const search = useCommandState((state) => state.search)
const count = useCommandState((state) => state.filtered.count)
return (
<p role="status" aria-live="polite" className="sr-only">
{search ? `${count} ${count === 1 ? "result" : "results"}` : ""}
</p>
)
}
/**
* The search box on the band and the command dialog it opens — from the
* button or from anywhere with ⌘K or Ctrl+K. The dialog is the kit's
* CommandDialog: named and described for assistive technology, focus in the
* search field, the arrow keys moving through customers, projects, people
* and actions as they filter, Enter opening one. Escape closes it and focus
* goes back to where it was. What was opened is said in the band's status
* line and joins the recent list; wire `open` to your router.
*/
export function WorkspaceSearch({ index, defaultOpen }: { index: SearchIndex; defaultOpen: boolean }) {
const [open, setOpen] = React.useState(defaultOpen)
const [recent, setRecent] = React.useState<{ kind: string; entry: SearchEntry }[]>(() =>
[
{ kind: "project", entry: index.projects[0] },
{ kind: "customer", entry: index.customers[0] },
{ kind: "person", entry: index.people[0] },
].filter((item) => item.entry !== undefined)
)
const [notice, setNotice] = React.useState("")
// ⌘K is the band's, not the window's: a page holds other bands, and the
// page's own palette may want the chord. React brings a press here from
// inside the portalled palette too — once per portal container it passes,
// so each press counts once.
const [once] = React.useState(firstDelivery)
function onKeyDown(event: React.KeyboardEvent) {
if (!matchesHotkey(event.nativeEvent, "mod+k") || !once(event.nativeEvent)) return
event.preventDefault()
setOpen((current) => !current)
}
function choose(kind: string, entry: SearchEntry) {
setOpen(false)
setNotice(`Opened ${entry.label}.`)
setRecent((current) => [{ kind, entry }, ...current.filter((item) => item.entry.id !== entry.id)].slice(0, 3))
}
function start(label: string) {
setOpen(false)
setNotice(`Started ${label}.`)
}
const groups: [string, string, SearchEntry[]][] = [
["customer", "Customers", index.customers],
["project", "Projects", index.projects],
["person", "People", index.people],
]
return (
<div className="flex flex-col gap-4" onKeyDown={onKeyDown}>
<Button
variant="outline"
onClick={() => setOpen(true)}
aria-keyshortcuts="Meta+K Control+K"
className="h-10 w-full justify-start gap-2 px-3 font-normal text-muted-foreground sm:max-w-md"
>
<SearchIcon aria-hidden="true" />
<span className="flex-1 text-start">Search customers, projects and people</span>
<KbdShortcut keys="mod+k" size="sm" className="max-sm:hidden" />
</Button>
<div className="flex flex-col gap-2">
<h3 className="type-eyebrow">Recently opened</h3>
<ul className="flex flex-wrap gap-2">
{recent.map(({ kind, entry }) => (
<li key={entry.id} className="inline-flex items-center gap-1.5 rounded-full bg-muted py-1 ps-1.5 pe-2.5 text-xs">
{kind === "project" ? <FolderKanbanIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> : <Face entry={entry} />}
{entry.label}
</li>
))}
</ul>
</div>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
<CommandDialog
open={open}
onOpenChange={setOpen}
title="Search Northwind"
description="Find a customer, a project or a person, or start something new."
className="gap-0 sm:max-w-xl"
>
{/* The command root names the combobox by its label; the dialog shell keeps the sizing. */}
<Command label="Search Northwind" loop filter={score}>
<CommandInput placeholder="Search customers, projects and people" />
<ResultCount />
<CommandList className="max-h-80">
<CommandEmpty>Nothing matches. Try a company, a project code or a name.</CommandEmpty>
{groups.map(([kind, heading, entries]) => (
<CommandGroup key={kind} heading={heading}>
{entries.map((entry) => (
<CommandItem key={entry.id} value={`${entry.label} ${entry.hint}`} keywords={entry.keywords} onSelect={() => choose(kind, entry)}>
{kind === "project" ? <FolderKanbanIcon aria-hidden="true" className="text-muted-foreground" /> : <Face entry={entry} />}
<span className="truncate">{entry.label}</span>
<span className="truncate text-xs text-muted-foreground">{entry.hint}</span>
</CommandItem>
))}
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup heading="Actions">
{ACTIONS.map((action) => (
<CommandItem key={action.id} value={action.label} onSelect={() => start(action.label)}>
<action.icon aria-hidden="true" className="text-muted-foreground" />
{action.label}
<CommandShortcut>{action.shortcut}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<div aria-hidden="true" className="flex items-center gap-4 border-t border-rule bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Kbd>
<ArrowUpIcon />
</Kbd>
<Kbd>
<ArrowDownIcon />
</Kbd>
Move
</span>
<span className="flex items-center gap-1.5">
<Kbd>
<CornerDownLeftIcon />
</Kbd>
Open
</span>
<span className="flex items-center gap-1.5">
<Kbd>Esc</Kbd>
Close
</span>
</div>
</CommandDialog>
</div>
)
}/**
* What `modal-dialogs-05` searches: the workspace's customers in
* `db.customers`, its projects in `db.projects` and its people in
* `db.members`, each as a line the search dialog can list and match.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the index as props; it never imports this module.
*/
import { db } from "@/lib/sample-data"
/** How many customers the index carries: the ones paying the most. */
export const CUSTOMER_LIMIT = 40
export type SearchEntry = {
id: string
/** What the line reads. */
label: string
/** The quieter second part: a contact, a code, a role. */
hint: string
/** More words the entry answers to. */
keywords: string[]
avatarUrl?: string
}
export type SearchIndex = { customers: SearchEntry[]; projects: SearchEntry[]; people: SearchEntry[] }
const title = (word: string) => word.charAt(0).toUpperCase() + word.slice(1).replaceAll("_", " ")
/** The top customers by what they pay, every project and every active person. */
export function searchIndex(): SearchIndex {
return {
customers: db.customers
.all()
.filter((customer) => customer.status !== "churned")
.sort((a, b) => b.mrrCents - a.mrrCents || a.company.localeCompare(b.company))
.slice(0, CUSTOMER_LIMIT)
.map((customer) => ({
id: customer.id,
label: customer.company,
hint: customer.name,
keywords: [customer.name, customer.email, customer.country, customer.plan],
avatarUrl: customer.avatarUrl,
})),
projects: db.projects
.all()
.sort((a, b) => a.name.localeCompare(b.name))
.map((project) => ({ id: project.id, label: project.name, hint: `${project.code} · ${title(project.status)}`, keywords: [project.code, project.status] })),
people: db.members
.all()
.filter((member) => member.status === "active")
.sort((a, b) => a.name.localeCompare(b.name))
.map((member) => ({ id: member.id, label: member.name, hint: title(member.role), keywords: [member.email, member.role], avatarUrl: member.avatarUrl })),
}
}npx shadcn@latest add @vibra/modal-dialogs-05Upload with progress
modal-dialogs-06A folder and its Upload dialog: browse or drop files, have the types, the size, the count and the names checked, watch each climb its own progress bar, and find them at the top of the folder.
import * as React from "react"
import { UploadToFolder } from "./modal-dialogs-06.client"
import { folderContents } from "./modal-dialogs-06.data"
export type ModalDialogs06Props = {
/** Opens the upload on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A folder and its Upload dialog: browse or drop files, see each climb its
* own progress bar, and find them at the top of the folder when they land.
*
* A server component: the folder comes from `./modal-dialogs-06.data`.
*/
export function ModalDialogs06({ defaultOpen = false }: ModalDialogs06Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<UploadToFolder folder={folderContents()} titleId={titleId} defaultOpen={defaultOpen} />
</div>
</section>
)
}
export default ModalDialogs06"use client"
import * as React from "react"
import { FileTextIcon, UploadIcon } from "lucide-react"
import { useInterval } from "@/hooks/use-interval"
import { formatBytes, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FileUpload } from "@/components/ui/file-upload"
import type { Folder, StoredFile } from "./modal-dialogs-06.data"
export const ACCEPT = [".pdf", ".png", ".jpg", ".jpeg"]
export const MAX_BYTES = 20 * 1024 * 1024
export const MAX_FILES = 5
/** How often the progress moves, and how much it carries each time. */
const TICK_MS = 150
const BYTES_PER_TICK = 1.5 * 1024 * 1024
const filesWord = (count: number) => `${formatNumber(count)} ${count === 1 ? "file" : "files"}`
/**
* The folder card and its Upload dialog. The dialog is the kit's FileUpload:
* browse or drop, with the types, the size and the count checked as files
* arrive and each refusal listed by name. Upload first checks the names
* against the folder: a file the folder already holds is refused in a danger
* callout, with nothing sent, until it is taken off the list. Otherwise each
* file climbs its own progress bar in proportion to its size; when the last
* one lands the dialog closes, the files join the folder at the top and the
* card's status line says what arrived. Focus opens on Browse files and goes
* back to Upload files.
*/
export function UploadToFolder({ folder, titleId, defaultOpen }: { folder: Folder; titleId: string; defaultOpen: boolean }) {
const [open, setOpen] = React.useState(defaultOpen)
const [picked, setPicked] = React.useState<File[]>([])
const [progress, setProgress] = React.useState<Record<string, number> | null>(null)
const [refusal, setRefusal] = React.useState("")
const [files, setFiles] = React.useState<StoredFile[]>(folder.files)
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const popup = React.useRef<HTMLDivElement>(null)
const uploading = progress !== null
const total = files.reduce((sum, file) => sum + file.sizeBytes, 0)
function reset(next: boolean) {
if (!next && uploading) return
setOpen(next)
if (next) return
setPicked([])
setRefusal("")
}
function start() {
if (picked.length === 0 || uploading) return
const taken = new Set(files.map((file) => file.name.toLowerCase()))
const clashes = picked.filter((file) => taken.has(file.name.toLowerCase())).map((file) => file.name)
if (clashes.length > 0) {
setRefusal(
`${clashes.join(", ")} ${clashes.length === 1 ? "is" : "are"} already in ${folder.name}. Rename ${clashes.length === 1 ? "it" : "them"} or take ${clashes.length === 1 ? "it" : "them"} off the list, then upload again.`
)
return
}
setRefusal("")
setProgress(Object.fromEntries(picked.map((file) => [file.name, 0])))
}
function finish() {
const bytes = picked.reduce((sum, file) => sum + file.size, 0)
setFiles((current) => [...picked.map((file) => ({ id: `${file.name}-${file.lastModified}`, name: file.name, sizeBytes: file.size, owner: "You", daysAgo: 0 })), ...current])
setNotice(`Uploaded ${filesWord(picked.length)} (${formatBytes(bytes, 1)}) to ${folder.name}.`)
setProgress(null)
setPicked([])
setOpen(false)
}
// The upload: each file moves by the same number of bytes a tick, so a
// small one lands first. Paused (null) whenever nothing is on its way.
useInterval(
() => {
if (!progress) return
const next = Object.fromEntries(
picked.map((file) => [file.name, Math.min(100, (progress[file.name] ?? 0) + Math.max(4, (BYTES_PER_TICK / Math.max(1, file.size)) * 100))])
)
if (picked.every((file) => next[file.name] >= 100)) return finish()
setProgress(next)
},
uploading ? TICK_MS : null
)
return (
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{folder.name}</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{filesWord(files.length)} · {formatBytes(total, 1)}
</CardDescription>
<CardAction>
<Dialog open={open} onOpenChange={reset}>
<DialogTrigger ref={trigger} render={<Button size="sm" />}>
<UploadIcon data-icon="inline-start" aria-hidden="true" />
Upload files
</DialogTrigger>
<DialogContent
ref={popup}
initialFocus={() => popup.current?.querySelector<HTMLElement>("[data-slot=file-dropzone] button") ?? true}
finalFocus={trigger}
className="gap-5 sm:max-w-lg"
>
<DialogHeader>
<DialogTitle>Upload to {folder.name}</DialogTitle>
<DialogDescription>
PDF, PNG or JPG, up to {formatBytes(MAX_BYTES, 0)} each, {MAX_FILES} at a time. A name the folder already holds is refused.
</DialogDescription>
</DialogHeader>
<FileUpload
label="Files"
accept={ACCEPT}
maxSize={MAX_BYTES}
maxFiles={MAX_FILES}
value={picked}
uploading={progress ?? undefined}
disabled={uploading}
onFilesAccepted={(added) => {
setPicked((current) => [...current, ...added])
setRefusal("")
}}
onRemove={(file) => {
setPicked((current) => current.filter((entry) => entry !== file))
setRefusal("")
}}
/>
{refusal ? (
<Callout variant="danger" role="alert" title="Nothing was uploaded">
{refusal}
</Callout>
) : null}
<DialogFooter>
<DialogClose render={<Button variant="outline" disabled={uploading} focusableWhenDisabled />}>Cancel</DialogClose>
<Button onClick={start} disabled={picked.length === 0 || uploading} focusableWhenDisabled aria-busy={uploading || undefined}>
{uploading ? "Uploading" : picked.length > 1 ? `Upload ${picked.length} files` : "Upload"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ul aria-label={`Files in ${folder.name}`} className="divide-y divide-border">
{files.slice(0, 6).map((file) => (
<li key={file.id} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
<FileTextIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{file.name}</span>
<span className="hidden text-sm text-muted-foreground sm:inline">{file.owner}</span>
<span className="w-16 text-right text-sm text-muted-foreground tabular-nums">{formatBytes(file.sizeBytes, 1)}</span>
<span className="w-20 text-right text-sm text-muted-foreground tabular-nums">{file.daysAgo === 0 ? "Just now" : `${file.daysAgo}d ago`}</span>
</li>
))}
</ul>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</CardContent>
</Card>
)
}/**
* What `modal-dialogs-06` reads: one folder in `db.files` and the files in
* it — their names, sizes, owners in `db.members` and when each last changed,
* counted from `REFERENCE_DATE` — so an upload can refuse a name the folder
* already holds.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the folder as props; it never imports this module.
*/
import { REFERENCE_DATE, db } from "@/lib/sample-data"
/** The folder the band uploads to. */
export const FOLDER = "Invoices"
export type StoredFile = { id: string; name: string; sizeBytes: number; owner: string; daysAgo: number }
export type Folder = { name: string; files: StoredFile[] }
/** The folder's files, the most recently changed first. */
export function folderContents(): Folder {
const folder = db.files.all().find((entry) => entry.kind === "folder" && entry.name === FOLDER)
if (!folder) throw new Error(`modal-dialogs-06 uploads to "${FOLDER}", which is not a folder in db.files`)
const members = new Map(db.members.all().map((member) => [member.id, member.name]))
return {
name: folder.name,
files: db.files
.all()
.filter((entry) => entry.parentId === folder.id && entry.kind !== "folder")
.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())
.map((entry) => ({
id: entry.id,
name: entry.name,
sizeBytes: entry.sizeBytes,
owner: members.get(entry.ownerId) ?? "Former member",
daysAgo: Math.max(0, Math.floor((REFERENCE_DATE.getTime() - entry.modifiedAt.getTime()) / 86_400_000)),
})),
}
}npx shadcn@latest add @vibra/modal-dialogs-06Add a card, with no network named
modal-dialogs-07An account's cards and the Add a card dialog: every card drawn the same and named by its last four digits, the new one written as it is printed and checked beside each field — and nothing sent anywhere.
import * as React from "react"
import { PaymentMethods } from "./modal-dialogs-07.client"
import { wallet } from "./modal-dialogs-07.data"
export type ModalDialogs07Props = {
/** Opens Add a card on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* An account's cards and the dialog that adds one: every card drawn the same
* and named by its last four digits, the new one checked as it is typed, and
* nothing sent anywhere.
*
* A server component: the cards come from `./modal-dialogs-07.data`.
*/
export function ModalDialogs07({ defaultOpen = false }: ModalDialogs07Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-07"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<PaymentMethods wallet={wallet()} titleId={titleId} defaultOpen={defaultOpen} />
</div>
</section>
)
}
export default ModalDialogs07"use client"
import * as React from "react"
import { CreditCardIcon, LockIcon, PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { cvcLength, detectBrand, formatCardNumber, formatExpiry, luhn, validateCvc, validateExpiry, validateName } from "@/components/ui/payment-method-form"
import { StatusBadge } from "@/components/ui/status-badge"
import type { StoredCard, Wallet } from "./modal-dialogs-07.data"
/** The number the demo's bank declines: a test card, never a real one. */
export const DECLINED = "4000000000000002"
type Draft = { name: string; number: string; expiry: string; cvc: string }
type Errors = Partial<Record<keyof Draft, string>>
const digits = (value: string) => value.replace(/\D/g, "")
const expiryOf = (card: Pick<StoredCard, "expMonth" | "expYear">) => `${String(card.expMonth).padStart(2, "0")}/${String(card.expYear).slice(-2)}`
/** What is wrong with a number, in words that name no network. */
function checkNumber(value: string): string | undefined {
const number = digits(value)
if (!number) return "Enter the card number."
if (!detectBrand(number)) return "This card is not one we can take."
if (number.length < 15) return "The number is too short."
if (!luhn(number)) return "Check the card number: a digit is off."
return undefined
}
/** A card drawn as a card, the same for every network: none of them is named or pictured. */
function CardGlyph() {
return (
<span aria-hidden="true" className="flex h-7 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground ring-1 ring-border">
<CreditCardIcon className="size-4" />
</span>
)
}
/**
* The account's cards and the Add a card dialog. Every card is drawn the
* same and named by its last four digits: no network is named or pictured.
* The dialog opens on the name; the number and the expiry are written the
* way they are printed as they are typed, and each field is checked when it
* is left and again on Add, beside the field. Nothing is sent anywhere: a
* card already on file, or the test number the demo's bank declines, is
* refused in a danger callout; an added card joins the list — as the
* default when asked — and the card's status line says so.
*/
export function PaymentMethods({ wallet, titleId, defaultOpen }: { wallet: Wallet; titleId: string; defaultOpen: boolean }) {
const [open, setOpen] = React.useState(defaultOpen)
const [cards, setCards] = React.useState<StoredCard[]>(wallet.cards)
const [draft, setDraft] = React.useState<Draft>({ name: "", number: "", expiry: "", cvc: "" })
const [makeDefault, setMakeDefault] = React.useState(true)
const [errors, setErrors] = React.useState<Errors>({})
const [refusal, setRefusal] = React.useState("")
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const nameField = React.useRef<HTMLInputElement>(null)
const id = React.useId()
const brand = detectBrand(digits(draft.number))
const checks: Record<keyof Draft, () => string | undefined> = {
name: () => validateName(draft.name) ?? undefined,
number: () => checkNumber(draft.number),
expiry: () => validateExpiry(draft.expiry, wallet.now) ?? undefined,
cvc: () => validateCvc(draft.cvc, brand) ?? undefined,
}
function update(patch: Partial<Draft>) {
setDraft((current) => ({ ...current, ...patch }))
setRefusal("")
}
function leave(key: keyof Draft) {
setErrors((current) => ({ ...current, [key]: checks[key]() }))
}
function reset(next: boolean) {
setOpen(next)
if (next) return
setDraft({ name: "", number: "", expiry: "", cvc: "" })
setErrors({})
setRefusal("")
}
function add(event: React.FormEvent) {
event.preventDefault()
const found: Errors = {}
for (const key of Object.keys(checks) as (keyof Draft)[]) {
const message = checks[key]()
if (message) found[key] = message
}
setErrors(found)
if (Object.keys(found).length > 0) return
const number = digits(draft.number)
const [month, year] = formatExpiry(draft.expiry).split("/").map(Number)
const last4 = number.slice(-4)
const card = { last4, expMonth: month, expYear: 2000 + year }
const twin = cards.find((entry) => entry.last4 === last4 && entry.expMonth === card.expMonth && entry.expYear === card.expYear)
if (twin) return setRefusal(`The card ending ${last4}, expiring ${expiryOf(card)}, is already on file. Use it, or add a different card.`)
if (number === DECLINED) return setRefusal(`The bank declined the card ending ${last4}. Nothing was saved; try another card.`)
// Wire real billing here: hand the card to your payment provider's own fields and keep
// only what it returns — this demo keeps the card in the band's state.
setCards((current) => [
{ id: `new-${last4}-${card.expYear}`, ...card, isDefault: makeDefault, expired: false },
...current.map((entry) => (makeDefault ? { ...entry, isDefault: false } : entry)),
])
setNotice(`Added the card ending ${last4}, expiring ${expiryOf(card)}.${makeDefault ? " It is now the default." : ""}`)
reset(false)
}
const described = (key: keyof Draft) =>
errors[key] ? { "aria-invalid": true, "aria-describedby": `${id}-${key}-error` } : {}
return (
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Payment methods</h2>
</CardTitle>
<CardDescription>{wallet.account} is billed on the first of the month.</CardDescription>
<CardAction>
<Dialog open={open} onOpenChange={reset}>
<DialogTrigger ref={trigger} render={<Button size="sm" />}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Add card
</DialogTrigger>
<DialogContent initialFocus={nameField} finalFocus={trigger} className="sm:max-w-md">
<form onSubmit={add} noValidate className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Add a card</DialogTitle>
<DialogDescription>It is charged from the next bill, on the first of the month.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-name`} className="text-sm font-medium">
Name on card
</label>
<Input ref={nameField} id={`${id}-name`} autoComplete="cc-name" value={draft.name} onChange={(event) => update({ name: event.target.value })} onBlur={() => leave("name")} {...described("name")} />
<FieldError id={`${id}-name-error`}>{errors.name}</FieldError>
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-number`} className="text-sm font-medium">
Card number
</label>
<div className="relative">
<Input
id={`${id}-number`}
autoComplete="cc-number"
inputMode="numeric"
placeholder="1234 1234 1234 1234"
value={draft.number}
onChange={(event) => update({ number: formatCardNumber(event.target.value, detectBrand(digits(event.target.value))) })}
onBlur={() => leave("number")}
className="pe-14 tabular-nums"
{...described("number")}
/>
<span className="pointer-events-none absolute inset-y-0 end-2 flex items-center">
<CardGlyph />
</span>
</div>
<FieldError id={`${id}-number-error`}>{errors.number}</FieldError>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-expiry`} className="text-sm font-medium">
Expiry
</label>
<Input id={`${id}-expiry`} autoComplete="cc-exp" inputMode="numeric" placeholder="MM/YY" value={draft.expiry} onChange={(event) => update({ expiry: formatExpiry(event.target.value) })} onBlur={() => leave("expiry")} className="tabular-nums" {...described("expiry")} />
<FieldError id={`${id}-expiry-error`}>{errors.expiry}</FieldError>
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-cvc`} className="text-sm font-medium">
Security code
</label>
<Input id={`${id}-cvc`} autoComplete="cc-csc" inputMode="numeric" placeholder={"•".repeat(cvcLength(brand))} value={draft.cvc} onChange={(event) => update({ cvc: digits(event.target.value).slice(0, cvcLength(brand)) })} onBlur={() => leave("cvc")} className="tabular-nums" {...described("cvc")} />
<FieldError id={`${id}-cvc-error`}>{errors.cvc}</FieldError>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={makeDefault} onCheckedChange={setMakeDefault} />
Make this the default card
</label>
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<LockIcon aria-hidden="true" className="size-3.5 shrink-0" />
Nothing is sent from this page: the details are checked here and stay here.
</p>
{refusal ? (
<Callout variant="danger" role="alert" title="The card was not added">
{refusal}
</Callout>
) : null}
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>Cancel</DialogClose>
<Button type="submit">Add card</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ul aria-label="Cards on file" className="divide-y divide-border">
{cards.map((card) => (
<li key={card.id} className="flex flex-wrap items-center gap-3 py-2.5 first:pt-0 last:pb-0">
<CardGlyph />
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium tabular-nums">Card ending {card.last4}</span>
<span className="text-xs text-muted-foreground tabular-nums">Expires {expiryOf(card)}</span>
</span>
<span className="flex items-center gap-1.5">
{card.expired ? <StatusBadge size="sm" status="expired" variant="danger" label="Expired" /> : null}
{card.isDefault ? <StatusBadge size="sm" status="default" variant="primary" label="Default" /> : null}
</span>
</li>
))}
</ul>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</CardContent>
</Card>
)
}/**
* What `modal-dialogs-07` reads: the cards one account keeps in
* `db.paymentMethods`, by the last four digits and the expiry only — the
* network is never shown — and which have run out by `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the cards as props; it never imports this module.
*/
import { REFERENCE_DATE, db } from "@/lib/sample-data"
/** The account whose cards the band shows. */
export const ACCOUNT = "Trellis Energy"
export type StoredCard = { id: string; last4: string; expMonth: number; expYear: number; isDefault: boolean; expired: boolean }
export type Wallet = { account: string; cards: StoredCard[]; now: Date }
/** The account's cards, the default first, each flagged when it has run out. */
export function wallet(): Wallet {
const customer = db.customers.all().find((entry) => entry.company === ACCOUNT)
if (!customer) throw new Error(`modal-dialogs-07 shows the cards of "${ACCOUNT}", which is not a customer`)
// A card is good to the end of its month.
const month = REFERENCE_DATE.getUTCFullYear() * 12 + REFERENCE_DATE.getUTCMonth() + 1
return {
account: customer.company,
now: REFERENCE_DATE,
cards: db.paymentMethods
.all()
.filter((card) => card.customerId === customer.id)
.sort((a, b) => Number(b.default) - Number(a.default))
.map((card) => ({
id: card.id,
last4: card.last4,
expMonth: card.expMonth,
expYear: card.expYear,
isDefault: card.default,
expired: card.expYear * 12 + card.expMonth < month,
})),
}
}npx shadcn@latest add @vibra/modal-dialogs-07Delete account with type-to-confirm
modal-dialogs-08Deleting your own account: what happens to your open tasks, API keys and files, who takes over each project you lead, and your address typed out before anything goes.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DeleteAccount } from "./modal-dialogs-08.client"
import { accountHoldings } from "./modal-dialogs-08.data"
export type ModalDialogs08Props = {
/** Opens the confirmation on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* Deleting your own account: what happens to your tasks, keys and files,
* who takes over the projects you lead, and your address typed out before
* anything goes.
*
* A server component: what the account holds comes from
* `./modal-dialogs-08.data`.
*/
export function ModalDialogs08({ defaultOpen = false }: ModalDialogs08Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-08"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Delete account</h2>
</CardTitle>
<CardDescription>For your own account only. An admin removes other people from Members.</CardDescription>
</CardHeader>
<CardContent>
<DeleteAccount holdings={accountHoldings()} defaultOpen={defaultOpen} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ModalDialogs08"use client"
import * as React from "react"
import { UserXIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { UserCell } from "@/components/ui/user-cell"
import type { AccountHoldings } from "./modal-dialogs-08.data"
const count = (value: number, one: string, many = `${one}s`) => `${formatNumber(value)} ${value === 1 ? one : many}`
/**
* Delete account, with the typing guard and the one thing that can block it.
* The alert opens on Keep my account. It says what happens to everything the
* account holds, asks who takes over each project it leads, and asks for the
* email address typed out: Delete account stays off — and says why — until
* the address matches exactly, capitals included. Pressed with a project still
* left without a lead, it refuses in a danger callout that names the project
* and keeps the dialog open with what was chosen. A deletion closes it and the
* band's status line says what happens next.
*/
export function DeleteAccount({ holdings, defaultOpen }: { holdings: AccountHoldings; defaultOpen: boolean }) {
const [open, setOpen] = React.useState(defaultOpen)
const [heirs, setHeirs] = React.useState<Record<string, string>>({})
const [typed, setTyped] = React.useState("")
const [refusal, setRefusal] = React.useState("")
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const keep = React.useRef<HTMLButtonElement>(null)
const id = React.useId()
const names = new Map(holdings.colleagues.map((person) => [person.id, person.name]))
// Exactly the address, capitals included; only the spaces around it are forgiven.
const typedRight = typed.trim() === holdings.email
function reset(next: boolean) {
setOpen(next)
if (next) return
setTyped("")
setRefusal("")
}
function confirm() {
if (!typedRight) return
const orphans = holdings.leads.filter((project) => !heirs[project.id])
if (orphans.length > 0) {
setRefusal(
`${orphans.map((project) => project.name).join(" and ")} still ${orphans.length === 1 ? "has" : "have"} you as ${orphans.length === 1 ? "its" : "their"} lead. Choose who takes ${orphans.length === 1 ? "it" : "them"} over, then delete.`
)
return
}
setRefusal("")
const handovers = holdings.leads.map((project) => `${project.name} goes to ${names.get(heirs[project.id])}`)
setNotice(`Your account will be deleted and you will be signed out. ${handovers.join("; ")}${handovers.length ? ". " : ""}Your files go to ${holdings.owner}.`)
reset(false)
}
return (
<div className="flex flex-col gap-4">
<UserCell name={holdings.name} email={holdings.email} src={holdings.avatarUrl} />
<p className="max-w-prose text-sm text-pretty text-muted-foreground">
Deleting your account signs you out everywhere and removes you from Northwind. Your work stays with the workspace.
</p>
<AlertDialog open={open} onOpenChange={reset}>
<AlertDialogTrigger ref={trigger} render={<Button variant="destructive" className="w-fit" />}>
<UserXIcon data-icon="inline-start" aria-hidden="true" />
Delete my account
</AlertDialogTrigger>
<AlertDialogContent initialFocus={keep} finalFocus={trigger} className="data-[size=default]:max-w-[calc(100%-2rem)] data-[size=default]:sm:max-w-md">
<AlertDialogHeader>
<AlertDialogMedia className="bg-danger-muted text-danger">
<UserXIcon aria-hidden="true" />
</AlertDialogMedia>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
<AlertDialogDescription>Your sign-in stops working at once, and this cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<ul className="flex list-disc flex-col gap-1 ps-5 text-sm marker:text-muted-foreground">
<li className="tabular-nums">
Your {count(holdings.openTasks, "open task")} {holdings.openTasks === 1 ? "becomes" : "become"} unassigned.
</li>
<li className="tabular-nums">
Your {count(holdings.activeKeys, "API key")} {holdings.activeKeys === 1 ? "stops" : "stop"} working.
</li>
<li className="tabular-nums">
Your {count(holdings.files, "file")} {holdings.files === 1 ? "moves" : "move"} to {holdings.owner}.
</li>
</ul>
{holdings.leads.map((project) => (
<div key={project.id} className="flex flex-col gap-2">
<label htmlFor={`${id}-${project.id}`} className="text-sm font-medium">
Who leads {project.name} <span className="font-mono text-xs text-muted-foreground">{project.code}</span> after you?
</label>
<NativeSelect
id={`${id}-${project.id}`}
value={heirs[project.id] ?? ""}
onChange={(event) => {
setHeirs((current) => ({ ...current, [project.id]: event.target.value }))
setRefusal("")
}}
aria-invalid={refusal && !heirs[project.id] ? true : undefined}
aria-describedby={refusal && !heirs[project.id] ? `${id}-refusal` : undefined}
className="w-full"
>
<NativeSelectOption value="">Choose a new lead</NativeSelectOption>
{holdings.colleagues.map((person) => (
<NativeSelectOption key={person.id} value={person.id}>
{person.name}
</NativeSelectOption>
))}
</NativeSelect>
</div>
))}
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-email`} className="text-sm font-medium">
Type <span className="font-mono text-xs font-normal">{holdings.email}</span> to confirm
</label>
<Input
id={`${id}-email`}
value={typed}
onChange={(event) => setTyped(event.target.value)}
// The match counts capitals: a phone keyboard adds none, and corrects nothing.
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-describedby={`${id}-email-hint`}
className="font-mono text-sm"
/>
<p id={`${id}-email-hint`} className="text-xs text-muted-foreground">
{typedRight ? "The address matches." : "Delete account stays off until the address matches exactly."}
</p>
</div>
{refusal ? (
<Callout id={`${id}-refusal`} variant="danger" role="alert" title="Your account was not deleted">
{refusal}
</Callout>
) : null}
<AlertDialogFooter>
<AlertDialogCancel ref={keep}>Keep my account</AlertDialogCancel>
<Button
variant="destructive"
onClick={confirm}
disabled={!typedRight}
focusableWhenDisabled
aria-describedby={typedRight ? undefined : `${id}-email-hint`}
>
Delete account
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</div>
)
}/**
* What `modal-dialogs-08` reads: one person in `db.members` and everything
* their account holds up — the projects they lead in `db.projects`, their
* open tasks in `db.tasks`, the API keys they made in `db.apiKeys` and the
* files they own in `db.files` — so deleting it can say what happens to each.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the summary as props; it never imports this module.
*/
import { db } from "@/lib/sample-data"
/** Whose account the band deletes: the person signed in. */
export const SIGNED_IN = "Hassan Ortiz"
export type Colleague = { id: string; name: string }
export type AccountHoldings = {
name: string
email: string
avatarUrl?: string
/** Projects that would lose their lead, each needing a new one. */
leads: { id: string; name: string; code: string }[]
openTasks: number
activeKeys: number
files: number
/** Who their files move to: the workspace's owner. */
owner: string
/** Everyone active who could take a project over. */
colleagues: Colleague[]
}
/** What the signed-in person's account holds, and who could take it on. */
export function accountHoldings(): AccountHoldings {
const members = db.members.all()
const me = members.find((member) => member.name === SIGNED_IN)
if (!me) throw new Error(`modal-dialogs-08 deletes the account of "${SIGNED_IN}", who is not a member`)
const owner = members.find((member) => member.role === "owner")
return {
name: me.name,
email: me.email,
avatarUrl: me.avatarUrl,
leads: db.projects
.all()
.filter((project) => project.lead === me.id && project.status !== "shipped")
.map((project) => ({ id: project.id, name: project.name, code: project.code })),
openTasks: db.tasks.all().filter((task) => task.assignee === me.id && task.status !== "done").length,
activeKeys: db.apiKeys.all().filter((key) => key.createdBy === me.id && key.status === "active").length,
files: db.files.all().filter((file) => file.ownerId === me.id && file.kind !== "folder").length,
owner: owner?.name ?? "the workspace owner",
colleagues: members
.filter((member) => member.id !== me.id && member.status === "active" && member.role !== "viewer")
.sort((a, b) => a.name.localeCompare(b.name))
.map(({ id, name }) => ({ id, name })),
}
}npx shadcn@latest add @vibra/modal-dialogs-08Unsaved changes
modal-dialogs-09A profile form that asks before an edit is lost: leaving with changes opens an alert listing each change before and after, which keeps editing, discards, or saves and leaves.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ProfileEditor } from "./modal-dialogs-09.client"
import { profileForm } from "./modal-dialogs-09.data"
export type ModalDialogs09Props = {
/** Opens the unsaved-changes alert on the first render, over two sample edits — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A profile form that asks before an edit is lost: leaving with changes
* opens an alert that lists them, before and after, and offers to keep
* editing, discard them, or save and leave.
*
* A server component: the profile comes from `./modal-dialogs-09.data`.
*/
export function ModalDialogs09({ defaultOpen = false }: ModalDialogs09Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-09"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Your profile</h2>
</CardTitle>
<CardDescription>What colleagues see beside your name. Nothing changes until you save.</CardDescription>
</CardHeader>
<CardContent>
<ProfileEditor form={profileForm()} defaultOpen={defaultOpen} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ModalDialogs09"use client"
import * as React from "react"
import { ArrowLeftIcon } from "lucide-react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { UserCell } from "@/components/ui/user-cell"
import type { Profile, ProfileForm } from "./modal-dialogs-09.data"
type Editable = Omit<Profile, "email" | "avatarUrl">
export const FIELDS: { key: keyof Editable; label: string }[] = [
{ key: "name", label: "Name" },
{ key: "title", label: "Job title" },
{ key: "department", label: "Department" },
{ key: "workplace", label: "Works from" },
]
const WORKPLACE = { office: "The office", remote: "Home" } as const
const shown = (key: keyof Editable, value: string) => (key === "workplace" ? WORKPLACE[value as Editable["workplace"]] : value || "nothing")
/** The edits a preview opens on, so the alert has something to ask about. */
function sampleEdit(profile: Editable): Editable {
return { ...profile, title: `Senior ${profile.title.toLowerCase()}`, workplace: profile.workplace === "remote" ? "office" : "remote" }
}
/**
* A profile form that will not lose an edit. Leaving it with changes — the
* Back button, or closing the tab — asks first: the alert lists each change
* as before and after, opens on Keep editing, and offers to discard them or
* to save and leave. Saving checks the form again, and an empty name is
* refused in a danger callout inside the alert, with the changes kept. The
* form's status line says what was saved or thrown away.
*/
export function ProfileEditor({ form, defaultOpen }: { form: ProfileForm; defaultOpen: boolean }) {
const initial: Editable = { name: form.profile.name, title: form.profile.title, department: form.profile.department, workplace: form.profile.workplace }
const [saved, setSaved] = React.useState<Editable>(initial)
const [draft, setDraft] = React.useState<Editable>(() => (defaultOpen ? sampleEdit(initial) : initial))
const [asking, setAsking] = React.useState(defaultOpen)
const [refusal, setRefusal] = React.useState("")
const [notice, setNotice] = React.useState("")
const [inlineError, setInlineError] = React.useState("")
const back = React.useRef<HTMLButtonElement>(null)
const keep = React.useRef<HTMLButtonElement>(null)
const id = React.useId()
const changes = FIELDS.filter((field) => draft[field.key] !== saved[field.key])
const dirty = changes.length > 0
const changeWord = (count: number) => `${count} ${count === 1 ? "change" : "changes"}`
// Closing the tab with changes asks the browser's own question.
React.useEffect(() => {
if (!dirty) return
const guard = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener("beforeunload", guard)
return () => window.removeEventListener("beforeunload", guard)
}, [dirty])
function set<K extends keyof Editable>(key: K, value: Editable[K]) {
setDraft((current) => ({ ...current, [key]: value }))
setNotice("")
}
function leave() {
if (dirty) return setAsking(true)
setNotice("Nothing had changed, so there was nothing to save.")
}
function save(andLeave: boolean) {
if (!draft.name.trim()) {
const message = "Your name cannot be empty. Keep editing to write it, or discard your changes."
if (andLeave) setRefusal(message)
else setNotice("")
return message
}
setSaved({ ...draft, name: draft.name.trim() })
setNotice(`Saved ${changeWord(changes.length)} to your profile.`)
setAsking(false)
setRefusal("")
return null
}
function discard() {
setDraft(saved)
setNotice(`Discarded ${changeWord(changes.length)}.`)
setAsking(false)
setRefusal("")
}
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<UserCell name={saved.name} email={form.profile.email} src={form.profile.avatarUrl} />
<Button ref={back} variant="outline" size="sm" onClick={leave}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Back to directory
</Button>
</div>
<form
noValidate
onSubmit={(event) => {
event.preventDefault()
setInlineError(save(false) ?? "")
}}
className="grid gap-4 sm:grid-cols-2"
>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-name`} className="text-sm font-medium">
Name
</label>
<Input
id={`${id}-name`}
value={draft.name}
onChange={(event) => {
set("name", event.target.value)
setInlineError("")
}}
aria-invalid={inlineError ? true : undefined}
aria-describedby={inlineError ? `${id}-name-error` : undefined}
/>
{inlineError ? (
<p id={`${id}-name-error`} role="alert" className="text-sm text-danger">
{inlineError}
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-title`} className="text-sm font-medium">
Job title
</label>
<Input id={`${id}-title`} value={draft.title} onChange={(event) => set("title", event.target.value)} />
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-department`} className="text-sm font-medium">
Department
</label>
<NativeSelect id={`${id}-department`} value={draft.department} onChange={(event) => set("department", event.target.value as Editable["department"])} className="w-full">
{form.departments.map((department) => (
<NativeSelectOption key={department} value={department}>
{department}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-2">
<span id={`${id}-workplace`} className="text-sm font-medium">
Works from
</span>
<SegmentedControl
aria-labelledby={`${id}-workplace`}
options={[
{ value: "office", label: WORKPLACE.office },
{ value: "remote", label: WORKPLACE.remote },
]}
value={draft.workplace}
onValueChange={(value) => set("workplace", value as Editable["workplace"])}
/>
</div>
<div className="flex flex-wrap items-center gap-2 sm:col-span-2">
<Button type="submit" disabled={!dirty} focusableWhenDisabled>
Save changes
</Button>
{dirty ? (
<Button type="button" variant="ghost" onClick={discard}>
Discard
</Button>
) : null}
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{notice || (dirty ? `${changeWord(changes.length)} not saved yet.` : "")}
</p>
</div>
</form>
<AlertDialog
open={asking}
onOpenChange={(next) => {
setAsking(next)
if (!next) setRefusal("")
}}
>
<AlertDialogContent initialFocus={keep} finalFocus={back} className="data-[size=default]:sm:max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Leave without saving?</AlertDialogTitle>
<AlertDialogDescription>
Your profile has {changeWord(changes.length)} that {changes.length === 1 ? "is" : "are"} not saved. Leave now and {changes.length === 1 ? "it is" : "they are"} lost.
</AlertDialogDescription>
</AlertDialogHeader>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 rounded-lg bg-surface p-3 text-sm ring-1 ring-border">
{changes.map((field) => (
<React.Fragment key={field.key}>
<dt className="text-muted-foreground">{field.label}</dt>
<dd>
<span className="text-muted-foreground line-through">{shown(field.key, saved[field.key])}</span>
<span aria-hidden="true"> → </span>
<span className="sr-only">, now </span>
<span className="font-medium">{shown(field.key, draft[field.key])}</span>
</dd>
</React.Fragment>
))}
</dl>
{refusal ? (
<Callout variant="danger" role="alert" title="Not saved">
{refusal}
</Callout>
) : null}
<AlertDialogFooter>
<AlertDialogCancel ref={keep}>Keep editing</AlertDialogCancel>
<Button variant="destructive" onClick={discard}>
Discard and leave
</Button>
<Button onClick={() => save(true)}>Save and leave</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}/**
* What `modal-dialogs-09` reads: one person's profile in `db.employees` — the
* name they go by, their job title, their department and where they work
* from — and the departments the company has, for the form that edits it.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the profile as props; it never imports this module.
*/
import { db, type Employee } from "@/lib/sample-data"
/** Whose profile the band edits: the person signed in. */
export const SIGNED_IN = "Orla Alvarez"
export type Profile = {
name: string
email: string
avatarUrl?: string
title: string
department: Employee["department"]
workplace: "office" | "remote"
}
export type ProfileForm = { profile: Profile; departments: Employee["department"][] }
/** The signed-in person's profile, and every department there is. */
export function profileForm(): ProfileForm {
const employees = db.employees.all()
const me = employees.find((employee) => employee.name === SIGNED_IN)
if (!me) throw new Error(`modal-dialogs-09 edits the profile of "${SIGNED_IN}", who is not an employee`)
return {
profile: {
name: me.name,
email: me.email,
avatarUrl: me.avatarUrl,
title: me.role,
department: me.department,
workplace: me.status === "remote" ? "remote" : "office",
},
departments: [...new Set(employees.map((employee) => employee.department))].sort(),
}
}npx shadcn@latest add @vibra/modal-dialogs-09Keyboard shortcuts sheet
modal-dialogs-10The shortcuts used most on the band, and every one in a dialog grouped by where it works, with a filter and a switch that turns the single-key shortcuts off.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ShortcutsSheet } from "./modal-dialogs-10.client"
export type ModalDialogs10Props = {
/** Opens the shortcuts on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The keyboard shortcuts: the ones used most on the band, and every one in
* a dialog that filters as it is typed and turns single-key shortcuts off.
*
* A server component with nothing to read: the shortcuts are the island's
* vocabulary.
*/
export function ModalDialogs10({ defaultOpen = false }: ModalDialogs10Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-10"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Keyboard shortcuts</h2>
</CardTitle>
<CardDescription>The quickest way round Northwind, and a switch for anyone they get in the way of.</CardDescription>
</CardHeader>
<CardContent>
<ShortcutsSheet defaultOpen={defaultOpen} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ModalDialogs10"use client"
import * as React from "react"
import { KeyboardIcon, SearchIcon } from "lucide-react"
import { isTypingTarget } from "@/lib/hotkeys"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { Switch } from "@/components/ui/switch"
type Shortcut = { keys: string; does: string }
/** The shortcuts, by where they work. A space in `keys` is a sequence: press one, then the other. */
export const SHORTCUTS: { group: string; items: Shortcut[] }[] = [
{
group: "Anywhere",
items: [
{ keys: "mod+k", does: "Search the workspace" },
{ keys: "shift+?", does: "Show these shortcuts" },
{ keys: "mod+/", does: "Toggle the sidebar" },
{ keys: "esc", does: "Close a dialog or a menu" },
],
},
{
group: "Going places",
items: [
{ keys: "g h", does: "Go to Home" },
{ keys: "g p", does: "Go to Projects" },
{ keys: "g c", does: "Go to Customers" },
{ keys: "g i", does: "Go to Invoices" },
],
},
{
group: "In a table",
items: [
{ keys: "j", does: "Next row" },
{ keys: "k", does: "Previous row" },
{ keys: "x", does: "Select the row" },
{ keys: "enter", does: "Open the row" },
],
},
{
group: "While editing",
items: [
{ keys: "mod+s", does: "Save" },
{ keys: "mod+z", does: "Undo" },
{ keys: "mod+shift+z", does: "Redo" },
{ keys: "mod+enter", does: "Save and close" },
],
},
]
/** A shortcut with no ⌘, Ctrl or Alt in it: one a reader can set off by typing. */
export const isSingleKey = (keys: string) => !/(mod|ctrl|meta|cmd|alt)\+/.test(keys) && keys !== "esc" && keys !== "enter"
function Keys({ keys }: { keys: string }) {
const steps = keys.split(" ")
return (
<span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
{steps.map((step, index) => (
<React.Fragment key={`${step}-${index}`}>
{index > 0 ? <span>then</span> : null}
<KbdShortcut keys={step} size="sm" />
</React.Fragment>
))}
</span>
)
}
/**
* The shortcuts sheet: a dialog listing every shortcut by where it works,
* with a filter that opens under the cursor and a switch that turns the
* single-key shortcuts off (WCAG 2.1.4: a reader who types or dictates must
* be able to stop keys firing on their own). ? opens it from anywhere
* outside a field while single keys are on; ⌘ and Ctrl chords keep
* working either way. The switch's state is said in a status line.
*/
export function ShortcutsSheet({ defaultOpen }: { defaultOpen: boolean }) {
const [open, setOpen] = React.useState(defaultOpen)
const [query, setQuery] = React.useState("")
const [singleKeys, setSingleKeys] = React.useState(true)
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const field = React.useRef<HTMLInputElement>(null)
const id = React.useId()
// "?" is the band's, not the window's: a page holds other bands, and a key
// that types a character must not answer for all of them. "?" wherever the
// layout puts it: Shift on some keyboards, not on others.
function onKeyDown(event: React.KeyboardEvent) {
if (!singleKeys || event.key !== "?" || event.metaKey || event.ctrlKey || event.altKey || event.repeat) return
if (isTypingTarget(event.target)) return
event.preventDefault()
setOpen(true)
}
const needle = query.trim().toLowerCase()
const groups = SHORTCUTS.map((group) => ({
...group,
items: group.items.filter((item) => !needle || item.does.toLowerCase().includes(needle) || item.keys.includes(needle)),
})).filter((group) => group.items.length > 0)
function toggle(next: boolean) {
setSingleKeys(next)
setNotice(next ? "Single-key shortcuts are on." : "Single-key shortcuts are off. Shortcuts with ⌘ or Ctrl still work.")
}
return (
<div className="flex flex-col gap-4" onKeyDown={onKeyDown}>
<ul className="grid gap-2 sm:grid-cols-2">
{SHORTCUTS[0].items.slice(0, 2).concat(SHORTCUTS[3].items.slice(0, 2)).map((item) => (
<li key={item.keys} className="flex items-center justify-between gap-3 rounded-lg px-3 py-2 ring-1 ring-border">
<span className="text-sm">{item.does}</span>
<Keys keys={item.keys} />
</li>
))}
</ul>
<div className="flex flex-wrap items-center gap-3">
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setQuery("")
}}
>
<DialogTrigger ref={trigger} render={<Button variant="outline" />}>
<KeyboardIcon data-icon="inline-start" aria-hidden="true" />
All shortcuts
</DialogTrigger>
<DialogContent initialFocus={field} finalFocus={trigger} className="max-h-[calc(100svh-2rem)] gap-4 overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>All shortcuts</DialogTitle>
<DialogDescription>A space means one key and then the next. ⌘ is Ctrl outside a Mac.</DialogDescription>
</DialogHeader>
<InputGroup>
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput ref={field} type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Find a shortcut" aria-label="Find a shortcut" />
</InputGroup>
{groups.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No shortcut does “{query.trim()}”.</p>
) : (
<div className="grid gap-x-8 gap-y-5 sm:grid-cols-2">
{groups.map((group, index) => (
<div key={group.group} className="flex flex-col gap-1">
<h3 id={`${id}-group-${index}`} className="type-eyebrow">
{group.group}
</h3>
<ul aria-labelledby={`${id}-group-${index}`} className="flex flex-col divide-y divide-border">
{group.items.map((item) => {
const off = !singleKeys && isSingleKey(item.keys)
return (
<li key={item.keys} data-off={off || undefined} className="flex items-center justify-between gap-3 py-2 data-off:text-muted-foreground">
<span className="text-sm">
{item.does}
{off ? <span className="ms-2 text-xs">Off</span> : null}
</span>
<Keys keys={item.keys} />
</li>
)
})}
</ul>
</div>
))}
</div>
)}
<div className="flex flex-col gap-2 rounded-lg bg-surface p-3 ring-1 ring-border">
<div className="flex items-center justify-between gap-4">
<label htmlFor={`${id}-single`} className="flex flex-col gap-0.5">
<span className="text-sm font-medium">Single-key shortcuts</span>
<span className="text-xs text-muted-foreground">Turn off if keys fire while you type or dictate.</span>
</label>
<Switch id={`${id}-single`} checked={singleKeys} onCheckedChange={toggle} />
</div>
<p role="status" aria-live="polite" className="text-xs font-medium">
{notice}
</p>
</div>
</DialogContent>
</Dialog>
<p className="text-sm text-muted-foreground">
Single-key shortcuts are {singleKeys ? "on" : "off"}.{singleKeys ? " Press ? anywhere outside a field to see them all." : ""}
</p>
</div>
</div>
)
}npx shadcn@latest add @vibra/modal-dialogs-10Session timeout warning
modal-dialogs-11The idle-session warning an app opens on its own: the time the session ends, a countdown to it that stands still under reduced motion, and the choice to stay or sign out — then, when time runs out, the way back in.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SessionTimeout } from "./modal-dialogs-11.client"
import { session } from "./modal-dialogs-11.data"
export type ModalDialogs11Props = {
/** Opens the warning on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The idle-session warning an app opens on its own: the time the session
* ends, a countdown beside it that stands still for a reader who asked for
* less motion, and the choice to stay or sign out.
*
* A server component: the session comes from `./modal-dialogs-11.data`.
*/
export function ModalDialogs11({ defaultOpen = false }: ModalDialogs11Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-11"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Session</h2>
</CardTitle>
<CardDescription>Idle sessions end on their own, to keep invoices and customers private.</CardDescription>
</CardHeader>
<CardContent>
<SessionTimeout session={session()} defaultOpen={defaultOpen} />
</CardContent>
</Card>
</div>
</section>
)
}
export default ModalDialogs11"use client"
import * as React from "react"
import { TimerIcon } from "lucide-react"
import { useInterval } from "@/hooks/use-interval"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { UserCell } from "@/components/ui/user-cell"
import type { Session } from "./modal-dialogs-11.data"
const TIME = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "UTC" })
const at = (date: Date) => `${TIME.format(date)} UTC`
const clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
/**
* The idle-session warning. It names the time the session ends, and beside
* it counts down to that time — a second at a time, except for a reader who
* asked for less motion, who sees the time and "less than 5 minutes" with
* nothing ticking. The time runs out either way: then the alert says the
* session has ended in a danger callout and offers to sign in again. Stay
* signed in, the safe answer, is where focus opens and what Escape does.
* What was chosen is said in the band's status line.
*/
export function SessionTimeout({ session, defaultOpen }: { session: Session; defaultOpen: boolean }) {
const [open, setOpen] = React.useState(defaultOpen)
const [left, setLeft] = React.useState(session.warnSeconds)
const [endsAt, setEndsAt] = React.useState(session.endsAt)
const [notice, setNotice] = React.useState("")
const reduced = useReducedMotion()
const stay = React.useRef<HTMLButtonElement>(null)
const signIn = React.useRef<HTMLButtonElement>(null)
const trigger = React.useRef<HTMLButtonElement>(null)
const expired = left <= 0
// The count, a second at a time — paused while closed, and never ticking
// for a reader who asked for less motion.
useInterval(() => setLeft((current) => Math.max(0, current - 1)), open && !reduced && !expired ? 1000 : null)
// The session still ends on time for that reader: nothing ticks, so the
// count stands where it opened, and one timer runs out the whole of it.
React.useEffect(() => {
if (!open || !reduced || expired) return
const timer = window.setTimeout(() => setLeft(0), session.warnSeconds * 1000)
return () => window.clearTimeout(timer)
}, [open, reduced, expired, session.warnSeconds])
// When the time runs out with the alert open, its answers change under the
// reader's focus — the one it was on is gone — so focus moves to the answer left.
React.useEffect(() => {
if (open && expired) signIn.current?.focus()
}, [open, expired])
function reopen(next: boolean) {
setOpen(next)
if (next) setLeft(session.warnSeconds)
// Closing without a choice — Escape — is staying, while there is a session to stay in.
else if (!expired) extend()
else setNotice("You were signed out. Sign in again to carry on; your drafts are kept.")
}
function extend() {
const until = new Date(endsAt.getTime() + session.extension * 60_000)
setEndsAt(until)
setNotice(`You are signed in until ${at(until)}.`)
}
function signOut() {
setNotice("You were signed out. Anything unsaved is kept as a draft.")
setOpen(false)
}
function signBackIn() {
const until = new Date(endsAt.getTime() + session.extension * 60_000)
setEndsAt(until)
setNotice(`Signed in again, until ${at(until)}.`)
setLeft(session.warnSeconds)
setOpen(false)
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<UserCell name={session.name} email={session.email} src={session.avatarUrl} />
<AlertDialog open={open} onOpenChange={reopen}>
<AlertDialogTrigger ref={trigger} render={<Button variant="outline" size="sm" />}>
<TimerIcon data-icon="inline-start" aria-hidden="true" />
Preview the warning
</AlertDialogTrigger>
<AlertDialogContent initialFocus={expired ? signIn : stay} finalFocus={trigger}>
<AlertDialogHeader>
<AlertDialogMedia>
<TimerIcon aria-hidden="true" />
</AlertDialogMedia>
<AlertDialogTitle>
{expired ? `Your session ended at ${at(endsAt)}` : `Your session ends at ${at(endsAt)}`}
</AlertDialogTitle>
<AlertDialogDescription>
You have been away for {session.idleMinutes} minutes. Anything unsaved is kept as a draft.
</AlertDialogDescription>
</AlertDialogHeader>
{expired ? (
<Callout variant="danger" role="alert" title="Signed out for safety">
The session ended at {at(endsAt)}. Sign in again to carry on where you left off.
</Callout>
) : (
<div className="flex items-baseline justify-center gap-2 rounded-lg bg-surface py-3 ring-1 ring-border">
{reduced ? (
<span className="text-sm font-medium">Less than {Math.ceil(left / 60)} minutes left</span>
) : (
<>
{/* The ticking figure is for the eye; the title already says the time. */}
<span aria-hidden="true" className="type-numeral text-3xl">
{clock(left)}
</span>
<span aria-hidden="true" className="text-sm text-muted-foreground">
left
</span>
</>
)}
</div>
)}
<AlertDialogFooter>
{expired ? (
<AlertDialogAction ref={signIn} onClick={signBackIn}>
Sign in again
</AlertDialogAction>
) : (
<>
<AlertDialogAction variant="outline" onClick={signOut}>
Sign out now
</AlertDialogAction>
<AlertDialogCancel ref={stay} variant="default">
Stay signed in
</AlertDialogCancel>
</>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<p className="text-sm text-muted-foreground tabular-nums">
Idle sessions end after {session.idleMinutes + session.warnSeconds / 60} minutes. This one runs until {at(endsAt)}.
</p>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</div>
)
}/**
* What `modal-dialogs-11` reads: who is signed in (the workspace owner in
* `db.members`) and their session's clock — idle since a while before
* `REFERENCE_DATE`, ending a few minutes after it — so the warning can name
* the time it ends rather than only count down to it.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the session as props; it never imports this module.
*/
import { REFERENCE_DATE, db } from "@/lib/sample-data"
/** How long a session may sit idle before it ends, in minutes. */
export const IDLE_LIMIT = 30
/** How long before the end the warning opens, in minutes. */
export const WARN_BEFORE = 5
/** How much more time Stay signed in buys, in minutes. */
export const EXTENSION = 30
export type Session = {
name: string
email: string
avatarUrl?: string
/** When the idle session ends: `WARN_BEFORE` minutes after now. */
endsAt: Date
/** How long they have been away when the warning opens, in minutes. */
idleMinutes: number
warnSeconds: number
extension: number
}
/** The signed-in person's session as the warning finds it. */
export function session(): Session {
const owner = db.members.all().find((member) => member.role === "owner")
if (!owner) throw new Error("modal-dialogs-11 signs in the workspace owner, and there is none")
return {
name: owner.name,
email: owner.email,
avatarUrl: owner.avatarUrl,
endsAt: new Date(REFERENCE_DATE.getTime() + WARN_BEFORE * 60_000),
idleMinutes: IDLE_LIMIT - WARN_BEFORE,
warnSeconds: WARN_BEFORE * 60,
extension: EXTENSION,
}
}npx shadcn@latest add @vibra/modal-dialogs-11Plan upgrade
modal-dialogs-12The plan card and its Upgrade dialog: what the next plan adds, monthly or yearly with the saving in words, the card to pay with, and exactly what is charged today and when the full price starts.
import * as React from "react"
import { PlanUpgrade } from "./modal-dialogs-12.client"
import { upgradeOffer } from "./modal-dialogs-12.data"
export type ModalDialogs12Props = {
/** Opens the upgrade on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The plan card and its Upgrade dialog: what the next plan adds, monthly or
* yearly, the card to pay with, and what is charged today to the cent.
*
* A server component: the plans, the month and the cards come from
* `./modal-dialogs-12.data`.
*/
export function ModalDialogs12({ defaultOpen = false }: ModalDialogs12Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-12"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<PlanUpgrade offer={upgradeOffer()} titleId={titleId} defaultOpen={defaultOpen} />
</div>
</section>
)
}
export default ModalDialogs12"use client"
import * as React from "react"
import { CheckIcon, SparklesIcon } from "lucide-react"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { SegmentedControl } from "@/components/ui/segmented-control"
import type { PayCard, PlanOffer, Upgrade } from "./modal-dialogs-12.data"
type Period = "monthly" | "yearly"
const money = (cents: number) => formatCurrency(cents / 100)
const whole = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const expiry = (card: PayCard) => `${String(card.expMonth).padStart(2, "0")}/${String(card.expYear).slice(-2)}`
/** What changes hands today: the new plan for the rest of the month, less what is left of the old one. */
export function dueToday(offer: Upgrade, period: Period): number {
const credit = Math.round((offer.current.monthlyCents * offer.daysLeft) / offer.daysInMonth)
if (period === "yearly") return offer.target.yearlyCents - credit
return Math.round((offer.target.monthlyCents * offer.daysLeft) / offer.daysInMonth) - credit
}
/** The yearly price against twelve months of the monthly one, as a whole percentage. */
export const yearlySaving = (plan: PlanOffer) => Math.round((1 - plan.yearlyCents / (plan.monthlyCents * 12)) * 100)
/**
* The plan card and the Upgrade dialog. The dialog shows what the next plan
* adds, lets the reader bill monthly or yearly — the saving said in words —
* and choose the card, then says exactly what is charged today and when the
* full price starts. A card that has run out is refused in a danger callout
* naming it; an upgrade closes the dialog, and the card's status line says
* what was charged to which card.
*/
export function PlanUpgrade({ offer, titleId, defaultOpen }: { offer: Upgrade; titleId: string; defaultOpen: boolean }) {
const usable = offer.cards.find((card) => !card.expired) ?? offer.cards[0]
const [open, setOpen] = React.useState(defaultOpen)
const [period, setPeriod] = React.useState<Period>("monthly")
const [cardId, setCardId] = React.useState(usable?.id ?? "")
const [refusal, setRefusal] = React.useState("")
const [plan, setPlan] = React.useState(offer.current)
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const periodRef = React.useRef<HTMLDivElement>(null)
const id = React.useId()
const upgraded = plan.name === offer.target.name
const gains = offer.target.features.filter((feature) => !offer.current.features.includes(feature))
const due = dueToday(offer, period)
const card = offer.cards.find((entry) => entry.id === cardId)
function confirm() {
if (!card) return setRefusal("Choose a card to pay with.")
if (card.expired) return setRefusal(`The card ending ${card.last4} ran out in ${expiry(card)}, so nothing was charged. Choose another card.`)
// Wire real billing here: charge `due` to `card` in your billing action and move the plan
// on its answer — this demo keeps the upgrade in the band's state.
setPlan(offer.target)
setNotice(`${offer.account} is on ${offer.target.name} from today. ${money(due)} was charged to the card ending ${card.last4}.`)
setOpen(false)
}
return (
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Plan</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{offer.account} is on {plan.name}, {whole(plan.monthlyCents)} a month for {formatNumber(plan.seats)} seats.
</CardDescription>
<CardAction>
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next)
if (!next) setRefusal("")
}}
>
<DialogTrigger ref={trigger} disabled={upgraded} render={<Button size="sm" focusableWhenDisabled />}>
<SparklesIcon data-icon="inline-start" aria-hidden="true" />
{upgraded ? `On ${offer.target.name}` : `Upgrade to ${offer.target.name}`}
</DialogTrigger>
<DialogContent
initialFocus={() => periodRef.current?.querySelector<HTMLElement>("[role=radio][tabindex='0']") ?? true}
finalFocus={trigger}
className="gap-5 sm:max-w-lg"
>
<DialogHeader>
<DialogTitle>Upgrade to {offer.target.name}</DialogTitle>
<DialogDescription>
Everything in {offer.current.name}, and {gains.length} things it does not have. The change is immediate.
</DialogDescription>
</DialogHeader>
<ul className="grid gap-1.5 text-sm sm:grid-cols-2">
{gains.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<CheckIcon aria-hidden="true" className="size-4 shrink-0 text-success" />
{feature}
</li>
))}
</ul>
<div className="flex flex-col gap-2">
<span id={`${id}-period`} className="text-sm font-medium">
Billing
</span>
<div ref={periodRef}>
<SegmentedControl
aria-labelledby={`${id}-period`}
fullWidth
options={[
{ value: "monthly", label: `${whole(offer.target.monthlyCents)} a month` },
{ value: "yearly", label: `${whole(offer.target.yearlyCents)} a year` },
]}
value={period}
onValueChange={(value) => setPeriod(value as Period)}
/>
</div>
<p className="text-xs text-muted-foreground tabular-nums">Yearly works out {yearlySaving(offer.target)}% cheaper than twelve months.</p>
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-card`} className="text-sm font-medium">
Pay with
</label>
<NativeSelect
id={`${id}-card`}
value={cardId}
onChange={(event) => {
setCardId(event.target.value)
setRefusal("")
}}
aria-invalid={refusal ? true : undefined}
aria-describedby={refusal ? `${id}-refusal` : undefined}
className="w-full"
>
{offer.cards.map((entry) => (
<NativeSelectOption key={entry.id} value={entry.id}>
Card ending {entry.last4}, expires {expiry(entry)}
{entry.expired ? " (expired)" : ""}
{entry.isDefault ? " · default" : ""}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-2 rounded-lg bg-surface p-3 ring-1 ring-border">
<dl className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 text-sm tabular-nums">
<dt className="text-muted-foreground">
{offer.target.name}, {period === "yearly" ? "the year" : `the ${offer.daysLeft} days left in the month`}
</dt>
<dd className="text-right">
{money(period === "yearly" ? offer.target.yearlyCents : Math.round((offer.target.monthlyCents * offer.daysLeft) / offer.daysInMonth))}
</dd>
<dt className="text-muted-foreground">What is left of {offer.current.name}</dt>
<dd className="text-right">−{money(Math.round((offer.current.monthlyCents * offer.daysLeft) / offer.daysInMonth))}</dd>
<div className="col-span-2 grid grid-cols-subgrid border-t border-rule pt-1 font-medium">
<dt>Due today</dt>
<dd className="text-right">{money(due)}</dd>
</div>
</dl>
<p className="text-xs text-muted-foreground tabular-nums">
{period === "yearly" ? "Renews a year from today." : `Then ${whole(offer.target.monthlyCents)} on ${DAY.format(offer.nextBill)} and every month after.`}
</p>
</div>
{refusal ? (
<Callout id={`${id}-refusal`} variant="danger" role="alert" title="Not upgraded">
{refusal}
</Callout>
) : null}
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Not now</DialogClose>
<Button onClick={confirm}>Upgrade and pay {money(due)}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ul className="grid gap-1.5 text-sm sm:grid-cols-2 lg:grid-cols-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2">
<CheckIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
{feature}
</li>
))}
</ul>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</CardContent>
</Card>
)
}/**
* What `modal-dialogs-12` reads: the plan an account is on and the one above
* it in `db.plans`, what the rest of the month is worth on each, and the
* cards the account keeps in `db.paymentMethods` — by their last four digits
* and expiry only. "Now" is `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the offer as props; it never imports this module.
*/
import { REFERENCE_DATE, db } from "@/lib/sample-data"
/** The account that upgrades, and the plan it moves to. */
export const ACCOUNT = "Trellis Energy"
export const TARGET = "Enterprise"
export type PlanOffer = { name: string; monthlyCents: number; yearlyCents: number; seats: number; features: string[] }
export type PayCard = { id: string; last4: string; expMonth: number; expYear: number; expired: boolean; isDefault: boolean }
export type Upgrade = {
account: string
current: PlanOffer
target: PlanOffer
/** Days left in the month, today included, and how many it has. */
daysLeft: number
daysInMonth: number
/** The first of next month: when the new plan bills in full. */
nextBill: Date
cards: PayCard[]
}
const offer = (name: string): PlanOffer => {
const plan = db.plans.all().find((entry) => entry.name === name)
if (!plan) throw new Error(`modal-dialogs-12 offers "${name}", which is not in db.plans`)
return { name: plan.name, monthlyCents: plan.priceMonthlyCents, yearlyCents: plan.priceYearlyCents, seats: plan.seatsIncluded, features: plan.features }
}
/** The account's plan, the one it can move to, the month left, and its cards. */
export function upgradeOffer(): Upgrade {
const customer = db.customers.all().find((entry) => entry.company === ACCOUNT)
if (!customer) throw new Error(`modal-dialogs-12 upgrades "${ACCOUNT}", which is not a customer`)
const current = offer(customer.plan.charAt(0).toUpperCase() + customer.plan.slice(1))
const year = REFERENCE_DATE.getUTCFullYear()
const month = REFERENCE_DATE.getUTCMonth()
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
const thisMonth = year * 12 + month + 1
return {
account: customer.company,
current,
target: offer(TARGET),
daysLeft: daysInMonth - REFERENCE_DATE.getUTCDate() + 1,
daysInMonth,
nextBill: new Date(Date.UTC(year, month + 1, 1)),
cards: db.paymentMethods
.all()
.filter((card) => card.customerId === customer.id)
.sort((a, b) => Number(b.default) - Number(a.default))
.map((card) => ({
id: card.id,
last4: card.last4,
expMonth: card.expMonth,
expYear: card.expYear,
expired: card.expYear * 12 + card.expMonth < thisMonth,
isDefault: card.default,
})),
}
}npx shadcn@latest add @vibra/modal-dialogs-12Export options
modal-dialogs-13The orders card and its Export dialog: which orders, CSV, CSV with semicolons or JSON, the columns and a row per product — with the orders, the rows and the file's size counted as each choice changes.
import * as React from "react"
import { ExportOrders } from "./modal-dialogs-13.client"
import { buildExport, exportContext, type ExportFile, type ExportRequest, type Result } from "./modal-dialogs-13.data"
export type ModalDialogs13Props = {
/** Opens the export on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The orders card with an Export dialog: which orders, the format, the
* columns and a row per product, with the rows and the size counted as the
* reader chooses.
*
* A server component. It reads the orders through `./modal-dialogs-13.data`
* and hands the island `exportOrders`, a server function defined here, which
* checks the request again and builds the file from the store.
*/
export function ModalDialogs13({ defaultOpen = false }: ModalDialogs13Props) {
const titleId = React.useId()
async function exportOrders(request: ExportRequest): Promise<Result<ExportFile>> {
"use server"
return buildExport(request)
}
return (
<section
data-section="modal-dialogs-13"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<ExportOrders context={exportContext()} onExport={exportOrders} titleId={titleId} defaultOpen={defaultOpen} />
</div>
</section>
)
}
export default ModalDialogs13"use client"
import * as React from "react"
import { DownloadIcon } from "lucide-react"
import { downloadText } from "@/lib/export"
import { formatBytes, formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { Spinner } from "@/components/ui/spinner"
import { StatusBadge } from "@/components/ui/status-badge"
import { Switch } from "@/components/ui/switch"
import type { ExportColumn, ExportContext, ExportFile, ExportFormat, ExportRange, ExportRequest, Result } from "./modal-dialogs-13.data"
const FORMATS: { value: ExportFormat; label: string; hint: string }[] = [
{ value: "csv", label: "CSV", hint: "Commas between fields and a point in decimals. Opens in any spreadsheet." },
{ value: "semicolon", label: "CSV with ;", hint: "Semicolons between fields and a comma in decimals, for spreadsheets set up that way." },
{ value: "json", label: "JSON", hint: "An array of objects with exact times and plain numbers, for scripts and imports." },
]
const LINE_KEYS = ["product", "quantity", "lineTotal"] as const
const LINE_LABELS = { product: "Product", quantity: "Quantity", lineTotal: "Line total" } as const
const STATUS_TONES = { fulfilled: "success", refunded: "warning", paid: "info", pending: "warning", cancelled: "danger" } as const
const STATUS_LABELS = { pending: "Pending", paid: "Paid", fulfilled: "Fulfilled", refunded: "Refunded", cancelled: "Cancelled" } as const
const DAY_MS = 86_400_000
const WHEN = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZone: "UTC" })
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const isoDay = (date: Date) => date.toISOString().slice(0, 10)
const orders = (count: number) => `${formatNumber(count)} ${count === 1 ? "order" : "orders"}`
const rowsWord = (count: number) => `${formatNumber(count)} ${count === 1 ? "row" : "rows"}`
/**
* What a request would put in the file, worked out from the stamps the band
* holds: the orders in its range, the rows they make, and about how many
* bytes. The server builds the real thing; this is what the dialog shows
* while the reader chooses.
*/
export function estimate(context: ExportContext, request: ExportRequest): { orders: number; rows: number; bytes: number } {
let from = 0
let to = context.now.getTime() + 1
if (request.range === "custom") {
const start = Date.parse(`${request.from ?? ""}T00:00:00Z`)
const end = Date.parse(`${request.to ?? ""}T00:00:00Z`)
if (Number.isNaN(start) || Number.isNaN(end) || start > end) return { orders: 0, rows: 0, bytes: 0 }
from = start
to = end + DAY_MS
} else {
from = context.presets.find((preset) => preset.value === request.range)?.since?.getTime() ?? 0
}
let count = 0
let lines = 0
context.placed.forEach((placed, index) => {
if (placed < from || placed >= to) return
count += 1
lines += context.lines[index]
})
const rows = request.perProduct ? lines : count
const keys: string[] = [...context.columns.filter((column) => request.columns.includes(column.key)).map((column) => column.key), ...(request.perProduct ? LINE_KEYS : [])]
if (keys.length === 0 || rows === 0) return { orders: count, rows, bytes: 0 }
const width = (key: string) => context.widths[key as keyof ExportContext["widths"]] ?? 8
let bytes: number
if (request.format === "json") {
// Pretty-printed: a key and its quotes, the value, and the indent on every line.
bytes = 4 + rows * (8 + keys.reduce((sum, key) => sum + key.length + width(key) + (key === "placed" ? 8 : 0) + 10, 0))
} else {
const labels = keys.map((key) => context.columns.find((column) => column.key === key)?.label ?? LINE_LABELS[key as keyof typeof LINE_LABELS])
const header = labels.reduce((sum, label) => sum + label.length, 0) + keys.length
bytes = header + rows * keys.reduce((sum, key) => sum + width(key) + 1, 0) + (request.format === "semicolon" ? 3 : 0)
}
return { orders: count, rows, bytes }
}
/**
* The orders card and its Export dialog. The dialog opens on the range, and
* each choice — the range with its count, the format, the columns, a row per
* product — updates what the file will hold and roughly how big it is before
* anything is made. A range with no orders in it is refused by the server in
* a danger callout that says where the book starts; no columns is caught
* under the columns. A file downloads, the dialog closes, and the card's
* status line names the file and its size.
*/
export function ExportOrders({
context,
onExport,
titleId,
defaultOpen,
}: {
context: ExportContext
onExport: (request: ExportRequest) => Promise<Result<ExportFile>>
titleId: string
defaultOpen: boolean
}) {
const [open, setOpen] = React.useState(defaultOpen)
const [range, setRange] = React.useState<ExportRange>("30")
const [from, setFrom] = React.useState(() => isoDay(new Date(context.now.getTime() - 30 * DAY_MS)))
const [to, setTo] = React.useState(() => isoDay(context.now))
const [format, setFormat] = React.useState<ExportFormat>("csv")
const [columns, setColumns] = React.useState<ExportColumn[]>(context.columns.map((column) => column.key))
const [perProduct, setPerProduct] = React.useState(false)
const [noColumns, setNoColumns] = React.useState(false)
const [refusal, setRefusal] = React.useState("")
const [pending, setPending] = React.useState(false)
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const rangeField = React.useRef<HTMLSelectElement>(null)
const id = React.useId()
const request: ExportRequest = { range, from, to, format, columns, perProduct }
const guess = estimate(context, request)
const extension = format === "json" ? "json" : "csv"
const counts = Object.fromEntries(context.presets.map((preset) => [preset.value, estimate(context, { ...request, range: preset.value, perProduct: false }).orders]))
function change(next: boolean) {
setOpen(next)
if (next) return
setRefusal("")
setNoColumns(false)
}
function toggleColumn(key: ExportColumn, on: boolean) {
setColumns((current) => (on ? context.columns.map((column) => column.key).filter((entry) => entry === key || current.includes(entry)) : current.filter((entry) => entry !== key)))
setNoColumns(false)
setRefusal("")
}
async function submit(event: React.FormEvent) {
event.preventDefault()
if (pending) return
if (columns.length === 0) {
setNoColumns(true)
return
}
setPending(true)
setRefusal("")
let result: Result<ExportFile>
try {
result = await onExport(request)
} catch {
result = { ok: false, error: { code: "network", message: "The export did not reach the server. Try again." } }
}
setPending(false)
if (!result.ok) {
setRefusal(result.error.message)
if (result.error.field === "range") rangeField.current?.focus()
return
}
const file = result.data
downloadText(file.filename, file.content, file.type)
setNotice(`Exported ${orders(file.orders)}${file.rows !== file.orders ? ` as ${rowsWord(file.rows)}` : ""} to ${file.filename}, ${formatBytes(file.bytes, 1)}.`)
setOpen(false)
}
return (
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Orders</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{orders(context.placed.length)} since {DAY.format(context.first)}.
</CardDescription>
<CardAction>
<Dialog open={open} onOpenChange={change}>
<DialogTrigger ref={trigger} render={<Button size="sm" variant="outline" />}>
<DownloadIcon data-icon="inline-start" aria-hidden="true" />
Export
</DialogTrigger>
<DialogContent initialFocus={rangeField} finalFocus={trigger} className="max-h-[calc(100svh-2rem)] overflow-y-auto sm:max-w-lg">
<form onSubmit={submit} className="flex flex-col gap-5" noValidate>
<DialogHeader>
<DialogTitle>Export orders</DialogTitle>
<DialogDescription>Choose the orders and what to keep of them. The file downloads to this device.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-range`} className="text-sm font-medium">
Orders placed
</label>
<NativeSelect
ref={rangeField}
id={`${id}-range`}
value={range}
onChange={(event) => {
setRange(event.target.value as ExportRange)
setRefusal("")
}}
aria-invalid={refusal ? true : undefined}
aria-describedby={refusal ? `${id}-refusal` : undefined}
className="w-full"
>
{context.presets.map((preset) => (
<NativeSelectOption key={preset.value} value={preset.value}>
{preset.label} ({orders(counts[preset.value])})
</NativeSelectOption>
))}
<NativeSelectOption value="custom">Between two days</NativeSelectOption>
</NativeSelect>
{range === "custom" ? (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label htmlFor={`${id}-from`} className="text-xs text-muted-foreground">
First day
</label>
<Input
id={`${id}-from`}
type="date"
value={from}
max={isoDay(context.now)}
onChange={(event) => {
setFrom(event.target.value)
setRefusal("")
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${id}-to`} className="text-xs text-muted-foreground">
Last day
</label>
<Input
id={`${id}-to`}
type="date"
value={to}
max={isoDay(context.now)}
onChange={(event) => {
setTo(event.target.value)
setRefusal("")
}}
/>
</div>
</div>
) : null}
</div>
<div className="flex flex-col gap-2">
<span id={`${id}-format`} className="text-sm font-medium">
Format
</span>
<SegmentedControl
aria-labelledby={`${id}-format`}
aria-describedby={`${id}-format-hint`}
fullWidth
options={FORMATS.map((option) => ({ value: option.value, label: option.label }))}
value={format}
onValueChange={(value) => setFormat(value as ExportFormat)}
/>
<p id={`${id}-format-hint`} className="text-xs text-muted-foreground">
{FORMATS.find((option) => option.value === format)?.hint}
</p>
</div>
<fieldset className="flex flex-col gap-2" aria-describedby={noColumns ? `${id}-columns-error` : undefined}>
<legend className="mb-2 text-sm font-medium">
Columns{" "}
<span className="font-normal text-muted-foreground tabular-nums">
· {columns.length} of {context.columns.length}
</span>
</legend>
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 sm:grid-cols-4">
{context.columns.map((column) => (
<label key={column.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={columns.includes(column.key)}
onCheckedChange={(checked) => toggleColumn(column.key, checked)}
aria-invalid={noColumns ? true : undefined}
/>
{column.label}
</label>
))}
</div>
<FieldError id={`${id}-columns-error`}>{noColumns ? "Choose at least one column to export." : null}</FieldError>
</fieldset>
<div className="flex items-center justify-between gap-4">
<label htmlFor={`${id}-lines`} className="flex flex-col gap-0.5">
<span className="text-sm font-medium">A row for each product</span>
<span className="text-xs text-muted-foreground">Adds the product, quantity and line total; an order's columns repeat on each of its rows.</span>
</label>
<Switch id={`${id}-lines`} checked={perProduct} onCheckedChange={setPerProduct} />
</div>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-lg bg-surface p-3 text-sm ring-1 ring-border">
<span className="tabular-nums">
{orders(guess.orders)}
{perProduct ? `, ${rowsWord(guess.rows)}` : ""}
{guess.bytes > 0 ? ` · about ${formatBytes(guess.bytes, 0)}` : ""}
</span>
<span className="font-mono text-xs text-muted-foreground">
orders-{isoDay(context.now)}.{extension}
</span>
</div>
{refusal ? (
<Callout id={`${id}-refusal`} variant="danger" role="alert" title="Nothing was exported">
{refusal}
</Callout>
) : null}
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>Cancel</DialogClose>
<Button type="submit" aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" data-icon="inline-start" /> : <DownloadIcon data-icon="inline-start" aria-hidden="true" />}
{guess.orders > 0 ? `Export ${orders(guess.orders)}` : "Export"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ul aria-label="Latest orders" className="divide-y divide-border">
{context.latest.map((order) => (
<li key={order.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 py-2 first:pt-0 last:pb-0 sm:grid-cols-[7rem_minmax(0,1fr)_8rem_6rem_6rem]">
<span className="font-mono text-xs text-muted-foreground sm:order-none">{order.number}</span>
<span className="text-right text-sm font-medium tabular-nums sm:order-4">{formatCurrency(order.totalCents / 100)}</span>
<span className="truncate text-sm sm:order-1">{order.company}</span>
<span className="text-right sm:order-5">
<StatusBadge size="sm" status={order.status} variant={STATUS_TONES[order.status]} label={STATUS_LABELS[order.status]} />
</span>
<span className="hidden text-sm text-muted-foreground tabular-nums sm:order-2 sm:block">{WHEN.format(order.placedAt)}</span>
</li>
))}
</ul>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</CardContent>
</Card>
)
}/**
* What `modal-dialogs-13` reads, and the file it makes: every order in
* `db.orders`, with the customer's company from `db.customers` and each
* line's product from `db.products`. "Now" is `REFERENCE_DATE`.
*
* `exportContext` hands the island a stamp per order — when it was placed
* and how many lines it has — so the dialog counts the rows as the options
* change without holding the orders. `buildExport` is what the section's
* server function runs: it checks the request again and returns the file,
* or a refusal that says why there is none.
*
* Every read happens inside a function and runs on the server. The island
* takes the context as props and calls the action it is handed; it never
* imports this module.
*/
import { exportFilename, rowsToCsv } from "@/lib/export"
import { REFERENCE_DATE, asString, db, fields, type Order, type Result } from "@/lib/sample-data"
export type { Result }
export type ExportRange = "7" | "30" | "90" | "all" | "custom"
export type ExportFormat = "csv" | "semicolon" | "json"
export type ExportColumn = "number" | "placed" | "customer" | "status" | "items" | "total" | "payment" | "country"
/** The columns a file can carry, in the order it carries them. */
export const COLUMNS: { key: ExportColumn; label: string }[] = [
{ key: "number", label: "Order" },
{ key: "placed", label: "Placed" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "items", label: "Items" },
{ key: "total", label: "Total" },
{ key: "payment", label: "Payment" },
{ key: "country", label: "Country" },
]
/** What a row per product adds after the chosen columns. */
export const LINE_COLUMNS = [
{ key: "product", label: "Product" },
{ key: "quantity", label: "Quantity" },
{ key: "lineTotal", label: "Line total" },
] as const
const PRESETS: { value: Exclude<ExportRange, "custom">; label: string; days: number | null }[] = [
{ value: "7", label: "Last 7 days", days: 7 },
{ value: "30", label: "Last 30 days", days: 30 },
{ value: "90", label: "Last 90 days", days: 90 },
{ value: "all", label: "Everything", days: null },
]
const PAYMENT: Record<Order["paymentMethod"], string> = { card: "Card", wallet: "Wallet", bank: "Bank transfer", cash: "Cash", voucher: "Gift voucher" }
const STATUS: Record<Order["status"], string> = { pending: "Pending", paid: "Paid", fulfilled: "Fulfilled", refunded: "Refunded", cancelled: "Cancelled" }
const DAY_MS = 86_400_000
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
export type ExportRequest = {
range: ExportRange
/** A custom range, as the date fields give it: "2026-08-01". Both days count. */
from?: string
to?: string
format: ExportFormat
columns: ExportColumn[]
/** A row for every product in an order, instead of one for the order. */
perProduct: boolean
}
export type ExportFile = { filename: string; type: string; content: string; orders: number; rows: number; bytes: number }
export type LatestOrder = { id: string; number: string; company: string; placedAt: Date; totalCents: number; status: Order["status"] }
export type ExportContext = {
now: Date
first: Date
presets: { value: Exclude<ExportRange, "custom">; label: string; since: Date | null }[]
columns: { key: ExportColumn; label: string }[]
/** When each order was placed, in ms, and how many products it has — the same index in both. */
placed: number[]
lines: number[]
/** The average characters each field takes in a file, for the size the dialog estimates. */
widths: Record<ExportColumn | (typeof LINE_COLUMNS)[number]["key"], number>
latest: LatestOrder[]
}
const utcDay = (text: string) => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
return match ? Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : Number.NaN
}
const stamp = (date: Date) => date.toISOString().slice(0, 16).replace("T", " ")
const money = (cents: number) => (cents / 100).toFixed(2)
type Flat = Record<string, string | number>
function flatten(): { order: Order; row: Flat; lines: Flat[] }[] {
const companies = new Map(db.customers.all().map((customer) => [customer.id, customer.company]))
const products = new Map(db.products.all().map((product) => [product.id, product.name]))
return db.orders
.all()
.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
.map((order) => {
const row: Flat = {
number: order.number,
placed: stamp(order.placedAt),
customer: companies.get(order.customerId) ?? "Walk-in",
status: STATUS[order.status],
items: order.items.reduce((sum, item) => sum + item.qty, 0),
total: money(order.totalCents),
payment: PAYMENT[order.paymentMethod],
country: order.country,
}
const lines = order.items.map((item) => ({
product: products.get(item.productId) ?? item.productId,
quantity: item.qty,
lineTotal: money(item.qty * item.unitCents),
}))
return { order, row, lines }
})
}
/** The orders, as stamps and averages, and the five placed last. */
export function exportContext(): ExportContext {
const flat = flatten()
const average = (values: (string | number)[]) => Math.round(values.reduce<number>((sum, value) => sum + String(value).length, 0) / Math.max(1, values.length))
const widths = Object.fromEntries([
...COLUMNS.map((column) => [column.key, average(flat.map((entry) => entry.row[column.key]))]),
...LINE_COLUMNS.map((column) => [column.key, average(flat.flatMap((entry) => entry.lines.map((line) => line[column.key])))]),
]) as ExportContext["widths"]
return {
now: REFERENCE_DATE,
first: flat[flat.length - 1].order.placedAt,
presets: PRESETS.map((preset) => ({
value: preset.value,
label: preset.label,
since: preset.days === null ? null : new Date(REFERENCE_DATE.getTime() - preset.days * DAY_MS),
})),
columns: COLUMNS,
placed: flat.map((entry) => entry.order.placedAt.getTime()),
lines: flat.map((entry) => entry.order.items.length),
widths,
latest: flat.slice(0, 5).map(({ order, row }) => ({
id: order.id,
number: order.number,
company: String(row.customer),
placedAt: order.placedAt,
totalCents: order.totalCents,
status: order.status,
})),
}
}
/** The window a request asks for, in ms: from, inclusive, to to, exclusive — or why it is not one. */
function windowOf(request: Partial<ExportRequest>): Result<{ from: number; to: number; words: string }> {
const now = REFERENCE_DATE.getTime()
if (request.range !== "custom") {
const preset = PRESETS.find((entry) => entry.value === request.range)
if (!preset) return { ok: false, error: { code: "range", message: "Choose which orders to export.", field: "range" } }
return { ok: true, data: { from: preset.days === null ? 0 : now - preset.days * DAY_MS, to: now + 1, words: preset.days === null ? "at all" : `in the last ${preset.days} days` } }
}
const from = utcDay(asString(request.from))
const to = utcDay(asString(request.to))
if (Number.isNaN(from) || Number.isNaN(to)) return { ok: false, error: { code: "range", message: "Give the range both a first and a last day.", field: "range" } }
if (from > to) return { ok: false, error: { code: "range", message: "The range starts after it ends. Swap the two days.", field: "range" } }
return { ok: true, data: { from, to: to + DAY_MS, words: `between ${DAY.format(from)} and ${DAY.format(to)}` } }
}
/** Fields joined by semicolons, quoted when they carry one, a quote or a line break. */
function semicolons(rows: Flat[], columns: { key: string; label: string }[]): string {
const field = (value: string | number) => {
const text = String(value)
return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
}
return [columns.map((column) => field(column.label)), ...rows.map((row) => columns.map((column) => field(row[column.key] ?? "")))]
.map((cells) => cells.join(";"))
.join("\n")
}
/**
* The file an export asks for, checked again here: at least one column, a
* range that is one, and orders in it — a refusal names the first order the
* store has, so the reader knows where the book starts.
*/
export function buildExport(sent: ExportRequest): Result<ExportFile> {
// Read as the dialog sends it, whatever arrives: the columns a list, the rest looked up.
const request = fields(sent)
const wanted: unknown[] = Array.isArray(request.columns) ? request.columns : []
const chosen = COLUMNS.filter((column) => wanted.includes(column.key))
if (chosen.length === 0) return { ok: false, error: { code: "columns", message: "Choose at least one column to export.", field: "columns" } }
const span = windowOf(request)
if (!span.ok) return span
const flat = flatten()
const picked = flat.filter((entry) => entry.order.placedAt.getTime() >= span.data.from && entry.order.placedAt.getTime() < span.data.to)
if (picked.length === 0) {
const first = flat[flat.length - 1].order.placedAt
return {
ok: false,
error: { code: "empty", message: `No orders were placed ${span.data.words}. The first order in the store was placed on ${DAY.format(first)}.`, field: "range" },
}
}
const columns: { key: string; label: string }[] = [...chosen, ...(request.perProduct ? LINE_COLUMNS : [])].map((column) => ({
key: column.key,
label: column.key === "placed" && request.format !== "json" ? "Placed (UTC)" : column.label,
}))
const rows: Flat[] = picked.flatMap((entry) => {
const base = Object.fromEntries(chosen.map((column) => [column.key, entry.row[column.key]]))
return request.perProduct ? entry.lines.map((line) => ({ ...base, ...line })) : [base]
})
let content: string
let type = "text/csv;charset=utf-8"
let extension = "csv"
if (request.format === "json") {
// Numbers stay numbers and the time stays exact.
const precise = picked.flatMap((entry) => {
const base: Record<string, string | number> = {}
for (const column of chosen) {
if (column.key === "placed") base.placed = entry.order.placedAt.toISOString()
else if (column.key === "total") base.total = entry.order.totalCents / 100
else base[column.key] = entry.row[column.key]
}
return request.perProduct
? entry.lines.map((line) => ({ ...base, product: line.product, quantity: line.quantity, lineTotal: Number(line.lineTotal) }))
: [base]
})
content = JSON.stringify(precise, null, 2)
type = "application/json;charset=utf-8"
extension = "json"
} else if (request.format === "semicolon") {
// A decimal comma, and a byte-order mark so the spreadsheet reads UTF-8.
const decimals = rows.map((row) =>
Object.fromEntries(Object.entries(row).map(([key, value]) => [key, key === "total" || key === "lineTotal" ? String(value).replace(".", ",") : value]))
)
content = `${semicolons(decimals, columns)}`
} else {
content = rowsToCsv(rows, columns)
}
return {
ok: true,
data: {
filename: exportFilename("orders", extension, REFERENCE_DATE),
type,
content,
orders: picked.length,
rows: rows.length,
bytes: new TextEncoder().encode(content).length,
},
}
}npx shadcn@latest add @vibra/modal-dialogs-13Feedback
modal-dialogs-14A revenue report with a Give feedback dialog: how useful it is on a five-step scale in words, what the note is about, a few words with the characters left, and a reply only when asked for.
import * as React from "react"
import { ReportFeedback } from "./modal-dialogs-14.client"
import { checkFeedback, feedbackContext, type FeedbackInput, type FeedbackReceipt, type Result } from "./modal-dialogs-14.data"
export type ModalDialogs14Props = {
/** Opens the feedback on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A report with a Give feedback dialog: how useful it is, in words, what the
* note is about, a few words with the characters left, and a reply only when
* asked for.
*
* A server component. It reads the report through `./modal-dialogs-14.data`
* and hands the island `sendFeedback`, a server function defined here, which
* checks the note before it is taken.
*/
export function ModalDialogs14({ defaultOpen = false }: ModalDialogs14Props) {
const titleId = React.useId()
async function sendFeedback(input: FeedbackInput): Promise<Result<FeedbackReceipt>> {
"use server"
return checkFeedback(input)
}
return (
<section
data-section="modal-dialogs-14"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<ReportFeedback context={feedbackContext()} onSend={sendFeedback} titleId={titleId} defaultOpen={defaultOpen} />
</div>
</section>
)
}
export default ModalDialogs14"use client"
import * as React from "react"
import { MessageSquareIcon } from "lucide-react"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldError, FieldLabel, FieldTitle } from "@/components/ui/field"
import { PercentageBar } from "@/components/ui/percentage-bar"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
import type { FeedbackContext, FeedbackInput, FeedbackReceipt, Result } from "./modal-dialogs-14.data"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const SWATCHES = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4"]
// A choice as a chip: the kit's choice card, sized to its word.
const CHIP = "has-[>[data-slot=field]]:w-auto *:data-[slot=field]:gap-2 *:data-[slot=field]:px-3 *:data-[slot=field]:py-2"
// A step on the scale: the same card, one of five equal cells, the dot over the word.
const STEP = "*:data-[slot=field]:px-0.5 *:data-[slot=field]:py-2.5"
/**
* A report with a feedback dialog. The dialog asks how useful the report is
* on a five-step scale in words, what the note is about, and for a few words, with the
* characters left counted under the box and a reply only when asked for. An
* empty note is refused by the server in a danger callout; a note over the
* limit is caught under the box, with what was written kept. A sent note
* closes the dialog and the report's status line thanks the reader for
* exactly what they sent.
*/
export function ReportFeedback({
context,
onSend,
titleId,
defaultOpen,
}: {
context: FeedbackContext
onSend: (input: FeedbackInput) => Promise<Result<FeedbackReceipt>>
titleId: string
defaultOpen: boolean
}) {
const [open, setOpen] = React.useState(defaultOpen)
const [rating, setRating] = React.useState("")
const [topics, setTopics] = React.useState<string[]>([])
const [comment, setComment] = React.useState("")
const [mayReply, setMayReply] = React.useState(false)
const [tooLong, setTooLong] = React.useState(false)
const [refusal, setRefusal] = React.useState("")
const [pending, setPending] = React.useState(false)
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const ratingGroup = React.useRef<HTMLDivElement>(null)
const box = React.useRef<HTMLTextAreaElement>(null)
const id = React.useId()
const length = comment.trim().length
const left = context.limit - length
const shares = context.rows.map((row) => Math.round((row.mrrCents / Math.max(1, context.totalCents)) * 100))
function change(next: boolean) {
setOpen(next)
if (next) return
setRefusal("")
setTooLong(false)
}
async function send(event: React.FormEvent) {
event.preventDefault()
if (pending) return
if (left < 0) {
setTooLong(true)
box.current?.focus()
return
}
setPending(true)
setRefusal("")
let result: Result<FeedbackReceipt>
try {
result = await onSend({ rating: rating ? Number(rating) : null, topics, comment, mayReply })
} catch {
result = { ok: false, error: { code: "network", message: "The note did not reach the server. Try again." } }
}
setPending(false)
if (!result.ok) {
if (result.error.field === "comment") {
setTooLong(true)
box.current?.focus()
} else {
setRefusal(result.error.message)
ratingGroup.current?.querySelector<HTMLElement>("[role=radio]")?.focus()
}
return
}
const receipt = result.data
const about = receipt.topics.length ? ` about ${receipt.topics.map((topic) => topic.toLowerCase()).join(", ")}` : ""
setNotice(
`Thank you, ${context.me.name.split(" ")[0]}. Your note${about} reached the team behind ${receipt.report}${receipt.rating ? `, rated ${receipt.rating.toLowerCase()}` : ""}.${receipt.replyTo ? ` Any reply goes to ${receipt.replyTo}.` : ""}`
)
setRating("")
setTopics([])
setComment("")
setMayReply(false)
setOpen(false)
}
return (
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{context.report}</h2>
</CardTitle>
<CardDescription className="tabular-nums">
What active customers pay each month, {money(context.totalCents)} in all.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<PercentageBar
height="sm"
showLegend={false}
segments={context.rows.map((row, index) => ({ label: row.plan, value: row.mrrCents, color: `chart-${index + 1}` }))}
/>
<ul aria-label="Plans" className="divide-y divide-border">
{context.rows.map((row, index) => (
<li key={row.plan} className="grid grid-cols-[minmax(0,1fr)_auto_3rem] items-baseline gap-x-4 py-2 text-sm first:pt-0 last:pb-0 sm:grid-cols-[minmax(0,1fr)_8rem_auto_3rem]">
<span className="flex items-center gap-2 font-medium">
<span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${SWATCHES[index]}`} />
{row.plan}
</span>
<span className="hidden text-muted-foreground tabular-nums sm:block">{formatNumber(row.customers)} customers</span>
<span className="text-right tabular-nums">{money(row.mrrCents)}</span>
<span className="text-right text-muted-foreground tabular-nums">{shares[index]}%</span>
</li>
))}
</ul>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</CardContent>
<CardFooter className="flex-wrap justify-between gap-3">
<span className="text-sm text-muted-foreground">Is this report useful?</span>
<Dialog open={open} onOpenChange={change}>
<DialogTrigger ref={trigger} render={<Button size="sm" variant="outline" />}>
<MessageSquareIcon data-icon="inline-start" aria-hidden="true" />
Give feedback
</DialogTrigger>
<DialogContent
initialFocus={() => ratingGroup.current?.querySelector<HTMLElement>("[role=radio]") ?? true}
finalFocus={trigger}
className="max-h-[calc(100svh-2rem)] overflow-y-auto sm:max-w-lg"
>
<form onSubmit={send} className="flex flex-col gap-5" noValidate>
<DialogHeader>
<DialogTitle>Feedback on {context.report}</DialogTitle>
<DialogDescription>It goes to the people who build this report. Nothing about your customers is attached.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<span id={`${id}-rating`} className="text-sm font-medium">
How useful is it?
</span>
<RadioGroup
ref={ratingGroup}
aria-labelledby={`${id}-rating`}
aria-describedby={refusal ? `${id}-refusal` : undefined}
value={rating}
onValueChange={(value) => {
setRating(String(value))
setRefusal("")
}}
className="grid grid-cols-5 gap-1.5 sm:gap-2"
>
{context.ratings.map((word, index) => (
<FieldLabel key={word} htmlFor={`${id}-rating-${index}`} className={STEP}>
<Field orientation="horizontal" className="flex-col gap-1.5">
<RadioGroupItem id={`${id}-rating-${index}`} value={String(index + 1)} />
<FieldTitle className="justify-center text-center text-xs leading-tight sm:text-sm">{word}</FieldTitle>
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-sm font-medium">
What is it about? <span className="font-normal text-muted-foreground">Optional</span>
</legend>
<div className="flex flex-wrap gap-2">
{context.topics.map((topic, index) => (
<FieldLabel key={topic} htmlFor={`${id}-topic-${index}`} className={CHIP}>
<Field orientation="horizontal">
<Checkbox
id={`${id}-topic-${index}`}
checked={topics.includes(topic)}
onCheckedChange={(checked) => {
setTopics((current) => (checked ? context.topics.filter((entry) => entry === topic || current.includes(entry)) : current.filter((entry) => entry !== topic)))
setRefusal("")
}}
/>
<FieldTitle>{topic}</FieldTitle>
</Field>
</FieldLabel>
))}
</div>
</fieldset>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-comment`} className="text-sm font-medium">
Anything else? <span className="font-normal text-muted-foreground">Optional</span>
</label>
<Textarea
ref={box}
id={`${id}-comment`}
value={comment}
onChange={(event) => {
setComment(event.target.value)
setTooLong(false)
setRefusal("")
}}
rows={4}
placeholder="What did you come to this report to find out?"
aria-invalid={tooLong ? true : undefined}
aria-describedby={tooLong ? `${id}-count ${id}-comment-error` : `${id}-count`}
className="max-h-48 resize-none"
/>
<p id={`${id}-count`} data-over={left < 0 || undefined} className="text-xs text-muted-foreground tabular-nums data-over:font-medium data-over:text-danger">
{left >= 0 ? `${formatNumber(left)} of ${formatNumber(context.limit)} characters left` : `${formatNumber(-left)} ${left === -1 ? "character" : "characters"} over the limit`}
</p>
<FieldError id={`${id}-comment-error`}>
{tooLong ? `A note stops at ${formatNumber(context.limit)} characters. Take out ${formatNumber(-left)} and send again.` : null}
</FieldError>
</div>
<label className="flex items-start gap-2 text-sm">
<Checkbox checked={mayReply} onCheckedChange={setMayReply} className="mt-0.5" />
<span>
You can reply to me at <span className="font-medium break-all">{context.me.email}</span>
</span>
</label>
{refusal ? (
<Callout id={`${id}-refusal`} variant="danger" role="alert" title="Not sent">
{refusal}
</Callout>
) : null}
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>Cancel</DialogClose>
<Button type="submit" aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" data-icon="inline-start" /> : null}
Send feedback
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
)
}/**
* What `modal-dialogs-14` reads, and the one check it runs: the report the
* feedback is about — monthly revenue by plan, from the active customers in
* `db.customers` — the member signed in, from `db.members`, and
* `checkFeedback`, which the section's server function calls before a note
* is taken.
*
* Every read happens inside a function and runs on the server. The island
* takes the context as props and calls the action it is handed; it never
* imports this module.
*/
import { asString, db, fields, type Customer, type Result } from "@/lib/sample-data"
export type { Result }
/** Who is signed in, and the report they are reading. */
export const SIGNED_IN = "Iris Brandt"
export const REPORT = "Revenue by plan"
/** The longest note the form takes, in characters. */
export const COMMENT_LIMIT = 500
/** How useful the report is, from 1 to 5: the word on the scale, and how a thank-you says it. */
export const RATINGS = [
{ word: "Not at all", said: "not useful" },
{ word: "A little", said: "a little useful" },
{ word: "Fairly", said: "fairly useful" },
{ word: "Very", said: "very useful" },
{ word: "Essential", said: "essential" },
] as const
/** What a note can be about. */
export const TOPICS = ["The numbers", "Speed", "Layout", "Exports", "Something missing"] as const
const PLAN_ORDER: Customer["plan"][] = ["enterprise", "team", "starter", "free"]
export type PlanRevenue = { plan: string; customers: number; mrrCents: number }
export type FeedbackContext = {
report: string
rows: PlanRevenue[]
totalCents: number
me: { name: string; email: string; avatarUrl?: string }
ratings: string[]
topics: string[]
limit: number
}
export type FeedbackInput = {
/** 1 to 5, or null when the reader chose none. */
rating: number | null
topics: string[]
comment: string
/** Whether the report's team may write back. */
mayReply: boolean
}
export type FeedbackReceipt = { report: string; rating: string | null; topics: string[]; replyTo: string | null }
function signedIn() {
const me = db.members.all().find((member) => member.name === SIGNED_IN)
if (!me) throw new Error(`modal-dialogs-14 is signed in as "${SIGNED_IN}", who is not a member`)
return me
}
/** The report — each plan's active customers and what they pay a month — and who is reading it. */
export function feedbackContext(): FeedbackContext {
const active = db.customers.all().filter((customer) => customer.status === "active")
const rows = PLAN_ORDER.map((plan) => {
const on = active.filter((customer) => customer.plan === plan)
return { plan: plan.charAt(0).toUpperCase() + plan.slice(1), customers: on.length, mrrCents: on.reduce((sum, customer) => sum + customer.mrrCents, 0) }
})
const me = signedIn()
return {
report: REPORT,
rows,
totalCents: rows.reduce((sum, row) => sum + row.mrrCents, 0),
me: { name: me.name, email: me.email, avatarUrl: me.avatarUrl },
ratings: RATINGS.map((rating) => rating.word),
topics: [...TOPICS],
limit: COMMENT_LIMIT,
}
}
/**
* A note is taken when it says something: a rating, a topic or a few words,
* and no more words than the form allows. A topic the form does not offer is
* dropped; a reply goes to the signed-in member's address, and only when they
* asked for one.
*/
export function checkFeedback(sent: FeedbackInput): Result<FeedbackReceipt> {
const input = fields(sent)
const comment = asString(input.comment).trim()
if (comment.length > COMMENT_LIMIT) {
return {
ok: false,
error: { code: "too-long", message: `A note stops at ${COMMENT_LIMIT} characters, and this one has ${comment.length}. Shorten it and send again.`, field: "comment" },
}
}
const rating = input.rating ?? null
if (rating !== null && (!Number.isInteger(rating) || rating < 1 || rating > RATINGS.length)) {
return { ok: false, error: { code: "rating", message: "Choose how useful the report is from the five on offer.", field: "rating" } }
}
const offered: unknown[] = Array.isArray(input.topics) ? input.topics : []
const topics = [...new Set(offered.filter((topic): topic is (typeof TOPICS)[number] => (TOPICS as readonly unknown[]).includes(topic)))]
if (rating === null && topics.length === 0 && !comment) {
return { ok: false, error: { code: "empty", message: "There is nothing to send yet. Choose how useful the report is, or say in a few words what would make it better.", field: "rating" } }
}
return {
ok: true,
data: {
report: REPORT,
rating: rating === null ? null : RATINGS[rating - 1].said,
topics,
replyTo: input.mayReply === true ? signedIn().email : null,
},
}
}npx shadcn@latest add @vibra/modal-dialogs-14Filters on mobile
modal-dialogs-15An orders list with a Filters dialog that fills a phone's screen: status, when, the total and the country, each choice counting the orders it would leave, and a Show button that counts them all.
import * as React from "react"
import { FilteredOrders } from "./modal-dialogs-15.client"
import { orderBook } from "./modal-dialogs-15.data"
export type ModalDialogs15Props = {
/** Opens the filters on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The orders list with a Filters dialog that fills a phone's screen: status,
* when, the total and the country, each with the count it would leave, and a
* Show button that counts them all.
*
* A server component: the orders come from `./modal-dialogs-15.data`.
*/
export function ModalDialogs15({ defaultOpen = false }: ModalDialogs15Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-15"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<FilteredOrders book={orderBook()} titleId={titleId} defaultOpen={defaultOpen} />
</div>
</section>
)
}
export default ModalDialogs15"use client"
import * as React from "react"
import { SlidersHorizontalIcon } from "lucide-react"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { FilterChip } from "@/components/ui/filter-chip"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { StatusBadge } from "@/components/ui/status-badge"
import type { OrderBook, OrderRow, OrderStatus } from "./modal-dialogs-15.data"
type Placed = "any" | "7" | "30" | "90"
export type Filters = { statuses: OrderStatus[]; placed: Placed; min: string; max: string; country: string }
export const NO_FILTERS: Filters = { statuses: [], placed: "any", min: "", max: "", country: "" }
/** What a preview opens on, so the dialog has choices to show. */
const SAMPLE: Filters = { ...NO_FILTERS, statuses: ["pending", "paid"], placed: "30" }
const PLACED: { value: Placed; label: string }[] = [
{ value: "any", label: "Any time" },
{ value: "7", label: "Last 7 days" },
{ value: "30", label: "Last 30 days" },
{ value: "90", label: "Last 90 days" },
]
const FILTER_NAMES = { statuses: "the status", placed: "the date", total: "the total", country: "the country" } as const
const STATUS_TONES = { fulfilled: "success", refunded: "warning", paid: "info", pending: "warning", cancelled: "danger" } as const
const DAY_MS = 86_400_000
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const whole = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
const orders = (count: number) => `${formatNumber(count)} ${count === 1 ? "order" : "orders"}`
const SHOWN = 8
/** A dollar amount as typed: null when the field is empty, NaN when it is not a number. */
const dollars = (text: string) => (text.trim() === "" ? null : Number(text))
/** Whether an order passes the filters — all of them, or all but one, to count what that one would let through. */
export function passes(row: OrderRow, filters: Filters, now: number, skip?: keyof typeof FILTER_NAMES): boolean {
if (skip !== "statuses" && filters.statuses.length > 0 && !filters.statuses.includes(row.status)) return false
if (skip !== "placed" && filters.placed !== "any" && row.placed < now - Number(filters.placed) * DAY_MS) return false
if (skip !== "total") {
const min = dollars(filters.min)
const max = dollars(filters.max)
if (min !== null && !(row.totalCents >= min * 100)) return false
if (max !== null && !(row.totalCents <= max * 100)) return false
}
if (skip !== "country" && filters.country && row.country !== filters.country) return false
return true
}
/** Which filters are on, by the name a chip and a refusal give them. */
function onFilters(filters: Filters): (keyof typeof FILTER_NAMES)[] {
return [
...(filters.statuses.length > 0 ? (["statuses"] as const) : []),
...(filters.placed !== "any" ? (["placed"] as const) : []),
...(filters.min.trim() || filters.max.trim() ? (["total"] as const) : []),
...(filters.country ? (["country"] as const) : []),
]
}
function list(words: string[]) {
return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} or ${words[words.length - 1]}`
}
/** A filter in words, for its chip and the status line. */
function describe(key: keyof typeof FILTER_NAMES, filters: Filters, labels: Map<OrderStatus, string>): string {
if (key === "statuses") return list(filters.statuses.map((status, index) => (index === 0 ? labels.get(status)! : labels.get(status)!.toLowerCase())))
if (key === "placed") return PLACED.find((option) => option.value === filters.placed)!.label
if (key === "country") return filters.country
const min = dollars(filters.min)
const max = dollars(filters.max)
if (min !== null && max !== null) return `${whole(min * 100)} to ${whole(max * 100)}`
return min !== null ? `${whole(min * 100)} or more` : `Up to ${whole((max ?? 0) * 100)}`
}
/**
* The orders list and its Filters dialog, which fills the screen on a phone
* and sits in the middle on a wider one. Every choice carries the count it
* would leave, given the others, and the Show button counts the orders the
* whole set leaves. Show refuses a total that is not a number or runs
* backwards under its field, and a set that leaves nothing in a danger
* callout naming the filter to drop. Applied filters become chips that clear
* on their own; Escape or Close drops what was not shown. The list's count
* line is the status line.
*/
export function FilteredOrders({ book, titleId, defaultOpen }: { book: OrderBook; titleId: string; defaultOpen: boolean }) {
const [open, setOpen] = React.useState(defaultOpen)
const [applied, setApplied] = React.useState<Filters>(NO_FILTERS)
const [draft, setDraft] = React.useState<Filters>(defaultOpen ? SAMPLE : NO_FILTERS)
const [errors, setErrors] = React.useState<{ min?: string; max?: string }>({})
const [refusal, setRefusal] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const body = React.useRef<HTMLDivElement>(null)
const chips = React.useRef<HTMLDivElement>(null)
const minField = React.useRef<HTMLInputElement>(null)
const maxField = React.useRef<HTMLInputElement>(null)
const focusChip = React.useRef<number | null>(null)
const id = React.useId()
const now = book.now.getTime()
const labels = new Map(book.statuses.map((status) => [status.value, status.label]))
const matching = book.rows.filter((row) => passes(row, applied, now))
const live = book.rows.filter((row) => passes(row, draft, now)).length
const on = onFilters(applied)
const totals = book.rows.map((row) => row.totalCents)
const count = (skip: keyof typeof FILTER_NAMES, test: (row: OrderRow) => boolean) => book.rows.filter((row) => test(row) && passes(row, draft, now, skip)).length
// A chip that clears hands focus to the chip that took its place, or to Filters.
React.useEffect(() => {
if (focusChip.current === null) return
const removes = chips.current?.querySelectorAll<HTMLButtonElement>("[data-slot=filter-chip-remove]") ?? []
const next = removes[Math.min(focusChip.current, removes.length - 1)]
focusChip.current = null
;(next ?? trigger.current)?.focus()
}, [applied])
function change(next: boolean) {
setOpen(next)
setErrors({})
setRefusal("")
if (next) setDraft(applied)
}
function edit(patch: Partial<Filters>) {
setDraft((current) => ({ ...current, ...patch }))
setRefusal("")
if ("min" in patch || "max" in patch) setErrors({})
}
function show(event: React.FormEvent) {
event.preventDefault()
const min = dollars(draft.min)
const max = dollars(draft.max)
const found: { min?: string; max?: string } = {}
if (min !== null && !(min >= 0)) found.min = "Write the lowest total in dollars, like 50."
if (max !== null && !(max >= 0)) found.max = "Write the highest total in dollars, like 500."
else if (min !== null && max !== null && min > max) found.max = `The highest total is below the lowest, ${whole(min * 100)}. Raise it, or lower the other.`
if (found.min || found.max) {
setErrors(found)
;(found.min ? minField : maxField).current?.focus()
return
}
if (live === 0) {
const ways = onFilters(draft)
.map((key) => ({ key, left: book.rows.filter((row) => passes(row, draft, now, key)).length }))
.sort((a, b) => b.left - a.left)
const best = ways[0]
setRefusal(
`No orders match all of these filters.${best && best.left > 0 ? ` Without ${FILTER_NAMES[best.key]}, ${orders(best.left)} would.` : ""}`
)
return
}
setApplied(draft)
setOpen(false)
}
function clear(key: keyof typeof FILTER_NAMES, index: number) {
focusChip.current = index
setApplied((current) => ({
...current,
...(key === "statuses" ? { statuses: [] } : key === "placed" ? { placed: "any" as const } : key === "total" ? { min: "", max: "" } : { country: "" }),
}))
}
const summary = on.map((key) => {
const words = describe(key, applied, labels)
if (key === "placed") return `placed in the ${words.toLowerCase()}`
if (key === "total") return `totalling ${words.replace("Up to", "up to")}`
if (key === "country") return `shipping to ${words}`
return words.toLowerCase()
})
return (
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Orders</h2>
</CardTitle>
<CardDescription role="status" aria-live="polite" className="tabular-nums">
Showing {formatNumber(matching.length)} of {orders(book.rows.length)}
{summary.length ? `: ${summary.join(", ")}` : ", newest first"}.
</CardDescription>
<CardAction>
<Dialog open={open} onOpenChange={change}>
<DialogTrigger ref={trigger} render={<Button size="sm" variant="outline" aria-label={on.length ? `Filters, ${on.length} on` : "Filters"} />}>
<SlidersHorizontalIcon data-icon="inline-start" aria-hidden="true" />
Filters
{on.length ? (
<Badge className="ms-0.5 h-4.5 min-w-4.5 px-1 tabular-nums">
{on.length}
</Badge>
) : null}
</DialogTrigger>
<DialogContent
initialFocus={() => body.current?.querySelector<HTMLElement>("[role=checkbox]") ?? true}
finalFocus={trigger}
className="flex flex-col gap-0 p-0 sm:max-h-[calc(100svh-4rem)] sm:max-w-md max-sm:top-0 max-sm:left-0 max-sm:h-dvh max-sm:max-w-none max-sm:translate-x-0 max-sm:translate-y-0 max-sm:rounded-none max-sm:ring-0"
>
<form onSubmit={show} noValidate className="flex min-h-0 flex-1 flex-col">
<DialogHeader className="border-b border-rule p-4 pe-12">
<DialogTitle>Filter orders</DialogTitle>
<DialogDescription>Each choice shows how many orders it leaves. The list changes when you press Show.</DialogDescription>
</DialogHeader>
<div ref={body} className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto p-4">
<fieldset className="flex flex-col gap-1">
<legend className="mb-2 text-sm font-medium">Status</legend>
{book.statuses.map((status) => {
const leaves = count("statuses", (row) => row.status === status.value)
return (
<label key={status.value} className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50 has-[[data-checked]]:bg-brand-muted">
<Checkbox
aria-label={`${status.label}, ${orders(leaves)}`}
checked={draft.statuses.includes(status.value)}
onCheckedChange={(checked) =>
edit({
statuses: checked
? book.statuses.map((entry) => entry.value).filter((value) => value === status.value || draft.statuses.includes(value))
: draft.statuses.filter((value) => value !== status.value),
})
}
/>
{/* The box's name says both; these are for the eye. */}
<span aria-hidden="true" className="text-sm">
{status.label}
</span>
<span aria-hidden="true" className="ms-auto text-sm text-muted-foreground tabular-nums">
{formatNumber(leaves)}
</span>
</label>
)
})}
</fieldset>
<div className="flex flex-col gap-1">
<span id={`${id}-placed`} className="mb-2 text-sm font-medium">
Placed
</span>
<RadioGroup aria-labelledby={`${id}-placed`} value={draft.placed} onValueChange={(value) => edit({ placed: value as Placed })} className="gap-1">
{PLACED.map((option) => {
const leaves = count("placed", (row) => option.value === "any" || row.placed >= now - Number(option.value) * DAY_MS)
return (
<label key={option.value} className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50 has-[[data-checked]]:bg-brand-muted">
<RadioGroupItem value={option.value} aria-label={`${option.label}, ${orders(leaves)}`} />
<span aria-hidden="true" className="text-sm">
{option.label}
</span>
<span aria-hidden="true" className="ms-auto text-sm text-muted-foreground tabular-nums">
{formatNumber(leaves)}
</span>
</label>
)
})}
</RadioGroup>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-sm font-medium">
Total <span className="font-normal text-muted-foreground tabular-nums">· orders run from {whole(Math.min(...totals))} to {whole(Math.max(...totals))}</span>
</legend>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label htmlFor={`${id}-min`} className="text-xs text-muted-foreground">
At least
</label>
<InputGroup>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
ref={minField}
id={`${id}-min`}
inputMode="decimal"
value={draft.min}
onChange={(event) => edit({ min: event.target.value })}
placeholder="0"
aria-invalid={errors.min ? true : undefined}
aria-describedby={errors.min ? `${id}-min-error` : undefined}
className="tabular-nums"
/>
</InputGroup>
<FieldError id={`${id}-min-error`}>{errors.min}</FieldError>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={`${id}-max`} className="text-xs text-muted-foreground">
At most
</label>
<InputGroup>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
ref={maxField}
id={`${id}-max`}
inputMode="decimal"
value={draft.max}
onChange={(event) => edit({ max: event.target.value })}
placeholder="Any"
aria-invalid={errors.max ? true : undefined}
aria-describedby={errors.max ? `${id}-max-error` : undefined}
className="tabular-nums"
/>
</InputGroup>
<FieldError id={`${id}-max-error`}>{errors.max}</FieldError>
</div>
</div>
</fieldset>
<div className="flex flex-col gap-2">
<label htmlFor={`${id}-country`} className="text-sm font-medium">
Ships to
</label>
<NativeSelect id={`${id}-country`} value={draft.country} onChange={(event) => edit({ country: event.target.value })} className="w-full">
<NativeSelectOption value="">Any country ({formatNumber(count("country", () => true))})</NativeSelectOption>
{book.countries.map((country) => (
<NativeSelectOption key={country} value={country}>
{country} ({formatNumber(count("country", (row) => row.country === country))})
</NativeSelectOption>
))}
</NativeSelect>
</div>
</div>
{/* Under the choices, not among them: it stays in view however far they scroll. */}
{refusal ? (
<div className="px-4 pb-4">
<Callout variant="danger" role="alert" title="Nothing to show">
{refusal}
</Callout>
</div>
) : null}
<DialogFooter className="mx-0 mb-0 flex-row justify-between rounded-none sm:justify-between sm:rounded-b-xl">
<Button type="button" variant="ghost" onClick={() => edit(NO_FILTERS)} disabled={onFilters(draft).length === 0} focusableWhenDisabled>
Clear all
</Button>
<Button type="submit" className="max-sm:flex-1">
Show {orders(live)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{on.length ? (
<div ref={chips} role="group" aria-label="Filters on" className="flex flex-wrap items-center gap-2">
{on.map((key, index) => (
<FilterChip
key={key}
label={key === "statuses" ? "Status" : key === "placed" ? "Placed" : key === "total" ? "Total" : "Country"}
value={describe(key, applied, labels)}
onRemove={() => clear(key, index)}
/>
))}
</div>
) : null}
<ul aria-label="Matching orders" className="divide-y divide-border">
{matching.slice(0, SHOWN).map((row) => (
<li key={row.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 py-2 first:pt-0 last:pb-0 sm:grid-cols-[7rem_minmax(0,1fr)_5rem_6rem_6rem]">
<span className="font-mono text-xs text-muted-foreground">{row.number}</span>
<span className="text-right text-sm font-medium tabular-nums sm:order-4">{formatCurrency(row.totalCents / 100)}</span>
<span className="truncate text-sm sm:order-1">{row.company}</span>
<span className="text-right sm:order-5">
<StatusBadge size="sm" status={row.status} variant={STATUS_TONES[row.status]} label={labels.get(row.status)} />
</span>
<span className="hidden text-sm text-muted-foreground tabular-nums sm:order-2 sm:block">{DAY.format(row.placed)}</span>
</li>
))}
</ul>
{matching.length > SHOWN ? <p className="text-sm text-muted-foreground tabular-nums">And {orders(matching.length - SHOWN)} more.</p> : null}
</CardContent>
</Card>
)
}/**
* What `modal-dialogs-15` reads: every order in `db.orders`, slimmed to what
* the list and its filters need — the number, the customer's company from
* `db.customers`, when it was placed, the total, the status and the country.
* "Now" is `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the rows as props and filters them itself, so every count in
* the dialog moves as the reader chooses; it never imports this module.
*/
import { REFERENCE_DATE, db, type Order } from "@/lib/sample-data"
export type OrderStatus = Order["status"]
export type OrderRow = {
id: string
number: string
company: string
/** When it was placed, in ms. */
placed: number
totalCents: number
status: OrderStatus
country: string
}
export type OrderBook = {
now: Date
rows: OrderRow[]
statuses: { value: OrderStatus; label: string }[]
countries: string[]
}
const STATUSES: { value: OrderStatus; label: string }[] = [
{ value: "pending", label: "Pending" },
{ value: "paid", label: "Paid" },
{ value: "fulfilled", label: "Fulfilled" },
{ value: "refunded", label: "Refunded" },
{ value: "cancelled", label: "Cancelled" },
]
/** Every order, newest first, with the statuses and countries there are to filter by. */
export function orderBook(): OrderBook {
const companies = new Map(db.customers.all().map((customer) => [customer.id, customer.company]))
const rows = db.orders
.all()
.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
.map((order) => ({
id: order.id,
number: order.number,
company: companies.get(order.customerId) ?? "Walk-in",
placed: order.placedAt.getTime(),
totalCents: order.totalCents,
status: order.status,
country: order.country,
}))
return {
now: REFERENCE_DATE,
rows,
statuses: STATUSES,
countries: [...new Set(rows.map((row) => row.country))].sort((a, b) => a.localeCompare(b)),
}
}npx shadcn@latest add @vibra/modal-dialogs-15Payment failed
modal-dialogs-16The billing card and the alert a failed payment raises: which card failed and why, every invoice now overdue with the sum, and a card that works to pay them with — made the default so the next one does not fail.
import * as React from "react"
import { PaymentFailed } from "./modal-dialogs-16.client"
import { failedPayment } from "./modal-dialogs-16.data"
export type ModalDialogs16Props = {
/** Opens the alert on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The billing card and the alert a failed payment raises: which card failed
* and why, every invoice now overdue, and a card that works to pay them with.
*
* A server component: the account, its invoices and its cards come from
* `./modal-dialogs-16.data`.
*/
export function ModalDialogs16({ defaultOpen = false }: ModalDialogs16Props) {
const titleId = React.useId()
return (
<section
data-section="modal-dialogs-16"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<PaymentFailed billing={failedPayment()} titleId={titleId} defaultOpen={defaultOpen} />
</div>
</section>
)
}
export default ModalDialogs16"use client"
import * as React from "react"
import { CircleAlertIcon, CircleCheckIcon, CreditCardIcon } from "lucide-react"
import { formatCurrency, formatNumber } from "@/lib/format"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { StatusBadge } from "@/components/ui/status-badge"
import type { BillCard, BillInvoice, FailedPayment } from "./modal-dialogs-16.data"
const money = (cents: number) => formatCurrency(cents / 100)
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const expiry = (card: BillCard) => `${String(card.expMonth).padStart(2, "0")}/${String(card.expYear).slice(-2)}`
const STATUS_TONES = { overdue: "danger", paid: "success", open: "info", draft: "neutral", void: "neutral" } as const
const STATUS_LABELS = { overdue: "Overdue", paid: "Paid", open: "Open", draft: "Draft", void: "Void" } as const
const invoicesWord = (count: number) => `${formatNumber(count)} ${count === 1 ? "invoice" : "invoices"}`
const both = (words: string[]) => (words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`)
/**
* The billing card and the alert a failed payment raises. The alert says
* which card failed and why, lists every invoice now overdue with the sum,
* and offers the account's cards — opening on the first one still good, with
* a box to make it the default so the next invoice does not fail the same
* way. The card that failed is refused in a danger callout. Paying closes the
* alert and the card's status line says what was paid with which card;
* Remind me later, and Escape, say when it will ask again.
*/
export function PaymentFailed({ billing, titleId, defaultOpen }: { billing: FailedPayment; titleId: string; defaultOpen: boolean }) {
const failed = billing.cards.find((card) => card.isDefault) ?? billing.cards[0]
const usable = billing.cards.find((card) => !card.expired) ?? failed
const [open, setOpen] = React.useState(defaultOpen && billing.overdue.length > 0)
const [cardId, setCardId] = React.useState(usable?.id ?? "")
const [makeDefault, setMakeDefault] = React.useState(true)
const [defaultId, setDefaultId] = React.useState(failed?.id)
const [paid, setPaid] = React.useState<string[]>([])
const [refusal, setRefusal] = React.useState("")
const [notice, setNotice] = React.useState("")
const trigger = React.useRef<HTMLButtonElement>(null)
const cards = React.useRef<HTMLDivElement>(null)
const id = React.useId()
const owed = billing.overdue.filter((invoice) => !paid.includes(invoice.id))
const total = owed.reduce((sum, invoice) => sum + invoice.amountCents, 0)
const lastMissed: BillInvoice | undefined = owed[owed.length - 1]
const card = billing.cards.find((entry) => entry.id === cardId)
const tomorrow = new Date(billing.now.getTime() + 86_400_000)
function close(next: boolean) {
setOpen(next)
setRefusal("")
// Closing without paying — Remind me later, or Escape — puts it off a day.
if (!next && owed.length > 0) {
setNotice(`Reminder set for ${DAY.format(tomorrow)}. ${invoicesWord(owed.length)}, ${money(total)}, stay overdue until paid.`)
}
}
function pay() {
if (!card) return setRefusal("Choose a card to pay with.")
if (card.expired) {
return setRefusal(`The card ending ${card.last4} ran out in ${expiry(card)}, which is why the payment failed. Choose a card that has not.`)
}
// Wire real billing here: pay `owed` with `card` in your billing action and settle the
// invoices on its answer — this demo keeps the payment in the band's state.
const madeDefault = makeDefault && card.id !== defaultId
if (madeDefault) setDefaultId(card.id)
setPaid((current) => [...current, ...owed.map((invoice) => invoice.id)])
setNotice(
`Paid ${money(total)} for ${both(owed.map((invoice) => invoice.number))} with the card ending ${card.last4}${madeDefault ? ", now the default card" : ""}. The receipt went to ${billing.email}.`
)
setRefusal("")
setOpen(false)
}
const settled = owed.length === 0
return (
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Billing</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{billing.account} is on {billing.plan}, {money(billing.monthlyCents)} a month.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div
data-settled={settled || undefined}
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg bg-danger-muted p-3 ring-1 ring-danger/30 data-settled:bg-success-muted data-settled:ring-success/30"
>
{settled ? (
<CircleCheckIcon aria-hidden="true" className="size-4 shrink-0 text-success" />
) : (
<CircleAlertIcon aria-hidden="true" className="size-4 shrink-0 text-danger" />
)}
<p className="min-w-0 flex-1 text-sm tabular-nums">
{settled ? (
<span className="font-medium">Paid up. Nothing is overdue.</span>
) : (
<>
<span className="font-medium text-danger">Your last payment failed.</span> {invoicesWord(owed.length)} {owed.length === 1 ? "is" : "are"} overdue,{" "}
{money(total)} in all.
</>
)}
</p>
<AlertDialog open={open} onOpenChange={close}>
<AlertDialogTrigger ref={trigger} disabled={settled} render={<Button size="sm" variant={settled ? "outline" : "destructive"} focusableWhenDisabled />}>
{settled ? "Paid" : "Review and pay"}
</AlertDialogTrigger>
<AlertDialogContent
initialFocus={() => cards.current?.querySelector<HTMLElement>("[role=radio][aria-checked=true]") ?? true}
finalFocus={trigger}
className="max-h-[calc(100svh-2rem)] overflow-y-auto data-[size=default]:max-w-[calc(100%-2rem)] data-[size=default]:sm:max-w-md"
>
<AlertDialogHeader>
<AlertDialogMedia className="bg-danger-muted text-danger">
<CreditCardIcon aria-hidden="true" />
</AlertDialogMedia>
<AlertDialogTitle>
{failed ? `We could not charge the card ending ${failed.last4}` : "Your last payment failed"}
</AlertDialogTitle>
<AlertDialogDescription>
{failed?.expired ? `It ran out in ${expiry(failed)}` : "The bank declined it"}
{lastMissed ? `, so ${lastMissed.number} went unpaid on ${DAY.format(lastMissed.dueAt)}` : ""}. Pay what is overdue with a card that works.
</AlertDialogDescription>
</AlertDialogHeader>
<dl className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 rounded-lg bg-surface p-3 text-sm tabular-nums ring-1 ring-border">
{owed.map((invoice) => (
<React.Fragment key={invoice.id}>
<dt>
<span className="font-mono text-xs">{invoice.number}</span>
<span className="text-muted-foreground"> · due {DAY.format(invoice.dueAt)}</span>
</dt>
<dd className="text-right">{money(invoice.amountCents)}</dd>
</React.Fragment>
))}
<div className="col-span-2 grid grid-cols-subgrid border-t border-rule pt-1 font-medium">
<dt>Overdue now</dt>
<dd className="text-right">{money(total)}</dd>
</div>
</dl>
<div className="flex flex-col gap-2">
<span id={`${id}-cards`} className="text-sm font-medium">
Pay with
</span>
<RadioGroup
ref={cards}
aria-labelledby={`${id}-cards`}
aria-describedby={refusal ? `${id}-refusal` : undefined}
value={cardId}
onValueChange={(value) => {
setCardId(String(value))
setRefusal("")
}}
className="gap-2"
>
{billing.cards.map((entry) => (
<FieldLabel key={entry.id} htmlFor={`${id}-${entry.id}`}>
<Field orientation="horizontal">
<RadioGroupItem id={`${id}-${entry.id}`} value={entry.id} aria-describedby={`${id}-${entry.id}-hint`} />
<FieldContent>
<FieldTitle>Card ending {entry.last4}</FieldTitle>
<FieldDescription id={`${id}-${entry.id}-hint`} className="tabular-nums">
{entry.expired ? `Ran out ${expiry(entry)}` : `Expires ${expiry(entry)}`}
{entry.id === defaultId ? " · default" : ""}
{entry.id === failed?.id ? " · the payment failed on it" : ""}
</FieldDescription>
</FieldContent>
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
{card && card.id !== defaultId && !card.expired ? (
<label className="flex items-start gap-2 text-sm">
<Checkbox checked={makeDefault} onCheckedChange={setMakeDefault} className="mt-0.5" />
Make it the default card, so the next invoice goes to it
</label>
) : null}
{refusal ? (
<Callout id={`${id}-refusal`} variant="danger" role="alert" title="Nothing was charged">
{refusal}
</Callout>
) : null}
<AlertDialogFooter>
<AlertDialogCancel>Remind me later</AlertDialogCancel>
<AlertDialogAction onClick={pay}>Pay {money(total)} now</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<ul aria-label="Latest invoices" className="divide-y divide-border">
{billing.latest.map((invoice) => {
const status = paid.includes(invoice.id) ? "paid" : invoice.status
return (
<li key={invoice.id} className="grid grid-cols-[minmax(0,1fr)_auto_5.5rem] items-center gap-x-4 py-2 text-sm first:pt-0 last:pb-0 sm:grid-cols-[8rem_minmax(0,1fr)_auto_5.5rem]">
<span className="font-mono text-xs">{invoice.number}</span>
<span className="hidden text-muted-foreground tabular-nums sm:block">Issued {DAY.format(invoice.issuedAt)}</span>
<span className="text-right tabular-nums">{money(invoice.amountCents)}</span>
<span className="text-right">
<StatusBadge size="sm" status={status} variant={STATUS_TONES[status]} label={STATUS_LABELS[status]} />
</span>
</li>
)
})}
</ul>
<p role="status" aria-live="polite" className="text-sm font-medium">
{notice}
</p>
</CardContent>
</Card>
)
}/**
* What `modal-dialogs-16` reads: one account whose payment failed — its plan
* and billing address in `db.customers`, its latest invoices and the ones
* overdue in `db.invoices`, and the cards it keeps in `db.paymentMethods`, by
* their last four digits and expiry only. "Now" is `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes the account as props; it never imports this module.
*/
import { REFERENCE_DATE, db, type Invoice } from "@/lib/sample-data"
/** The account whose last charge failed. */
export const ACCOUNT = "Fathom Energy"
export type BillInvoice = { id: string; number: string; amountCents: number; issuedAt: Date; dueAt: Date; status: Invoice["status"] }
export type BillCard = { id: string; last4: string; expMonth: number; expYear: number; expired: boolean; isDefault: boolean }
export type FailedPayment = {
account: string
plan: string
monthlyCents: number
/** Where receipts go. */
email: string
/** The five invoices issued last, newest first. */
latest: BillInvoice[]
/** Every invoice past its due date and unpaid, oldest first. */
overdue: BillInvoice[]
/** The default card first. */
cards: BillCard[]
now: Date
}
const invoiceOf = (invoice: Invoice): BillInvoice => ({
id: invoice.id,
number: invoice.number,
amountCents: invoice.amountCents,
issuedAt: invoice.issuedAt,
dueAt: invoice.dueAt,
status: invoice.status,
})
/** The account, what it owes and since when, and the cards it could pay with. */
export function failedPayment(): FailedPayment {
const customer = db.customers.all().find((entry) => entry.company === ACCOUNT)
if (!customer) throw new Error(`modal-dialogs-16 bills "${ACCOUNT}", which is not a customer`)
// A card is good to the end of the month on it.
const thisMonth = REFERENCE_DATE.getUTCFullYear() * 12 + REFERENCE_DATE.getUTCMonth() + 1
const invoices = db.invoices.all().filter((invoice) => invoice.customerId === customer.id)
return {
account: customer.company,
plan: customer.plan.charAt(0).toUpperCase() + customer.plan.slice(1),
monthlyCents: customer.mrrCents,
email: customer.email,
latest: [...invoices].sort((a, b) => b.issuedAt.getTime() - a.issuedAt.getTime()).slice(0, 5).map(invoiceOf),
overdue: invoices
.filter((invoice) => invoice.status === "overdue")
.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime())
.map(invoiceOf),
cards: db.paymentMethods
.all()
.filter((card) => card.customerId === customer.id)
.sort((a, b) => Number(b.default) - Number(a.default))
.map((card) => ({
id: card.id,
last4: card.last4,
expMonth: card.expMonth,
expYear: card.expYear,
expired: card.expYear * 12 + card.expMonth < thisMonth,
isDefault: card.default,
})),
now: REFERENCE_DATE,
}
}npx shadcn@latest add @vibra/modal-dialogs-16