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@latest add https://uipkge.dev/r/lit/enterprise-workspace-switcher.json
Manifest:https://uipkge.dev/r/lit/enterprise-workspace-switcher.jsonInstalls to:src/uip/blocks/enterprise-workspace-switcher/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
default-active-idstringws-1optional
user-namestringElena Rostovaoptional
user-emailstring[email protected]optional
user-avatarstring—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
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-enterprise-workspace-switcher': UipEnterpriseWorkspaceSwitcher
  }
}

Files installed (1)

  • src/uip/blocks/enterprise-workspace-switcher/enterprise-workspace-switcher.ts22.5 kB
    import { LitElement, css, html, nothing } from 'lit'
    import {
      Building2,
      Check,
      ChevronsUpDown,
      Circle,
      Globe,
      LogOut,
      Plus,
      Settings,
      UserPlus,
    } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/avatar/avatar'
    import '../../components/badge/badge'
    import '../../components/button/button'
    import '../../components/dialog/dialog'
    import '../../components/dropdown-menu/dropdown-menu'
    import '../../components/input/input'
    import type { UipInput } from '../../components/input/input'
    import '../../components/select/select'
    import type { UipSelect } from '../../components/select/select'
    
    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 type WorkspaceActionKey = 'settings' | 'billing' | 'invite' | 'logout'
    
    type Plan = 'pro' | 'enterprise'
    
    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 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',
    }
    
    const radioCircle =
      'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square shrink-0 rounded-full border shadow-sm transition-colors duration-200 outline-none focus-visible:ring-[3px] size-4 data-[state=checked]:border-primary mt-0.5'
    
    /**
     * <uip-enterprise-workspace-switcher> — the registry
     * EnterpriseWorkspaceSwitcher block as a web component.
     *
     * Class strings are React's verbatim. React state (workspaces, active id,
     * dialog + form fields) becomes @state fields. `defaultActiveId` and the
     * user become `default-active-id` / `user-name` / `user-email` /
     * `user-avatar` attributes; `onChange` / `onCreate` / `onAction` become
     * `change` / `create` / `action` CustomEvents (bubbles, composed, detail =
     * the workspace or action key).
     *
     * Deviations: React's `className` on DropdownMenu parts reaches the shadow
     * DOM via `[&::part(…)]:…` on the host (their utilities only exist in the
     * shadow sheet). The plan cards keep React's card visuals 1:1 but the radio
     * circle is block-local markup (React's `RadioGroup` only renders plain
     * labels, so it can't host the rich card content) — a real `radiogroup`
     * with roving tabindex and arrows/Home/End.
     */
    export class UipEnterpriseWorkspaceSwitcher extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        defaultActiveId: { attribute: 'default-active-id' },
        userName: { attribute: 'user-name' },
        userEmail: { attribute: 'user-email' },
        userAvatar: { attribute: 'user-avatar' },
        workspaces: { state: true },
        activeWorkspaceId: { state: true },
        isCreateDialogOpen: { state: true },
        newOrgName: { state: true },
        newOrgRegion: { state: true },
        newOrgPlan: { state: true },
      }
    
      defaultActiveId = 'ws-1'
      userName = 'Elena Rostova'
      userEmail = '[email protected]'
      userAvatar = ''
      private workspaces: WorkspaceItem[] = DEFAULT_WORKSPACES
      private activeWorkspaceId = ''
      private isCreateDialogOpen = false
      private newOrgName = 'Stripe Developer Platform'
      private newOrgRegion = 'us-east'
      private newOrgPlan: Plan = 'pro'
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'enterprise-workspace-switcher')
        if (!this.activeWorkspaceId) this.activeWorkspaceId = this.defaultActiveId
      }
    
      private get activeWorkspace(): WorkspaceItem {
        return this.workspaces.find((w) => w.id === this.activeWorkspaceId) || this.workspaces[0] || FALLBACK_WORKSPACE
      }
    
      private get slugPreview() {
        const cleaned = this.newOrgName
          .trim()
          .toLowerCase()
          .replace(/[^a-z0-9]+/g, '-')
          .replace(/^-+|-+$/g, '')
        return cleaned || 'stripe-dev'
      }
    
      private get userInitials() {
        return (
          this.userName
            .split(' ')
            .map((p) => p[0])
            .join('')
            .toUpperCase() || 'U'
        )
      }
    
      private handleSelectWorkspace(workspace: WorkspaceItem) {
        this.activeWorkspaceId = workspace.id
        this.dispatchEvent(new CustomEvent('change', { detail: workspace, bubbles: true, composed: true }))
      }
    
      private handleCreateWorkspace(e: Event) {
        e.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 = [newWs, ...this.workspaces]
        this.activeWorkspaceId = newWs.id
        this.dispatchEvent(new CustomEvent('create', { detail: newWs, bubbles: true, composed: true }))
        this.dispatchEvent(new CustomEvent('change', { detail: newWs, bubbles: true, composed: true }))
    
        this.newOrgName = ''
        this.isCreateDialogOpen = false
      }
    
      private onPlanKey(e: KeyboardEvent, value: Plan) {
        const order: Plan[] = ['pro', 'enterprise']
        const at = order.indexOf(value)
        let next: Plan | null = null
        if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = order[(at + 1) % order.length] ?? null
        else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = order[(at + order.length - 1) % order.length] ?? null
        else if (e.key === 'Home') next = 'pro'
        else if (e.key === 'End') next = 'enterprise'
        else if (e.key === ' ' || e.key === 'Enter') next = value
        if (!next) return
        e.preventDefault()
        this.newOrgPlan = next
        if (e.key !== ' ' && e.key !== 'Enter') {
          this.renderRoot.querySelector<HTMLElement>(`[data-plan="${next}"]`)?.focus()
        }
      }
    
      private planCard(value: Plan, heading: string, price: string, desc: string) {
        const checked = this.newOrgPlan === value
        return html`
          <div
            data-plan=${value}
            role="radio"
            aria-checked=${checked ? 'true' : 'false'}
            tabindex=${checked ? '0' : '-1'}
            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',
              checked && 'border-primary bg-primary/5 ring-primary/40 shadow-sm ring-1',
            )}
            @click=${() => (this.newOrgPlan = value)}
            @keydown=${(e: KeyboardEvent) => this.onPlanKey(e, value)}
          >
            <div class="flex items-start justify-between gap-2">
              <div class="space-y-0.5">
                <span class="text-foreground text-sm font-semibold">${heading}</span>
                <p class="text-primary text-xs font-medium">${price}</p>
              </div>
              <span data-uipkge="" data-slot="radio-group-item" data-state=${checked ? 'checked' : 'unchecked'} class=${radioCircle}>
                ${checked
                  ? html`<span data-uipkge="" data-slot="radio-group-indicator" class="relative flex items-center justify-center">
                      ${icon(Circle, 'circle', 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 fill-current text-current size-2')}
                    </span>`
                  : nothing}
              </span>
            </div>
            <p class="text-muted-foreground mt-2 text-xs leading-relaxed">${desc}</p>
          </div>
        `
      }
    
      render() {
        const active = this.activeWorkspace
        return html`
          <div data-slot="enterprise-workspace-switcher" class=${cn('w-full')}>
            <uip-dropdown-menu
              class="[&::part(content)]:w-80 [&::part(content)]:rounded-xl [&::part(content)]:p-2 [&::part(content)]:shadow-xl sm:[&::part(content)]:w-96 [&::part(ws-section)]:p-0 [&::part(ws-section)]:font-normal [&::part(ws-row)]:my-0.5 [&::part(ws-row)]:cursor-pointer [&::part(ws-row)]:gap-3 [&::part(ws-row)]:rounded-lg [&::part(ws-row)]:p-2 [&::part(ws-create)]:cursor-pointer [&::part(ws-create)]:gap-2.5 [&::part(ws-create)]:rounded-lg [&::part(ws-create)]:text-foreground [&::part(ws-action)]:cursor-pointer [&::part(ws-action)]:gap-2.5 [&::part(ws-action)]:rounded-lg [&::part(ws-action)]:text-muted-foreground [&::part(ws-logout)]:cursor-pointer [&::part(ws-logout)]:gap-2.5 [&::part(ws-logout)]:rounded-lg [&::part(ws-sep)]:my-1.5 [&::part(user-label)]:p-0 [&::part(user-label)]:font-normal"
            >
              <button
                slot="trigger"
                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"
              >
                <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', active.color)}>
                    ${active.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>
    
                <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">${active.name}</span>
                    <uip-badge
                      variant="secondary"
                      class="shrink-0 text-xs font-medium [&::part(base)]:px-1.5 [&::part(base)]:py-0"
                    >
                      ${active.role}
                    </uip-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: ${active.plan}</span>
                    <span class="shrink-0">${active.regionCode}</span>
                  </div>
                </div>
    
                ${icon(ChevronsUpDown, 'chevrons-up-down', 'text-muted-foreground/70 group-hover:text-foreground size-4 shrink-0 transition-colors')}
              </button>
    
              <uip-dropdown-menu-content align="start" side-offset="6">
                <uip-dropdown-menu-label part="user-label">
                  <div class="bg-muted/40 flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left">
                    <uip-avatar class="size-8 rounded-full border">
                      ${this.userAvatar
                        ? html`<uip-avatar-image src=${this.userAvatar} alt=${this.userName}></uip-avatar-image>`
                        : nothing}
                      <uip-avatar-fallback class="text-xs font-bold [&::part(base)]:bg-primary/10 [&::part(base)]:text-primary"
                        >${this.userInitials}</uip-avatar-fallback
                      >
                    </uip-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">${this.userEmail}</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>
                </uip-dropdown-menu-label>
    
                <uip-dropdown-menu-separator part="ws-sep"></uip-dropdown-menu-separator>
    
                <uip-dropdown-menu-label part="ws-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">${this.workspaces.length} total</span>
                  </div>
                </uip-dropdown-menu-label>
    
                ${this.workspaces.map(
                  (ws) => html`
                    <uip-dropdown-menu-item part="ws-row" @select=${() => this.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', this.activeWorkspaceId === ws.id ? 'text-foreground font-semibold' : 'text-foreground/90')}>
                              ${ws.name}
                            </span>
                            ${ws.role === 'Owner'
                              ? html`<uip-badge variant="secondary" class="shrink-0 text-xs font-medium [&::part(base)]:px-1 [&::part(base)]:py-0">Owner</uip-badge>`
                              : nothing}
                            ${ws.role === 'Admin'
                              ? html`<uip-badge variant="outline" class="shrink-0 text-xs font-medium [&::part(base)]:px-1 [&::part(base)]:py-0">Admin</uip-badge>`
                              : nothing}
                          </div>
                          <p class="text-muted-foreground truncate text-xs">
                            ${ws.planTier} · ${ws.members} Member${ws.members === 1 ? '' : 's'} · ${ws.regionCode}
                          </p>
                        </div>
                        ${this.activeWorkspaceId === ws.id ? icon(Check, 'check', 'text-success size-4 shrink-0') : nothing}
                      </uip-dropdown-menu-item>
                    `,
                  )}
    
                <uip-dropdown-menu-separator part="ws-sep"></uip-dropdown-menu-separator>
    
                <uip-dropdown-menu-group>
                  <uip-dropdown-menu-item part="ws-create" @select=${() => (this.isCreateDialogOpen = true)}>
                    <div class="border-primary/30 bg-primary/10 text-primary flex size-6 items-center justify-center rounded-md border">
                      ${icon(Plus, 'plus', 'size-3.5')}
                    </div>
                    <span class="font-medium">Create New Workspace</span>
                  </uip-dropdown-menu-item>
    
                  <uip-dropdown-menu-item
                    part="ws-action"
                    @select=${() =>
                      this.dispatchEvent(new CustomEvent('action', { detail: 'settings', bubbles: true, composed: true }))}
                  >
                    <div class="border-border bg-muted flex size-6 items-center justify-center rounded-md border">
                      ${icon(Settings, 'settings', 'size-3.5')}
                    </div>
                    <span>Organization Settings & Billing</span>
                  </uip-dropdown-menu-item>
    
                  <uip-dropdown-menu-item
                    part="ws-action"
                    @select=${() =>
                      this.dispatchEvent(new CustomEvent('action', { detail: 'invite', bubbles: true, composed: true }))}
                  >
                    <div class="border-border bg-muted flex size-6 items-center justify-center rounded-md border">
                      ${icon(UserPlus, 'user-plus', 'size-3.5')}
                    </div>
                    <span>Invite Team Members</span>
                  </uip-dropdown-menu-item>
                </uip-dropdown-menu-group>
    
                <uip-dropdown-menu-separator part="ws-sep"></uip-dropdown-menu-separator>
    
                <uip-dropdown-menu-item
                  part="ws-logout"
                  variant="destructive"
                  @select=${() =>
                    this.dispatchEvent(new CustomEvent('action', { detail: 'logout', bubbles: true, composed: true }))}
                >
                  <div class="border-destructive/20 bg-destructive/10 text-destructive flex size-6 items-center justify-center rounded-md border">
                    ${icon(LogOut, 'log-out', 'size-3.5')}
                  </div>
                  <span>Log out</span>
                </uip-dropdown-menu-item>
              </uip-dropdown-menu-content>
            </uip-dropdown-menu>
    
            <uip-dialog
              heading="Create New Workspace"
              description="Spin up a dedicated organization workspace with multi-region routing and isolated billing."
              class="[&::part(content)]:rounded-2xl [&::part(content)]:p-6 [&::part(content)]:shadow-sm sm:[&::part(content)]:max-w-lg"
              ?open=${this.isCreateDialogOpen}
              @open-change=${(e: CustomEvent<{ open: boolean }>) => {
                this.isCreateDialogOpen = e.detail.open
              }}
            >
              <span slot="header-start" class="border-primary/20 bg-primary/10 text-primary flex size-10 items-center justify-center rounded-xl border shadow-xs">
                ${icon(Building2, 'building-2', 'size-5')}
              </span>
              <form class="space-y-4 py-2" @submit=${this.handleCreateWorkspace}>
                <div class="space-y-1.5">
                  <uip-label for="new-workspace-name" class="text-foreground text-sm font-medium">Workspace Name</uip-label>
                  <uip-input
                    id="new-workspace-name"
                    class="[&::part(control)]:h-10 text-sm"
                    .value=${this.newOrgName}
                    placeholder="e.g. Stripe Developer Platform"
                    autocomplete="off"
                    required
                    @input=${(e: Event) => {
                      this.newOrgName = (e.target as UipInput).value
                    }}
                  ></uip-input>
                </div>
    
                <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">
                    ${icon(Globe, 'globe', 'text-muted-foreground size-4 shrink-0')}
                    <span class="text-muted-foreground">uipkge.dev/</span>
                    <span class="text-foreground truncate font-semibold">${this.slugPreview}</span>
                  </div>
                </div>
    
                <div class="space-y-1.5">
                  <label class="text-foreground text-sm font-medium">Primary Region</label>
                  <uip-select
                    class="w-full [&::part(trigger)]:h-10"
                    .value=${this.newOrgRegion}
                    @change=${(e: Event) => {
                      this.newOrgRegion = (e.target as UipSelect).value
                    }}
                  >
                    <option value="us-east">US East (N. Virginia)</option>
                    <option value="eu-central">EU Central (Frankfurt)</option>
                    <option value="ap-south">AP South (Mumbai)</option>
                  </uip-select>
                  <p class="text-muted-foreground text-xs">
                    Primary cluster location for low-latency queries and database replication.
                  </p>
                </div>
    
                <div class="space-y-2">
                  <label class="text-foreground text-sm font-medium">Choose Workspace Plan</label>
                  <div role="radiogroup" aria-label="Choose Workspace Plan" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
                    ${this.planCard('pro', 'Pro Team', '$29 / month', 'Up to 25 members, standard 99.9% SLA, and daily automated backups.')}
                    ${this.planCard('enterprise', 'Enterprise Scale', 'Custom billing', 'Unlimited members, dedicated VPC peering, 99.99% uptime SLA & SSO.')}
                  </div>
                </div>
              </form>
    
              <div slot="footer" class="border-border/60 flex gap-2 border-t pt-4 sm:justify-end">
                <uip-button type="button" variant="outline" @click=${() => (this.isCreateDialogOpen = false)}>
                  Cancel
                </uip-button>
                <uip-button
                  type="button"
                  ?disabled=${!this.newOrgName.trim()}
                  @click=${() => this.renderRoot.querySelector('form')?.requestSubmit()}
                >
                  Create Workspace
                </uip-button>
              </div>
            </uip-dialog>
          </div>
        `
      }
    }
    
    customElements.get('uip-enterprise-workspace-switcher') ||
      customElements.define('uip-enterprise-workspace-switcher', UipEnterpriseWorkspaceSwitcher)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-enterprise-workspace-switcher': UipEnterpriseWorkspaceSwitcher
      }
    }
    

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