Shell

Workspace Switcher

Axis 1 of the shell: the control that changes which organisation’s data you are looking at. It takes no data props — it reads workspaces out of useShell() and reports a choice back through one callback.

Two axes, perpendicular

A Comitor user belongs to many workspaces, and each workspace has many products enabled. Switching workspace does not switch product, and switching product does not switch workspace — which is exactly why the two cannot collapse into one flat menu.

Axis 1

WorkspaceSwitcher

Changes the data context — Acme Corp ▸ Umbrella Health. This page.

Axis 2

AppLauncher

Changes the product inside one workspace — Tasks ▸ Chat ▸ CRM. Documented separately.

Not to be confused with the four display axes — light/dark, colour palette, layout density, font size — which are independent of both and live at Display Axes.

Live demo

Open the switcher, pick a workspace, try the four footer rows. Every one of them reports what it called into the panel on the right — that is the entire API surface of this component, plus a Popover.

Three workspaces, so no search box. The current row keeps its check mark; the others keep the space the check would take, which is what holds the plan badges in one column.

Last callback

The search box appears at eight

SEARCH_THRESHOLD, a constant internal to the component rather than an export, is 8. Nothing switches it on — the switcher counts workspaces.length and decides for itself. The count is of the whole list, never the filtered one, so the box does not vanish from under the cursor once a query has narrowed things down.

Nine workspaces. Type dong and “Đông Á Media” matches; phu my finds “Phú Mỹ Industrial”. Slugs match too, so sao-viet works without a single tone mark being typed.

Last callback

  • The filter is normalizeVi on both sides — tones stripped, lower-cased, trimmed — over name and slug. Vietnamese users type without diacritics constantly; a switcher that only matched the exact string would be useless to them. The same function is exported from @comitor/ui, so app-side filtering can be made to agree with it.
  • plan and role are not searched — they are shown, not indexed.
  • The list is capped at max-h-64 and scrolls; the search box stays pinned above it. Rows sit in a gap-1 column so their rounded hover backgrounds cannot fuse into one slab.
  • When the query matches nothing, the list is replaced by labels.workspaceEmpty — not by an empty box.

Collapsed — the icon rail

With collapsed, the trigger drops to a 36px square holding nothing but the avatar, and gains a right-side Tooltip carrying the workspace name. The panel is unchanged — same width, same list, same footer.

Hover the avatar for the tooltip, click it for the panel. Note the avatar here is 24px inside a 36px button — padding, not fill. The expanded trigger uses 32px instead, so that the avatar stands as tall as the two lines of text beside it.

Last callback

The avatar is seeded on name

No logoUrl, and the avatar falls back to initials on a deterministic tone — getAvatarToneClasses(workspace.name). The seed is the name, not the slug and not the id, and that choice is load-bearing: the switcher is never the only place a workspace is drawn. A member list, a share sheet, a breadcrumb — each of them reaches for LetterAvatar, which seeds on the name it is given. Seed one of them on the slug and the same organisation comes out in two different colours in the same product.

On the right, the same three workspaces drawn by LetterAvatar instead. Open the switcher and compare: same seed, same hash, same tone out of the same table of eight.

Acme CorpAcme Corp
Umbrella HealthUmbrella Health
Hoàng Gia RetailHoàng Gia Retail

Last callback

  • Initials are Vietnamese-style — getVietnameseInitials takes the first letter of the first word and the first letter of the last, not of the first two words, so “Phú Mỹ Industrial” becomes “PI” and not “PM”.
  • The tile is rounded-md, never a circle. Round avatars mean people in this system; square ones mean workspaces and apps.
  • One tone table serves the whole package. Outside the DOM — PDF, email, canvas — getAvatarToneColors(name, mode) from @comitor/ui/tokens returns the same tone as hex, and getAvatarToneIndex(name) gives you the slot number if you need to key something else off it. The tone table itself is not exported: reach for one of those three functions rather than rebuilding the mapping app-side, which is the shortest path to two colours for one workspace.

Footer actions

Below a separator, up to four rows: create a workspace, open its settings, invite members, sign out. Each row exists only because its callback was passed to the shell — there is no row to disable and no dead “Invite members” for a viewer who cannot invite anyone. Pass none of the four and the separator goes too.

workspace-actions.tsx
'use client'

