rapidUI

Organization Switcher

Shows which organisation the user works in and lets them switch, with optional sub-organisations, search and favourites.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/organization-switcher.json

Installs to @/components/ui/organization-switcher.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0

Copy the source into your project:

components/ui/organization-switcher.tsx
'use client'

import { Combobox as ComboboxPrimitive } from '@base-ui/react/combobox'
import { Select as SelectPrimitive } from '@base-ui/react/select'
import {
	Building2Icon,
	CheckIcon,
	ChevronDownIcon,
	ChevronRightIcon,
	ChevronsUpDownIcon,
	SearchIcon,
	StarIcon,
} from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'

type Organization = {
	/** Unique id; this is the value of the switcher */
	id: string
	name: string
	/** Makes this a sub-organisation of the organisation with that id */
	parentId?: string | null
	/** A short line under the name, e.g. the kind of organisation */
	description?: string
	/** An icon or image for the tile in the trigger. Without it the tile shows the first letter of the name. */
	logo?: React.ReactNode
	disabled?: boolean
}

/** An organisation with its place in the tree; what `OrganizationSwitcherList` hands to its children. */
type OrganizationNode = Organization & {
	/** 0 for a top-level organisation, 1 for its sub-organisations and so on */
	depth: number
	/** The organisations above it, top level first */
	ancestors: Organization[]
	/** The names of the organisations above it, e.g. "Ekdalen Municipality / Building and Environment" */
	path: string
	/** Whether it is one of the `favorites` */
	favorite: boolean
	/** Whether it has sub-organisations */
	hasChildren: boolean
	/** Whether its sub-organisations are listed; false once the user has collapsed it */
	expanded: boolean
}

const getPath = (organizations: Organization[]) => organizations.map((organization) => organization.name).join(' / ')

/** Orders a flat list as a tree: every organisation is followed by its sub-organisations. */
function getOrganizationNodes(
	organizations: readonly Organization[],
	favorites: ReadonlySet<string>,
	collapsed: ReadonlySet<string>,
) {
	const ids = new Set(organizations.map((organization) => organization.id))
	const children = new Map<string | null, Organization[]>()
	for (const organization of organizations) {
		const { parentId } = organization
		// A sub-organisation whose parent is not in the list is shown at the top level
		const parent = parentId != null && parentId !== organization.id && ids.has(parentId) ? parentId : null
		children.set(parent, [...(children.get(parent) ?? []), organization])
	}

	const nodes: OrganizationNode[] = []
	function visit(organization: Organization, ancestors: Organization[]) {
		const subOrganizations = children.get(organization.id) ?? []
		const hasChildren = subOrganizations.length > 0
		nodes.push({
			...organization,
			depth: ancestors.length,
			ancestors,
			path: getPath(ancestors),
			favorite: favorites.has(organization.id),
			hasChildren,
			expanded: hasChildren && !collapsed.has(organization.id),
		})
		for (const child of subOrganizations) visit(child, [...ancestors, organization])
	}
	for (const organization of children.get(null) ?? []) visit(organization, [])
	return nodes
}

type OrganizationSwitcherContextValue = {
	/**
	 * What the parts are built on: a Base UI Select, a Combobox with a search field (`searchable`), or that Combobox
	 * as a grid, where a row has more cells than the organisation: the favourite button (`favorites`) and the button
	 * that collapses its sub-organisations (`collapsible`).
	 */
	mode: 'select' | 'list' | 'grid'
	collapsible: boolean
	/** How many buttons a row can have after the organisation; the popup makes room for them */
	actions: number
	/** True while there is text in the search field; the list is then flat instead of a tree */
	searching: boolean
	/** The organisations to list: the tree without collapsed branches, or the matches while searching */
	matches: OrganizationNode[]
	/** The favourites as they were when the list opened, so rows don't move while the user marks favourites */
	favorites: OrganizationNode[]
	selected: OrganizationNode | undefined
	toggleFavorite: (id: string) => void
	toggleCollapsed: (id: string) => void
}

const OrganizationSwitcherContext = React.createContext<OrganizationSwitcherContextValue | null>(null)

// Rows outside the tree, such as favourites, are not indented and show their path instead
const OrganizationSwitcherFlatContext = React.createContext(false)

function useOrganizationSwitcher() {
	const context = React.useContext(OrganizationSwitcherContext)
	if (!context) {
		throw new Error('OrganizationSwitcher parts must be used within an OrganizationSwitcher.')
	}
	return context
}

const NO_IDS: readonly string[] = []

/**
 * Holds the organisations, the selected one and the favourites. Without `searchable` the list is a Base UI Select;
 * with it, a Base UI Combobox whose search field sits inside the popup.
 */
