Menu

Change language

GitHub repo

Enterprise Workspace Switcher

blocklayout

Multi-organization workspace switcher dropdown and creation modal. Features active organization display, 3 tier levels (Enterprise Scale, Pro Team, Free Sandbox), team role badges, member counts, and an organization creation dialog with dynamic slug preview, region selector, and tier plan radios.

Installation

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

Variants

Loading interactive previews…

Schema

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

WorkspaceItem
interface WorkspaceItem {
  id: string
  name: string
  slug?: string
  plan: string
  planTier: string
  members: number
  region: string
  regionCode: string
  role: 'Owner' | 'Admin' | 'Member' | 'Personal'
  color: string
  initials: string
}
WorkspaceUserInfo
interface WorkspaceUserInfo {
  name: string
  email: string
  avatar?: string
}

Files installed (2)

  • src/app/components/blocks/enterprise-workspace-switcher/enterprise-workspace-switcher.component.ts22.2 kB
    // Multi-organization workspace switcher dropdown and creation modal: active organization display,
    // tier levels, team role badges, member counts, and an organization creation dialog with dynamic
    // slug preview, region selector, and tier plan radios. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'
    import {
      Building2,
      Check,
      ChevronsUpDown,
      Globe,
      LogOut,
      LucideAngularModule,
      Plus,
      Settings,
      UserPlus,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuLabelComponent,
      UiDropdownMenuSeparatorComponent,
      UiDropdownMenuTriggerComponent,
    } from '@/ui/dropdown-menu/dropdown-menu.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiRadioGroupComponent, UiRadioGroupItemComponent } from '@/ui/radio-group/radio-group.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    
    export interface WorkspaceItem {
      id: string
      name: string
      slug?: string
      plan: string
      planTier: string
      members: number
      region: string
      regionCode: string
      role: 'Owner' | 'Admin' | 'Member' | 'Personal'
      color: string
      initials: string
    }
    
    export interface WorkspaceUserInfo {
      name: string
      email: string
      avatar?: string
    }
    
    const DEFAULT_WORKSPACES: WorkspaceItem[] = [
      {
        id: 'ws-1',
        name: 'UIPKGE Enterprise Inc.',
        slug: 'uipkge-enterprise',
        plan: 'Enterprise Scale',
        planTier: 'Enterprise Plan',
        members: 64,
        region: 'US East (N. Virginia)',
        regionCode: 'US-East',
        role: 'Owner',
        color: 'bg-primary/10 text-primary border border-primary/20',
        initials: 'UE',
      },
      {
        id: 'ws-2',
        name: 'Acme Design Systems Lab',
        slug: 'acme-design',
        plan: 'Pro Team',
        planTier: 'Pro Team',
        members: 18,
        region: 'EU Central (Frankfurt)',
        regionCode: 'EU-Central',
        role: 'Admin',
        color: 'bg-info/10 text-info border border-info/20',
        initials: 'AD',
      },
      {
        id: 'ws-3',
        name: 'Personal Sandbox',
        slug: 'personal-sandbox',
        plan: 'Developer Tier',
        planTier: 'Free Developer Tier',
        members: 1,
        region: 'Global Edge',
        regionCode: 'Global',
        role: 'Personal',
        color: 'bg-success/10 text-success border border-success/20',
        initials: 'PS',
      },
    ]
    
    const DEFAULT_USER: WorkspaceUserInfo = {
      name: 'Elena Rostova',
      email: 'elena@uipkge.dev',
      avatar: '',
    }
    
    const FALLBACK_WORKSPACE: WorkspaceItem = {
      id: 'ws-fallback',
      name: 'Workspace',
      plan: 'Standard',
      planTier: 'Standard',
      members: 1,
      region: 'US-East',
      regionCode: 'US-East',
      role: 'Owner',
      color: 'bg-muted text-foreground border border-border',
      initials: 'WS',
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-enterprise-workspace-switcher, [ui-enterprise-workspace-switcher]',
      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,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuLabelComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiRadioGroupComponent,
        UiRadioGroupItemComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
      ],
      template: `
        <div data-slot="enterprise-workspace-switcher" [class]="rootClass">
          <ui-dropdown-menu>
            <button
              type="button"
              ui-dropdown-menu-trigger
              class="group border-border/80 bg-card hover:bg-accent/40 focus-visible:ring-ring flex w-full items-center gap-3 rounded-xl border p-2.5 text-left shadow-xs transition-[background-color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:outline-none"
              aria-label="Select organization workspace"
            >
              <!-- Org Avatar / Monogram -->
              <div class="relative size-10 shrink-0">
                <div
                  [class]="
                    cn(
                      'flex size-10 items-center justify-center rounded-lg text-sm font-semibold shadow-2xs',
                      activeWorkspace().color
                    )
                  "
                >
                  {{ activeWorkspace().initials }}
                </div>
                <span
                  class="border-card bg-success absolute -top-0.5 -right-0.5 size-2.5 rounded-full border-2"
                  title="Active Organization"
                  aria-label="Active workspace status: Online"
                ></span>
              </div>
    
              <!-- Active Org Info & Role Badge -->
              <div class="min-w-0 flex-1">
                <div class="flex items-center gap-1.5">
                  <span class="text-foreground truncate text-sm font-semibold tracking-tight">
                    {{ activeWorkspace().name }}
                  </span>
                  <span ui-badge variant="secondary" class="shrink-0 px-1.5 py-0 text-xs font-medium">
                    {{ activeWorkspace().role }}
                  </span>
                </div>
                <div class="text-muted-foreground mt-0.5 flex items-center gap-1.5 truncate text-xs">
                  <span class="truncate font-medium">Tier: {{ activeWorkspace().plan }}</span>
                  <span class="shrink-0">{{ activeWorkspace().regionCode }}</span>
                </div>
              </div>
    
              <lucide-icon
                [img]="ChevronsUpDown"
                class="text-muted-foreground/70 group-hover:text-foreground size-4 shrink-0 transition-colors"
              />
            </button>
    
            <ui-dropdown-menu-content
              class="border-border bg-popover w-80 rounded-xl p-2 shadow-xl sm:w-96"
              align="start"
              [sideOffset]="6"
            >
              <!-- User Header Profile Info -->
              <ui-dropdown-menu-label class="p-0 font-normal">
                <div class="bg-muted/40 flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left">
                  <ui-avatar class="size-8 rounded-full border">
                    @if (userState().avatar) {
                      <ui-avatar-image [src]="userState().avatar" [alt]="userState().name" />
                    }
                    <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-bold">{{
                      userInitials()
                    }}</ui-avatar-fallback>
                  </ui-avatar>
                  <div class="grid min-w-0 flex-1 text-left leading-tight">
                    <span class="text-muted-foreground text-xs font-medium">Signed in as</span>
                    <span class="text-foreground truncate text-xs font-semibold">{{ userState().email }}</span>
                  </div>
                  <div class="flex items-center gap-1">
                    <span class="bg-success size-2 rounded-full"></span>
                    <span class="text-muted-foreground text-xs font-medium">Ready</span>
                  </div>
                </div>
              </ui-dropdown-menu-label>
    
              <ui-dropdown-menu-separator class="my-1.5" />
    
              <!-- Workspaces Section -->
              <div class="flex items-center justify-between px-2 py-1">
                <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Workspaces</span>
                <span class="text-muted-foreground/70 text-xs">{{ workspaces().length }} total</span>
              </div>
    
              <div class="my-1 space-y-1">
                @for (ws of workspaces(); track ws.id) {
                  <ui-dropdown-menu-item
                    class="hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors"
                    (select)="handleSelectWorkspace(ws)"
                  >
                    <div
                      [class]="
                        cn(
                          'flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold shadow-2xs',
                          ws.color
                        )
                      "
                    >
                      {{ ws.initials }}
                    </div>
    
                    <div class="min-w-0 flex-1">
                      <div class="flex items-center gap-1.5">
                        <span
                          [class]="
                            cn(
                              'truncate text-sm font-medium',
                              activeWorkspaceId() === ws.id ? 'text-foreground font-semibold' : 'text-foreground/90'
                            )
                          "
                        >
                          {{ ws.name }}
                        </span>
                        @if (ws.role === 'Owner') {
                          <span ui-badge variant="secondary" class="shrink-0 px-1 py-0 text-xs font-medium">Owner</span>
                        }
                        @if (ws.role === 'Admin') {
                          <span ui-badge variant="outline" class="shrink-0 px-1 py-0 text-xs font-medium">Admin</span>
                        }
                      </div>
                      <p class="text-muted-foreground truncate text-xs">
                        {{ ws.planTier }} · {{ ws.members }} Member{{ ws.members === 1 ? '' : 's' }} · {{ ws.regionCode }}
                      </p>
                    </div>
    
                    @if (activeWorkspaceId() === ws.id) {
                      <lucide-icon [img]="Check" class="text-success size-4 shrink-0" aria-hidden="true" />
                    }
                  </ui-dropdown-menu-item>
                }
              </div>
    
              <ui-dropdown-menu-separator class="my-1.5" />
    
              <!-- Actions -->
              <div>
                <ui-dropdown-menu-item
                  class="text-foreground hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition-colors"
                  (select)="isCreateDialogOpen.set(true)"
                >
                  <div
                    class="border-primary/30 bg-primary/10 text-primary flex size-6 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="Plus" class="size-3.5" />
                  </div>
                  <span class="font-medium">Create New Workspace</span>
                </ui-dropdown-menu-item>
    
                <ui-dropdown-menu-item
                  class="text-muted-foreground hover:text-foreground hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition-colors"
                  (select)="workspaceAction.emit('settings')"
                >
                  <div class="border-border bg-muted flex size-6 items-center justify-center rounded-md border">
                    <lucide-icon [img]="Settings" class="size-3.5" />
                  </div>
                  <span>Organization Settings & Billing</span>
                </ui-dropdown-menu-item>
    
                <ui-dropdown-menu-item
                  class="text-muted-foreground hover:text-foreground hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition-colors"
                  (select)="workspaceAction.emit('invite')"
                >
                  <div class="border-border bg-muted flex size-6 items-center justify-center rounded-md border">
                    <lucide-icon [img]="UserPlus" class="size-3.5" />
                  </div>
                  <span>Invite Team Members</span>
                </ui-dropdown-menu-item>
              </div>
    
              <ui-dropdown-menu-separator class="my-1.5" />
    
              <!-- Logout -->
              <ui-dropdown-menu-item
                class="text-destructive hover:bg-destructive/10 focus:bg-destructive/10 focus:text-destructive flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors"
                (select)="workspaceAction.emit('logout')"
              >
                <div
                  class="border-destructive/20 bg-destructive/10 text-destructive flex size-6 items-center justify-center rounded-md border"
                >
                  <lucide-icon [img]="LogOut" class="size-3.5" />
                </div>
                <span>Log out</span>
              </ui-dropdown-menu-item>
            </ui-dropdown-menu-content>
          </ui-dropdown-menu>
    
          <!-- Create New Workspace Modal Dialog -->
          <ui-dialog [open]="isCreateDialogOpen()" (openChange)="isCreateDialogOpen.set($event)">
            <ui-dialog-content class="border-border bg-background rounded-2xl p-6 shadow-sm sm:max-w-lg">
              <div ui-dialog-header class="space-y-2 text-left">
                <div
                  class="border-primary/20 bg-primary/10 text-primary flex size-10 items-center justify-center rounded-xl border shadow-xs"
                >
                  <lucide-icon [img]="Building2" class="size-5" />
                </div>
                <h2 ui-dialog-title class="text-foreground text-lg font-bold tracking-tight">Create New Workspace</h2>
                <p ui-dialog-description class="text-muted-foreground text-sm leading-relaxed">
                  Spin up a dedicated organization workspace with multi-region routing and isolated billing.
                </p>
              </div>
    
              <form class="space-y-4 py-2" (submit)="handleCreateWorkspace($event)">
                <!-- Workspace Name -->
                <div class="space-y-1.5">
                  <label for="new-workspace-name-angular" class="text-foreground text-sm font-medium">Workspace Name</label>
                  <ui-input
                    id="new-workspace-name-angular"
                    [value]="newOrgName()"
                    (valueChange)="newOrgName.set($event)"
                    placeholder="e.g. Stripe Developer Platform"
                    autoComplete="off"
                    class="h-10 text-sm"
                  />
                </div>
    
                <!-- URL Slug Preview -->
                <div class="space-y-1.5">
                  <label class="text-muted-foreground text-xs font-medium">Workspace URL Slug</label>
                  <div class="border-border bg-muted/40 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs">
                    <lucide-icon [img]="Globe" class="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
                    <span class="text-muted-foreground">uipkge.dev/</span>
                    <span class="text-foreground truncate font-semibold">{{ slugPreview() }}</span>
                  </div>
                </div>
    
                <!-- Primary Region Selection -->
                <div class="space-y-1.5">
                  <label class="text-foreground text-sm font-medium">Primary Region</label>
                  <ui-select [value]="newOrgRegion()" (valueChange)="newOrgRegion.set($event)">
                    <button ui-select-trigger class="h-10 w-full">
                      <ui-select-value placeholder="Select primary region" />
                    </button>
                    <ui-select-content>
                      <ui-select-item value="us-east">US East (N. Virginia)</ui-select-item>
                      <ui-select-item value="eu-central">EU Central (Frankfurt)</ui-select-item>
                      <ui-select-item value="ap-south">AP South (Mumbai)</ui-select-item>
                    </ui-select-content>
                  </ui-select>
                  <p class="text-muted-foreground text-xs">
                    Primary cluster location for low-latency queries and database replication.
                  </p>
                </div>
    
                <!-- Plan Selection Radios -->
                <div class="space-y-2">
                  <label class="text-foreground text-sm font-medium">Choose Workspace Plan</label>
                  <ui-radio-group
                    [value]="newOrgPlan()"
                    (valueChange)="newOrgPlan.set($event)"
                    class="grid grid-cols-1 gap-3 sm:grid-cols-2"
                  >
                    <label for="plan-pro-angular" [class]="planCardClass('pro')">
                      <div class="flex items-start justify-between gap-2">
                        <div class="space-y-0.5">
                          <span class="text-foreground text-sm font-semibold">Pro Team</span>
                          <p class="text-primary text-xs font-medium">$29 / month</p>
                        </div>
                        <ui-radio-group-item id="plan-pro-angular" value="pro" class="mt-0.5" />
                      </div>
                      <p class="text-muted-foreground mt-2 text-xs leading-relaxed">
                        Up to 25 members, standard 99.9% SLA, and daily automated backups.
                      </p>
                    </label>
    
                    <label for="plan-enterprise-angular" [class]="planCardClass('enterprise')">
                      <div class="flex items-start justify-between gap-2">
                        <div class="space-y-0.5">
                          <span class="text-foreground text-sm font-semibold">Enterprise Scale</span>
                          <p class="text-primary text-xs font-medium">Custom billing</p>
                        </div>
                        <ui-radio-group-item id="plan-enterprise-angular" value="enterprise" class="mt-0.5" />
                      </div>
                      <p class="text-muted-foreground mt-2 text-xs leading-relaxed">
                        Unlimited members, dedicated VPC peering, 99.99% uptime SLA & SSO.
                      </p>
                    </label>
                  </ui-radio-group>
                </div>
    
                <div ui-dialog-footer class="border-border/60 flex gap-2 border-t pt-4 sm:justify-end">
                  <button ui-button type="button" variant="outline" (click)="isCreateDialogOpen.set(false)">Cancel</button>
                  <button ui-button type="submit" [disabled]="!newOrgName().trim()">Create Workspace</button>
                </div>
              </form>
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiEnterpriseWorkspaceSwitcherComponent {
      protected readonly Building2 = Building2
      protected readonly Check = Check
      protected readonly ChevronsUpDown = ChevronsUpDown
      protected readonly Globe = Globe
      protected readonly LogOut = LogOut
      protected readonly Plus = Plus
      protected readonly Settings = Settings
      protected readonly UserPlus = UserPlus
      protected readonly cn = cn
    
      @Input() set initialWorkspaces(v: WorkspaceItem[] | undefined) {
        if (v) this.workspaces.set(v.map((w) => ({ ...w })))
      }
      @Input() set defaultActiveId(v: string | undefined) {
        if (v) this.activeWorkspaceId.set(v)
      }
      @Input() set user(v: WorkspaceUserInfo | undefined) {
        if (v) this.userState.set({ ...v })
      }
      @Input('class') className?: string
      @Output() workspaceChange = new EventEmitter<WorkspaceItem>()
      @Output() workspaceCreate = new EventEmitter<WorkspaceItem>()
      @Output() workspaceAction = new EventEmitter<'settings' | 'billing' | 'invite' | 'logout'>()
    
      readonly workspaces = signal<WorkspaceItem[]>(DEFAULT_WORKSPACES.map((w) => ({ ...w })))
      readonly activeWorkspaceId = signal('ws-1')
      readonly userState = signal<WorkspaceUserInfo>({ ...DEFAULT_USER })
      readonly isCreateDialogOpen = signal(false)
    
      // Dialog form state
      readonly newOrgName = signal('Stripe Developer Platform')
      readonly newOrgRegion = signal('us-east')
      readonly newOrgPlan = signal('pro')
    
      readonly activeWorkspace = computed<WorkspaceItem>(() => {
        const list = this.workspaces()
        return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0] ?? { ...FALLBACK_WORKSPACE }
      })
    
      readonly slugPreview = computed(() => {
        const cleaned = this.newOrgName()
          .trim()
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, '-')
          .replace(/^-+|-+$/g, '')
        return cleaned || 'stripe-dev'
      })
    
      readonly userInitials = computed(() => {
        return (
          this.userState()
            .name.split(' ')
            .map((p) => p[0])
            .join('')
            .toUpperCase() || 'U'
        )
      })
    
      get rootClass(): string {
        return cn('w-full', this.className)
      }
    
      planCardClass(plan: string): string {
        return cn(
          'border-border/80 hover:border-primary/50 relative flex cursor-pointer flex-col justify-between rounded-xl border p-3.5 shadow-xs transition-colors',
          this.newOrgPlan() === plan && 'border-primary bg-primary/5 ring-primary/40 shadow-sm ring-1',
        )
      }
    
      handleSelectWorkspace(workspace: WorkspaceItem): void {
        this.activeWorkspaceId.set(workspace.id)
        this.workspaceChange.emit(workspace)
      }
    
      handleCreateWorkspace(event: Event): void {
        event.preventDefault()
        if (!this.newOrgName().trim()) return
    
        const regionMap: Record<string, { full: string; code: string }> = {
          'us-east': { full: 'US East (N. Virginia)', code: 'US-East' },
          'eu-central': { full: 'EU Central (Frankfurt)', code: 'EU-Central' },
          'ap-south': { full: 'AP South (Mumbai)', code: 'AP-South' },
        }
        const regionInfo = regionMap[this.newOrgRegion()] ?? { full: 'US East (N. Virginia)', code: 'US-East' }
    
        const parts = this.newOrgName().trim().split(/\s+/)
        const initials =
          parts.length > 1
            ? `${parts[0]?.[0] ?? ''}${parts[1]?.[0] ?? ''}`.toUpperCase()
            : (parts[0]?.slice(0, 2) ?? 'WS').toUpperCase()
    
        const newWs: WorkspaceItem = {
          id: `ws-${Date.now()}`,
          name: this.newOrgName().trim(),
          slug: this.slugPreview(),
          plan: this.newOrgPlan() === 'enterprise' ? 'Enterprise Scale' : 'Pro Team',
          planTier: this.newOrgPlan() === 'enterprise' ? 'Enterprise Custom' : 'Pro Team',
          members: 1,
          region: regionInfo.full,
          regionCode: regionInfo.code,
          role: 'Owner',
          color:
            this.newOrgPlan() === 'enterprise'
              ? 'bg-primary/10 text-primary border border-primary/20'
              : 'bg-info/10 text-info border border-info/20',
          initials,
        }
    
        this.workspaces.update((prev) => [newWs, ...prev])
        this.activeWorkspaceId.set(newWs.id)
        this.workspaceCreate.emit(newWs)
        this.workspaceChange.emit(newWs)
        this.newOrgName.set('')
        this.isCreateDialogOpen.set(false)
      }
    }
    
  • src/app/components/blocks/enterprise-workspace-switcher/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/enterprise-workspace-switcher.json