import { useRouter } from 'next/navigation'
import { ShellProvider, WorkspaceSwitcher } from '@comitor/ui/shell'
import { signOut } from '@/lib/auth'
import { EN, WORKSPACES } from './shell-config'
import { openCreateDialog, openInviteDialog } from './workspace-dialogs'

/**
 * Each footer row exists only because its callback was passed. Hand the shell
 * nothing and the separator and the whole block disappear — there is no row to
 * disable, no dead "Invite members" for a viewer who cannot invite anyone.
 */
export function WorkspaceStrip({ canCreate, isAdmin }: { canCreate: boolean; isAdmin: boolean }) {
  const router = useRouter()

  return (
    <ShellProvider
      workspaces={WORKSPACES}
      labels={EN}
      onCreateWorkspace={canCreate ? openCreateDialog : undefined}
      onWorkspaceSettings={isAdmin ? (id) => router.push(`/w/${id}/settings`) : undefined}
      onInviteMembers={isAdmin ? (id) => openInviteDialog(id) : undefined}
      onLogout={signOut}
    >
      {/* Or keep the callbacks for the rest of the shell and drop the block here. */}
      <WorkspaceSwitcher showActions={false} />
    </ShellProvider>
  )
}
  • Every row closes the panel before running its callback. An action that opens a dialog would otherwise open it underneath a popover that is still on screen.
  • onWorkspaceSettings and onInviteMembers receive the current workspace id — the panel acts on the workspace you are in, not on a row you happened to hover.
  • Sign-out is the same onLogout that UserMenu and MobileMenu call. One handler for the whole shell.
  • showActions={false} hides the block without touching the callbacks, for a header trigger that should stay a pure switcher.

One workspace, or none

With a single workspace there is nothing to switch to, so the list section is replaced by a plain identity block: avatar, name, and plan ?? slug underneath. The footer actions still make sense there — creating a second workspace is exactly what such a user might do — so they stay. With an empty workspaces array the component renders null outright, and Sidebar drops the whole strip rather than leaving a 56px band of nothing.

One workspace. The trigger still opens — it is where the actions live — but there is no list, no search, and no check mark to place.

Last callback

Where it sits

Two placements, and AppShell picks between them from one prop. In sidebar-first, the default, the switcher is the top band of the sidebar — the demos above. In header-first it moves into the header as the logo slot, and the sidebar’s own band is turned off in the same breath, so the two can never both be on screen. That is also the one place in the package that passes className to this component.

app/(app)/layout.tsx
'use client'

import type { ReactNode } from 'react'
import { AppShell } from '@comitor/ui/shell'
import { EN, NAV, USER, WORKSPACES } from './shell-config'

/**
 * layout="header-first" moves the switcher out of the sidebar and into the
 * header, where AppShell mounts it as the logo slot with a fixed width:
 *
 *   <WorkspaceSwitcher className="w-56 shrink-0" />
 *
 * and turns Sidebar's own switcher strip off, so the two never appear at once.
 * `className` lands on the trigger button, not on the panel — the panel is
 * w-80 either way.
 */
export default function AppLayout({ children }: { children: ReactNode }) {
  return (
    <AppShell layout="header-first" workspaces={WORKSPACES} nav={NAV} user={USER} labels={EN}>
      {children}
    </AppShell>
  )
}

Why Popover and not DropdownMenu

The plain menus of the shell — UserMenu, row actions in a table — are a DropdownMenu. This one is not, and the reason is recorded in the source: Radix’s menu implements typeahead. Printable keystrokes inside an open menu are captured to jump to the item that starts with them. That behaviour is correct for a menu and fatal here — the search box would never receive a single character it was typed.

Building on Popover gives the panel a free-form layout with a working text input, at a cost that is paid honestly rather than papered over: no roving focus, no arrow-key navigation, no aria-activedescendant. So the rows are plain buttons in a role="group" and are reached with Tab, and the component makes no ARIA promise it does not keep — see Accessibility below. AppLauncher sits on a Popover for the same reason and pays the cost differently: its tiles are a grid, so it writes its own ←/→/↑/↓ handling rather than leaving keyboard users with Tab.

The panel is w-80 (320px), not the 288px it started at: the current row is the tightest one in the list, because it alone carries both a plan badge and a check mark, and at 288px its secondary line was clipped by about 4px. It was not widened by 4px — names and role strings are app data, so the width has margin rather than fitting one sample set exactly.