function OrganizationSwitcher({
	items,
	value,
	defaultValue = null,
	onValueChange,
	searchable = false,
	favorites,
	onFavoritesChange,
	collapsible = false,
	collapsed,
	defaultCollapsed = NO_IDS,
	onCollapsedChange,
	disabled = false,
	open,
	defaultOpen,
	onOpenChange,
	children,
}: Readonly<{
	/** A flat list. Give a sub-organisation the id of its organisation as `parentId`. */
	items: readonly Organization[]
	/** The id of the selected organisation or sub-organisation */
	value?: string | null
	defaultValue?: string | null
	onValueChange?: (value: string, organization: Organization) => void
	/** Lets the user filter the list with `OrganizationSwitcherSearch`; use it for long lists */
	searchable?: boolean
	/** The ids of the user's favourite organisations. Needs `searchable`. Save them in `onFavoritesChange`. */
	favorites?: readonly string[]
	onFavoritesChange?: (favorites: string[], change: { id: string; favorite: boolean }) => void
	/** Lets the user collapse an organisation's sub-organisations with `OrganizationSwitcherExpander`. Needs `searchable`. */
	collapsible?: boolean
	/** The ids of the collapsed organisations; everything else is expanded */
	collapsed?: readonly string[]
	defaultCollapsed?: readonly string[]
	onCollapsedChange?: (collapsed: string[], change: { id: string; collapsed: boolean }) => void
	disabled?: boolean
	open?: boolean
	defaultOpen?: boolean
	onOpenChange?: (open: boolean) => void
	children?: React.ReactNode
}>) {
	const [internalValue, setInternalValue] = React.useState(defaultValue)
	const controlled = value !== undefined
	const selectedValue = controlled ? value : internalValue
	const [query, setQuery] = React.useState('')
	const searching = searchable && query.trim() !== ''
	const { contains } = ComboboxPrimitive.useFilter({ sensitivity: 'base' })
	const searchableMode = favorites || collapsible ? 'grid' : 'list'
	const mode = searchable ? searchableMode : 'select'

	const favoriteIds = favorites ?? NO_IDS
	const [favoritesAtOpen, setFavoritesAtOpen] = React.useState(favoriteIds)
	const [internalCollapsed, setInternalCollapsed] = React.useState(defaultCollapsed)
	const collapsedIds = collapsed ?? internalCollapsed
	const nodes = React.useMemo(
		() => getOrganizationNodes(items, new Set(favoriteIds), new Set(collapsedIds)),
		[items, favoriteIds, collapsedIds],
	)
	const selected = selectedValue == null ? undefined : nodes.find((node) => node.id === selectedValue)
	const getName = (id: string) => nodes.find((node) => node.id === id)?.name ?? ''

	const context = React.useMemo<OrganizationSwitcherContextValue>(() => {
		const search = query.trim()
		const canCollapse = mode === 'grid' && collapsible
		// A collapsed organisation hides everything below it; the search still looks through all of them
		const tree = canCollapse
			? nodes.filter((node) => node.ancestors.every((ancestor) => !collapsedIds.includes(ancestor.id)))
			: nodes
		return {
			mode,
			collapsible: canCollapse,
			actions: (favorites ? 1 : 0) + (collapsible ? 1 : 0),
			searching,
			matches: searching ? nodes.filter((node) => contains(node.name, search)) : tree,
			favorites: mode === 'grid' && !searching ? nodes.filter((node) => favoritesAtOpen.includes(node.id)) : [],
			selected,
			toggleFavorite(id) {
				const favorite = !favoriteIds.includes(id)
				const next = favorite ? [...favoriteIds, id] : favoriteIds.filter((favoriteId) => favoriteId !== id)
				onFavoritesChange?.(next, { id, favorite })
			},
			toggleCollapsed(id) {
				const nowCollapsed = !collapsedIds.includes(id)
				const next = nowCollapsed ? [...collapsedIds, id] : collapsedIds.filter((collapsedId) => collapsedId !== id)
				if (collapsed === undefined) setInternalCollapsed(next)
				onCollapsedChange?.(next, { id, collapsed: nowCollapsed })
			},
		}
	}, [
		mode,
		collapsible,
		favorites,
		searching,
		query,
		nodes,
		favoritesAtOpen,
		selected,
		favoriteIds,
		onFavoritesChange,
		collapsed,
		collapsedIds,
		onCollapsedChange,
		contains,
	])

	function handleValueChange(next: string | null) {
		const organization = next == null ? undefined : items.find((item) => item.id === next)
		if (!organization) return
		if (!controlled) setInternalValue(organization.id)
		onValueChange?.(organization.id, organization)
	}

	const rootProps = {
		value: selectedValue,
		onValueChange: handleValueChange,
		disabled,
		open,
		defaultOpen,
		onOpenChange: (nextOpen: boolean) => {
			if (nextOpen) setFavoritesAtOpen(favoriteIds)
			onOpenChange?.(nextOpen)
		},
	}

	return (
		<OrganizationSwitcherContext.Provider value={context}>
			{searchable ? (
				// The parts filter and render the rows themselves, so the combobox gets no `items`
				<ComboboxPrimitive.Root<string>
					{...rootProps}
					onInputValueChange={setQuery}
					itemToStringLabel={getName}
					filter={null}
					grid={mode === 'grid'}
					autoHighlight
				>
					{children}
				</ComboboxPrimitive.Root>
			) : (
				<SelectPrimitive.Root<string> {...rootProps}>{children}</SelectPrimitive.Root>
			)}
		</OrganizationSwitcherContext.Provider>
	)
}

/**
 * The button that shows the current organisation and opens the list. Give it an `aria-label` such as "Organisation".
 * In a sidebar, pass `render={<SidebarMenuButton size="lg" />}`.
 */
