Support request
Severity, area, reproduction steps and attachments — with the response time shown before submitting.
Live preview
Source
This exact file renders the preview above.
'use client'
import { Alert } from '@/components/ui/alert'
import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { FileUpload } from '@/components/ui/file-upload'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, minLength, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Support request
*
* Structured for the person who will answer it: severity, affected area,
* reproduction steps and an attachment slot. Asking for steps up front is
* worth three round trips later.
*
* Severity selection changes the response-time notice, so the expectation is
* set before submission rather than in a reply.
*/
const responseTimes: Record<string, string> = {
low: 'Two working days',
normal: 'One working day',
high: 'Four hours during business hours',
critical: 'One hour, any time',
}
export default function SupportRequestForm() {
const form = useDemoForm({
schema: {
email: { validators: [required('Email'), email()] },
area: { initial: 'components', validators: [required('Area')] },
severity: { initial: 'normal' },
summary: { validators: [required('Summary'), minLength(10, 'Summary')] },
steps: { validators: [required('Steps'), minLength(20, 'Steps')] },
},
})
const severity = form.values.severity ?? 'normal'
return (
<FormShell
title="Open a support request"
description="The more precise the reproduction, the faster this gets fixed."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Open request"
submittingLabel="Opening request"
onReset={form.reset}
successTitle="Request opened"
width="lg"
>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => <Input {...field} type="email" autoComplete="email" {...form.field('email')} />}
</Field>
<Field name="area" label="Affected area" required error={form.error('area')}>
{(field) => (
<Select
{...field}
{...form.field('area')}
options={[
{ value: 'components', label: 'Components' },
{ value: 'sections', label: 'Sections' },
{ value: 'forms', label: 'Forms' },
{ value: 'theme', label: 'Theming and tokens' },
{ value: 'docs', label: 'Documentation' },
]}
/>
)}
</Field>
<Fieldset legend="Severity" name="severity" hint="Pick the lowest severity that is honest.">
{(['low', 'normal', 'high', 'critical'] as const).map((level) => (
<Radio
key={level}
id={`severity-${level}`}
name="severity"
value={level}
label={level.charAt(0).toUpperCase() + level.slice(1)}
checked={severity === level}
onChange={() => form.setValue('severity', level)}
/>
))}
</Fieldset>
<Alert tone={severity === 'critical' ? 'warning' : 'info'} title="Expected first response">
{responseTimes[severity]}
</Alert>
<Field
name="summary"
label="Summary"
required
error={form.error('summary')}
hint="One line, as specific as you can."
>
{(field) => (
<Input
{...field}
placeholder="Dialog does not return focus on Escape"
{...form.field('summary')}
/>
)}
</Field>
<Field
name="steps"
label="Steps to reproduce"
required
error={form.error('steps')}
hint="What you did, what happened, what you expected."
>
{(field) => <Textarea {...field} rows={6} {...form.field('steps')} />}
</Field>
<Field
name="attachments"
label="Attachments"
showOptional
hint="Screenshots or a short recording help."
>
{(field) => (
<FileUpload
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
multiple
hint="PNG, JPG or MP4, up to 5 MB"
maxSizeBytes={5 * 1024 * 1024}
/>
)}
</Field>
</FormShell>
)
}
components/blocks/forms/support-request.tsx
'use client'
import { Alert } from '@/components/ui/alert'
import { Field, Fieldset } from '@/components/ui/field'
import { Input, Textarea } from '@/components/ui/input'
import { Radio } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { FileUpload } from '@/components/ui/file-upload'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, minLength, required } from '@/lib/validation'
import { FormShell } from './_shell'
/**
* Support request
*
* Structured for the person who will answer it: severity, affected area,
* reproduction steps and an attachment slot. Asking for steps up front is
* worth three round trips later.
*
* Severity selection changes the response-time notice, so the expectation is
* set before submission rather than in a reply.
*/
const responseTimes: Record<string, string> = {
low: 'Two working days',
normal: 'One working day',
high: 'Four hours during business hours',
critical: 'One hour, any time',
}
export default function SupportRequestForm() {
const form = useDemoForm({
schema: {
email: { validators: [required('Email'), email()] },
area: { initial: 'components', validators: [required('Area')] },
severity: { initial: 'normal' },
summary: { validators: [required('Summary'), minLength(10, 'Summary')] },
steps: { validators: [required('Steps'), minLength(20, 'Steps')] },
},
})
const severity = form.values.severity ?? 'normal'
return (
<FormShell
title="Open a support request"
description="The more precise the reproduction, the faster this gets fixed."
formRef={form.formRef}
onSubmit={form.handleSubmit}
status={form.status}
serverError={form.serverError}
invalidCount={form.invalidCount}
submitted={form.submitted}
submitLabel="Open request"
submittingLabel="Opening request"
onReset={form.reset}
successTitle="Request opened"
width="lg"
>
<Field name="email" label="Email" required error={form.error('email')}>
{(field) => <Input {...field} type="email" autoComplete="email" {...form.field('email')} />}
</Field>
<Field name="area" label="Affected area" required error={form.error('area')}>
{(field) => (
<Select
{...field}
{...form.field('area')}
options={[
{ value: 'components', label: 'Components' },
{ value: 'sections', label: 'Sections' },
{ value: 'forms', label: 'Forms' },
{ value: 'theme', label: 'Theming and tokens' },
{ value: 'docs', label: 'Documentation' },
]}
/>
)}
</Field>
<Fieldset legend="Severity" name="severity" hint="Pick the lowest severity that is honest.">
{(['low', 'normal', 'high', 'critical'] as const).map((level) => (
<Radio
key={level}
id={`severity-${level}`}
name="severity"
value={level}
label={level.charAt(0).toUpperCase() + level.slice(1)}
checked={severity === level}
onChange={() => form.setValue('severity', level)}
/>
))}
</Fieldset>
<Alert tone={severity === 'critical' ? 'warning' : 'info'} title="Expected first response">
{responseTimes[severity]}
</Alert>
<Field
name="summary"
label="Summary"
required
error={form.error('summary')}
hint="One line, as specific as you can."
>
{(field) => (
<Input
{...field}
placeholder="Dialog does not return focus on Escape"
{...form.field('summary')}
/>
)}
</Field>
<Field
name="steps"
label="Steps to reproduce"
required
error={form.error('steps')}
hint="What you did, what happened, what you expected."
>
{(field) => <Textarea {...field} rows={6} {...form.field('steps')} />}
</Field>
<Field
name="attachments"
label="Attachments"
showOptional
hint="Screenshots or a short recording help."
>
{(field) => (
<FileUpload
id={field.id}
name={field.name}
aria-describedby={field['aria-describedby']}
multiple
hint="PNG, JPG or MP4, up to 5 MB"
maxSizeBytes={5 * 1024 * 1024}
/>
)}
</Field>
</FormShell>
)
}
components/ui/file-upload.tsx
'use client'
import { useRef, useState, type DragEvent } from 'react'
import { UploadCloud, File as FileIcon, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { Button } from './button'
/**
* FileUpload
*
* A drop zone that is genuinely operable from the keyboard: the visible
* surface is a `<button>` that opens the native picker, so Space and Enter
* work, and the file input itself stays in the DOM for form submission.
*
* Nothing is uploaded — this is a template demo. Selected files are listed
* locally and can be removed.
*/
export interface FileUploadProps {
name?: string
id?: string
accept?: string
multiple?: boolean
disabled?: boolean
/** Human-readable constraint, e.g. "PNG or PDF, up to 10 MB". */
hint?: string
maxSizeBytes?: number
className?: string
'aria-describedby'?: string
onFilesChange?: (files: File[]) => void
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function FileUpload({
name = 'files',
id = 'file-upload',
accept,
multiple = false,
disabled = false,
hint,
maxSizeBytes,
className,
onFilesChange,
...aria
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<File[]>([])
const [dragging, setDragging] = useState(false)
const [error, setError] = useState<string | null>(null)
const accept_ = (incoming: FileList | null) => {
if (!incoming) return
const next = Array.from(incoming)
const oversize = maxSizeBytes ? next.find((file) => file.size > maxSizeBytes) : undefined
if (oversize) {
setError(`${oversize.name} is larger than ${formatBytes(maxSizeBytes ?? 0)}.`)
return
}
setError(null)
const merged = multiple ? [...files, ...next] : next.slice(0, 1)
setFiles(merged)
onFilesChange?.(merged)
}
const onDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
setDragging(false)
if (disabled) return
accept_(event.dataTransfer.files)
}
const remove = (index: number) => {
const next = files.filter((_, i) => i !== index)
setFiles(next)
onFilesChange?.(next)
if (inputRef.current) inputRef.current.value = ''
}
return (
<div className={cn('flex flex-col gap-3', className)}>
<div
onDragOver={(event) => {
event.preventDefault()
if (!disabled) setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={cn(
'rounded-md border border-dashed transition-colors duration-150 ease-standard',
dragging ? 'border-accent bg-accent-soft' : 'border-line-strong bg-surface-sunken',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
aria-describedby={aria['aria-describedby']}
className="flex w-full flex-col items-center gap-2 px-4 py-8 text-center disabled:cursor-not-allowed"
>
<UploadCloud className="size-6 text-ink-subtle" aria-hidden="true" />
<span className="text-sm font-medium text-ink">
Drop {multiple ? 'files' : 'a file'} here, or{' '}
<span className="text-accent underline underline-offset-4">browse</span>
</span>
{hint ? <span className="text-xs text-ink-muted">{hint}</span> : null}
</button>
<input
ref={inputRef}
id={id}
name={name}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
className="sr-only"
onChange={(event) => accept_(event.target.files)}
/>
</div>
{error ? (
<p role="alert" className="text-xs font-medium text-danger">
{error}
</p>
) : null}
{files.length > 0 ? (
<ul className="flex flex-col gap-1.5" aria-label="Selected files">
{files.map((file, index) => (
<li
key={`${file.name}-${index}`}
className="flex items-center gap-2.5 rounded-md border border-line bg-surface px-3 py-2 text-sm"
>
<FileIcon className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-ink">{file.name}</span>
<span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
{formatBytes(file.size)}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={() => remove(index)}
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" aria-hidden="true" />
</Button>
</li>
))}
</ul>
) : null}
</div>
)
}
components/ui/alert.tsx
import type { ReactNode } from 'react'
import { Info, CheckCircle2, AlertTriangle, OctagonAlert } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Alert
*
* A block-level message attached to a region of the page. The icon is chosen
* from the tone, and `role` switches to `alert` for danger so screen readers
* interrupt — informational tones use the polite `status` role instead.
*/
export type AlertTone = 'info' | 'success' | 'warning' | 'danger' | 'neutral'
export interface AlertProps {
tone?: AlertTone
title?: ReactNode
children?: ReactNode
/** Rendered under the body — usually one or two buttons. */
actions?: ReactNode
/** Slot for a dismiss control supplied by the caller. */
trailing?: ReactNode
className?: string
/** Force the live-region politeness rather than deriving it from tone. */
live?: 'off' | 'polite' | 'assertive'
}
const tones = {
info: { icon: Info, surface: 'bg-info-soft border-info-line', accent: 'text-info' },
success: {
icon: CheckCircle2,
surface: 'bg-success-soft border-success-line',
accent: 'text-success',
},
warning: {
icon: AlertTriangle,
surface: 'bg-warning-soft border-warning-line',
accent: 'text-warning',
},
danger: {
icon: OctagonAlert,
surface: 'bg-danger-soft border-danger-line',
accent: 'text-danger',
},
neutral: { icon: Info, surface: 'bg-surface-sunken border-line', accent: 'text-ink-muted' },
} as const
export function Alert({
tone = 'info',
title,
children,
actions,
trailing,
className,
live,
}: AlertProps) {
const entry = tones[tone]
const Icon = entry.icon
const politeness = live ?? (tone === 'danger' ? 'assertive' : 'polite')
return (
<div
role={tone === 'danger' ? 'alert' : 'status'}
aria-live={politeness === 'off' ? undefined : politeness}
className={cn('flex gap-3 rounded-md border p-3 text-sm', entry.surface, className)}
>
<Icon className={cn('mt-0.5 size-4 shrink-0', entry.accent)} aria-hidden="true" />
<div className="min-w-0 flex-1">
{title ? <p className="font-semibold text-ink-strong">{title}</p> : null}
{children ? <div className={cn('text-ink-muted', title && 'mt-1')}>{children}</div> : null}
{actions ? <div className="mt-3 flex flex-wrap gap-2">{actions}</div> : null}
</div>
{trailing ? <div className="shrink-0">{trailing}</div> : null}
</div>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Structured for the person who will answer it. Asking for reproduction steps up front is worth three round trips later, and showing the response time before submission sets the expectation where it belongs.
- The response-time alert changes tone at critical severity, so the consequence of the choice is visible.
- Attachments are optional but prompted — a screenshot resolves more tickets than a paragraph.
- Ask for “what you did, what happened, what you expected” rather than “describe the issue”.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Severity radio group
- Dynamic response-time notice
- Attachments
- Success
Accessibility
- Dynamic notice
- The response-time Alert is a polite live region, so changing severity is announced.
- Upload keyboard path
- The drop zone is a button; drag-and-drop is an enhancement.
- Grouped severity
- Severity is a Fieldset, not four floating radios.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All formsSimple contact
Three fields with a live character budget and both minimum and maximum length rules.
starterFeatured4 variantsFile upload
A drop zone that is fully operable from the keyboard, with local file listing, size validation and removal.
intermediate5 variantsDetailed contact
Routing metadata alongside the message, so an enquiry reaches the right team without triage.
starter4 variants