WorkspaceSwitcher props

PropTypeDefaultDescription
collapsedbooleanfalseIcon-rail rendering: a 36px square button holding just the avatar, wrapped in a right-side Tooltip carrying the workspace name. Sidebar passes its own sidebarCollapsed down, so only a hand-built frame sets this.
showActionsbooleantrueShow the footer block (create, settings, invite, sign out). Off, the panel is the workspace list alone. The block is dropped anyway when none of the four callbacks was passed to the shell.
align'start' | 'center' | 'end''start'Alignment of the panel against the trigger, forwarded to PopoverContent. The default opens flush with the left edge of the sidebar; center and end are for a trigger sitting elsewhere in a header.
classNamestringMerged onto the trigger button in both renderings — not onto the panel. AppShell uses it in the header-first layout (w-56 shrink-0) to give the trigger a fixed width in the header row.

The Workspace type

The data contract for axis 1. Three required fields; everything else is optional and simply omits the thing it would have drawn.

PropTypeDefaultDescription
idrequiredstringIdentity. Matched against ShellProvider currentWorkspaceId, and handed to onWorkspaceChange, onWorkspaceSettings and onInviteMembers.
namerequiredstringVisible name, source of the initials, and the seed of the avatar colour. Not the slug — see below.
slugrequiredstringURL / subdomain identifier. Searched alongside the name, and shown as the second line of the single-workspace panel when there is no plan. The shell displays it and never navigates to it.
logoUrlstringWorkspace logo. Present, it fills the avatar; absent, the tinted initials take over. Both are rounded-md, never a circle — a circle would read as a person.
planstringPre-formatted plan name ("Free", "Pro", "Business"). Second line of the expanded trigger, and a secondary Badge on every row of the list. Display only: the shell infers no permission from it.
rolestringThe user’s role in this workspace, already a display string — the shell maps no codes. First half of the secondary line in the list.
memberCountnumberSecond half of that line, rendered as `labels.members(memberCount)` and joined to the role with " · ". Only one of the two is needed; the line is dropped when both are missing.

ShellProvider props that drive it

The switcher has no data props of its own — these go on ShellProvider, or on AppShell, which forwards every one of them.

PropTypeDefaultDescription
workspacesWorkspace[][]Everything the user belongs to. Empty, the switcher renders null (and Sidebar drops the strip rather than leaving an empty band). Exactly one, and the list section is replaced by a plain identity block.
currentWorkspaceIdstringworkspaces[0]The workspace in context. Keep it in app state and update it from onWorkspaceChange — the switcher holds no selection of its own.
onWorkspaceChange(workspaceId: string) => voidFires on picking a different workspace; picking the current one closes the panel and calls nothing. Routing, subdomain swap and cache reset are the app’s job.
onCreateWorkspace() => voidRenders the first footer row (Plus icon). Omit it and the row does not exist.
onWorkspaceSettings(workspaceId: string) => voidSecond footer row (Settings icon), called with the current workspace id.
onInviteMembers(workspaceId: string) => voidThird footer row (UserPlus icon), also called with the current workspace id.
onLogout() => voidLast footer row (LogOut icon), tinted with --destructive-ink. Shared with UserMenu and MobileMenu — one sign-out handler for the whole shell.
labelsPartial<ShellLabels>DEFAULT_SHELL_LABELSDisplay strings, spread over the Vietnamese defaults key by key. The nine keys this component reads are listed below.

Labels it reads

Nine of the 33 keys in ShellLabels. Defaults are Vietnamese and are spread under your overrides, so a partial object is enough — but override all nine for an English app, or the panel ends up bilingual.

PropTypeDefaultDescription
workspaceSwitcherLabelstring'Đổi không gian làm việc'aria-label of the trigger button, in both the expanded and the collapsed rendering.
workspacestring'Không gian làm việc'Accessible name of the role="group" wrapping the list of workspaces.
workspaceSearchPlaceholderstring'Tìm không gian làm việc…'Placeholder AND aria-label of the search input — one key doing both jobs, so the visible text and the accessible name can never drift apart.
workspaceEmptystring'Không tìm thấy không gian làm việc'Shown in place of the list when the query matches nothing.
members(count: number) => string(count) => `${count} thành viên`The whole member-count phrase on the secondary line of each row, not just the unit — a function because it interpolates the number, and word order around it changes by language.
createWorkspacestring'Tạo không gian làm việc'Visible text of the create row.
workspaceSettingsstring'Cài đặt không gian làm việc'Visible text of the settings row.
inviteMembersstring'Mời thành viên'Visible text of the invite row.
logoutstring'Đăng xuất'Visible text of the sign-out row, the one tinted as destructive.