function OrganizationSwitcherTrigger({
	render,
	children,
	...props
}: Omit<React.ComponentProps<'button'>, 'value' | 'defaultValue'> & {
	/** Replaces the button; the default styles are left out so the element keeps its own look */
	render?: React.ReactElement
}) {
	const { mode } = useOrganizationSwitcher()
	const triggerProps = {
		'data-slot': 'organization-switcher-trigger',
		render: render ?? (
			<button
				type="button"
				className="flex h-12 w-full min-w-0 items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-3 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-accent data-popup-open:text-accent-foreground"
			/>
		),
		...props,
		children: (
			<>
				{children}
				<ChevronsUpDownIcon aria-hidden="true" className="ml-auto size-4 shrink-0" />
			</>
		),
	}

	return mode === 'select' ? (
		<SelectPrimitive.Trigger {...triggerProps} />
	) : (
		<ComboboxPrimitive.Trigger {...triggerProps} />
	)
}

/**
 * The selected organisation inside the trigger: a logo tile, the selected organisation's name and, under it, the
 * organisations above it or, for a top-level organisation, its description.
 */
function OrganizationSwitcherValue({
	placeholder,
	className,
	...props
}: React.ComponentProps<'span'> & {
	/** Shown while nothing is selected, e.g. "Select organisation" */
	placeholder?: React.ReactNode
}) {
	const { selected } = useOrganizationSwitcher()
	const top = selected?.ancestors[0] ?? selected
	// The organisation the user works in is the main line; a sub-organisation names the ones above it underneath
	const description = selected && selected.depth > 0 ? getPath(selected.ancestors) : selected?.description
	// A sub-organisation without a logo of its own shows its organisation's logo, but its own initial
	const logo = selected?.logo ?? top?.logo ?? Array.from(selected?.name.trim() ?? '')[0]?.toUpperCase()

	return (
		<>
			<span
				data-slot="organization-switcher-logo"
				aria-hidden="true"
				className="flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-primary text-sm font-semibold text-primary-foreground [&_img]:size-full [&_img]:object-cover [&_svg]:size-4"
			>
				{logo ?? <Building2Icon />}
			</span>
			<span
				data-slot="organization-switcher-value"
				data-placeholder={selected ? undefined : ''}
				className={cn('grid min-w-0 flex-1 text-left leading-tight', className)}
				{...props}
			>
				<span className="truncate font-medium">{selected?.name ?? placeholder}</span>
				{description ? <span className="truncate text-xs text-muted-foreground">{description}</span> : null}
			</span>
		</>
	)
}

function OrganizationSwitcherContent({
	className,
	side = 'bottom',
	align = 'start',
	sideOffset = 4,
	...props
}: Omit<React.ComponentProps<'div'>, 'ref'> &
	Pick<ComboboxPrimitive.Positioner.Props, 'side' | 'align' | 'sideOffset'>) {
	const { mode, actions } = useOrganizationSwitcher()
	const searchable = mode !== 'select'
	const positionerProps = { side, align, sideOffset, className: 'isolate z-50' }
	const popupProps = {
		'data-slot': 'organization-switcher-content',
		className: cn(
			'max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-60 origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-border outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
			!searchable && 'overflow-y-auto',
			// Room for the buttons next to the names
			mode === 'grid' && (actions > 1 ? 'min-w-80' : 'min-w-72'),
			className,
		),
		...props,
	}

	return searchable ? (
		<ComboboxPrimitive.Portal>
			<ComboboxPrimitive.Positioner {...positionerProps}>
				<ComboboxPrimitive.Popup {...popupProps} />
			</ComboboxPrimitive.Positioner>
		</ComboboxPrimitive.Portal>
	) : (
		<SelectPrimitive.Portal>
			<SelectPrimitive.Positioner {...positionerProps} alignItemWithTrigger={false}>
				<SelectPrimitive.Popup {...popupProps} />
			</SelectPrimitive.Positioner>
		</SelectPrimitive.Portal>
	)
}

/** A heading above the list, e.g. "Switch organisation" */
function OrganizationSwitcherLabel({ className, ...props }: React.ComponentProps<'div'>) {
	return (
		<div
			data-slot="organization-switcher-label"
			className={cn('px-3 pt-2.5 pb-1 text-xs text-muted-foreground', className)}
			{...props}
		/>
	)
}

/** The search field. It needs `searchable` on `OrganizationSwitcher` and renders nothing without it. */
function OrganizationSwitcherSearch({ className, placeholder, ...props }: ComboboxPrimitive.Input.Props) {
	const { mode } = useOrganizationSwitcher()
	if (mode === 'select') return null

	return (
		<div data-slot="organization-switcher-search" className="flex h-10 items-center gap-2 border-b px-3">
			<SearchIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
			<ComboboxPrimitive.Input
				aria-label={typeof placeholder === 'string' ? placeholder : undefined}
				placeholder={placeholder}
				className={cn(
					'h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground',
					className,
				)}
				{...props}
			/>
		</div>
	)
}

