Skip to content

Support request

Severity, area, reproduction steps and attachments — with the response time shown before submitting.

ContactintermediateFeaturedsupportbugseverityattachmentsla

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.

All forms