Enterprise Workspace Switcher
blocklayoutMulti-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
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/enterprise-workspace-switcher.json$npx shadcn@latest add https://uipkge.dev/r/lit/enterprise-workspace-switcher.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/enterprise-workspace-switcher.json$bunx shadcn@latest add https://uipkge.dev/r/lit/enterprise-workspace-switcher.jsonManifest:
https://uipkge.dev/r/lit/enterprise-workspace-switcher.jsonInstalls to:src/uip/blocks/enterprise-workspace-switcher/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
default-active-id | string | ws-1 | optional |
user-name | string | Elena Rostova | optional |
user-email | string | [email protected] | optional |
user-avatar | string | — | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
WorkspaceIteminterface 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
}HTMLElementTagNameMapinterface 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