/** The message when the search has no matches. It renders nothing without `searchable`. */
function OrganizationSwitcherEmpty({ className, children, ...props }: React.ComponentProps<'output'>) {
	const { mode, matches } = useOrganizationSwitcher()
	if (mode === 'select') return null

	return (
		// Stays in the page as a live region, so it only takes up space when it has text
		<output
			data-slot="organization-switcher-empty"
			aria-live="polite"
			aria-atomic="true"
			className={cn('block px-3 py-6 text-center text-sm text-muted-foreground empty:p-0', className)}
			{...props}
		>
			{matches.length === 0 ? children : null}
		</output>
	)
}

type OrganizationSwitcherRenderItem = (organization: OrganizationNode, index: number) => React.ReactNode

/** One row per organisation. In a grid the row holds the organisation and its buttons side by side. */
function OrganizationSwitcherRows({
	organizations,
	children,
}: {
	organizations: OrganizationNode[]
	children: OrganizationSwitcherRenderItem
}) {
	const { mode } = useOrganizationSwitcher()

	return organizations.map((organization, index) =>
		mode === 'grid' ? (
			<ComboboxPrimitive.Row
				key={organization.id}
				data-slot="organization-switcher-row"
				className="group/organization-switcher-row flex items-center gap-0.5"
			>
				{children(organization, index)}
			</ComboboxPrimitive.Row>
		) : (
			<React.Fragment key={organization.id}>{children(organization, index)}</React.Fragment>
		),
	)
}

/**
 * The scrolling list. Give it a function to list every organisation, or `OrganizationSwitcherFavorites` and
 * `OrganizationSwitcherGroup` to list them in groups.
 */
function OrganizationSwitcherList({
	className,
	children,
	...props
}: Omit<React.ComponentProps<'div'>, 'children' | 'ref'> & {
	children: React.ReactNode | OrganizationSwitcherRenderItem
}) {
	const { mode, matches } = useOrganizationSwitcher()
	const content =
		typeof children === 'function' ? (
			<OrganizationSwitcherRows organizations={matches}>{children}</OrganizationSwitcherRows>
		) : (
			children
		)

	if (mode === 'select') {
		return (
			<SelectPrimitive.List data-slot="organization-switcher-list" className={cn('p-1', className)} {...props}>
				{content}
			</SelectPrimitive.List>
		)
	}

	return (
		<ComboboxPrimitive.List
			data-slot="organization-switcher-list"
			className={cn(
				'max-h-[min(--spacing(72),calc(var(--available-height)---spacing(10)))] scroll-py-1 overflow-y-auto overscroll-contain p-1',
				matches.length === 0 && 'p-0',
				className,
			)}
			{...props}
		>
			{content}
		</ComboboxPrimitive.List>
	)
}

function OrganizationSwitcherGroupFrame({
	label,
	className,
	children,
	...props
}: Omit<React.ComponentProps<'div'>, 'ref'> & { label?: React.ReactNode }) {
	const { mode } = useOrganizationSwitcher()
	const Group = mode === 'select' ? SelectPrimitive.Group : ComboboxPrimitive.Group
	const GroupLabel = mode === 'select' ? SelectPrimitive.GroupLabel : ComboboxPrimitive.GroupLabel

	return (
		<Group data-slot="organization-switcher-group" className={cn('not-first:mt-1', className)} {...props}>
			{label ? (
				<GroupLabel
					data-slot="organization-switcher-group-label"
					className="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground [&_svg]:size-3.5"
				>
					{label}
				</GroupLabel>
			) : null}
			{children}
		</Group>
	)
}

/** Every organisation, in tree order, under an optional `label`. While searching it lists the matches without it. */
function OrganizationSwitcherGroup({
	label,
	children,
	...props
}: Omit<React.ComponentProps<'div'>, 'children' | 'ref'> & {
	/** A heading for the group, e.g. "All organisations" */
	label?: React.ReactNode
	children: OrganizationSwitcherRenderItem
}) {
	const { searching, matches } = useOrganizationSwitcher()
	if (matches.length === 0) return null

	return (
		<OrganizationSwitcherGroupFrame label={searching ? undefined : label} {...props}>
			<OrganizationSwitcherRows organizations={matches}>{children}</OrganizationSwitcherRows>
		</OrganizationSwitcherGroupFrame>
	)
}

/**
 * The user's favourite organisations, as a group above the others. It needs `favorites` and `searchable` on
 * `OrganizationSwitcher`, and renders nothing while searching or when there are no favourites.
 */
function OrganizationSwitcherFavorites({
	label,
	children,
	...props
}: Omit<React.ComponentProps<'div'>, 'children' | 'ref'> & {
	/** A heading for the group, e.g. "Favourites" */
	label?: React.ReactNode
	children: OrganizationSwitcherRenderItem
}) {
	const { favorites } = useOrganizationSwitcher()
	if (favorites.length === 0) return null

	return (
		<OrganizationSwitcherFlatContext.Provider value={true}>
			<OrganizationSwitcherGroupFrame label={label} {...props}>
				<OrganizationSwitcherRows organizations={favorites}>{children}</OrganizationSwitcherRows>
			</OrganizationSwitcherGroupFrame>
		</OrganizationSwitcherFlatContext.Provider>
	)
}

/**
 * One organisation in the list; the children are its name. Sub-organisations are indented by `level` in the tree.
 * Search results and favourites are not indented, so `path` is shown under the name there instead of `description`.
 */
