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.jsonInstalls to @/components/ui/organization-switcher.
Manual installation
Install the dependencies:
pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0Copy the source into your project:
'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,OrganizationSwitcherFavoriteandOrganizationSwitcherExpander - Optional sub-organisations in any depth: give an organisation a
parentId, and the list hands you each organisation with itsdepthandpath, in tree order OrganizationSwitcherValueshows a logo tile and the selected organisation, with the organisations above a sub-organisation under itsearchablewithOrganizationSwitcherSearchfor 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-slotattributes 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.
With Search
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 ownOrganizationSwitcherTrigger- The button that shows the current organisation and opens the listOrganizationSwitcherValue- The logo tile, name and second line of the selected organisation, inside the triggerOrganizationSwitcherContent- The popupOrganizationSwitcherLabel- A heading above the list, e.g. "Switch organisation"OrganizationSwitcherSearch- The search field; only withsearchableOrganizationSwitcherEmpty- The message when the search has no matches; only withsearchableOrganizationSwitcherList- The scrolling list. Give it a function to list every organisation, or groupsOrganizationSwitcherGroup- Every organisation under an optionallabel, e.g. "All organisations"OrganizationSwitcherFavorites- The favourites as a group with alabel; hidden when there are none and while searchingOrganizationSwitcherItem- One organisation in the list; its children are the nameOrganizationSwitcherFavorite- The star after an item that adds or removes a favouriteOrganizationSwitcherExpander- 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
| Prop | Type | Default | Description |
|---|---|---|---|
items | Organization[] | - | A flat list of organisations and sub-organisations |
value | string | null | - | The id of the selected organisation (controlled) |
defaultValue | string | null | null | The id selected at first (uncontrolled) |
onValueChange | (value: string, organization: Organization) => void | - | Called when the user picks another organisation |
searchable | boolean | false | Lets the user filter the list with OrganizationSwitcherSearch |
favorites | string[] | - | 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 |
collapsible | boolean | false | Lets the user collapse sub-organisations with OrganizationSwitcherExpander; needs searchable |
collapsed | string[] | - | The ids of the collapsed organisations (controlled) |
defaultCollapsed | string[] | [] | The ids collapsed at first; everything else is expanded |
onCollapsedChange | (collapsed: string[], change: { id: string, collapsed: boolean }) => void | - | Called when a chevron is pressed |
disabled | boolean | false | Shows the organisation but keeps the list closed |
open | boolean | - | Whether the list is open (controlled) |
defaultOpen | boolean | false | Whether 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
| Field | Type | Description |
|---|---|---|
id | string | Unique id; the value of the switcher |
name | string | The name shown in the trigger, and what the search matches |
parentId | string | null | The id of the organisation it belongs to. Leave it out for a top-level organisation |
description | string | A second line in the trigger, e.g. "Municipality" or an organisation number |
logo | ReactNode | An icon or img for the tile in the trigger |
disabled | boolean | For 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
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | - | The accessible name, e.g. "Organisation". Always set it |
render | ReactElement | a 48px button | Replaces 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
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
sideOffset | number | 4 | The gap between the trigger and the list |
aria-label | string | - | Names the popup; set it when the list is searchable |
OrganizationSwitcherItem props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | - | The id of the organisation |
level | number | 0 | How far to indent it in the tree; pass organization.depth |
path | string | - | The organisations above it; pass organization.path. Shown in search results, read out in the tree |
description | ReactNode | - | A second line under the name. Search results and favourites show path there instead |
disabled | boolean | false | Listed but not selectable |
OrganizationSwitcherFavorite props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | - | The id of the organisation |
favorite | boolean | - | Whether it is a favourite now; pass organization.favorite |
aria-label | string | - | 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
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | - | The id of the organisation |
expanded | boolean | - | Whether its sub-organisations are listed now; pass organization.expanded |
hasChildren | boolean | - | Whether it has sub-organisations; pass organization.hasChildren |
aria-label | string | - | 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
comboboxand the selected organisation is its value. Give it a name witharia-label, e.g. "Organisation". It shows a focus ring and opens with Enter, Space and the arrow keys - Without
searchablethe list is alistbox: 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 inOrganizationSwitcherEmptyis announced through a live region OrganizationSwitcherSearchuses itsplaceholderas its accessible name unless you passaria-label- The indentation of sub-organisations is only visual. Pass
pathto 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-selectedand a check mark, so it does not rely on colour - With
favoritesthe list is agridwhere 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 itsaria-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
collapsiblethe list is agridas well, and the chevron is the last cell of its row: the right arrow key moves to it and Enter presses it. It hasaria-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
Related Components
- 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