Skip to content

ProjectSwitcher ​

A project name in a toolbar that opens a menu of projects, recent projects and their actions. The app passes the lists and handles each pick; the component holds no project state.

tsx
import { ProjectSwitcher } from '@adecore/ui';
tsx
import { useState } from 'react';
import { Folder, FolderOpen, NotebookPen, Settings2, X } from 'lucide-react';
import { Icon, Menu, ProjectSwitcher, type ProjectSwitcherItem } from '@adecore/ui';

const PROJECTS: ProjectSwitcherItem[] = [
    { id: 'studio', name: 'Studio', icon: <Icon icon={Folder} size={16} />, description: '~/Projects/studio', hint: 'This Mac' },
    { id: 'website', name: 'Website', icon: <Icon icon={Folder} size={16} />, description: '~/Projects/website', hint: 'This Mac' },
    { id: 'archive', name: 'Archive', icon: <Icon icon={Folder} size={16} />, description: '/work/archive', hint: 'Laptop', muted: true }
];

const RECENT: ProjectSwitcherItem[] = [{ id: 'notes', name: 'Notes', icon: <Icon icon={Folder} size={16} />, description: '~/Projects/notes' }];

export default function ProjectSwitcherDemo() {
    const [current, setCurrent] = useState(PROJECTS[0]!);
    const [projects, setProjects] = useState(PROJECTS);
    const [recent, setRecent] = useState(RECENT);
    const [message, setMessage] = useState('');

    return (
        <div className="flex flex-col items-start gap-3">
            <ProjectSwitcher
                current={current}
                projects={projects.map((project): ProjectSwitcherItem => ({
                    ...project,
                    actions: (
                        <>
                            <Menu.Item onClick={() => setMessage(`Settings for ${project.name}`)}>
                                <Icon icon={Settings2} size={14} /> Project settings…
                            </Menu.Item>
                            <Menu.Separator />
                            <Menu.Item
                                onClick={() => {
                                    setProjects((rows) => rows.filter((row) => row.id !== project.id));
                                    setRecent((rows) => [project, ...rows]);
                                    setMessage(`Closed ${project.name}`);
                                }}
                            >
                                <Icon icon={X} size={14} /> Close project
                            </Menu.Item>
                        </>
                    )
                }))}
                recentProjects={recent}
                before={
                    <Menu.Item onClick={() => setMessage('Open the scratchpad')}>
                        <Icon icon={NotebookPen} size={14} /> Scratchpad
                    </Menu.Item>
                }
                onSelect={(project, event) => {
                    if (event.metaKey || event.ctrlKey) {
                        setMessage(`Open ${project.name} in another window`);

                        return;
                    }

                    setCurrent(project);
                    setProjects((rows) => (rows.some((row) => row.id === project.id) ? rows : [...rows, project]));
                    setRecent((rows) => rows.filter((row) => row.id !== project.id));
                    setMessage(`Selected ${project.name}`);
                }}
            >
                <Menu.Item onClick={() => setMessage('Choose a project folder')}>
                    <Icon icon={FolderOpen} size={14} /> Open folder
                </Menu.Item>
            </ProjectSwitcher>
            <span role="status" className="text-xs text-text-muted">
                {message || 'Choose a project or open its actions.'}
            </span>
        </div>
    );
}

Projects ​

Each ProjectSwitcherItem has an id, unique within its list, and a name. The lists are drawn as given, in order and unfiltered.

Item fieldWhat it draws
iconA node before the name, in the row and in the trigger when this is current.
descriptionA tooltip beside the row, for a folder path or connection details.
hintQuiet text at the end of the row, for a machine or workspace.
disabledA project that cannot be picked. Its actions stay reachable.
mutedA dimmed row that can still be picked, for a disconnected project the app can try to reopen.
actionsMenu.Items in a submenu beside the row.

projects fill the top of the menu. A nonempty recentProjects adds a Recent projects submenu.

onSelect(project, event) receives the original item and the click event, whose modifier keys let the app open a project in a new window on Cmd-click. ProjectSwitcherProps<Item> keeps your own item type.

current gives the trigger its name and icon, and need not be in either list. leading adds a node before the current icon, such as a machine indicator.

Without a current project ​

With current={null} the trigger shows label, or "Projects" (projectSwitcher.label) without one. With a current project, the accessible name joins both, such as "Product: Studio".

tsx
import { useState } from 'react';
import { Box, Plus } from 'lucide-react';
import { Button, Icon, Menu, ProjectSwitcher, type ProjectSwitcherItem } from '@adecore/ui';

const PRODUCTS: ProjectSwitcherItem[] = [
    { id: 'website', name: 'Website', icon: <Icon icon={Box} size={16} /> },
    { id: 'mobile', name: 'Mobile app', icon: <Icon icon={Box} size={16} /> }
];

export default function ProjectSwitcherEmptyDemo() {
    const [current, setCurrent] = useState<ProjectSwitcherItem | null>(null);

    return (
        <div className="flex items-center gap-3">
            <ProjectSwitcher label="Product" current={current} projects={PRODUCTS} onSelect={setCurrent}>
                <Menu.Item>
                    <Icon icon={Plus} size={14} /> New product
                </Menu.Item>
            </ProjectSwitcher>
            <Button size="sm" variant="secondary" disabled={current === null} onClick={() => setCurrent(null)}>
                Leave the product
            </Button>
        </div>
    );
}

Groups ​

The menu has three groups: the projects, the items in before, and Recent projects with the items in children. Put ways to open a project (open a folder, create one) in children. Empty groups draw no separator.

ProjectSwitcherGroups draws the same groups inside a Menu.Popup of your own, such as behind a plus button. It takes projects, recentProjects, before, children and onSelect.

tsx
<Menu.Root>
    <IconButton render={<Menu.Trigger />} icon={Plus} label="Open project" />
    <Menu.Popup className="min-w-60">
        <ProjectSwitcherGroups projects={projects} onSelect={open} />
    </Menu.Popup>
</Menu.Root>

Keyboard, typeahead and submenus come from Menu. Typeahead matches the project name, not its hint.

Props ​

PropTypeDefault
currentProjectSwitcherItem | nullRequired.
projectsreadonly Item[]Required.
onSelect(project: Item, event: MouseEvent<HTMLElement>) => voidRequired.
recentProjectsreadonly Item[][]
leadingReactNode
beforeReactNodeMenu items between the projects and Recent projects.
childrenReactNodeMenu items in the group of Recent projects.
labelstringprojectSwitcher.labelThe trigger's text while current is null; prefixes the accessible name otherwise.
disabledboolean
tabIndexnumberOn the trigger.
open, defaultOpen, onOpenChangeAs on Menu.Root.
classNamestringOn the trigger.
refRef<HTMLButtonElement>The trigger.

ProjectSwitcherProps and ProjectSwitcherItem are exported types.

Licenses and third-party notices are listed in each package.