function OrganizationSwitcherItem({
	value,
	level = 0,
	path,
	description,
	disabled,
	className,
	style,
	children,
	...props
}: Omit<React.ComponentProps<'div'>, 'ref'> & {
	/** The id of the organisation */
	value: string
	/** How deep the organisation sits in the tree; `organization.depth` */
	level?: number
	/** The organisations above it; `organization.path` */
	path?: string
	/** A second line under the name */
	description?: React.ReactNode
	disabled?: boolean
}) {
	const { mode, searching } = useOrganizationSwitcher()
	const flat = React.useContext(OrganizationSwitcherFlatContext) || searching
	const details = flat ? path : description
	const itemProps = {
		value,
		disabled,
		'data-slot': 'organization-switcher-item',
		className: cn(
			'relative flex min-h-8 w-full min-w-0 flex-1 cursor-default items-center rounded-md py-1.5 pr-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-accent data-highlighted:text-accent-foreground data-highlighted:**:text-accent-foreground',
			className,
		),
		style: { paddingInlineStart: `${0.5 + (flat ? 0 : level)}rem`, ...style },
		...props,
	}
	const indicator = <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
	const check = <CheckIcon aria-hidden="true" className="size-4" />
	const text = (
		<>
			{details ? <span className="truncate text-xs text-muted-foreground">{details}</span> : null}
			{/* The indentation is only visual, so the organisations above are given as text as well */}
			{!flat && path ? <span className="sr-only">, {path}</span> : null}
		</>
	)

	return mode === 'select' ? (
		<SelectPrimitive.Item {...itemProps}>
			<span className="grid min-w-0 flex-1 leading-tight">
				<SelectPrimitive.ItemText className="truncate">{children}</SelectPrimitive.ItemText>
				{text}
			</span>
			<SelectPrimitive.ItemIndicator render={indicator}>{check}</SelectPrimitive.ItemIndicator>
		</SelectPrimitive.Item>
	) : (
		<ComboboxPrimitive.Item {...itemProps}>
			<span className="grid min-w-0 flex-1 leading-tight">
				<span className="truncate">{children}</span>
				{text}
			</span>
			<ComboboxPrimitive.ItemIndicator render={indicator}>{check}</ComboboxPrimitive.ItemIndicator>
		</ComboboxPrimitive.Item>
	)
}

/**
 * The star next to an organisation that adds it to or removes it from the favourites. Put it after
 * `OrganizationSwitcherItem` and give it an `aria-label` that says what it does, e.g. "Add Education to favourites".
 * It is a cell of its own in the row: reach it with the right arrow key and press Enter. It needs `favorites` and
 * `searchable` on `OrganizationSwitcher` and renders nothing without them.
 */
function OrganizationSwitcherFavorite({
	value,
	favorite,
	className,
	...props
}: Omit<React.ComponentProps<'div'>, 'ref' | 'children'> & {
	/** The id of the organisation */
	value: string
	/** Whether it is a favourite now; `organization.favorite` */
	favorite: boolean
}) {
	const { mode, toggleFavorite } = useOrganizationSwitcher()
	if (mode !== 'grid') return null

	return (
		<ComboboxPrimitive.Item
			// A value of its own, so the cell is never the selected organisation
			value={`favorite:${value}`}
			data-slot="organization-switcher-favorite"
			data-favorite={favorite ? '' : undefined}
			className={cn(
				'flex size-8 shrink-0 cursor-default items-center justify-center rounded-md text-muted-foreground opacity-0 outline-hidden select-none group-hover/organization-switcher-row:opacity-100 group-has-data-highlighted/organization-switcher-row:opacity-100 data-favorite:text-warning data-favorite:opacity-100 data-highlighted:bg-accent data-highlighted:text-accent-foreground data-favorite:data-highlighted:text-warning',
				className,
			)}
			{...props}
			// Toggle the favourite instead of selecting the cell, and keep the list open
			onClick={(event) => {
				event.preventBaseUIHandler()
				toggleFavorite(value)
			}}
			onMouseUp={(event) => event.preventBaseUIHandler()}
		>
			<StarIcon aria-hidden="true" className={cn('size-4', favorite && 'fill-current')} />
		</ComboboxPrimitive.Item>
	)
}

/**
 * The chevron after an organisation that hides or shows its sub-organisations. Put it last in the row, after
 * `OrganizationSwitcherItem` and `OrganizationSwitcherFavorite`, and give it an `aria-label` that says what it does,
 * e.g. "Collapse Ekdalen Municipality". It is a cell of its own in the row: reach it with the right arrow key and
 * press Enter. An organisation without sub-organisations gets an empty space of the same width, so the rows line up.
 * It needs `collapsible` and `searchable` on `OrganizationSwitcher` and renders nothing without them.
 */
