{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "form",
  "title": "Form",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/form/form.component.ts",
      "content": "import {\n  Component,\n  DestroyRef,\n  Directive,\n  ElementRef,\n  Input,\n  type OnInit,\n  TemplateRef,\n  ViewChild,\n  afterEveryRender,\n  booleanAttribute,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport { UiRenderTemplateDirective, uniqueId } from '@/ui/popper/popper'\nimport { UiLabelComponent } from '@/ui/label/label.component'\n\nexport type FormStatusValue = 'error' | 'warning' | 'success' | null | undefined\n\ninterface Subscribable {\n  subscribe(fn: () => void): { unsubscribe(): void }\n}\n\n/** The parts of an @angular/forms AbstractControl the form parts read (structural, no import). */\nexport interface FormControlLike {\n  readonly invalid: boolean\n  readonly touched: boolean\n  readonly dirty: boolean\n  readonly errors: Record<string, unknown> | null\n}\n\n/** The parts of an @angular/forms FormGroup the form parts use: pass your FormGroup. */\nexport interface FormGroupLike extends FormControlLike {\n  get(path: string): FormControlLike | null\n  markAllAsTouched(): void\n  reset(value?: unknown): void\n  readonly events?: Subscribable\n}\n\n/**\n * React `Form` (react-hook-form FormProvider) for Angular reactive forms. Put it on the\n * `<form>` next to `[formGroup]` (it reads the same input) or on an `<ng-container [uiForm]=\"fg\">`.\n * It renders nothing. It records submit attempts, marks every control touched and focuses\n * the first invalid field, like react-hook-form's handleSubmit; FormField parts read errors\n * through it (shown after the first submit, then live -- react-hook-form's default modes).\n *\n * No @angular/forms import: any FormGroup satisfies the structural FormGroupLike.\n */\n@Directive({\n  selector: '[uiForm]',\n  standalone: true,\n  exportAs: 'uiForm',\n  host: { '(submit)': 'onSubmit()' },\n})\nexport class UiFormDirective {\n  private readonly host = inject(ElementRef).nativeElement as HTMLElement\n  private readonly destroyRef = inject(DestroyRef)\n  private sub?: { unsubscribe(): void }\n  private _group?: FormGroupLike\n\n  /** Bumped on every value / status change and submit, so readers re-evaluate (zoneless-safe). */\n  readonly version = signal(0)\n  readonly submitted = signal(false)\n\n  /** The FormGroup (the same `[formGroup]` the reactive-forms directive receives). */\n  @Input()\n  set formGroup(v: FormGroupLike | undefined) {\n    this.sub?.unsubscribe()\n    this._group = v\n    this.sub = v?.events?.subscribe(() => this.version.update((n) => n + 1))\n    this.version.update((n) => n + 1)\n  }\n  get formGroup(): FormGroupLike | undefined {\n    return this._group\n  }\n  /** Same as `formGroup`, for `<ng-container [uiForm]=\"fg\">`. */\n  @Input()\n  set uiForm(v: FormGroupLike | '' | undefined) {\n    if (v) this.formGroup = v\n  }\n\n  constructor() {\n    this.destroyRef.onDestroy(() => this.sub?.unsubscribe())\n  }\n\n  control(name: string): FormControlLike | null {\n    this.version()\n    return this._group?.get(name) ?? null\n  }\n\n  onSubmit(): void {\n    this.submitted.set(true)\n    this._group?.markAllAsTouched()\n    this.version.update((n) => n + 1)\n    if (this._group?.invalid) {\n      // react-hook-form shouldFocusError: focus the first invalid field once it is marked.\n      requestAnimationFrame(() => {\n        this.host.querySelector<HTMLElement>('[aria-invalid=\"true\"]:is(input, textarea, select, button)')?.focus()\n      })\n    }\n  }\n\n  /** Clear the submitted state (react-hook-form `reset`). */\n  reset(value?: Record<string, unknown>): void {\n    this._group?.reset(value)\n    this.submitted.set(false)\n    this.version.update((n) => n + 1)\n  }\n}\n\n/** Human message for a control's first error: a string, `{ message }`, or the error key. */\nfunction errorMessage(errors: Record<string, unknown> | null | undefined): string | undefined {\n  if (!errors) return undefined\n  const [key, value] = Object.entries(errors)[0] ?? []\n  if (key === undefined) return undefined\n  if (typeof value === 'string') return value\n  if (value && typeof value === 'object' && typeof (value as { message?: unknown }).message === 'string') {\n    return (value as { message: string }).message\n  }\n  return key\n}\n\n/**\n * React `FormField`: publishes the field name so the label / control / description /\n * message parts derive ids and error state. Use it on an `<ng-container>` (renders nothing)\n * or directly on the `<ui-form-item>`; bind the control itself with `formControlName`.\n */\n@Directive({\n  selector: '[uiFormField]',\n  standalone: true,\n  exportAs: 'uiFormField',\n})\nexport class UiFormFieldDirective {\n  private readonly form = inject(UiFormDirective, { optional: true })\n\n  /** The control name in the FormGroup (React `name`). */\n  @Input({ alias: 'uiFormField', required: true }) name = ''\n\n  get control(): FormControlLike | null {\n    return this.form?.control(this.name) ?? null\n  }\n\n  /** react-hook-form fieldState.error: only after a submit attempt, then live. */\n  get error(): { message?: string } | undefined {\n    const c = this.control\n    if (!c || !this.form?.submitted() || !c.invalid) return undefined\n    return { message: errorMessage(c.errors) }\n  }\n\n  get invalid(): boolean {\n    return !!this.control?.invalid\n  }\n  get isTouched(): boolean {\n    return !!this.control?.touched\n  }\n  get isDirty(): boolean {\n    return !!this.control?.dirty\n  }\n}\n\n/** React FormItem context: the per-item id the field parts build on. */\nexport class FormItemContext {\n  readonly id = uniqueId('form')\n}\n\nexport interface FormFieldState {\n  id: string\n  name: string\n  formItemId: string\n  formDescriptionId: string\n  formMessageId: string\n  error: { message?: string } | undefined\n  invalid: boolean\n  isTouched: boolean\n  isDirty: boolean\n}\n\n/**\n * React `useFormField()`: call it in an injection context inside a FormField. Returns a\n * getter-based view of the ids and error state (read it in templates / getters).\n */\nexport function injectFormField(): FormFieldState {\n  const field = inject(UiFormFieldDirective, { optional: true })\n  const item = inject(FormItemContext, { optional: true })\n  if (!field) throw new Error('useFormField must be used within <FormField> ([uiFormField])')\n  return {\n    get id() {\n      return item?.id ?? 'undefined'\n    },\n    get name() {\n      return field.name\n    },\n    get formItemId() {\n      return `${this.id}-form-item`\n    },\n    get formDescriptionId() {\n      return `${this.id}-form-item-description`\n    },\n    get formMessageId() {\n      return `${this.id}-form-item-message`\n    },\n    get error() {\n      return field.error\n    },\n    get invalid() {\n      return field.invalid\n    },\n    get isTouched() {\n      return field.isTouched\n    },\n    get isDirty() {\n      return field.isDirty\n    },\n  }\n}\n/** React name for {@link injectFormField}. */\nexport const useFormField = injectFormField\n\n/**\n * React FormItem: the `grid gap-1.5` field block. Optional `label` / `required` /\n * `description` / `status` / `help` props render the label row, hint and status help text\n * (and tint nested inputs by status); `layout=\"horizontal\"` puts the label in a\n * `labelWidth` column.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-form-item',\n  standalone: true,\n  imports: [UiLabelComponent],\n  providers: [FormItemContext],\n  host: {\n    'data-uipkge': '',\n    'data-slot': 'form-item',\n    '[class]': 'hostClass',\n    '[style.--label-width]': 'labelWidth || null',\n  },\n  template: `\n    @if (label || required) {\n      <div class=\"flex items-center gap-1\">\n        @if (label) {\n          <label ui-label [for]=\"ctx.id + '-form-item'\" [class]=\"labelClass\">{{ label }}</label>\n        }\n        @if (required) {\n          <span class=\"text-destructive text-sm\" aria-hidden=\"true\">*</span>\n        }\n      </div>\n    }\n    <div [class]=\"bodyClass\">\n      <ng-content />\n      @if (description) {\n        <p class=\"text-muted-foreground text-xs\">{{ description }}</p>\n      }\n      @if (help) {\n        <p [attr.role]=\"status === 'error' ? 'alert' : null\" [class]=\"helpClass\">{{ help }}</p>\n      }\n    </div>\n  `,\n})\nexport class UiFormItemComponent {\n  readonly ctx = inject(FormItemContext)\n\n  @Input() label?: string\n  @Input({ transform: booleanAttribute }) required = false\n  @Input() description?: string\n  @Input() status?: FormStatusValue\n  @Input() help?: string\n  @Input() layout: 'vertical' | 'horizontal' = 'vertical'\n  @Input() labelWidth?: string\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn(\n      'grid gap-1.5',\n      this.layout === 'horizontal' && 'grid-cols-[var(--label-width,140px)_1fr] items-start gap-x-4 gap-y-0',\n      this.className,\n    )\n  }\n\n  get labelClass(): string {\n    return cn(this.status === 'error' && 'text-destructive')\n  }\n\n  get bodyClass(): string {\n    return cn(\n      'space-y-1',\n      // An empty <ui-form-message> host stays in the DOM (React renders nothing): keep its\n      // previous sibling from picking up the space-y gap, as if it were the last child.\n      '[&>:has(+ui-form-message:empty:last-child)]:mb-0',\n      // React Radix Select renders a hidden native <select> as a direct sibling of <button>,\n      // giving <button> a 4px (mb-1) margin under space-y-1. Angular wraps them in <ui-select>,\n      // so give the trigger button that same 4px gap when a hidden select is present.\n      '[&>ui-select:has(select)>[data-slot=select-trigger]]:mb-1',\n      this.status === 'error' &&\n        '[&_input]:border-destructive [&_textarea]:border-destructive [&_button]:border-destructive',\n      this.status === 'warning' && '[&_input]:border-warning [&_textarea]:border-warning [&_button]:border-warning',\n      this.status === 'success' && '[&_input]:border-success [&_textarea]:border-success [&_button]:border-success',\n    )\n  }\n\n  get helpClass(): string {\n    return cn(\n      'text-xs',\n      this.status === 'error' && 'text-destructive',\n      this.status === 'warning' && 'text-warning',\n      this.status === 'success' && 'text-success',\n      !this.status && 'text-muted-foreground',\n    )\n  }\n}\n\n/** React FormLabel: the Label, linked to the field control and red while it has an error. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'label[ui-form-label], ui-form-label',\n  standalone: true,\n  host: {\n    // Runs after the inherited Label bindings, so these win.\n    '[attr.data-slot]': '\"form-label\"',\n    '[attr.data-error]': 'field.error ? \"true\" : \"false\"',\n  },\n  template: `<ng-content />`,\n})\nexport class UiFormLabelComponent extends UiLabelComponent {\n  readonly field = injectFormField()\n\n  constructor() {\n    super()\n    // React passes htmlFor={formItemId} before the user's props, so a `for` input still wins.\n    this.htmlFor = this.field.formItemId\n  }\n\n  override get hostClass(): string {\n    return cn(\n      'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',\n      cn('data-[error=true]:text-destructive', this.className),\n    )\n  }\n}\n\n/**\n * React FormControl (Radix Slot): put it on the control -- `<ui-input uiFormControl>`,\n * `<input uiFormControl>`, `<ui-textarea uiFormControl>`. The id / aria-describedby /\n * aria-invalid / data-slot land on the native input, textarea or select (the host itself\n * when it is one), which is where React's Slot props end up for Input.\n */\n@Directive({\n  selector: '[uiFormControl], [ui-form-control]',\n  standalone: true,\n})\nexport class UiFormControlDirective {\n  private readonly host = inject(ElementRef).nativeElement as HTMLElement\n  readonly field = injectFormField()\n\n  constructor() {\n    afterEveryRender(() => this.apply())\n  }\n\n  private get target(): HTMLElement | null {\n    if (this.host.matches('input, textarea, select, button')) return this.host\n    return this.host.querySelector<HTMLElement>('input, textarea, select')\n  }\n\n  private apply(): void {\n    const el = this.target\n    if (!el) return\n    const f = this.field\n    const describedBy = !f.error ? f.formDescriptionId : `${f.formDescriptionId} ${f.formMessageId}`\n    const set = (name: string, value: string) => {\n      if (el.getAttribute(name) !== value) el.setAttribute(name, value)\n    }\n    set('data-uipkge', '')\n    set('data-slot', 'form-control')\n    set('id', f.formItemId)\n    set('aria-describedby', describedBy)\n    set('aria-invalid', f.error ? 'true' : 'false')\n    // An Input's bordered wrapper mirrors aria-invalid for its destructive ring.\n    const wrapper = el.closest('[data-slot=\"input\"]')\n    if (wrapper && this.host.contains(wrapper)) {\n      if (wrapper.getAttribute('aria-invalid') !== (f.error ? 'true' : 'false'))\n        wrapper.setAttribute('aria-invalid', f.error ? 'true' : 'false')\n    }\n  }\n}\n\n/** React FormDescription: hint text wired to the control through aria-describedby. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'p[ui-form-description], ui-form-description',\n  standalone: true,\n  host: {\n    'data-uipkge': '',\n    'data-slot': 'form-description',\n    '[attr.id]': 'field.formDescriptionId',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiFormDescriptionComponent {\n  readonly field = injectFormField()\n  private readonly isP = inject(ElementRef).nativeElement.tagName === 'P'\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn(!this.isP && 'block', 'text-muted-foreground text-sm', this.className)\n  }\n}\n\n/**\n * React FormMessage: the field's error (role=alert), or the projected text when there is\n * none; renders nothing when both are empty.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-form-message',\n  standalone: true,\n  imports: [UiRenderTemplateDirective],\n  host: { '[attr.class]': '\"contents\"' },\n  template: `\n    <ng-template #content><ng-content /></ng-template>\n    @if (field.error?.message; as message) {\n      <p data-uipkge=\"\" data-slot=\"form-message\" [attr.id]=\"field.formMessageId\" role=\"alert\" [class]=\"pClass\">\n        {{ message }}\n      </p>\n    } @else if (hasContent) {\n      <p data-uipkge=\"\" data-slot=\"form-message\" [attr.id]=\"field.formMessageId\" role=\"alert\" [class]=\"pClass\">\n        <ng-container [uiRenderTemplate]=\"content\" />\n      </p>\n    }\n  `,\n})\nexport class UiFormMessageComponent implements OnInit {\n  readonly field = injectFormField()\n  @ViewChild('content', { static: true }) content!: TemplateRef<unknown>\n  @Input('class') className?: string\n  /** Whether anything was projected (React `children`), measured once. */\n  hasContent = false\n\n  ngOnInit(): void {\n    const probe = this.content.createEmbeddedView(null)\n    this.hasContent = probe.rootNodes.some(\n      (n: Node) => n.nodeType === Node.ELEMENT_NODE || (n.nodeType === Node.TEXT_NODE && !!n.textContent?.trim()),\n    )\n    probe.destroy()\n  }\n\n  get pClass(): string {\n    return cn('text-destructive text-sm', this.className)\n  }\n}\n\n/** React FormSection: optional divider + heading / subtitle, description, then the fields. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-form-section',\n  standalone: true,\n  host: {\n    'data-uipkge': '',\n    'data-slot': 'form-section',\n    '[class]': 'hostClass',\n  },\n  template: `\n    @if (divider || title || subtitle) {\n      <div [class]=\"dividerClass\">\n        @if (title || subtitle) {\n          <div class=\"space-y-1\">\n            @if (title) {\n              @switch (headingLevel) {\n                @case ('h2') {\n                  <h2 class=\"text-sm font-semibold\">{{ title }}</h2>\n                }\n                @case ('h3') {\n                  <h3 class=\"text-sm font-semibold\">{{ title }}</h3>\n                }\n                @case ('h5') {\n                  <h5 class=\"text-sm font-semibold\">{{ title }}</h5>\n                }\n                @default {\n                  <h4 class=\"text-sm font-semibold\">{{ title }}</h4>\n                }\n              }\n            }\n            @if (subtitle) {\n              <p class=\"text-muted-foreground text-xs\">{{ subtitle }}</p>\n            }\n          </div>\n        }\n      </div>\n    }\n    @if (description) {\n      <p class=\"text-muted-foreground text-xs\">{{ description }}</p>\n    }\n    <ng-content />\n  `,\n})\nexport class UiFormSectionComponent {\n  @Input() title?: string\n  @Input() subtitle?: string\n  @Input() description?: string\n  @Input({ transform: booleanAttribute }) divider = false\n  @Input() headingLevel: 'h2' | 'h3' | 'h4' | 'h5' = 'h4'\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn('block space-y-3', this.className)\n  }\n\n  get dividerClass(): string {\n    return cn(this.divider && 'border-t pt-4')\n  }\n}\n\nconst alignClasses = { left: 'justify-start', center: 'justify-center', right: 'justify-end' } as const\nconst gapClasses = { sm: 'gap-2', md: 'gap-3', lg: 'gap-4' } as const\n\n/** React FormActions: the button row. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-form-actions',\n  standalone: true,\n  host: {\n    'data-uipkge': '',\n    'data-slot': 'form-actions',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiFormActionsComponent {\n  @Input() align: 'left' | 'center' | 'right' = 'right'\n  @Input() gap: 'sm' | 'md' | 'lg' = 'md'\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn('flex flex-wrap items-center', alignClasses[this.align], gapClasses[this.gap], this.className)\n  }\n}\n\nconst statusContainer = {\n  error: 'bg-destructive/10 text-destructive border-destructive/20',\n  warning: 'bg-warning/10 text-warning border-warning/30',\n  success: 'bg-success/10 text-success border-success/30',\n} as const\n\n/** React FormStatus: a tinted banner with an icon; role=alert for errors, status otherwise. */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-form-status',\n  standalone: true,\n  host: {\n    'data-uipkge': '',\n    'data-slot': 'form-status',\n    '[attr.role]': 'status === \"error\" ? \"alert\" : \"status\"',\n    '[class]': 'hostClass',\n  },\n  template: `\n    @switch (status) {\n      @case ('error') {\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          class=\"lucide lucide-circle-alert size-4 shrink-0\"\n          aria-hidden=\"true\"\n        >\n          <circle cx=\"12\" cy=\"12\" r=\"10\" />\n          <line x1=\"12\" x2=\"12\" y1=\"8\" y2=\"12\" />\n          <line x1=\"12\" x2=\"12.01\" y1=\"16\" y2=\"16\" />\n        </svg>\n      }\n      @case ('warning') {\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          class=\"lucide lucide-triangle-alert size-4 shrink-0\"\n          aria-hidden=\"true\"\n        >\n          <path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\" />\n          <path d=\"M12 9v4\" />\n          <path d=\"M12 17h.01\" />\n        </svg>\n      }\n      @case ('success') {\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"24\"\n          height=\"24\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          class=\"lucide lucide-circle-check size-4 shrink-0\"\n          aria-hidden=\"true\"\n        >\n          <circle cx=\"12\" cy=\"12\" r=\"10\" />\n          <path d=\"m9 12 2 2 4-4\" />\n        </svg>\n      }\n    }\n    <span>{{ message }}</span>\n  `,\n})\nexport class UiFormStatusComponent {\n  @Input() status: NonNullable<FormStatusValue> = 'error'\n  @Input() message = ''\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn(\n      'flex items-center gap-2 rounded-md border px-3 py-2 text-sm',\n      statusContainer[this.status],\n      this.className,\n    )\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/form/form.component.ts"
    },
    {
      "path": "packages/registry-angular/components/form/index.ts",
      "content": "export {\n  UiFormDirective,\n  UiFormFieldDirective,\n  UiFormItemComponent,\n  UiFormLabelComponent,\n  UiFormControlDirective,\n  UiFormDescriptionComponent,\n  UiFormMessageComponent,\n  UiFormSectionComponent,\n  UiFormActionsComponent,\n  UiFormStatusComponent,\n  FormItemContext,\n  injectFormField,\n  useFormField,\n  type FormFieldState,\n  type FormStatusValue,\n  type FormGroupLike,\n  type FormControlLike,\n} from './form.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/form/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/label.json",
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "description": "Form parts for Angular reactive forms (the shadcn / react-hook-form API): uiForm + uiFormField publish the field, and FormItem / FormLabel / FormControl / FormDescription / FormMessage wire ids, aria-describedby / aria-invalid and error text automatically. Plus FormSection, FormActions and FormStatus helpers.",
  "categories": [
    "form"
  ]
}