UIPackage
Menu

Framework

Change language

Boilerplate 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.

Also available for React ->

Installation

$npx shadcn-vue@latest add https://uipkge.dev/r/vue/enterprise-workspace-switcher.json
Named registry:npx shadcn-vue@latest add @uipkge/enterprise-workspace-switcherInstalls to:app/components/blocks/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
initialWorkspacesWorkspaceItem[]() => [ { id: 'ws-1', name: 'UIPKGE Enterprise Inc.', slu…optional
defaultActiveIdstringoptional
userUserInfooptional
classHTMLAttributes['class']optional

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
}
UserInfo
interface UserInfo {
  name: string
  email: string
  avatar?: string
}

Files installed (1)

  • app/components/blocks/EnterpriseWorkspaceSwitcher.vue18.9 kB
    <script setup lang="ts">
    import { computed, ref } from 'vue'
    import type { HTMLAttributes } from 'vue'
    import { Building2, Check, ChevronsUpDown, Globe, LogOut, Plus, Settings, UserPlus } from 'lucide-vue-next'
    import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import {
      Dialog,
      DialogContent,
      DialogDescription,
      DialogFooter,
      DialogHeader,
      DialogTitle,
    } from '@/components/ui/dialog'
    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuGroup,
      DropdownMenuItem,
      DropdownMenuLabel,
      DropdownMenuSeparator,
      DropdownMenuTrigger,
    } from '@/components/ui/dropdown-menu'
    import { Input } from '@/components/ui/input'
    import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
    import { cn } from '@/lib/utils'
    
    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
    }
    
    interface Props {
      initialWorkspaces?: WorkspaceItem[]
      defaultActiveId?: string
      user?: UserInfo
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      initialWorkspaces: () => [
        {
          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',
        },
      ],
      defaultActiveId: 'ws-1',
      user: () => ({
        name: 'Elena Rostova',
        email: '[email protected]',
        avatar: '',
      }),
    })
    
    const emit = defineEmits<{
      (e: 'change', workspace: WorkspaceItem): void
      (e: 'create', workspace: WorkspaceItem): void
      (e: 'action', key: 'settings' | 'billing' | 'invite' | 'logout'): void
    }>()
    
    const workspaces = ref<WorkspaceItem[]>([...props.initialWorkspaces])
    const activeWorkspaceId = ref<string>(props.defaultActiveId)
    const isCreateDialogOpen = ref(false)
    
    const activeWorkspace = computed(() => {
      return (
        workspaces.value.find((w) => w.id === activeWorkspaceId.value) ||
        workspaces.value[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
    const newOrgName = ref('Stripe Developer Platform')
    const newOrgRegion = ref('us-east')
    const newOrgPlan = ref('pro')
    
    const slugPreview = computed(() => {
      const cleaned = newOrgName.value
        .trim()
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '')
      return cleaned || 'stripe-dev'
    })
    
    function selectWorkspace(workspace: WorkspaceItem) {
      activeWorkspaceId.value = workspace.id
      emit('change', workspace)
    }
    
    function handleOpenCreateDialog() {
      isCreateDialogOpen.value = true
    }
    
    function handleCreateWorkspace() {
      if (!newOrgName.value.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.value] || {
        full: 'US East (N. Virginia)',
        code: 'US-East',
      }
    
      const parts = newOrgName.value.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.value.trim(),
        slug: slugPreview.value,
        plan: newOrgPlan.value === 'enterprise' ? 'Enterprise Scale' : 'Pro Team',
        planTier: newOrgPlan.value === 'enterprise' ? 'Enterprise Custom' : 'Pro Team',
        members: 1,
        region: regionInfo.full,
        regionCode: regionInfo.code,
        role: 'Owner',
        color:
          newOrgPlan.value === 'enterprise'
            ? 'bg-primary/10 text-primary border border-primary/20'
            : 'bg-info/10 text-info border border-info/20',
        initials,
      }
    
      workspaces.value.unshift(newWs)
      activeWorkspaceId.value = newWs.id
      emit('create', newWs)
      emit('change', newWs)
    
      // Reset form
      newOrgName.value = ''
      isCreateDialogOpen.value = false
    }
    </script>
    
    <template>
      <div data-slot="enterprise-workspace-switcher" :class="cn('w-full', props.class)">
        <DropdownMenu>
          <DropdownMenuTrigger as-child>
            <button
              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"
                />
              </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>
          </DropdownMenuTrigger>
    
          <DropdownMenuContent
            class="border-border bg-popover w-80 rounded-xl p-2 shadow-xl sm:w-96"
            align="start"
            :side-offset="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">
                  <AvatarImage v-if="user.avatar" :src="user.avatar" :alt="user.name" />
                  <AvatarFallback class="bg-primary/10 text-primary text-xs font-bold">
                    {{
                      user.name
                        .split(' ')
                        .map((p) => p[0])
                        .join('')
                        .toUpperCase() || 'U'
                    }}
                  </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 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">
              <DropdownMenuItem
                v-for="ws in workspaces"
                :key="ws.id"
                class="hover:bg-accent focus:bg-accent flex cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors"
                @select="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>
                    <Badge v-if="ws.role === 'Owner'" variant="secondary" class="shrink-0 px-1 py-0 text-xs font-medium">
                      Owner
                    </Badge>
                    <Badge v-else-if="ws.role === 'Admin'" variant="outline" class="shrink-0 px-1 py-0 text-xs font-medium">
                      Admin
                    </Badge>
                  </div>
                  <p class="text-muted-foreground truncate text-xs">
                    {{ ws.planTier }} · {{ ws.members }} Member{{ ws.members === 1 ? '' : 's' }} ·
                    {{ ws.regionCode }}
                  </p>
                </div>
    
                <Check v-if="activeWorkspaceId === ws.id" class="text-success size-4 shrink-0" aria-hidden="true" />
              </DropdownMenuItem>
            </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"
                @select="handleOpenCreateDialog"
              >
                <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"
                @select="emit('action', '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"
                @select="emit('action', '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"
              @select="emit('action', '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 v-model: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" @submit.prevent="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"
                  v-model="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">
                <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">
                  <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>
                <Select v-model="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">
                <label class="text-foreground text-sm font-medium"> Choose Workspace Plan </label>
                <RadioGroup v-model="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" @click="isCreateDialogOpen = false"> Cancel </Button>
                <Button type="submit" :disabled="!newOrgName.trim()"> Create Workspace </Button>
              </DialogFooter>
            </form>
          </DialogContent>
        </Dialog>
      </div>
    </template>
    

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