Form Layouts
blocklayoutStructured 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
$pnpm dlx uipkge-ng@latest add form-layouts$npx uipkge-ng@latest add form-layouts$yarn dlx uipkge-ng@latest add form-layouts$bunx uipkge-ng@latest add form-layoutsManifest:
https://uipkge.dev/r/angular/form-layouts.jsonInstalls to:src/app/components/blocks/form-layouts/Variants
Loading interactive previews…
Loading interactive previews…
npm dependencies
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
export { UiFormLayoutsComponent, type FormLayoutsAccess } from './form-layouts.component'
Raw manifest:https://uipkge.dev/r/angular/form-layouts.json