Forms

Conform Storage Picker

A multi-select chip group of device storage sizes wired to Conform. Selected sizes are tracked in a react-stately list and mirrored into a hidden input for submission, with validity derived from the field metadata.

formconformstoragemulti-selectchipsvalidation

Bound to a Conform form

Pre-seeded with 128GB. Toggle chips to add or remove sizes; selected chips use the quebi brand accent.

Select every storage size this device ships in.

Required validation

Starts empty — submit without picking a size to see the field-derived error.

Source

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import type { FieldMetadata } from "@conform-to/react"
import { useEffect, useState } from "react"
import type { ListData } from "react-stately"
import { cn } from "@/lib/utils"
import { FieldError, Label } from "@/components/field"

/**
 * Device storage helpers (inlined to keep this component self-contained).
 *
 * Storage is tracked internally in gigabytes. `DEVICE_STORAGE_OPTIONS` is the
 * canonical set of sizes offered; `normalizeStorageValue` parses a free-form
 * label (e.g. "1TB", "512 gb") back into a GB number; `formatStorageDisplay`
 * renders a GB number back into a human label (e.g. 1024 → "1TB").
 */
export const DEVICE_STORAGE_OPTIONS = [32, 64, 128, 256, 512, 1024, 2048] as const

/** Render a GB amount as a human label, collapsing whole TB values. */
export function formatStorageDisplay(storageGb: number): string {
  if (storageGb >= 1024 && storageGb % 1024 === 0) {
    return `${storageGb / 1024}TB`
  }
  return `${storageGb}GB`
}

/** Parse a free-form storage label into a GB number. */
export function normalizeStorageValue(value: string | number): number {
  if (typeof value === "number") return value

  const match = value.trim().match(/([\d.]+)\s*(tb|gb)?/i)
  if (!match) return 0

  const amount = Number.parseFloat(match[1])
  if (Number.isNaN(amount)) return 0

  const unit = (match[2] ?? "gb").toLowerCase()
  return unit === "tb" ? Math.round(amount * 1024) : Math.round(amount)
}

interface ConformStoragePickerProps {
  // Loose value type param: the field carries a comma-joined string of storage
  // labels; only name/default/required/errors/valid are read here.
  // biome-ignore lint/suspicious/noExplicitAny: form-schema type params vary per call site
  field: FieldMetadata<any, any, string[]>
  label?: string
  list: ListData<{ id: number; name: string }>
  description?: string
  className?: string
}

/**
 * ConformStoragePicker — a multi-select chip group of device storage sizes,
 * wired to Conform.
 *
 * Selected sizes are kept in a react-stately list (so they can be surfaced as
 * removable tags elsewhere) and mirrored into a hidden input as a comma-joined
 * string for form submission. Validity is derived from the Conform field.
 */
export function ConformStoragePicker({
  field,
  label,
  list,
  description,
  className,
}: ConformStoragePickerProps) {
  // Normalize existing storage values on mount (convert to "128GB" / "1TB" form).
  useEffect(() => {
    for (const item of list.items) {
      const normalized = normalizeStorageValue(item.name)
      const formatted = formatStorageDisplay(normalized)
      if (formatted !== item.name) {
        list.update(item.id, { ...item, name: formatted })
      }
    }
  }, [list])

  const [selectedStorage, setSelectedStorage] = useState<Set<number>>(
    new Set(list.items.map((item) => normalizeStorageValue(item.name))),
  )

  const hasErrors = !field.valid && !!field.errors

  const handleStorageToggle = (storageGb: number) => {
    const newSelection = new Set(selectedStorage)

    if (newSelection.has(storageGb)) {
      // Remove storage
      newSelection.delete(storageGb)
      const formatted = formatStorageDisplay(storageGb)
      const item = list.items.find((i) => i.name === formatted)
      if (item) {
        list.remove(item.id)
      }
    } else {
      // Add storage
      newSelection.add(storageGb)
      const maxId = list.items.length > 0 ? Math.max(...list.items.map((i) => i.id)) : 0
      const formatted = formatStorageDisplay(storageGb)
      list.append({ id: maxId + 1, name: formatted })
    }

    setSelectedStorage(newSelection)
  }

  // Sync state when the list changes externally (e.g. a tag is removed elsewhere).
  const currentStorageValues = new Set(list.items.map((item) => normalizeStorageValue(item.name)))
  if (
    selectedStorage.size !== currentStorageValues.size ||
    !Array.from(selectedStorage).every((s) => currentStorageValues.has(s))
  ) {
    setSelectedStorage(currentStorageValues)
  }

  return (
    <div className={cn("space-y-2", className)}>
      {label && <Label>{label}</Label>}

      {description && <p className="text-[12px] text-quebi-fg-muted">{description}</p>}

      <div className="flex flex-wrap gap-2">
        {DEVICE_STORAGE_OPTIONS.map((storageGb) => {
          const isSelected = selectedStorage.has(storageGb)
          return (
            <button
              key={storageGb}
              type="button"
              onClick={() => handleStorageToggle(storageGb)}
              className={cn(
                "rounded-quebi-sm px-3 py-1.5 font-medium text-sm transition-all duration-150",
                "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-quebi-brand/50 focus-visible:ring-offset-2 focus-visible:ring-offset-quebi-bg",
                isSelected
                  ? "bg-quebi-brand text-quebi-bg shadow-quebi-glow hover:bg-quebi-brand-hover"
                  : "border border-cyan-500/20 bg-transparent text-quebi-fg-muted hover:-translate-y-0.5 hover:text-white",
              )}
            >
              {formatStorageDisplay(storageGb)}
            </button>
          )
        })}
      </div>

      {/* Hidden input to sync selection with the form. */}
      <input
        type="hidden"
        name={field.name}
        value={list.items.map((item) => item.name).join(",")}
      />

      <FieldError>{hasErrors && field.errors?.join(", ")}</FieldError>
    </div>
  )
}