Accessibility

  • It is deliberately not a listbox. role="listbox" and role="option" promise a screen reader a widget driven by ↑/↓/Home/End with roving tabindex and aria-activedescendant. What actually happens is Tab through ordinary buttons in a popover. The component declares what it does: plain buttons inside a role="group" named by labels.workspace, with aria-current="true" on the workspace you are in.
  • The current row is not marked by colour alone. Its background tint — bg-accent/60 — measures 1.05:1 against the panel in light and 1.06:1 in dark, which is nothing at all for the one job it has. So the check mark stays, and it is always in the layout, merely invisible on the other rows: that both keeps the badge column aligned and satisfies WCAG 1.4.1. It is the same trick Combobox uses for its option rows.
  • The check mark is ink, not app accent. text-app-accent would put the default gold at 1.86:1 on the white panel, under the 3:1 that WCAG 1.4.11 asks of a non-text indicator. --popover-foreground measures 18.9:1 light and 16.6:1 dark — and it is a role token, so an app that repaints the panel gets a check mark that follows.
  • The trigger chevron carries information — it says this button opens a panel — so it answers to 3:1, and it must clear that on every background the trigger can stand on. In the light palette --muted-foreground measures 3.67:1 on --sidebar, 3.50:1 on --sidebar-accent under hover and 3.83:1 on --background — that last one because header-first moves this trigger onto the page background. Dark clears it more comfortably. An alpha step of the sidebar foreground was rejected for landing on ~3:1 in light while reading quite differently in dark, which is how translucent steps usually break exactly one theme.
  • Read the ratios in the component source with a date on them. The comments inside workspace-switcher.tsx quote a darker generation of the ink tokens. 0.3.0 pulled the default palette back to the approved comitor-ds colours and 0.9.1 ships that palette, so the token names are unchanged but their values moved, and the package now lists its known sub-threshold spots at the top of styles.css. Two of them land in this panel: the 11px plan and role lines are text and so answer to 4.5:1, but --muted-foreground gives 3.83:1 on the panel — 3.63:1 on the current row’s tint, 3.50:1 on a hovered one — in light while clearing the bar in dark; and the sign-out row on --destructive-ink gives 4.38:1 light and 4.06:1 dark. The remedy is not a patch here — it is the second palette, data-contrast="high", which the user turns on themselves. See Display Axes.
  • The search field signals focus twice. The border switches to --primary-ink and a 2px --ring/50 halo appears — which matters, because in the default light palette brand gold sits at 1.86:1 against the white field, so the border alone would be a state change nobody can see. The ring does the work there; in dark the brand step is bright (11.9:1) and the border reads on its own. Both are role tokens: point --primary-ink at a darker brand step and this field follows without a patch.
  • Sign-out is text, so it takes the ink token. --destructive is a background token — the fill under white text on a delete button — and using it for a label is the classic three-role mix-up. --destructive-ink is the step declared for red-on-page, and the hover state is a /10 tint rather than a solid fill, so the label never has to survive on a saturated red.
  • Opening the panel puts the caret in the search box when there is one — the component prevents Radix’s default of focusing the panel frame. Below eight workspaces there is no box and the default applies. Escape closes the panel and returns focus to the trigger; the popover is non-modal, so Tab walks the rows in DOM order.
  • The trigger’s accessible name is labels.workspaceSwitcherLabel, an aria-label that replaces the visible workspace name for assistive technology. A screen reader hears “Switch workspace, button”, and learns which workspace is current from the aria-current row inside the panel. In the collapsed rail the name is also carried by the tooltip. Worth knowing if your users drive the product by voice: the phrase that activates this button is the label, not the name on screen.
  • Translating the labels keeps the sidebar rule. A key that is both visible text and an accessible name must move as one — here workspaceSearchPlaceholder is a single key filling both roles, so it cannot drift; the sibling case to watch is collapseSidebar / collapseSidebarShort on Sidebar, where the short string has to stay a substring of the long one (WCAG 2.5.3).

Related