Menu

Change language

GitHub repo

Settings Page

blocklayout

Linear-style settings shell: a nav rail of icon sections (collapses to a horizontal tab strip on narrow screens) switching between fully composed panels — profile form with sticky save bar, notification switches, theme picker, workspace identity, billing summary, and a type-to-confirm danger zone. Swap the panel internals for your own forms.

Installation

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

Variants

Loading interactive previews…

Schema

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

SettingsSection
interface SettingsSection {
  id: SectionId
  label: string
  icon: LucideIconData
}

Files installed (2)

  • src/app/components/blocks/settings-page/settings-page.component.ts14.6 kB
    // Linear-style settings shell: a nav rail of icon sections (collapses to a horizontal
    // tab strip on narrow screens) switching between fully composed panels — profile form with
    // sticky save bar, notification switches, theme picker, workspace identity, billing summary,
    // and a type-to-confirm danger zone. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      Bell,
      Building2,
      CreditCard,
      LucideAngularModule,
      Palette,
      TriangleAlert,
      User,
      type LucideIconData,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiLabelComponent } from '@/ui/label/label.component'
    import { UiSectionCardComponent } from '@/ui/section-card/section-card.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export type SectionId = 'profile' | 'notifications' | 'appearance' | 'workspace' | 'billing' | 'danger'
    
    export interface SettingsSection {
      id: SectionId
      label: string
      icon: LucideIconData
    }
    
    const defaultSections: SettingsSection[] = [
      { id: 'profile', label: 'Profile', icon: User },
      { id: 'notifications', label: 'Notifications', icon: Bell },
      { id: 'appearance', label: 'Appearance', icon: Palette },
      { id: 'workspace', label: 'Workspace', icon: Building2 },
      { id: 'billing', label: 'Billing', icon: CreditCard },
      { id: 'danger', label: 'Danger zone', icon: TriangleAlert },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-settings-page, [ui-settings-page]',
      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,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiInputComponent,
        UiLabelComponent,
        UiSectionCardComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiSwitchComponent,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="settings-page" [class]="rootClass">
          <!-- Nav rail -->
          <nav class="border-border shrink-0 border-b p-3 lg:w-56 lg:border-r lg:border-b-0" aria-label="Settings sections">
            <div
              class="flex [scrollbar-width:none] gap-1 overflow-x-auto lg:flex-col lg:overflow-visible [&::-webkit-scrollbar]:hidden"
            >
              @for (section of sections(); track section.id) {
                <button type="button" [class]="navButtonClass(section.id)"
                  [attr.aria-current]="active() === section.id ? 'page' : null"
                  (click)="switchPanel(section.id)"
                >
                  <lucide-icon [img]="section.icon" class="size-4" aria-hidden="true" />
                  {{ section.label }}
                </button>
              }
            </div>
          </nav>
    
          <!-- Content pane -->
          <div class="flex min-w-0 flex-1 flex-col">
            @if (active() === 'profile') {
              <section class="flex flex-1 flex-col">
                <div class="flex-1 space-y-6 p-6">
                  <ui-section-card title="Public profile" description="How teammates see you across the workspace.">
                    <div class="space-y-4">
                      <div class="space-y-2">
                        <label ui-label for="sp-name">Display name</label>
                        <ui-input id="sp-name" defaultValue="Amara Osei" />
                      </div>
                      <div class="space-y-2">
                        <label ui-label for="sp-email">Email</label>
                        <ui-input id="sp-email" defaultValue="amara@acme.com" type="email" />
                      </div>
                      <div class="space-y-2">
                        <label ui-label for="sp-bio">Bio</label>
                        <ui-textarea
                          id="sp-bio"
                          defaultValue="Design engineer. Builds systems, breaks assumptions."
                          [rows]="3"
                        />
                      </div>
                      <div class="space-y-2">
                        <label ui-label for="sp-role">Role</label>
                        <ui-select [value]="role()" (valueChange)="role.set($event)">
                          <button ui-select-trigger id="sp-role" class="w-full sm:w-56">
                            <ui-select-value />
                          </button>
                          <ui-select-content>
                            <ui-select-item value="admin">Admin</ui-select-item>
                            <ui-select-item value="editor">Editor</ui-select-item>
                            <ui-select-item value="viewer">Viewer</ui-select-item>
                          </ui-select-content>
                        </ui-select>
                      </div>
                    </div>
                  </ui-section-card>
                </div>
                <div class="border-border flex items-center justify-end gap-2 border-t p-4">
                  <button ui-button variant="ghost" size="sm">Discard</button>
                  <button ui-button size="sm">Save changes</button>
                </div>
              </section>
            } @else if (active() === 'notifications') {
              <section class="flex-1 p-6">
                <ui-section-card title="Notifications" description="Choose what reaches you and where.">
                  <ul class="-my-4 divide-y">
                    <li class="flex items-center justify-between gap-4 py-4">
                      <div>
                        <p class="text-sm font-medium">Email notifications</p>
                        <p class="text-muted-foreground text-xs">Mentions, assignments and approvals.</p>
                      </div>
                      <button
                        ui-switch
                        [checked]="notifyEmail()"
                        (checkedChange)="notifyEmail.set($event)"
                        aria-label="Toggle email notifications"
                      ></button>
                    </li>
                    <li class="flex items-center justify-between gap-4 py-4">
                      <div>
                        <p class="text-sm font-medium">Push notifications</p>
                        <p class="text-muted-foreground text-xs">Real-time browser alerts while you work.</p>
                      </div>
                      <button
                        ui-switch
                        [checked]="notifyPush()"
                        (checkedChange)="notifyPush.set($event)"
                        aria-label="Toggle push notifications"
                      ></button>
                    </li>
                    <li class="flex items-center justify-between gap-4 py-4">
                      <div>
                        <p class="text-sm font-medium">Weekly digest</p>
                        <p class="text-muted-foreground text-xs">A summary of everything, every Monday.</p>
                      </div>
                      <button
                        ui-switch
                        [checked]="notifyDigest()"
                        (checkedChange)="notifyDigest.set($event)"
                        aria-label="Toggle weekly digest"
                      ></button>
                    </li>
                  </ul>
                </ui-section-card>
              </section>
            } @else if (active() === 'appearance') {
              <section class="flex-1 p-6">
                <ui-section-card title="Appearance" description="Theme follows your system by default.">
                  <div class="space-y-5">
                    <div class="grid grid-cols-3 gap-3">
                      @for (theme of themeChoices; track theme) {
                        <button
                          type="button"
                          [class]="themeButtonClass(theme)"
                          (click)="themeChoice.set(theme)"
                        >
                          <span class="block text-sm font-medium capitalize">{{ theme }}</span>
                          <span class="text-muted-foreground block text-xs">{{ themeHint(theme) }}</span>
                        </button>
                      }
                    </div>
                    <ui-separator />
                    <div class="flex items-center justify-between">
                      <div>
                        <p class="text-sm font-medium">Reduced motion</p>
                        <p class="text-muted-foreground text-xs">Collapse animations to instant transitions.</p>
                      </div>
                      <button ui-switch [checked]="false" aria-label="Toggle reduced motion"></button>
                    </div>
                  </div>
                </ui-section-card>
              </section>
            } @else if (active() === 'workspace') {
              <section class="flex-1 p-6">
                <ui-section-card title="Workspace" description="Identity and defaults for everyone here.">
                  <div class="space-y-4">
                    <div class="space-y-2">
                      <label ui-label for="sp-ws-name">Workspace name</label>
                      <ui-input id="sp-ws-name" defaultValue="Acme Inc" />
                    </div>
                    <div class="space-y-2">
                      <label ui-label for="sp-ws-url">URL slug</label>
                      <div class="flex">
                        <span
                          class="border-border bg-muted text-muted-foreground flex items-center rounded-l-md border px-3 font-mono text-xs"
                        >
                          acme.uipkge.app
                        </span>
                        <ui-input id="sp-ws-url" defaultValue="acme" class="rounded-l-none font-mono text-xs" />
                      </div>
                    </div>
                    <p class="text-muted-foreground text-xs">16 members · 4 pending invites</p>
                  </div>
                </ui-section-card>
              </section>
            } @else if (active() === 'billing') {
              <section class="flex-1 p-6">
                <ui-section-card title="Billing" description="Plan, seats and invoices.">
                  <div class="flex items-center justify-between gap-4">
                    <div>
                      <p class="text-sm font-medium">Team plan · $12 / seat / month</p>
                      <p class="text-muted-foreground mt-1 text-xs">Next invoice Sep 1, 2026 · 18 seats</p>
                    </div>
                    <button ui-button variant="outline" size="sm">Manage billing</button>
                  </div>
                </ui-section-card>
              </section>
            } @else {
              <section class="flex-1 p-6">
                <div class="border-destructive/40 rounded-xl border p-6">
                  <div class="flex items-center justify-between gap-4">
                    <div>
                      <p class="text-destructive text-sm font-medium">Delete this workspace</p>
                      <p class="text-muted-foreground mt-1 text-xs">
                        All projects, members and data will be permanently removed.
                      </p>
                    </div>
                    <button ui-button variant="destructive" size="sm" (click)="deleteOpen.set(true)">Delete…</button>
                  </div>
                </div>
              </section>
            }
          </div>
    
          <ui-dialog [open]="deleteOpen()" (openChange)="deleteOpen.set($event)">
            <ui-dialog-content class="max-w-sm">
              <div ui-dialog-header>
                <h2 ui-dialog-title>Delete workspace?</h2>
                <p ui-dialog-description>
                  This removes Acme Inc and all of its data for every member. This cannot be undone.
                </p>
              </div>
              <ui-input
                [value]="deleteConfirm()"
                (valueChange)="deleteConfirm.set($event)"
                placeholder='Type "DELETE" to confirm'
              />
              <div ui-dialog-footer>
                <button ui-button variant="outline" size="sm" (click)="deleteOpen.set(false)">Cancel</button>
                <button
                  ui-button
                  variant="destructive"
                  size="sm"
                  [disabled]="deleteConfirm() !== 'DELETE'"
                  (click)="deleteOpen.set(false)"
                >
                  Delete forever
                </button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiSettingsPageComponent {
      @Input('sections') set sectionsInput(v: SettingsSection[] | undefined) {
        if (v) this.sectionsOverride.set([...v])
      }
      @Input('initialSection') set initialSectionInput(v: SectionId | undefined) {
        if (v) this.active.set(v)
      }
      @Input('class') className?: string
    
      readonly sectionsOverride = signal<SettingsSection[] | null>(null)
      readonly sections = computed(() => this.sectionsOverride() ?? defaultSections)
      readonly active = signal<SectionId>('profile')
    
      readonly notifyEmail = signal(true)
      readonly notifyPush = signal(false)
      readonly notifyDigest = signal(true)
      readonly role = signal('editor')
      readonly themeChoice = signal<'light' | 'dark' | 'system'>('system')
      readonly themeChoices: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']
      readonly deleteOpen = signal(false)
      readonly deleteConfirm = signal('')
    
      get rootClass(): string {
        return cn('bg-background border-border flex min-h-svh flex-col rounded-xl border lg:flex-row', this.className)
      }
    
      navButtonClass(id: SectionId): string {
        // [class] overwrites the whole class attribute: the base layout/focus classes must be merged here.
        return cn(
          'focus-visible:ring-ring flex shrink-0 items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
          this.active() === id
            ? id === 'danger'
              ? 'text-destructive bg-destructive/10'
              : 'bg-accent text-accent-foreground'
            : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
        )
      }
    
      themeButtonClass(theme: 'light' | 'dark' | 'system'): string {
        return cn(
          'focus-visible:ring-ring rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none',
          this.themeChoice() === theme ? 'border-primary ring-primary/20 ring-2' : 'hover:bg-accent/50',
        )
      }
    
      themeHint(theme: 'light' | 'dark' | 'system'): string {
        return theme === 'light' ? 'Bright surfaces' : theme === 'dark' ? 'Low-light friendly' : 'Match device'
      }
    
      switchPanel(id: SectionId): void {
        this.active.set(id)
      }
    }
    
  • src/app/components/blocks/settings-page/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/settings-page.json