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 shadcn@latest add https://uipkge.dev/r/lit/form-layouts.json$npx shadcn@latest add https://uipkge.dev/r/lit/form-layouts.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/form-layouts.json$bunx shadcn@latest add https://uipkge.dev/r/lit/form-layouts.jsonManifest:
https://uipkge.dev/r/lit/form-layouts.jsonInstalls to:src/uip/blocks/form-layouts/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-form-layouts': UipFormLayouts
}
}Files installed (1)
src/uip/blocks/form-layouts/form-layouts.ts12 kB
import { LitElement, css, html, nothing } from 'lit' import { Circle, Globe, Link2, Lock } from 'lucide' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect imports: register the composed elements for standalone use // (consumers who install only this block still get working primitives). import '../../components/button/button' import '../../components/input/input' import type { UipInput } from '../../components/input/input' import '../../components/label/label' import '../../components/section-card/section-card' import '../../components/select/select' import type { UipSelect } from '../../components/select/select' import '../../components/switch/switch' import type { UipSwitch } from '../../components/switch/switch' import '../../components/textarea/textarea' import type { UipTextarea } from '../../components/textarea/textarea' const DESCRIPTION_LIMIT = 280 type Access = 'link' | 'restricted' const radioCircle = 'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square shrink-0 rounded-full border shadow-sm transition-colors duration-200 outline-none focus-visible:ring-[3px] size-4 data-[state=checked]:border-primary mt-0.5' /** * <uip-form-layouts> — the registry FormLayouts block as a web component. * * Class strings are React's FormLayouts verbatim. React state (all fields) * becomes @state fields; the slug preview, char counter, and validation hint * derive the same way. `SectionCard`'s `title` is `heading` here (`title` is * taken by HTMLElement). No props, no events: edit the markup in place * (React ships no callbacks either). * * Deviation: the Access plan cards keep React's card visuals 1:1 but the * radio circle is block-local markup (React's `RadioGroup`/`RadioGroupItem` * only render plain labels, so they can't host the rich card content). The * cards form a real `radiogroup` (roving tabindex, arrows/Home/End) with * Radix's circle + indicator classes verbatim. */ export class UipFormLayouts extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { name: { state: true }, description: { state: true }, visibility: { state: true }, startDate: { state: true }, budget: { state: true }, access: { state: true }, notifications: { state: true }, comments: { state: true }, submitted: { state: true }, } private name = '' private description = '' private visibility = 'internal' private startDate = '' private budget = '' private access: Access = 'link' private notifications = true private comments = true private submitted = false constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'form-layouts') } private get slug() { return ( this.name .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'your-project' ) } private handleSubmit(e: Event) { e.preventDefault() this.submitted = true if (this.name.trim() === '') return } private onAccessKey(e: KeyboardEvent, value: Access) { const order: Access[] = ['link', 'restricted'] const at = order.indexOf(value) let next: Access | null = null if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = order[(at + 1) % order.length] ?? null else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = order[(at + order.length - 1) % order.length] ?? null else if (e.key === 'Home') next = 'link' else if (e.key === 'End') next = 'restricted' else if (e.key === ' ' || e.key === 'Enter') next = value if (!next) return e.preventDefault() this.access = next if (e.key !== ' ' && e.key !== 'Enter') { this.renderRoot.querySelector<HTMLElement>(`[data-access="${next}"]`)?.focus() } } private accessCard(value: Access, titleText: string, desc: string, iconNode: typeof Link2, iconName: string) { const checked = this.access === value return html` <div data-access=${value} role="radio" aria-checked=${checked ? 'true' : 'false'} tabindex=${checked ? '0' : '-1'} class="hover:bg-accent/50 has-data-[state=checked]:border-primary flex items-start gap-3 rounded-lg border p-3 transition-colors" @click=${() => (this.access = value)} @keydown=${(e: KeyboardEvent) => this.onAccessKey(e, value)} > <span data-uipkge="" data-slot="radio-group-item" data-state=${checked ? 'checked' : 'unchecked'} class=${radioCircle}> ${checked ? html`<span data-uipkge="" data-slot="radio-group-indicator" class="relative flex items-center justify-center"> ${icon(Circle, 'circle', 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 fill-current text-current size-2')} </span>` : nothing} </span> <div class="cursor-pointer select-none"> <span class="flex items-center gap-1.5 text-sm font-medium"> ${icon(iconNode, iconName, 'size-4')} ${titleText} </span> <span class="text-muted-foreground mt-0.5 block text-xs">${desc}</span> </div> </div> ` } render() { const showNameError = this.submitted && this.name.trim() === '' return html` <div data-slot="form-layouts" class="max-w-2xl"> <form class="space-y-6" novalidate @submit=${this.handleSubmit}> <uip-section-card heading="Project details" description="Name your project and describe what it covers."> <div class="space-y-4"> <div class="space-y-2"> <uip-label for="fl-name">Name</uip-label> <uip-input id="fl-name" .value=${this.name} placeholder="Q3 launch site" autocomplete="off" aria-invalid=${showNameError ? 'true' : nothing} @input=${(e: Event) => { this.name = (e.target as UipInput).value }} ></uip-input> ${showNameError ? html`<p role="alert" class="text-destructive text-xs">Project name is required.</p>` : nothing} </div> <div class="border-border bg-muted/40 flex items-center gap-2 rounded-md border px-3 py-2"> ${icon(Globe, 'globe', 'text-muted-foreground size-4 shrink-0')} <p class="min-w-0 truncate text-xs"> <span class="text-muted-foreground">acme.uipkge.app/</span> <span class="text-foreground font-medium">${this.slug}</span> </p> </div> <div class="space-y-2"> <uip-label for="fl-description">Description</uip-label> <uip-textarea id="fl-description" .value=${this.description} rows="3" placeholder="What is this project about?" @input=${(e: Event) => { this.description = (e.target as UipTextarea).value ?? '' }} ></uip-textarea> <p class=${`text-right text-xs ${this.description.length > DESCRIPTION_LIMIT ? 'text-destructive' : 'text-muted-foreground'}`} > ${this.description.length}/${DESCRIPTION_LIMIT} </p> </div> </div> </uip-section-card> <uip-section-card heading="Configuration" description="Visibility, schedule and budget for the project."> <div class="grid gap-4 sm:grid-cols-3"> <div class="space-y-2"> <uip-label for="fl-visibility">Visibility</uip-label> <uip-select id="fl-visibility" class="w-full" .value=${this.visibility} @change=${(e: Event) => { this.visibility = (e.target as UipSelect).value }} > <option value="public">Public</option> <option value="internal">Internal</option> <option value="private">Private</option> </uip-select> </div> <div class="space-y-2"> <uip-label for="fl-start-date">Start date</uip-label> <uip-input id="fl-start-date" type="date" .value=${this.startDate} @input=${(e: Event) => { this.startDate = (e.target as UipInput).value }} ></uip-input> </div> <div class="space-y-2"> <uip-label for="fl-budget">Budget</uip-label> <uip-input id="fl-budget" type="number" min="0" step="500" class="text-left" .value=${this.budget} @input=${(e: Event) => { this.budget = (e.target as UipInput).value }} ></uip-input> </div> </div> </uip-section-card> <uip-section-card heading="Access" description="Decide who can open the project and what they can do."> <div class="space-y-5"> <div role="radiogroup" class="gap-3" aria-label="Access"> ${this.accessCard('link', 'Anyone with the link', 'Guests can view without signing in.', Link2, 'link-2')} ${this.accessCard('restricted', 'Restricted', 'Only invited members can open the project.', Lock, 'lock')} </div> <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> <uip-switch .checked=${this.notifications} aria-label="Toggle notifications" @change=${(e: Event) => { this.notifications = (e.target as UipSwitch).checked }} ></uip-switch> </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> <uip-switch .checked=${this.comments} aria-label="Toggle comments" @change=${(e: Event) => { this.comments = (e.target as UipSwitch).checked }} ></uip-switch> </div> </div> </div> </uip-section-card> <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"> <uip-button type="button" variant="ghost" size="sm"> Cancel </uip-button> <uip-button type="submit" size="sm" ?disabled=${this.name.trim() === ''}> Create project </uip-button> </div> </form> </div> ` } } customElements.get('uip-form-layouts') || customElements.define('uip-form-layouts', UipFormLayouts) declare global { interface HTMLElementTagNameMap { 'uip-form-layouts': UipFormLayouts } }
Raw manifest:https://uipkge.dev/r/lit/form-layouts.json