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 shadcn@latest add https://uipkge.dev/r/lit/form-layouts.json
Manifest:https://uipkge.dev/r/lit/form-layouts.jsonInstalls to:src/uip/blocks/form-layouts/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

HTMLElementTagNameMap
interface 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