Menu

Change language

GitHub repo

Enterprise Workspace Switcher

blocklayout

Multi-organization workspace switcher dropdown and creation modal inspired by Portico/Riter. Features active organization display with emerald ring, 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 shadcn-svelte@latest add https://uipkge.dev/r/svelte/enterprise-workspace-switcher.json
Manifest:https://uipkge.dev/r/svelte/enterprise-workspace-switcher.jsonInstalls to:src/lib/components/blocks/enterprise-workspace-switcher/

Variants

Loading interactive previews…

Files installed (1)

  • src/lib/components/blocks/enterprise-workspace-switcher/EnterpriseWorkspaceSwitcher.svelte18.5 kB
    <script lang="ts" module>
      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 UserInfo {
        name: string
        email: string
        avatar?: string
      }
    
      export interface EnterpriseWorkspaceSwitcherProps {
        initialWorkspaces?: WorkspaceItem[]
        defaultActiveId?: string
        user?: UserInfo
        class?: string
        onChange?: (workspace: WorkspaceItem) => void
        onCreate?: (workspace: WorkspaceItem) => void
        onAction?: (key: 'settings' | 'billing' | 'invite' | 'logout') => void
      }
    
      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: UserInfo = {
        name: 'Elena Rostova',
        email: 'elena@uipkge.dev',
        avatar: '',
      }
    </script>
    
    <script lang="ts">
      import { Building2, Check, ChevronsUpDown, Globe, LogOut, Plus, Settings, UserPlus } from '@lucide/svelte'
      import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar'
      import { Badge } from '$lib/components/ui/badge'
      import { Button } from '$lib/components/ui/button'
      import {
        Dialog,
        DialogContent,
        DialogDescription,
        DialogFooter,
        DialogHeader,
        DialogTitle,
      } from '$lib/components/ui/dialog'
      import {
        DropdownMenu,
        DropdownMenuContent,
        DropdownMenuGroup,
        DropdownMenuItem,
        DropdownMenuLabel,
        DropdownMenuSeparator,
        DropdownMenuTrigger,
      } from '$lib/components/ui/dropdown-menu'
      import { Input } from '$lib/components/ui/input'
      import { RadioGroup, RadioGroupItem } from '$lib/components/ui/radio-group'
      import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '$lib/components/ui/select'
      import { cn } from '$lib/utils'
    
      let {
        initialWorkspaces = DEFAULT_WORKSPACES,
        defaultActiveId = 'ws-1',
        user = DEFAULT_USER,
        class: className,
        onChange,
        onCreate,
        onAction,
      }: EnterpriseWorkspaceSwitcherProps = $props()
    
      let workspaces = $state<WorkspaceItem[]>([...initialWorkspaces])
      let activeWorkspaceId = $state<string>(defaultActiveId)
      let isCreateDialogOpen = $state(false)
    
      const activeWorkspace = $derived(
        workspaces.find((w) => w.id === activeWorkspaceId) ??
          workspaces[0] ?? {
            id: 'ws-fallback',
            name: 'Workspace',
            plan: 'Standard',
            planTier: 'Standard',
            members: 1,
            region: 'US-East',
            regionCode: 'US-East',
            role: 'Owner' as const,
            color: 'bg-muted text-foreground border border-border',
            initials: 'WS',
          },
      )
    
      // Dialog Form State
      let newOrgName = $state('Stripe Developer Platform')
      let newOrgRegion = $state('us-east')
      let newOrgPlan = $state('pro')
    
      const slugPreview = $derived(
        newOrgName
          .trim()
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, '-')
          .replace(/^-+|-+$/g, '') || 'stripe-dev',
      )
    
      const userInitials = $derived(
        user.name
          .split(' ')
          .map((p) => p[0])
          .join('')
          .toUpperCase() || 'U',
      )
    
      function selectWorkspace(workspace: WorkspaceItem) {
        activeWorkspaceId = workspace.id
        onChange?.(workspace)
      }
    
      function handleCreateWorkspace(e: SubmitEvent) {
        e.preventDefault()
        if (!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[newOrgRegion] ?? {
          full: 'US East (N. Virginia)',
          code: 'US-East',
        }
    
        const parts = 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: newOrgName.trim(),
          slug: slugPreview,
          plan: newOrgPlan === 'enterprise' ? 'Enterprise Scale' : 'Pro Team',
          planTier: newOrgPlan === 'enterprise' ? 'Enterprise Custom' : 'Pro Team',
          members: 1,
          region: regionInfo.full,
          regionCode: regionInfo.code,
          role: 'Owner',
          color:
            newOrgPlan === 'enterprise'
              ? 'bg-primary/10 text-primary border border-primary/20'
              : 'bg-info/10 text-info border border-info/20',
          initials,
        }
    
        workspaces = [newWs, ...workspaces]
        activeWorkspaceId = newWs.id
        onCreate?.(newWs)
        onChange?.(newWs)
    
        // Reset form
        newOrgName = ''
        isCreateDialogOpen = false
      }
    </script>
    
    <div data-slot="enterprise-workspace-switcher" class={cn('w-full', className)}>
      <DropdownMenu>
        <DropdownMenuTrigger>
          {#snippet child({ props })}
            <button
              {...props}
              type="button"
              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 with Emerald Ring -->
              <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>
                  <Badge variant="secondary" class="shrink-0 px-1.5 py-0 text-xs font-medium">
                    {activeWorkspace.role}
                  </Badge>
                </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>
    
              <!-- Chevron Icon -->
              <ChevronsUpDown
                class="text-muted-foreground/70 group-hover:text-foreground size-4 shrink-0 transition-colors"
              />
            </button>
          {/snippet}
        </DropdownMenuTrigger>
    
        <DropdownMenuContent class="border-border bg-popover w-80 rounded-xl p-2 shadow-xl sm:w-96" align="start" sideOffset={6}>
          <!-- User Header Profile Info -->
          <DropdownMenuLabel 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">
              <Avatar class="size-8 rounded-full border">
                {#if user.avatar}
                  <AvatarImage src={user.avatar} alt={user.name} />
                {/if}
                <AvatarFallback class="bg-primary/10 text-primary text-xs font-bold">
                  {userInitials}
                </AvatarFallback>
              </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">{user.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>
          </DropdownMenuLabel>
    
          <DropdownMenuSeparator 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">
            {#each workspaces as ws (ws.id)}
              <DropdownMenuItem
                class="hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors"
                onSelect={() => selectWorkspace(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'}
                      <Badge variant="secondary" class="shrink-0 px-1 py-0 text-xs font-medium">Owner</Badge>
                    {:else if ws.role === 'Admin'}
                      <Badge variant="outline" class="shrink-0 px-1 py-0 text-xs font-medium">Admin</Badge>
                    {/if}
                  </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}
                  <Check class="text-success size-4 shrink-0" aria-hidden="true" />
                {/if}
              </DropdownMenuItem>
            {/each}
          </div>
    
          <DropdownMenuSeparator class="my-1.5" />
    
          <!-- Actions -->
          <DropdownMenuGroup>
            <DropdownMenuItem
              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"
              onSelect={() => (isCreateDialogOpen = true)}
            >
              <div
                class="border-primary/30 bg-primary/10 text-primary flex size-6 items-center justify-center rounded-md border"
              >
                <Plus class="size-3.5" />
              </div>
              <span class="font-medium">Create New Workspace</span>
            </DropdownMenuItem>
    
            <DropdownMenuItem
              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"
              onSelect={() => onAction?.('settings')}
            >
              <div class="border-border bg-muted flex size-6 items-center justify-center rounded-md border">
                <Settings class="size-3.5" />
              </div>
              <span>Organization Settings & Billing</span>
            </DropdownMenuItem>
    
            <DropdownMenuItem
              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"
              onSelect={() => onAction?.('invite')}
            >
              <div class="border-border bg-muted flex size-6 items-center justify-center rounded-md border">
                <UserPlus class="size-3.5" />
              </div>
              <span>Invite Team Members</span>
            </DropdownMenuItem>
          </DropdownMenuGroup>
    
          <DropdownMenuSeparator class="my-1.5" />
    
          <!-- Logout -->
          <DropdownMenuItem
            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"
            onSelect={() => onAction?.('logout')}
          >
            <div
              class="border-destructive/20 bg-destructive/10 text-destructive flex size-6 items-center justify-center rounded-md border"
            >
              <LogOut class="size-3.5" />
            </div>
            <span>Log out</span>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    
      <!-- Create New Workspace Modal Dialog -->
      <Dialog bind:open={isCreateDialogOpen}>
        <DialogContent class="border-border bg-background rounded-2xl p-6 shadow-sm sm:max-w-lg">
          <DialogHeader 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"
            >
              <Building2 class="size-5" />
            </div>
            <DialogTitle class="text-foreground text-lg font-bold tracking-tight"> Create New Workspace </DialogTitle>
            <DialogDescription class="text-muted-foreground text-sm leading-relaxed">
              Spin up a dedicated organization workspace with multi-region routing and isolated billing.
            </DialogDescription>
          </DialogHeader>
    
          <form class="space-y-4 py-2" onsubmit={handleCreateWorkspace}>
            <!-- Workspace Name -->
            <div class="space-y-1.5">
              <label for="new-workspace-name" class="text-foreground text-sm font-medium"> Workspace Name </label>
              <Input
                id="new-workspace-name"
                bind:value={newOrgName}
                placeholder="e.g. Stripe Developer Platform"
                autocomplete="off"
                class="h-10 text-sm"
                required
              />
            </div>
    
            <!-- URL Slug Preview -->
            <div class="space-y-1.5">
              <span class="text-muted-foreground text-xs font-medium"> Workspace URL Slug </span>
              <div class="border-border bg-muted/40 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs">
                <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">
              <span class="text-foreground text-sm font-medium"> Primary Region </span>
              <Select bind:value={newOrgRegion}>
                <SelectTrigger class="h-10 w-full">
                  <SelectValue placeholder="Select primary region" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="us-east"> US East (N. Virginia) </SelectItem>
                  <SelectItem value="eu-central"> EU Central (Frankfurt) </SelectItem>
                  <SelectItem value="ap-south"> AP South (Mumbai) </SelectItem>
                </SelectContent>
              </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">
              <span class="text-foreground text-sm font-medium"> Choose Workspace Plan </span>
              <RadioGroup bind:value={newOrgPlan} class="grid grid-cols-1 gap-3 sm:grid-cols-2">
                <label
                  for="plan-pro"
                  class={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',
                    newOrgPlan === 'pro' && 'border-primary bg-primary/5 ring-primary/40 shadow-sm ring-1',
                  )}
                >
                  <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>
                    <RadioGroupItem id="plan-pro" 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"
                  class={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',
                    newOrgPlan === 'enterprise' && 'border-primary bg-primary/5 ring-primary/40 shadow-sm ring-1',
                  )}
                >
                  <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>
                    <RadioGroupItem id="plan-enterprise" 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>
              </RadioGroup>
            </div>
    
            <DialogFooter class="border-border/60 flex gap-2 border-t pt-4 sm:justify-end">
              <Button type="button" variant="outline" onclick={() => (isCreateDialogOpen = false)}> Cancel </Button>
              <Button type="submit" disabled={!newOrgName.trim()}> Create Workspace </Button>
            </DialogFooter>
          </form>
        </DialogContent>
      </Dialog>
    </div>
    

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