Menu

Change language

GitHub repo

Form Layouts

blocklayout

Structured create/edit entity form ("New project"): stacked SectionCards for project details (name with live slug preview, description with char counter), configuration (visibility Select, start date, budget), and access (link vs restricted RadioGroup, notification/comment Switch rows) with a sticky-feel footer bar. Create stays disabled until the name is filled; validation hints appear under empty required fields after the first submit attempt.

Installation

$npx uipkge-ng@latest add form-layouts
Manifest:https://uipkge.dev/r/angular/form-layouts.jsonInstalls to:src/app/components/blocks/form-layouts/

Variants

Loading interactive previews…

Files installed (2)

  • src/app/components/blocks/form-layouts/form-layouts.component.ts9.8 kB
    // Structured create/edit entity form ("New project"): stacked SectionCards for project
    // details (name with live slug preview, description with char counter), configuration
    // (visibility Select, start date, budget), and access (link vs restricted RadioGroup,
    // notification/comment Switch rows) with a sticky-feel footer bar. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import { Globe, Link2, Lock, LucideAngularModule } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiLabelComponent } from '@/ui/label/label.component'
    import { UiRadioGroupComponent, UiRadioGroupItemComponent } from '@/ui/radio-group/radio-group.component'
    import { UiSectionCardComponent } from '@/ui/section-card/section-card.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    const DESCRIPTION_LIMIT = 280
    
    export type FormLayoutsAccess = 'link' | 'restricted'
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-form-layouts, [ui-form-layouts]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiButtonComponent,
        UiInputComponent,
        UiLabelComponent,
        UiRadioGroupComponent,
        UiRadioGroupItemComponent,
        UiSectionCardComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSwitchComponent,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="form-layouts" [class]="rootClass">
          <form class="space-y-6" novalidate (submit)="handleSubmit($event)">
            <div ui-section-card title="Project details" description="Name your project and describe what it covers.">
              <div class="space-y-4">
                <div class="space-y-2">
                  <label ui-label for="fl-name">Name</label>
                  <ui-input
                    id="fl-name"
                    [value]="name()"
                    (valueChange)="name.set($event)"
                    placeholder="Q3 launch site"
                    autoComplete="off"
                    [aria-invalid]="showNameError() || undefined"
                  />
                  @if (showNameError()) {
                    <p role="alert" class="text-destructive text-xs">Project name is required.</p>
                  }
                </div>
                <div class="border-border bg-muted/40 flex items-center gap-2 rounded-md border px-3 py-2">
                  <lucide-icon [img]="Globe" class="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
                  <p class="min-w-0 truncate text-xs">
                    <span class="text-muted-foreground">acme.uipkge.app/</span>
                    <span class="text-foreground font-medium">{{ slug() }}</span>
                  </p>
                </div>
                <div class="space-y-2">
                  <label ui-label for="fl-description">Description</label>
                  <ui-textarea
                    id="fl-description"
                    [value]="description()"
                    (valueChange)="description.set($event)"
                    [rows]="3"
                    placeholder="What is this project about?"
                  />
                  <p
                    class="text-right text-xs"
                    [class]="description().length > limit ? 'text-destructive' : 'text-muted-foreground'"
                  >
                    {{ description().length }}/{{ limit }}
                  </p>
                </div>
              </div>
            </div>
    
            <div ui-section-card title="Configuration" description="Visibility, schedule and budget for the project.">
              <div class="grid gap-4 sm:grid-cols-3">
                <div class="space-y-2">
                  <label ui-label for="fl-visibility">Visibility</label>
                  <ui-select [value]="visibility()" (valueChange)="visibility.set($event)">
                    <button ui-select-trigger id="fl-visibility" class="w-full">
                      <ui-select-value />
                    </button>
                    <ui-select-content>
                      <ui-select-item value="public">Public</ui-select-item>
                      <ui-select-item value="internal">Internal</ui-select-item>
                      <ui-select-item value="private">Private</ui-select-item>
                    </ui-select-content>
                  </ui-select>
                </div>
                <div class="space-y-2">
                  <label ui-label for="fl-start-date">Start date</label>
                  <ui-input id="fl-start-date" type="date" [value]="startDate()" (valueChange)="startDate.set($event)" />
                </div>
                <div class="space-y-2">
                  <label ui-label for="fl-budget">Budget</label>
                  <ui-input
                    id="fl-budget"
                    type="number"
                    [min]="0"
                    [step]="500"
                    class="text-left"
                    [value]="budget()"
                    (valueChange)="budget.set($event)"
                  />
                </div>
              </div>
            </div>
    
            <div ui-section-card title="Access" description="Decide who can open the project and what they can do.">
              <div class="space-y-5">
                <ui-radio-group [value]="access()" (valueChange)="setAccess($event)" class="gap-3">
                  <div
                    class="hover:bg-accent/50 has-data-[state=checked]:border-primary flex items-start gap-3 rounded-lg border p-3 transition-colors"
                  >
                    <ui-radio-group-item id="fl-access-link" value="link" class="mt-0.5" />
                    <label for="fl-access-link" class="cursor-pointer select-none">
                      <span class="flex items-center gap-1.5 text-sm font-medium">
                        <lucide-icon [img]="Link2" class="size-4" aria-hidden="true" />
                        Anyone with the link
                      </span>
                      <span class="text-muted-foreground mt-0.5 block text-xs"> Guests can view without signing in. </span>
                    </label>
                  </div>
                  <div
                    class="hover:bg-accent/50 has-data-[state=checked]:border-primary flex items-start gap-3 rounded-lg border p-3 transition-colors"
                  >
                    <ui-radio-group-item id="fl-access-restricted" value="restricted" class="mt-0.5" />
                    <label for="fl-access-restricted" class="cursor-pointer select-none">
                      <span class="flex items-center gap-1.5 text-sm font-medium">
                        <lucide-icon [img]="Lock" class="size-4" aria-hidden="true" />
                        Restricted
                      </span>
                      <span class="text-muted-foreground mt-0.5 block text-xs">
                        Only invited members can open the project.
                      </span>
                    </label>
                  </div>
                </ui-radio-group>
    
                <div class="divide-y rounded-lg border">
                  <div class="flex items-center justify-between gap-4 p-4">
                    <div class="min-w-0">
                      <p class="text-sm font-medium">Notifications</p>
                      <p class="text-muted-foreground mt-0.5 text-xs">Email the team on status changes and mentions.</p>
                    </div>
                    <ui-switch
                      [checked]="notifications()"
                      (checkedChange)="notifications.set($event)"
                      aria-label="Toggle notifications"
                    />
                  </div>
                  <div class="flex items-center justify-between gap-4 p-4">
                    <div class="min-w-0">
                      <p class="text-sm font-medium">Comments</p>
                      <p class="text-muted-foreground mt-0.5 text-xs">Let viewers comment on tasks and updates.</p>
                    </div>
                    <ui-switch [checked]="comments()" (checkedChange)="comments.set($event)" aria-label="Toggle comments" />
                  </div>
                </div>
              </div>
            </div>
    
            <div
              class="bg-background/80 border-border sticky bottom-0 -mx-1 flex items-center justify-end gap-2 rounded-b-xl border-t px-1 py-4 backdrop-blur"
            >
              <button ui-button type="button" variant="ghost" size="sm">Cancel</button>
              <button ui-button type="submit" size="sm" [disabled]="name().trim() === ''">Create project</button>
            </div>
          </form>
        </div>
      `,
    })
    export class UiFormLayoutsComponent {
      protected readonly Globe = Globe
      protected readonly Link2 = Link2
      protected readonly Lock = Lock
    
      protected readonly limit = DESCRIPTION_LIMIT
    
      readonly name = signal('')
      readonly description = signal('')
      readonly visibility = signal('internal')
      readonly startDate = signal('')
      readonly budget = signal('')
      readonly access = signal<FormLayoutsAccess>('link')
      readonly notifications = signal(true)
      readonly comments = signal(true)
      readonly submitted = signal(false)
    
      @Input('class') className?: string
    
      readonly slug = computed(() => {
        const cleaned = this.name()
          .trim()
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, '-')
          .replace(/^-+|-+$/g, '')
        return cleaned || 'your-project'
      })
    
      readonly showNameError = computed(() => this.submitted() && this.name().trim() === '')
    
      get rootClass(): string {
        return cn('max-w-2xl', this.className)
      }
    
      setAccess(value: string): void {
        this.access.set(value as FormLayoutsAccess)
      }
    
      handleSubmit(event?: Event): void {
        event?.preventDefault()
        this.submitted.set(true)
        if (this.name().trim() === '') return
      }
    }
    
  • src/app/components/blocks/form-layouts/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/form-layouts.json