function OrganizationSwitcherExpander({
	value,
	expanded,
	hasChildren,
	className,
	...props
}: Omit<React.ComponentProps<'div'>, 'ref' | 'children'> & {
	/** The id of the organisation */
	value: string
	/** Whether its sub-organisations are listed now; `organization.expanded` */
	expanded: boolean
	/** Whether it has sub-organisations to hide; `organization.hasChildren` */
	hasChildren: boolean
}) {
	const { collapsible, searching, toggleCollapsed } = useOrganizationSwitcher()
	const flat = React.useContext(OrganizationSwitcherFlatContext)
	// Search results are a flat list, so there is nothing to collapse
	if (!collapsible || searching) return null

	if (flat || !hasChildren) {
		return <span data-slot="organization-switcher-expander-spacer" aria-hidden="true" className="size-8 shrink-0" />
	}

	const Icon = expanded ? ChevronDownIcon : ChevronRightIcon

	return (
		<ComboboxPrimitive.Item
			// A value of its own, so the cell is never the selected organisation
			value={`expander:${value}`}
			aria-expanded={expanded}
			data-slot="organization-switcher-expander"
			className={cn(
				'flex size-8 shrink-0 cursor-default items-center justify-center rounded-md text-muted-foreground outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground',
				className,
			)}
			{...props}
			// Collapse or expand instead of selecting the cell, and keep the list open
			onClick={(event) => {
				event.preventBaseUIHandler()
				toggleCollapsed(value)
			}}
			onMouseUp={(event) => event.preventBaseUIHandler()}
		>
			<Icon aria-hidden="true" className="size-4" />
		</ComboboxPrimitive.Item>
	)
}

export {
	OrganizationSwitcher,
	OrganizationSwitcherTrigger,
	OrganizationSwitcherValue,
	OrganizationSwitcherContent,
	OrganizationSwitcherLabel,
	OrganizationSwitcherSearch,
	OrganizationSwitcherEmpty,
	OrganizationSwitcherList,
	OrganizationSwitcherGroup,
	OrganizationSwitcherFavorites,
	OrganizationSwitcherItem,
	OrganizationSwitcherFavorite,
	OrganizationSwitcherExpander,
	type Organization,
	type OrganizationNode,
}

Shows which organisation the user is working in and lets them switch to another one, usually at the top of the sidebar. Organisations can have sub-organisations, such as the departments of a municipality, and a long list can be searched. Like the other components it is composed from parts: you give OrganizationSwitcher a flat list and the id of the selected organisation, and build the trigger, the list and the items yourself.

Features

  • Composable OrganizationSwitcherTrigger, OrganizationSwitcherValue, OrganizationSwitcherContent, OrganizationSwitcherLabel, OrganizationSwitcherSearch, OrganizationSwitcherEmpty, OrganizationSwitcherList, OrganizationSwitcherGroup, OrganizationSwitcherFavorites, OrganizationSwitcherItem, OrganizationSwitcherFavorite and OrganizationSwitcherExpander
  • Optional sub-organisations in any depth: give an organisation a parentId, and the list hands you each organisation with its depth and path, in tree order
  • OrganizationSwitcherValue shows a logo tile and the selected organisation, with the organisations above a sub-organisation under it
  • searchable with OrganizationSwitcherSearch for long lists; search results are flat and show which organisation each match belongs to
  • Optional favourites: a star on each row and a group of favourites above the others. The app saves them; the component only shows them and reports changes
  • Optional collapsing: a chevron on each organisation with sub-organisations hides or shows them
  • On its own or in a sidebar: render={<SidebarMenuButton size="lg" />} on the trigger makes it collapse to the logo tile with the sidebar
  • Controlled (value) or uncontrolled (defaultValue)
  • All text is yours, so it is written in the language of the page
  • Built on the Base UI Select and, with searchable, the Base UI Combobox
  • data-slot attributes on every part

Usage

import {
  type Organization,
  OrganizationSwitcher,
  OrganizationSwitcherContent,
  OrganizationSwitcherItem,
  OrganizationSwitcherLabel,
  OrganizationSwitcherList,
  OrganizationSwitcherTrigger,
  OrganizationSwitcherValue,
} from '@/components/ui/organization-switcher'
import { SidebarMenuButton } from '@/components/ui/sidebar'

const organizations: Organization[] = [
  { id: 'ekdalen', name: 'Ekdalen Municipality' },
  // Sub-organisations point at their organisation with parentId
  { id: 'building-environment', name: 'Building and Environment', parentId: 'ekdalen' },
  { id: 'social-services', name: 'Social Services', parentId: 'ekdalen' },
]

export function AppOrganizationSwitcher({ organizationId }: { organizationId: string }) {
  return (
    <OrganizationSwitcher items={organizations} value={organizationId} onValueChange={switchOrganization}>
      <OrganizationSwitcherTrigger aria-label="Organisation" render={<SidebarMenuButton size="lg" />}>
        <OrganizationSwitcherValue placeholder="Select organisation" />
      </OrganizationSwitcherTrigger>
      <OrganizationSwitcherContent>
        <OrganizationSwitcherLabel>Switch organisation</OrganizationSwitcherLabel>
        <OrganizationSwitcherList>
          {(organization) => (
            <OrganizationSwitcherItem
              key={organization.id}
              value={organization.id}
              level={organization.depth}
              path={organization.path}
            >
              {organization.name}
            </OrganizationSwitcherItem>
          )}
        </OrganizationSwitcherList>
      </OrganizationSwitcherContent>
    </OrganizationSwitcher>
  )
}

For a long list, add searchable and the two search parts:

