{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "material-selector",
  "title": "Material Selector",
  "description": "Construction-material pickers: a compact search combobox, a card-driven class → term drill-down wizard, and a status-gated field with a grouped picker modal that cascades EWC, unit and hazard suggestions.",
  "dependencies": [
    "@untitledui/icons@^0.0.22"
  ],
  "registryDependencies": [
    "@nx-ui/badge",
    "@nx-ui/button",
    "@nx-ui/combobox",
    "@nx-ui/data-material-terms",
    "@nx-ui/dialog",
    "@nx-ui/input-group",
    "@nx-ui/label",
    "@nx-ui/native-select",
    "@nx-ui/tooltip",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/domain/material-selector/index.ts",
      "content": "export {\n  MaterialTypeSelector,\n  type MaterialTypeSelectorProps,\n} from \"./material-type-selector\"\nexport {\n  MaterialClassAndTermSelectWizard,\n  type MaterialClassAndTermSelectWizardProps,\n} from \"./material-class-term-wizard\"\nexport {\n  MaterialVolumeInput,\n  type MaterialVolumeInputProps,\n  type VolumeUnit,\n} from \"./material-volume-input\"\nexport { MaterialField, type MaterialFieldProps } from \"./material-field\"\nexport {\n  MaterialPickerModal,\n  type MaterialPickerModalProps,\n} from \"./material-picker-modal\"\nexport {\n  buildMaterialPickerGroups,\n  findMaterialOption,\n  toPickPayload,\n  type MaterialPickerGroup,\n  type MaterialPickerOption,\n  type MaterialPickPayload,\n  type MaterialUnit,\n} from \"./material-picker-data\"\n",
      "type": "registry:component",
      "target": "components/blocks/domain/material-selector/index.ts"
    },
    {
      "path": "components/blocks/domain/material-selector/material-class-term-wizard.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ArrowLeft,\n  CheckCircle,\n  ChevronRight,\n  Folder,\n  SearchLg,\n} from \"@untitledui/icons\"\n\nimport {\n  findClass,\n  findTerm,\n  listClasses,\n  listTerms,\n  searchTerms,\n  termCount,\n  type MaterialClass,\n  type MaterialTerm,\n} from \"@/lib/data/material-terms\"\nimport { cn } from \"@/lib/utils\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"@/components/ui/input-group\"\n\nexport interface MaterialClassAndTermSelectWizardProps {\n  /** Controlled selected leaf class key. */\n  selectedClassKey?: string\n  /** Controlled selected term code. */\n  selectedTermCode?: string\n  onClassSelect?: (classKey: string) => void\n  onTermSelect?: (termCode: string) => void\n  /** Verb flavour: \"import\" → \"need\", \"export\" → \"have\". */\n  kind?: \"import\" | \"export\"\n  className?: string\n}\n\ntype ViewMode = \"classes\" | \"terms\"\n\n/**\n * MaterialClassAndTermSelectWizard — a card-driven drill-down for choosing a\n * material. Navigate top-level classes as cards (non-leaf classes drill in with\n * a term-count badge and breadcrumb; leaf classes flip to term selection), pick\n * a term card, or search across every term. A confirmation card summarises the\n * choice with a \"Change\" reset.\n */\nexport function MaterialClassAndTermSelectWizard({\n  selectedClassKey,\n  selectedTermCode,\n  onClassSelect,\n  onTermSelect,\n  kind = \"import\",\n  className,\n}: MaterialClassAndTermSelectWizardProps) {\n  const [path, setPath] = React.useState<string[]>([])\n  const [viewMode, setViewMode] = React.useState<ViewMode>(\"classes\")\n  const [activeLeaf, setActiveLeaf] = React.useState<string | null>(null)\n  const [query, setQuery] = React.useState(\"\")\n\n  const selectedTerm = selectedTermCode ? findTerm(selectedTermCode) : undefined\n  const selectedClass = selectedTerm\n    ? findClass(selectedTerm.parent)\n    : selectedClassKey\n      ? findClass(selectedClassKey)\n      : undefined\n\n  const debouncedQuery = useDebounced(query, 250)\n  const termResults = React.useMemo(\n    () => (debouncedQuery.trim() ? searchTerms(debouncedQuery) : []),\n    [debouncedQuery]\n  )\n\n  const currentParent = path.length > 0 ? path[path.length - 1] : undefined\n  // Only show classes that actually lead to terms.\n  const currentClasses = React.useMemo(\n    () => listClasses(currentParent).filter((c) => termCount(c.key) > 0),\n    [currentParent]\n  )\n\n  const verb = kind === \"export\" ? \"have\" : \"need\"\n\n  function reset() {\n    setPath([])\n    setViewMode(\"classes\")\n    setActiveLeaf(null)\n    setQuery(\"\")\n  }\n\n  function handleClassClick(cls: MaterialClass) {\n    if (cls.isLeaf) {\n      setActiveLeaf(cls.key)\n      setViewMode(\"terms\")\n      onClassSelect?.(cls.key)\n    } else {\n      setPath((p) => [...p, cls.key])\n    }\n  }\n\n  function handleTermClick(term: MaterialTerm) {\n    onClassSelect?.(term.parent)\n    onTermSelect?.(term.code)\n  }\n\n  // Confirmation card — a term is chosen.\n  if (selectedTerm && selectedClass) {\n    return (\n      <div\n        className={cn(\n          \"rounded-xl border border-border bg-primary/5 p-4\",\n          className\n        )}\n      >\n        <div className=\"flex items-start justify-between gap-3\">\n          <div className=\"flex items-start gap-3\">\n            <CheckCircle className=\"mt-0.5 size-5 shrink-0 text-primary\" />\n            <div>\n              <p className=\"text-xs font-medium tracking-wide text-muted-foreground uppercase\">\n                Selected material\n              </p>\n              <p className=\"mt-0.5 text-sm font-semibold text-foreground\">\n                {selectedTerm.label}\n              </p>\n              <p className=\"text-xs text-muted-foreground\">\n                {selectedClass.facetPath.join(\" / \")}\n              </p>\n            </div>\n          </div>\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={() => {\n              reset()\n              onTermSelect?.(\"\")\n              onClassSelect?.(\"\")\n            }}\n          >\n            Change\n          </Button>\n        </div>\n      </div>\n    )\n  }\n\n  const activeLeafClass = activeLeaf ? findClass(activeLeaf) : undefined\n  const terms = activeLeaf ? listTerms(activeLeaf) : []\n  const searching = query.trim().length > 0\n\n  return (\n    <div className={cn(\"flex flex-col gap-4\", className)}>\n      {/* Search */}\n      <InputGroup className=\"max-w-full\">\n        <InputGroupAddon align=\"inline-start\">\n          <SearchLg className=\"text-muted-foreground\" />\n        </InputGroupAddon>\n        <InputGroupInput\n          placeholder=\"Search materials\"\n          value={query}\n          onChange={(e) => setQuery(e.target.value)}\n        />\n      </InputGroup>\n\n      {searching ? (\n        <div className=\"flex flex-col gap-2\">\n          {termResults.length > 0 ? (\n            <>\n              <p className=\"text-sm text-muted-foreground\">\n                Found {termResults.length}{\" \"}\n                {termResults.length === 1 ? \"material\" : \"materials\"} matching\n                &ldquo;{query}&rdquo;\n              </p>\n              <div className=\"grid gap-3 md:grid-cols-2\">\n                {termResults.map((term) => (\n                  <TermCard\n                    key={term.code}\n                    term={term}\n                    selected={selectedTermCode === term.code}\n                    onSelect={handleTermClick}\n                  />\n                ))}\n              </div>\n            </>\n          ) : (\n            <div className=\"rounded-lg border border-border bg-muted/30 px-4 py-8 text-center\">\n              <p className=\"text-sm font-medium text-foreground\">\n                No materials found matching &ldquo;{query}&rdquo;\n              </p>\n              <p className=\"mt-1 text-xs text-muted-foreground\">\n                Try searching with different keywords or synonyms\n              </p>\n            </div>\n          )}\n        </div>\n      ) : viewMode === \"terms\" && activeLeafClass ? (\n        <div className=\"flex flex-col gap-3\">\n          <div className=\"flex items-center gap-2\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={() => {\n                setViewMode(\"classes\")\n                setActiveLeaf(null)\n              }}\n            >\n              <ArrowLeft className=\"size-4\" />\n              Back\n            </Button>\n            <p className=\"text-sm text-muted-foreground\">\n              {activeLeafClass.facetPath.join(\" / \")}\n            </p>\n          </div>\n          <p className=\"text-sm font-medium text-foreground\">\n            Which of the following terms best describes the{\" \"}\n            {activeLeafClass.name.toLowerCase()} you {verb}?\n          </p>\n          <div className=\"grid gap-3 md:grid-cols-2\">\n            {terms.map((term) => (\n              <TermCard\n                key={term.code}\n                term={term}\n                selected={selectedTermCode === term.code}\n                onSelect={handleTermClick}\n              />\n            ))}\n          </div>\n        </div>\n      ) : (\n        <div className=\"flex flex-col gap-3\">\n          {path.length > 0 && (\n            <div className=\"flex items-center gap-2\">\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => setPath((p) => p.slice(0, -1))}\n              >\n                <ArrowLeft className=\"size-4\" />\n                Back\n              </Button>\n              <p className=\"text-sm text-muted-foreground\">\n                {findClass(currentParent!)?.facetPath.join(\" / \")}\n              </p>\n            </div>\n          )}\n          <div className=\"grid gap-3 md:grid-cols-2\">\n            {currentClasses.map((cls) => (\n              <ClassCard key={cls.key} cls={cls} onClick={handleClassClick} />\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n\nfunction ClassCard({\n  cls,\n  onClick,\n}: {\n  cls: MaterialClass\n  onClick: (cls: MaterialClass) => void\n}) {\n  const count = termCount(cls.key)\n  return (\n    <button\n      type=\"button\"\n      onClick={() => onClick(cls)}\n      className=\"flex min-h-[76px] items-center justify-between gap-3 rounded-xl border border-border bg-background p-4 text-left shadow-xs ring-border transition-all hover:border-ring/50 hover:shadow-sm focus-visible:ring-4 focus-visible:ring-ring/25 focus-visible:outline-none\"\n    >\n      <span className=\"flex items-center gap-3\">\n        <Folder className=\"size-5 shrink-0 text-muted-foreground\" />\n        <span className=\"flex flex-col\">\n          <span className=\"text-sm font-semibold text-foreground\">{cls.name}</span>\n          <span className=\"text-xs text-muted-foreground\">\n            {count} {count === 1 ? \"material\" : \"materials\"}\n          </span>\n        </span>\n      </span>\n      <ChevronRight className=\"size-4 shrink-0 text-muted-foreground\" />\n    </button>\n  )\n}\n\nfunction TermCard({\n  term,\n  selected,\n  onSelect,\n}: {\n  term: MaterialTerm\n  selected: boolean\n  onSelect: (term: MaterialTerm) => void\n}) {\n  const synonyms = term.synonyms.slice(0, 2)\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={selected}\n      onClick={() => onSelect(term)}\n      className={cn(\n        \"flex flex-col gap-1 rounded-xl border p-4 text-left shadow-xs transition-all focus-visible:ring-4 focus-visible:ring-ring/25 focus-visible:outline-none\",\n        selected\n          ? \"border-primary bg-primary/5 ring-1 ring-primary\"\n          : \"border-border bg-background hover:border-ring/50 hover:shadow-sm\"\n      )}\n    >\n      <span className=\"flex items-center justify-between gap-2\">\n        <span className=\"text-sm font-semibold text-foreground\">{term.label}</span>\n        {selected && <CheckCircle className=\"size-4 shrink-0 text-primary\" />}\n      </span>\n      {synonyms.length > 0 && (\n        <span className=\"text-xs text-muted-foreground\">\n          Also known as: {synonyms.join(\", \")}\n        </span>\n      )}\n    </button>\n  )\n}\n\n/** Small debounce hook so search does not re-filter on every keystroke. */\nfunction useDebounced<T>(value: T, delayMs: number): T {\n  const [debounced, setDebounced] = React.useState(value)\n  React.useEffect(() => {\n    const t = setTimeout(() => setDebounced(value), delayMs)\n    return () => clearTimeout(t)\n  }, [value, delayMs])\n  return debounced\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/material-selector/material-class-term-wizard.tsx"
    },
    {
      "path": "components/blocks/domain/material-selector/material-field.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { PencilLine, Package, SearchLg } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\n\nimport type { MaterialPickerOption } from \"./material-picker-data\"\n\nexport interface MaterialFieldProps {\n  /** The picked material, or `null` when unset. */\n  value: MaterialPickerOption | null\n  /** Opens the picker modal — fired from both the empty state and \"Change\". */\n  onOpen: () => void\n  /**\n   * Gates the field (dashed border, muted). In a movement form this stays\n   * true until a material status has been chosen.\n   */\n  disabled?: boolean\n  /** Copy for the disabled state, e.g. \"Pick a status first\". */\n  disabledHint?: string\n  placeholder?: string\n  invalid?: boolean\n  className?: string\n}\n\n/**\n * MaterialField — the status-gated material trigger for movement forms. Three\n * states share one h-12 control: dashed-and-muted while gated, a search\n * placeholder once armed, and a two-line selected card (label over group ·\n * EWC) with a \"Change\" affordance. Pair it with `MaterialPickerModal`.\n */\nexport function MaterialField({\n  value,\n  onOpen,\n  disabled = false,\n  disabledHint = \"Pick a status first\",\n  placeholder = \"Search and pick a material\",\n  invalid = false,\n  className,\n}: MaterialFieldProps) {\n  const selected = value !== null\n\n  const secondary = selected\n    ? [value.groupLabel, value.ewcCode ? `EWC ${value.ewcCode}` : null]\n        .filter(Boolean)\n        .join(\" · \")\n    : null\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onOpen}\n      disabled={disabled}\n      aria-invalid={invalid || undefined}\n      data-slot=\"material-field\"\n      className={cn(\n        \"group flex h-12 w-full items-center gap-3 rounded-xl border px-3 text-left outline-none transition-[border-color,box-shadow,background-color] duration-100\",\n        invalid\n          ? \"border-destructive bg-background ring-4 ring-destructive/10\"\n          : disabled\n            ? \"cursor-not-allowed border-dashed border-border bg-muted/40\"\n            : \"border-input bg-background shadow-xs hover:border-ring/50 hover:shadow-sm focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25\",\n        className\n      )}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"flex size-8 shrink-0 items-center justify-center rounded-lg\",\n          selected\n            ? \"bg-utility-brand-50 text-utility-brand-700\"\n            : \"bg-muted text-muted-foreground\"\n        )}\n      >\n        {selected ? <Package className=\"size-4\" /> : <SearchLg className=\"size-4\" />}\n      </span>\n\n      {selected ? (\n        <>\n          <span className=\"flex min-w-0 flex-1 flex-col\">\n            <span className=\"truncate text-[13px] leading-tight font-semibold text-foreground\">\n              {value.label}\n            </span>\n            <span className=\"truncate text-[11px] leading-tight text-muted-foreground\">\n              {secondary}\n            </span>\n          </span>\n          <span className=\"flex shrink-0 items-center gap-1 text-[11.5px] font-semibold text-muted-foreground transition-colors group-hover:text-text-brand-secondary\">\n            <PencilLine aria-hidden className=\"size-3\" />\n            Change\n          </span>\n        </>\n      ) : (\n        <span className=\"min-w-0 flex-1 truncate text-[13px] text-muted-foreground\">\n          {disabled ? disabledHint : placeholder}\n        </span>\n      )}\n    </button>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/material-selector/material-field.tsx"
    },
    {
      "path": "components/blocks/domain/material-selector/material-picker-data.ts",
      "content": "/**\n * Material picker catalogue — the movement-form view over the shared material\n * taxonomy (`@/lib/data/material-terms`). Each term is enriched with the\n * paperwork cascade a pick should auto-fill: a suggested EWC code, the usual\n * trading unit, a reference density and hazard/POPs flags.\n *\n * The EWC strings are display snapshots (asterisk included on hazardous\n * entries); resolve them against the EWC dataset with `findEwcEntry` from the\n * ewc-selector block if you need the full entry.\n */\n\nimport {\n  findClass,\n  MATERIAL_CLASSES,\n  MATERIAL_TERMS,\n  REFERENCE_DENSITIES,\n  type MaterialTerm,\n} from \"@/lib/data/material-terms\"\n\nexport type MaterialUnit = \"tonnes\" | \"m3\"\n\nexport interface MaterialPickerOption {\n  /** The taxonomy term code, e.g. \"TOPSOIL_BS3882\". */\n  code: string\n  label: string\n  /** Leaf class key, e.g. \"soil.topsoil\". */\n  classKey: string\n  /** Top-level group key (\"soil\", \"aggregate\", …) and its display label. */\n  groupKey: string\n  groupLabel: string\n  description?: string\n  /** Suggested EWC code (with `*` when hazardous), or undefined for products. */\n  ewcCode?: string\n  ewcTitle?: string\n  unit: MaterialUnit\n  /** Reference bulk density (mid), t/m³. */\n  density?: number\n  hazardous: boolean\n  pops: boolean\n  common: boolean\n  warn?: string\n  /** Search haystack: label + taxonomy synonyms. */\n  keywords: string[]\n}\n\nexport interface MaterialPickerGroup {\n  key: string\n  label: string\n  note?: string\n  options: MaterialPickerOption[]\n}\n\n/** What a pick hands back so forms can auto-fill downstream fields. */\nexport interface MaterialPickPayload {\n  option: MaterialPickerOption\n  term: MaterialTerm\n  /** Suggested EWC code string (asterisked when hazardous), or null for products. */\n  suggestedEwc: string | null\n  suggestedEwcTitle: string | null\n  unit: MaterialUnit\n  hazardous: boolean\n  pops: boolean\n}\n\ninterface TermMeta {\n  description?: string\n  ewc?: string\n  ewcTitle?: string\n  unit?: MaterialUnit\n  hazardous?: boolean\n  pops?: boolean\n  common?: boolean\n  warn?: string\n}\n\nconst SOIL_STONES_NON_HAZ = \"Soil and stones other than those mentioned in 17 05 03\"\n\n/** Per-term paperwork cascade. Terms without an entry ship sensible defaults. */\nconst TERM_META: Record<string, TermMeta> = {\n  TOPSOIL_BS3882: { description: \"Certified multipurpose topsoil to BS 3882.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ, common: true },\n  TOPSOIL_GENERAL: { description: \"As-dug topsoil, unclassified.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ, common: true },\n  TOPSOIL_MANUFACTURED: { description: \"Blended growing medium from recycled fines.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ },\n  SUBSOIL_BS8601: { description: \"Certified subsoil to BS 8601.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ },\n  SUBSOIL_GENERAL: { description: \"General excavated subsoil.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ, common: true },\n  CLAY_FIRM: { description: \"Firm cohesive fill from bulk dig.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ, common: true },\n  CLAY_STIFF: { description: \"Stiff boulder clay.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ },\n  SILT: { description: \"Silty arisings — check moisture before haulage.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ },\n  SAND: { description: \"Granular as-dug sand.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ },\n  GRAVEL: { description: \"As-dug gravel and shingle.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ },\n  SAND_GRAVEL: { description: \"Mixed granular fill.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ },\n  PEAT: { description: \"Organic peaty arisings — low density, high shrinkage.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ },\n  LOAM: { description: \"Loamy topsoil blend.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ },\n  // Virgin aggregates trade as product — no EWC needed.\n  TYPE1_SUBBASE: { description: \"MOT Type 1 granular sub-base (product).\", common: true },\n  TYPE3_SUBBASE: { description: \"Permeable Type 3 sub-base (product).\" },\n  \"6F2_CAPPING\": { description: \"6F2 capping / selected fill (product).\" },\n  \"6F5_SELECTED_FILL\": { description: \"6F5 selected granular fill (product).\" },\n  LIMESTONE: { description: \"Crushed limestone aggregate (product).\" },\n  GRANITE: { description: \"Crushed granite aggregate (product).\" },\n  SANDSTONE: { description: \"Crushed sandstone aggregate (product).\" },\n  SHARP_SAND: { description: \"Concreting sand (product).\" },\n  BUILDING_SAND: { description: \"Soft building sand (product).\" },\n  CRUSHED_CONCRETE: { description: \"Recycled concrete aggregate from demolition.\", ewc: \"17 01 01\", ewcTitle: \"Concrete\", common: true },\n  CRUSHED_BRICK: { description: \"Recycled brick hardcore.\", ewc: \"17 01 02\", ewcTitle: \"Bricks\" },\n  RECYCLED_TYPE1: { description: \"Blended recycled Type 1 from crushed hardcore.\", ewc: \"17 01 07\", ewcTitle: \"Mixtures of concrete, bricks, tiles and ceramics other than those mentioned in 17 01 06\", common: true },\n  PLANINGS: { description: \"Bitumen-bound road planings.\", ewc: \"17 03 02\", ewcTitle: \"Bituminous mixtures other than those mentioned in 17 03 01\", common: true },\n  COAL_TAR_PLANINGS: {\n    description: \"Tar-bound planings from pre-1980s roads.\",\n    ewc: \"17 03 01*\",\n    ewcTitle: \"Bituminous mixtures containing coal tar\",\n    hazardous: true,\n    pops: true,\n    warn: \"Coal tar is a POPs waste — recovery is prohibited; consign for destruction.\",\n  },\n  READY_MIX: { description: \"Fresh ready-mixed concrete (product).\", unit: \"m3\" },\n  FOAMED_CONCRETE: { description: \"Foamed concrete for voids and reinstatement (product).\", unit: \"m3\" },\n  ASPHALT_SURFACE: { description: \"Hot asphalt surface course (product).\" },\n  ASPHALT_BINDER: { description: \"Hot asphalt binder course (product).\" },\n  INERT_WASTE: { description: \"Clean inert spoil for disposal or recovery.\", ewc: \"17 05 04\", ewcTitle: SOIL_STONES_NON_HAZ, common: true },\n  INERT_HARDCORE: { description: \"Clean mixed rubble and hardcore.\", ewc: \"17 01 07\", ewcTitle: \"Mixtures of concrete, bricks, tiles and ceramics other than those mentioned in 17 01 06\" },\n  CONTAMINATED_SOIL: {\n    description: \"Soil and stones containing hazardous substances.\",\n    ewc: \"17 05 03*\",\n    ewcTitle: \"Soil and stones containing hazardous substances\",\n    hazardous: true,\n    warn: \"Needs a WM3 classification; consignment notes on every load.\",\n  },\n  ASBESTOS_CONTAINING: {\n    description: \"Bonded or fibrous asbestos-containing material.\",\n    ewc: \"17 06 05*\",\n    ewcTitle: \"Construction materials containing asbestos\",\n    hazardous: true,\n    warn: \"Licensed carrier and a permitted receiver required.\",\n  },\n}\n\nfunction toOption(term: MaterialTerm): MaterialPickerOption | null {\n  const leaf = findClass(term.parent)\n  if (!leaf) return null\n  const groupKey = leaf.parentKey ?? leaf.key\n  const group = findClass(groupKey)\n  const meta = TERM_META[term.code] ?? {}\n  return {\n    code: term.code,\n    label: term.label,\n    classKey: leaf.key,\n    groupKey,\n    groupLabel: group?.name ?? leaf.name,\n    description: meta.description,\n    ewcCode: meta.ewc,\n    ewcTitle: meta.ewcTitle,\n    unit: meta.unit ?? \"tonnes\",\n    density: REFERENCE_DENSITIES[leaf.key]?.mid,\n    hazardous: meta.hazardous ?? false,\n    pops: meta.pops ?? false,\n    common: meta.common ?? false,\n    warn: meta.warn,\n    keywords: [term.label, ...term.synonyms, leaf.name].map((k) => k.toLowerCase()),\n  }\n}\n\nconst GROUP_NOTES: Record<string, string> = {\n  waste: \"EWC code and duty-of-care paperwork required\",\n  recycled: \"End-of-waste status depends on the protocol\",\n}\n\n/** The full picker catalogue, grouped by top-level taxonomy class. */\nexport function buildMaterialPickerGroups(): MaterialPickerGroup[] {\n  const groups: MaterialPickerGroup[] = MATERIAL_CLASSES.filter(\n    (c) => !c.parentKey\n  ).map((c) => ({\n    key: c.key,\n    label: c.name,\n    note: GROUP_NOTES[c.key],\n    options: [],\n  }))\n  for (const term of MATERIAL_TERMS) {\n    const option = toOption(term)\n    if (!option) continue\n    groups.find((g) => g.key === option.groupKey)?.options.push(option)\n  }\n  return groups.filter((g) => g.options.length > 0)\n}\n\n/** Find one picker option by its term code. */\nexport function findMaterialOption(code: string): MaterialPickerOption | null {\n  const term = MATERIAL_TERMS.find((t) => t.code === code)\n  return term ? toOption(term) : null\n}\n\n/** Build the auto-fill cascade payload for a picked option. */\nexport function toPickPayload(option: MaterialPickerOption): MaterialPickPayload {\n  const term = MATERIAL_TERMS.find((t) => t.code === option.code)!\n  return {\n    option,\n    term,\n    suggestedEwc: option.ewcCode ?? null,\n    suggestedEwcTitle: option.ewcTitle ?? null,\n    unit: option.unit,\n    hazardous: option.hazardous,\n    pops: option.pops,\n  }\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/material-selector/material-picker-data.ts"
    },
    {
      "path": "components/blocks/domain/material-selector/material-picker-modal.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AlertTriangle, Cube01, SearchLg } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\n\nimport {\n  buildMaterialPickerGroups,\n  toPickPayload,\n  type MaterialPickerGroup,\n  type MaterialPickerOption,\n  type MaterialPickPayload,\n} from \"./material-picker-data\"\n\nexport interface MaterialPickerModalProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  /**\n   * Fires with the picked option and its auto-fill cascade (suggested EWC,\n   * unit, hazard flags); the modal closes itself after.\n   */\n  onSelect: (payload: MaterialPickPayload) => void\n  /** Override the catalogue; defaults to the shared material taxonomy. */\n  groups?: MaterialPickerGroup[]\n  title?: string\n  description?: string\n}\n\n/**\n * MaterialPickerModal — the movement-form material picker: search plus\n * category chips over grouped two-column tiles. Each tile carries an initials\n * swatch toned by risk, Haz/POPs/Common badges, and a mono EWC · unit ·\n * density meta line. Picking returns a `MaterialPickPayload` so forms can\n * auto-fill the EWC code, quantity unit and hazard flags in one go.\n */\nexport function MaterialPickerModal({\n  open,\n  onOpenChange,\n  onSelect,\n  groups,\n  title = \"Pick a material\",\n  description = \"Choose the material that best fits this movement.\",\n}: MaterialPickerModalProps) {\n  const catalogue = React.useMemo(() => groups ?? buildMaterialPickerGroups(), [groups])\n\n  const [query, setQuery] = React.useState(\"\")\n  const [activeGroup, setActiveGroup] = React.useState<string>(\"all\")\n  const searchRef = React.useRef<HTMLInputElement>(null)\n\n  React.useEffect(() => {\n    if (!open) return\n    setQuery(\"\")\n    setActiveGroup(\"all\")\n  }, [open])\n\n  const trimmed = query.trim().toLowerCase()\n\n  const visibleGroups = React.useMemo(() => {\n    return catalogue\n      .filter((group) => activeGroup === \"all\" || group.key === activeGroup)\n      .map((group) => ({\n        ...group,\n        options: trimmed\n          ? group.options.filter((option) =>\n              option.keywords.some((k) => k.includes(trimmed))\n            )\n          : group.options,\n      }))\n      .filter((group) => group.options.length > 0)\n  }, [catalogue, activeGroup, trimmed])\n\n  const matchCount = visibleGroups.reduce((n, g) => n + g.options.length, 0)\n\n  const commit = (option: MaterialPickerOption) => {\n    onSelect(toPickPayload(option))\n    onOpenChange(false)\n  }\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent\n        className=\"flex h-[min(640px,85vh)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl\"\n        initialFocus={searchRef}\n      >\n        <DialogHeader className=\"flex-row items-center gap-3 border-b border-border px-5 py-4 pr-14\">\n          <span\n            aria-hidden\n            className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-utility-brand-50 text-utility-brand-700\"\n          >\n            <Cube01 className=\"size-4.5\" />\n          </span>\n          <div className=\"flex min-w-0 flex-col gap-0.5\">\n            <DialogTitle className=\"text-md\">{title}</DialogTitle>\n            <DialogDescription className=\"truncate text-xs\">\n              {description}\n            </DialogDescription>\n          </div>\n        </DialogHeader>\n\n        {/* Search + category chips */}\n        <div className=\"flex flex-col gap-2.5 border-b border-border px-5 py-3\">\n          <div className=\"relative\">\n            <SearchLg\n              aria-hidden\n              className=\"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground\"\n            />\n            <input\n              ref={searchRef}\n              type=\"text\"\n              value={query}\n              onChange={(e) => setQuery(e.target.value)}\n              placeholder=\"Search material (e.g. topsoil, asphalt, asbestos)…\"\n              aria-label=\"Search materials\"\n              className=\"h-9 w-full rounded-lg border border-input bg-background pr-3 pl-9 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25\"\n              data-slot=\"material-picker-search\"\n            />\n          </div>\n          <div className=\"flex flex-wrap gap-1.5\" role=\"group\" aria-label=\"Material category\">\n            {[{ key: \"all\", label: \"All\" }, ...catalogue].map((group) => (\n              <button\n                key={group.key}\n                type=\"button\"\n                aria-pressed={activeGroup === group.key}\n                onClick={() => setActiveGroup(group.key)}\n                className={cn(\n                  \"inline-flex h-7 items-center rounded-full border px-2.5 text-xs font-medium transition-colors\",\n                  activeGroup === group.key\n                    ? \"border-utility-brand-200 bg-utility-brand-50 text-utility-brand-700\"\n                    : \"border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground\"\n                )}\n              >\n                {group.label}\n              </button>\n            ))}\n          </div>\n        </div>\n\n        {/* Grouped tiles */}\n        <div className=\"min-h-0 flex-1 overflow-y-auto px-5 py-4\">\n          {matchCount === 0 && (\n            <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-center\">\n              <p className=\"text-sm font-medium text-foreground\">\n                No materials match “{query.trim()}”\n              </p>\n              <p className=\"max-w-64 text-xs text-muted-foreground\">\n                Try a synonym — “tarmac”, “rca”, “spoil” — or clear the search.\n              </p>\n              <Button\n                variant=\"outline\"\n                size=\"xs\"\n                className=\"mt-1\"\n                onClick={() => {\n                  setQuery(\"\")\n                  setActiveGroup(\"all\")\n                }}\n              >\n                Clear search\n              </Button>\n            </div>\n          )}\n\n          {visibleGroups.map((group) => (\n            <div key={group.key} className=\"pb-5 last:pb-0\">\n              <p className=\"pb-2 text-[11px] font-semibold tracking-wider text-muted-foreground uppercase\">\n                {group.label}\n                {group.note && (\n                  <span className=\"ml-1.5 font-normal normal-case tracking-normal text-muted-foreground/80\">\n                    · {group.note}\n                  </span>\n                )}\n              </p>\n              <div className=\"grid grid-cols-1 gap-2 md:grid-cols-2\">\n                {group.options.map((option) => (\n                  <MaterialTile\n                    key={option.code}\n                    option={option}\n                    onPick={() => commit(option)}\n                  />\n                ))}\n              </div>\n            </div>\n          ))}\n        </div>\n\n        {/* Footer strip */}\n        <div className=\"border-t border-border bg-muted/30 px-5 py-2.5\">\n          <p className=\"text-[11px] text-muted-foreground\">\n            Material selection auto-suggests the EWC code, units and hazardous\n            flags.\n          </p>\n        </div>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\n/** Two-letter initials for the tile swatch, e.g. \"Asphalt planings\" → \"AP\". */\nfunction initialsOf(label: string): string {\n  const words = label.replace(/\\(.*?\\)/g, \"\").trim().split(/[\\s-]+/)\n  const first = words[0]?.[0] ?? \"?\"\n  const second = words[1]?.[0] ?? words[0]?.[1] ?? \"\"\n  return (first + second).toUpperCase()\n}\n\nfunction MaterialTile({\n  option,\n  onPick,\n}: {\n  option: MaterialPickerOption\n  onPick: () => void\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onPick}\n      data-slot=\"material-picker-tile\"\n      data-code={option.code}\n      className=\"flex items-start gap-3 rounded-xl border border-border bg-background p-3 text-left shadow-xs transition-all outline-none hover:border-ring/50 hover:shadow-sm focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25\"\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"flex size-12 shrink-0 items-center justify-center rounded-lg text-sm font-semibold\",\n          option.hazardous\n            ? \"bg-utility-red-50 text-utility-red-700\"\n            : option.pops\n              ? \"bg-utility-yellow-50 text-utility-yellow-700\"\n              : \"bg-utility-brand-50 text-utility-brand-700\"\n        )}\n      >\n        {initialsOf(option.label)}\n      </span>\n      <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <span className=\"flex items-center gap-1.5\">\n          <span className=\"min-w-0 truncate text-[13.5px] font-semibold text-foreground\">\n            {option.label}\n          </span>\n          {option.hazardous && <DotBadge tone=\"error\">Haz</DotBadge>}\n          {option.pops && <DotBadge tone=\"warning\">POPs</DotBadge>}\n          {option.common && !option.hazardous && (\n            <DotBadge tone=\"brand\">Common</DotBadge>\n          )}\n        </span>\n        {option.description && (\n          <span className=\"line-clamp-2 text-xs leading-snug text-muted-foreground\">\n            {option.description}\n          </span>\n        )}\n        <span className=\"text-[11px] text-muted-foreground/80\">\n          <span className=\"font-mono\">{option.ewcCode ?? \"—\"}</span>\n          {\" · \"}\n          {option.unit === \"m3\" ? \"m³\" : option.unit}\n          {option.density ? ` · ${option.density} t/m³` : \"\"}\n        </span>\n        {option.warn && (\n          <span className=\"flex items-start gap-1 text-[11px] leading-snug text-utility-yellow-700\">\n            <AlertTriangle aria-hidden className=\"mt-0.5 size-3 shrink-0\" />\n            {option.warn}\n          </span>\n        )}\n      </span>\n    </button>\n  )\n}\n\nfunction DotBadge({\n  tone,\n  children,\n}: {\n  tone: \"error\" | \"warning\" | \"brand\"\n  children: React.ReactNode\n}) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-px text-[10px] font-semibold\",\n        tone === \"error\" && \"border-utility-red-200 bg-utility-red-50 text-utility-red-700\",\n        tone === \"warning\" &&\n          \"border-utility-yellow-200 bg-utility-yellow-50 text-utility-yellow-700\",\n        tone === \"brand\" &&\n          \"border-utility-brand-200 bg-utility-brand-50 text-utility-brand-700\"\n      )}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"size-1 rounded-full\",\n          tone === \"error\" && \"bg-utility-red-500\",\n          tone === \"warning\" && \"bg-utility-yellow-500\",\n          tone === \"brand\" && \"bg-utility-brand-500\"\n        )}\n      />\n      {children}\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/material-selector/material-picker-modal.tsx"
    },
    {
      "path": "components/blocks/domain/material-selector/material-type-selector.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Package } from \"@untitledui/icons\"\n\nimport {\n  findClass,\n  listLeafClasses,\n  searchClasses,\n  type MaterialClass,\n} from \"@/lib/data/material-terms\"\nimport {\n  Combobox,\n  ComboboxContent,\n  ComboboxEmpty,\n  ComboboxInput,\n  ComboboxItem,\n  ComboboxList,\n} from \"@/components/ui/combobox\"\nimport { Label } from \"@/components/ui/label\"\n\nexport interface MaterialTypeSelectorProps {\n  label?: string\n  placeholder?: string\n  /** Controlled selected class key, e.g. \"soil.topsoil\". */\n  value?: string | null\n  onValueChange?: (key: string | null) => void\n  disabled?: boolean\n  className?: string\n}\n\nconst breadcrumbOf = (cls: MaterialClass) =>\n  cls.facetPath.length > 1 ? cls.facetPath.slice(0, -1).join(\" / \") : undefined\n\n/**\n * MaterialTypeSelector — a compact, single-line autocomplete for picking a leaf\n * material class. Shows every leaf class by default; once the user types two or\n * more characters it searches names, synonyms and breadcrumbs. Each row shows\n * the class name with its parent breadcrumb as supporting text.\n *\n * Controlled by class key: pass `value` (e.g. \"soil.topsoil\") and handle\n * `onValueChange`, which fires with the key or `null` when cleared.\n */\nexport function MaterialTypeSelector({\n  label = \"Material type\",\n  placeholder = \"Search for a material type...\",\n  value = null,\n  onValueChange,\n  disabled = false,\n  className,\n}: MaterialTypeSelectorProps) {\n  const [query, setQuery] = React.useState(\"\")\n  const labelId = React.useId()\n\n  const items = React.useMemo<MaterialClass[]>(() => {\n    const q = query.trim()\n    return q.length >= 2 ? searchClasses(q) : listLeafClasses()\n  }, [query])\n\n  const selected = value ? (findClass(value) ?? null) : null\n\n  return (\n    <div className={className}>\n      <Label id={labelId} className=\"mb-1.5 block\">\n        {label}\n      </Label>\n      <Combobox\n        items={items}\n        // We drive the item list ourselves (synonym-aware search), so disable\n        // Base UI's built-in label filtering.\n        filter={null}\n        value={selected}\n        onValueChange={(cls: MaterialClass | null) =>\n          onValueChange?.(cls?.key ?? null)\n        }\n        onInputValueChange={setQuery}\n        disabled={disabled}\n        itemToStringLabel={(cls: MaterialClass) => cls.name}\n        isItemEqualToValue={(a: MaterialClass, b: MaterialClass) => a.key === b.key}\n      >\n        <div className=\"w-full max-w-sm\">\n          <ComboboxInput placeholder={placeholder} aria-labelledby={labelId} />\n          <ComboboxContent>\n            <ComboboxEmpty>No material types found.</ComboboxEmpty>\n            <ComboboxList>\n              {(cls: MaterialClass) => (\n                <ComboboxItem key={cls.key} value={cls}>\n                  <Package className=\"mt-0.5 shrink-0 text-muted-foreground\" />\n                  <span className=\"flex flex-col\">\n                    <span>{cls.name}</span>\n                    {breadcrumbOf(cls) && (\n                      <span className=\"text-xs text-muted-foreground\">\n                        {breadcrumbOf(cls)}\n                      </span>\n                    )}\n                  </span>\n                </ComboboxItem>\n              )}\n            </ComboboxList>\n          </ComboboxContent>\n        </div>\n      </Combobox>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/material-selector/material-type-selector.tsx"
    },
    {
      "path": "components/blocks/domain/material-selector/material-volume-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { InfoCircle } from \"@untitledui/icons\"\n\nimport { getReferenceDensity, type ReferenceDensity } from \"@/lib/data/material-terms\"\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\nimport { NativeSelect, NativeSelectOption } from \"@/components/ui/native-select\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\nexport type VolumeUnit = \"m3\" | \"t\"\n\nexport interface MaterialVolumeInputProps {\n  label?: string\n  value: number | null\n  unit: VolumeUnit\n  onValueChange: (value: number | null) => void\n  onUnitChange: (unit: VolumeUnit) => void\n  /** Leaf material class key — used to look up the reference density for conversion. */\n  classKey?: string | null\n  disabled?: boolean\n  className?: string\n}\n\n/**\n * MaterialVolumeInput — a number input with a trailing m³ / tonnes unit select.\n * When the unit toggles and a reference density is known for the class, the\n * value auto-converts (m³→t = value × mid; t→m³ = value ÷ mid). A tooltip\n * explains the conversion and shows the density range. Without a known density\n * the value is cleared on toggle (there is no basis to convert).\n */\nexport function MaterialVolumeInput({\n  label = \"Quantity\",\n  value,\n  unit,\n  onValueChange,\n  onUnitChange,\n  classKey,\n  disabled = false,\n  className,\n}: MaterialVolumeInputProps) {\n  const density = classKey ? getReferenceDensity(classKey) : undefined\n  const inputId = React.useId()\n\n  function handleUnitChange(next: VolumeUnit) {\n    if (next === unit) return\n    if (value != null && density) {\n      const converted =\n        next === \"t\" ? value * density.mid : value / density.mid\n      onValueChange(Math.round(converted * 100) / 100)\n    } else if (value != null && !density) {\n      // No basis to convert — clear rather than carry a misleading number.\n      onValueChange(null)\n    }\n    onUnitChange(next)\n  }\n\n  return (\n    <div className={cn(\"flex flex-col gap-1.5\", className)}>\n      <div className=\"flex items-center gap-1.5\">\n        <Label htmlFor={inputId}>{label}</Label>\n        {density && <DensityTooltip density={density} />}\n      </div>\n      <div className=\"flex items-stretch gap-2\">\n        <input\n          id={inputId}\n          type=\"number\"\n          inputMode=\"decimal\"\n          min={0}\n          step=\"0.01\"\n          disabled={disabled}\n          value={value ?? \"\"}\n          onChange={(e) =>\n            onValueChange(e.target.value === \"\" ? null : Number(e.target.value))\n          }\n          placeholder=\"0\"\n          className=\"h-10 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50\"\n        />\n        <NativeSelect\n          value={unit}\n          disabled={disabled}\n          onChange={(e) => handleUnitChange(e.target.value as VolumeUnit)}\n          aria-label=\"Unit\"\n        >\n          <NativeSelectOption value=\"m3\">m³</NativeSelectOption>\n          <NativeSelectOption value=\"t\">tonnes</NativeSelectOption>\n        </NativeSelect>\n      </div>\n    </div>\n  )\n}\n\nfunction DensityTooltip({ density }: { density: ReferenceDensity }) {\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger\n          render={\n            <button\n              type=\"button\"\n              aria-label=\"How conversion works\"\n              className=\"text-muted-foreground transition-colors hover:text-foreground\"\n            />\n          }\n        >\n          <InfoCircle className=\"size-4\" />\n        </TooltipTrigger>\n        <TooltipContent className=\"max-w-xs\">\n          <p>\n            Automatically converts between cubic metres (m³) and tonnes for known\n            materials, using industry-standard reference densities.\n          </p>\n          <p className=\"mt-1.5\">\n            Example: 10&nbsp;m³ ≈ {(10 * density.mid).toFixed(1)}&nbsp;t at{\" \"}\n            {density.mid}&nbsp;t/m³.\n          </p>\n          <p className=\"mt-1.5\">\n            Density range: {density.min}–{density.max}&nbsp;t/m³\n            {density.source ? ` · ${density.source}` : \"\"}\n          </p>\n        </TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/material-selector/material-volume-input.tsx"
    }
  ],
  "type": "registry:block"
}