Menu

Change language

GitHub repo

Onboarding Wizard

blockauth

Multi-step workspace setup flow in a SectionCard: a four-step Stepper (Workspace → Team → Preferences → Done) with check icons on completed steps, a workspace-name input with live URL slug preview, teammate invites as removable email chips with a skip link, preference Switch rows, and a success panel with a glowing check circle. Footer carries Back/Continue actions plus a Step X of 4 counter. Data is stubbed inline — wire your API of choice.

Installation

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

Variants

Loading interactive previews…

Schema

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

Preference
interface Preference {
  id: string
  label: string
  description: string
  enabled: boolean
}

Files installed (2)

  • src/app/components/blocks/onboarding-wizard/onboarding-wizard.component.ts10.6 kB
    import { Component, Input, OnInit, computed, signal, ChangeDetectionStrategy } from '@angular/core'
    import { Check, ChevronLeft, ChevronRight, Globe, LucideAngularModule, Plus, X } from 'lucide-angular'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiSectionCardComponent } from '@/ui/section-card/section-card.component'
    import { UiStepperComponent, type StepperStep } from '@/ui/stepper/stepper.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    
    interface Preference {
      id: string
      label: string
      description: string
      enabled: boolean
    }
    
    const steps: StepperStep[] = [
      { id: 1, title: 'Workspace' },
      { id: 2, title: 'Team' },
      { id: 3, title: 'Preferences' },
      { id: 4, title: 'Done' },
    ]
    
    const defaultPreferences: Preference[] = [
      {
        id: 'digests',
        label: 'Email digests',
        description: 'A daily summary of workspace activity.',
        enabled: true,
      },
      {
        id: 'updates',
        label: 'Product updates',
        description: 'Occasional notes about new features and improvements.',
        enabled: false,
      },
      {
        id: 'reports',
        label: 'Weekly reports',
        description: 'Usage analytics delivered every Monday morning.',
        enabled: true,
      },
    ]
    
    const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    
    /**
     * Angular port of the React OnboardingWizard block: a SectionCard with a four-step Stepper
     * (Workspace, Team, Preferences, Done), a workspace-name input with a live URL slug, teammate
     * invites as removable chips, preference Switch rows and a success panel. Backward navigation
     * through the stepper is free; forward goes through the footer button only.
     *
     * Icons inside `ui-button` sit in `<lucide-icon>`, so the React `size="sm"` `has-[>svg]:px-2.5`
     * padding is set explicitly on those buttons.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-onboarding-wizard, [ui-onboarding-wizard]',
      standalone: true,
      // React renders the SectionCard itself: the host stays out of layout and `class` goes to the card.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiInputComponent,
        UiSectionCardComponent,
        UiStepperComponent,
        UiSwitchComponent,
      ],
      template: `
        <ui-section-card
          data-slot="onboarding-wizard"
          [title]="'Set up your workspace'"
          [description]="'Four quick steps and your team is ready to collaborate.'"
          [class]="className"
        >
          <ui-stepper [steps]="steps" [value]="step()" (valueChange)="onStepperInput($event)" class="mb-6" />
    
          @if (step() === 1) {
            <div class="space-y-4">
              <div class="space-y-2">
                <label for="onboarding-workspace-name" class="text-sm font-medium">Workspace name</label>
                <ui-input
                  id="onboarding-workspace-name"
                  [value]="workspaceName()"
                  (valueChange)="workspaceName.set($event)"
                  placeholder="Acme Inc"
                  autoComplete="off"
                />
              </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">uipkge.dev/</span>
                  <span class="text-foreground font-medium">{{ slug() }}</span>
                </p>
              </div>
            </div>
          }
    
          @if (step() === 2) {
            <div class="space-y-4">
              <form class="flex gap-2" (submit)="onInviteSubmit($event)">
                <ui-input
                  [value]="inviteDraft()"
                  (valueChange)="onInviteDraft($event)"
                  type="email"
                  placeholder="[email protected]"
                  [status]="inviteInvalid() ? 'error' : undefined"
                  class="flex-1"
                />
                <button ui-button type="submit" variant="outline" size="sm" class="shrink-0 px-2.5">
                  <lucide-icon [img]="Plus" aria-hidden="true" />
                  Add
                </button>
              </form>
              @if (inviteInvalid()) {
                <p role="alert" class="text-destructive text-xs">Enter a valid email address.</p>
              }
              @if (invites().length > 0) {
                <div class="flex flex-wrap gap-2">
                  @for (email of invites(); track email) {
                    <span ui-badge variant="secondary" class="gap-1 py-1 pr-1 pl-2.5"
                      >{{ email
                      }}<button
                        type="button"
                        [attr.aria-label]="'Remove ' + email"
                        class="hover:bg-foreground/10 focus-visible:ring-ring min-h-6 rounded-full p-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
                        (click)="removeInvite(email)"
                      >
                        <lucide-icon [img]="X" class="size-3" aria-hidden="true" />
                      </button>
                    </span>
                  }
                </div>
              } @else {
                <p class="text-muted-foreground text-xs">No teammates invited yet.</p>
              }
              <button
                type="button"
                class="text-muted-foreground hover:text-foreground focus-visible:ring-ring min-h-6 text-xs underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
                (click)="goNext()"
              >
                Skip for now
              </button>
            </div>
          }
    
          @if (step() === 3) {
            <div class="divide-y rounded-lg border">
              @for (pref of preferences(); track pref.id) {
                <div class="flex items-center justify-between gap-4 p-4">
                  <div class="min-w-0">
                    <p class="text-sm font-medium">{{ pref.label }}</p>
                    <p class="text-muted-foreground mt-0.5 text-xs">{{ pref.description }}</p>
                  </div>
                  <button
                    ui-switch
                    [attr.aria-label]="pref.label"
                    [checked]="pref.enabled"
                    (checkedChange)="setPreference(pref.id, $event)"
                  ></button>
                </div>
              }
            </div>
          }
    
          @if (step() === 4) {
            <div class="space-y-4 py-6 text-center">
              <div
                class="border-success/30 bg-success/10 text-success mx-auto flex size-16 items-center justify-center rounded-full border shadow-xs"
              >
                <lucide-icon [img]="Check" class="size-8" aria-hidden="true" />
              </div>
              <div>
                <p class="text-lg font-semibold">You're all set</p>
                <p class="text-muted-foreground mx-auto mt-1 max-w-sm text-sm">{{ doneMessage() }}</p>
              </div>
              <button ui-button variant="outline" size="sm" (click)="reset()">Set up another workspace</button>
            </div>
          }
    
          <div slot="footer" class="flex w-full items-center justify-between">
            @if (step() > 1 && step() < 4) {
              <button ui-button variant="ghost" size="sm" class="px-2.5" (click)="step.set(step() - 1)">
                <lucide-icon [img]="ChevronLeft" aria-hidden="true" />
                Back
              </button>
            } @else {
              <span aria-hidden="true"></span>
            }
            <span class="text-muted-foreground text-xs">Step {{ step() }} of 4</span>
            @if (step() < 4) {
              <button
                ui-button
                size="sm"
                class="px-2.5"
                [disabled]="step() === 1 && workspaceName().trim() === ''"
                (click)="goNext()"
              >
                {{ step() === 3 ? 'Finish' : 'Continue' }}
                <lucide-icon [img]="ChevronRight" aria-hidden="true" />
              </button>
            } @else {
              <span aria-hidden="true"></span>
            }
          </div>
        </ui-section-card>
      `,
    })
    export class UiOnboardingWizardComponent implements OnInit {
      /** Jump straight to a step (1 Workspace · 2 Team · 3 Preferences · 4 Done). */
      @Input() initialStep = 1
      /** Pre-fill the workspace name so later steps read naturally in isolation. */
      @Input() initialWorkspaceName = 'Acme Inc'
      /** Pre-seed invited teammates. */
      @Input() initialInvites: string[] = []
      @Input('class') className?: string
    
      protected readonly steps = steps
      protected readonly Check = Check
      protected readonly ChevronLeft = ChevronLeft
      protected readonly ChevronRight = ChevronRight
      protected readonly Globe = Globe
      protected readonly Plus = Plus
      protected readonly X = X
    
      readonly step = signal(1)
      readonly workspaceName = signal('Acme Inc')
      readonly invites = signal<string[]>([])
      readonly inviteDraft = signal('')
      readonly inviteInvalid = signal(false)
      readonly preferences = signal<Preference[]>(defaultPreferences.map((p) => ({ ...p })))
    
      readonly slug = computed(
        () =>
          this.workspaceName()
            .trim()
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, '-')
            .replace(/^-+|-+$/g, '') || 'your-workspace',
      )
    
      readonly doneMessage = computed(() => {
        const name = this.workspaceName() || 'Your workspace'
        const count = this.invites().length
        return count > 0
          ? `${name} is ready and ${count} invitation${count === 1 ? '' : 's'} sent.`
          : `${name} is ready — invite teammates anytime from settings.`
      })
    
      ngOnInit(): void {
        this.step.set(this.initialStep)
        this.workspaceName.set(this.initialWorkspaceName)
        this.invites.set([...this.initialInvites])
      }
    
      addInvite(): void {
        const email = this.inviteDraft().trim()
        if (!EMAIL_RE.test(email) || this.invites().includes(email)) {
          this.inviteInvalid.set(true)
          return
        }
        this.inviteInvalid.set(false)
        this.invites.update((prev) => [...prev, email])
        this.inviteDraft.set('')
      }
    
      onInviteSubmit(event: Event): void {
        event.preventDefault()
        this.addInvite()
      }
    
      onInviteDraft(value: string): void {
        this.inviteDraft.set(value)
        this.inviteInvalid.set(false)
      }
    
      removeInvite(email: string): void {
        this.invites.update((prev) => prev.filter((e) => e !== email))
      }
    
      setPreference(id: string, checked: boolean): void {
        this.preferences.update((prev) => prev.map((p) => (p.id === id ? { ...p, enabled: checked } : p)))
      }
    
      onStepperInput(value: number): void {
        // Free backward navigation; forward goes through the footer button only.
        const current = this.step()
        if (value < current && current !== 4) this.step.set(value)
      }
    
      goNext(): void {
        if (this.step() < 4) this.step.update((s) => s + 1)
      }
    
      reset(): void {
        this.step.set(1)
        this.workspaceName.set('')
        this.invites.set([])
        this.inviteDraft.set('')
      }
    }
    
  • src/app/components/blocks/onboarding-wizard/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/onboarding-wizard.json