<OrganizationSwitcher items={organizations} value={organizationId} onValueChange={switchOrganization} searchable>
  <OrganizationSwitcherTrigger aria-label="Organisation">
    <OrganizationSwitcherValue placeholder="Select organisation" />
  </OrganizationSwitcherTrigger>
  <OrganizationSwitcherContent aria-label="Switch organisation">
    <OrganizationSwitcherSearch placeholder="Search organisations" />
    <OrganizationSwitcherEmpty>No organisations found</OrganizationSwitcherEmpty>
    <OrganizationSwitcherList>{/* the same items */}</OrganizationSwitcherList>
  </OrganizationSwitcherContent>
</OrganizationSwitcher>

Examples

Default

A short list of organisations without sub-organisations. description is the second line in the trigger and in the list.

Sub-organisations

A municipality and its departments. level indents the departments and path names the municipality for screen readers. The department is selected, so the trigger shows the department with the municipality under it. The municipality itself can be selected as well.

searchable with OrganizationSwitcherSearch and OrganizationSwitcherEmpty. The list is a tree until you type; search results are a flat list where each sub-organisation shows its path.

Favourites

Pass the user's favourite ids as favorites and save them in onFavoritesChange; the component does not store them. OrganizationSwitcherFavorite adds a star to each row, and OrganizationSwitcherFavorites lists the favourites in a group above OrganizationSwitcherGroup. The group keeps its rows while the list is open, so nothing moves under the pointer when a star is pressed; it is up to date the next time the list opens. Favourites need searchable.

Collapsible

With collapsible, put an OrganizationSwitcherExpander last in each row. An organisation with sub-organisations gets a chevron that hides or shows them; the others get an empty space of the same width so the rows line up. Everything is expanded at first unless you pass defaultCollapsed, here the two other municipalities. Searching still finds organisations in collapsed branches. Collapsing needs searchable.

Collapsible with Favourites

Both together, as in the RDF Dashboard: the star comes before the chevron in each row.

With Logos

logo on an organisation takes an icon or an img. A sub-organisation without a logo uses the one of its top-level organisation, and an organisation without any logo shows the first letter of its name. disabled on an item keeps the organisation in the list but makes it unavailable.

In a Sidebar

Pass render={<SidebarMenuButton size="lg" />} to the trigger so it follows the sidebar's colours and collapses to the logo tile. Add tooltip to the SidebarMenuButton to show the name of the organisation while the sidebar is collapsed.

Controlled

Hold the id in state, or in the URL or a cookie, with value and onValueChange. The second argument of onValueChange is the organisation. Announce the switch to screen readers when the page content changes with it.

Translated

Swedish text, with nothing selected yet. The text is in your own JSX, so there is nothing to override.

Disabled

Subcomponents

  • OrganizationSwitcher - Holds the organisations and the selected one; renders no element of its own
  • OrganizationSwitcherTrigger - The button that shows the current organisation and opens the list
  • OrganizationSwitcherValue - The logo tile, name and second line of the selected organisation, inside the trigger
  • OrganizationSwitcherContent - The popup
  • OrganizationSwitcherLabel - A heading above the list, e.g. "Switch organisation"
  • OrganizationSwitcherSearch - The search field; only with searchable
  • OrganizationSwitcherEmpty - The message when the search has no matches; only with searchable
  • OrganizationSwitcherList - The scrolling list. Give it a function to list every organisation, or groups
  • OrganizationSwitcherGroup - Every organisation under an optional label, e.g. "All organisations"
  • OrganizationSwitcherFavorites - The favourites as a group with a label; hidden when there are none and while searching
  • OrganizationSwitcherItem - One organisation in the list; its children are the name
  • OrganizationSwitcherFavorite - The star after an item that adds or removes a favourite
  • OrganizationSwitcherExpander - The chevron last in a row that hides or shows the sub-organisations

OrganizationSwitcherList, OrganizationSwitcherGroup and OrganizationSwitcherFavorites take a function as children and call it once per organisation. They set the key, so the function can return a fragment with an item its star and its chevron.

OrganizationSwitcher props

PropTypeDefaultDescription
itemsOrganization[]-A flat list of organisations and sub-organisations
valuestring | null-The id of the selected organisation (controlled)
defaultValuestring | nullnullThe id selected at first (uncontrolled)
onValueChange(value: string, organization: Organization) => void-Called when the user picks another organisation
searchablebooleanfalseLets the user filter the list with OrganizationSwitcherSearch
favoritesstring[]-The ids of the user's favourites; needs searchable
onFavoritesChange(favorites: string[], change: { id: string, favorite: boolean }) => void-Called when a star is pressed; save the new list
collapsiblebooleanfalseLets the user collapse sub-organisations with OrganizationSwitcherExpander; needs searchable
collapsedstring[]-The ids of the collapsed organisations (controlled)
defaultCollapsedstring[][]The ids collapsed at first; everything else is expanded
onCollapsedChange(collapsed: string[], change: { id: string, collapsed: boolean }) => void-Called when a chevron is pressed
disabledbooleanfalseShows the organisation but keeps the list closed
openboolean-Whether the list is open (controlled)
defaultOpenbooleanfalseWhether the list is open at first
onOpenChange(open: boolean) => void-Called when the list opens or closes

searchable decides which Base UI component the parts are built on, so set it on OrganizationSwitcher and keep the same parts. A common choice is searchable={organizations.length > 7}; the search parts render nothing without it.

Organization

FieldTypeDescription
idstringUnique id; the value of the switcher
namestringThe name shown in the trigger, and what the search matches
parentIdstring | nullThe id of the organisation it belongs to. Leave it out for a top-level organisation
descriptionstringA second line in the trigger, e.g. "Municipality" or an organisation number
logoReactNodeAn icon or img for the tile in the trigger
disabledbooleanFor you to pass on to the item

The child functions get an OrganizationNode: the organisation plus depth (0 at the top level), ancestors (the organisations above it), path (their names, e.g. "Ekdalen Municipality / Building and Environment"), favorite, hasChildren and expanded. Organisations keep the order you pass them in, with sub-organisations placed under their parent. A sub-organisation whose parent is not in the list, for example because the user has no access to it, is shown at the top level.

OrganizationSwitcherTrigger props

PropTypeDefaultDescription
aria-labelstring-The accessible name, e.g. "Organisation". Always set it
renderReactElementa 48px buttonReplaces the button, e.g. <SidebarMenuButton size="lg" />; it keeps its own styles

The trigger takes any children, so you can leave out OrganizationSwitcherValue and build the content yourself.

OrganizationSwitcherContent props

PropTypeDefaultDescription
side'top' | 'bottom' | 'left' | 'right''bottom'Which side of the trigger the list opens on
align'start' | 'center' | 'end''start'How the list lines up with the trigger
sideOffsetnumber4The gap between the trigger and the list
aria-labelstring-Names the popup; set it when the list is searchable

OrganizationSwitcherItem props

PropTypeDefaultDescription
valuestring-The id of the organisation
levelnumber0How far to indent it in the tree; pass organization.depth
pathstring-The organisations above it; pass organization.path. Shown in search results, read out in the tree
descriptionReactNode-A second line under the name. Search results and favourites show path there instead
disabledbooleanfalseListed but not selectable

OrganizationSwitcherFavorite props

PropTypeDefaultDescription
valuestring-The id of the organisation
favoriteboolean-Whether it is a favourite now; pass organization.favorite
aria-labelstring-What pressing it does, e.g. "Add Education to favourites". Always set it

The star is shown for favourites and, for the other rows, when the row is hovered or highlighted. It renders nothing unless OrganizationSwitcher has both favorites and searchable.

OrganizationSwitcherExpander props

PropTypeDefaultDescription
valuestring-The id of the organisation
expandedboolean-Whether its sub-organisations are listed now; pass organization.expanded
hasChildrenboolean-Whether it has sub-organisations; pass organization.hasChildren
aria-labelstring-What pressing it does, e.g. "Collapse Ekdalen Municipality". Always set it

Put it last in the row, after the item and the star. The chevron sits at the end of the row and not in front of the name, so that the arrow keys always land on the organisation first. An organisation without sub-organisations, and a row in the favourites group, gets an empty space of the same width. While searching it renders nothing, because search results are a flat list. It renders nothing unless OrganizationSwitcher has both collapsible and searchable.

Collapsed organisations are remembered for as long as the OrganizationSwitcher is mounted. To keep them between visits, hold them in collapsed and save them in onCollapsedChange.

Accessibility

  • The trigger is a combobox and the selected organisation is its value. Give it a name with aria-label, e.g. "Organisation". It shows a focus ring and opens with Enter, Space and the arrow keys
  • Without searchable the list is a listbox: arrow keys move, typing jumps to a name, Enter selects and Escape closes
  • With searchable, focus moves to the search field and the arrow keys move through the matches. The first match is highlighted, so Enter selects it. The text in OrganizationSwitcherEmpty is announced through a live region
  • OrganizationSwitcherSearch uses its placeholder as its accessible name unless you pass aria-label
  • The indentation of sub-organisations is only visual. Pass path to every item so a sub-organisation also names the organisations above it, e.g. "Building permits, Ekdalen Municipality / Building and Environment"
  • The selected organisation has aria-selected and a check mark, so it does not rely on colour
  • With favorites the list is a grid where each row has two cells: the organisation and its star. The right arrow key moves from the organisation to the star and Enter presses it; the list stays open and focus stays in the search field. The star has no pressed state of its own, so say what it will do in its aria-label ("Add … to favourites" or "Remove … from favourites"); a filled star shows a favourite, so it does not rely on colour either
  • The favourites group does not change while the list is open, so rows don't move when a star is pressed
  • With collapsible the list is a grid as well, and the chevron is the last cell of its row: the right arrow key moves to it and Enter presses it. It has aria-expanded, and the highlight stays on it after it is pressed. Sub-organisations of a collapsed organisation are removed from the list, not just hidden, and the search still finds them
  • The logo tile is decorative and hidden from assistive technology; the name next to it carries the meaning
  • Rows are at least 32px tall and the trigger is 48px, 40px when the sidebar is collapsed
  • Switching organisation usually changes the whole page. Move focus to the main heading or announce the change, as in the Controlled example
  • Sidebar - Where the switcher usually sits
  • Select - Pick one value in a form
  • Combobox - A filterable list of options in a form
  • Dropdown Menu - For a menu of actions, such as the account menu