/**
* Showcase · OrgSwitcher — sidebar organization/tenant switcher (Slack/Linear pattern)
*
* Requested by dxs-platform as a "first-class OrgSwitcher component".
* GATE 0 — Framework-Component Test verdict: **COMPOSITION PATTERN**, not a framework
* component. It owns NO new behaviour — search/keyboard/filtering come from `Command`
* (cmdk), open/focus-trap/dismiss from `Popover`, the trigger from `Button` + `Avatar`.
* It is fully expressible today from existing primitives + tokens (the issue itself asks
* to "compose an interim from existing primitives"), its `current`/`organizations`/
* `onSelect`/`footerActions` shape is screen-shaped, and `Sidebar` already ships the
* `brand`/`product` + `onProductClick` slot designed to host exactly this. So it lives
* here as a real, copy-pasteable recipe — NOT in `src/components/`.
*
* Composition map (OrgSwitcher block → @godxjp/ui primitive):
* sidebar-top card ........... Sidebar `brand` slot (renders above the nav scroll area)
* current-org trigger ........ Button(ghost) + Avatar(fallback initial) + Text(name/role)
* floating panel ............. Popover (open/close/focus-trap/Esc/outside-click)
* searchable org list ........ Command + CommandInput + CommandList + CommandItem
* current marker ............. Check icon + sr-only "(đang chọn)" (never colour-only)
* footer actions ............. Button(ghost) rows — "Tạo tổ chức" / "Tham gia bằng mã mời"
* app chrome ................. AppShell + Sidebar + Topbar + PageContainer
*
* a11y: trigger has an accessible name announcing the current org; the list is cmdk's
* combobox→listbox with `role=option`; the active org carries a visible Check AND an
* sr-only status word; every icon is `aria-hidden`; footer actions are siblings of the
* list (no nested interactive controls). RTL-safe: logical spacing only (`ps-/pe-/ms-/
* text-start`); `border-t`/`p-*` are direction-neutral.
*/
import * as React from "react";
import {
Boxes,
Check,
ChevronsUpDown,
LayoutDashboard,
Plus,
Settings,
Shield,
TicketPlus,
Users,
} from "lucide-react";
import { Button, Heading, Text } from "@godxjp/ui/general";
import {
AppShell,
Flex,
PageContainer,
Separator,
Sidebar,
type SidebarSectionProp,
Topbar,
} from "@godxjp/ui/layout";
import {
Avatar,
AvatarFallback,
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Popover,
PopoverContent,
PopoverTrigger,
} from "@godxjp/ui/data-display";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@godxjp/ui/data-entry";
type Org = { id: string; name: string; role: string };
const ORGS: Org[] = [
{ id: "acme-hq", name: "Acme Holdings", role: "Quản trị viên" },
{ id: "acme-jp", name: "Acme Japan K.K.", role: "Quản lý chi nhánh" },
{ id: "acme-logi", name: "Acme Logistics", role: "Điều phối" },
{ id: "bluewave", name: "BlueWave Retail", role: "Thành viên" },
{ id: "northwind", name: "Northwind Trading", role: "Kế toán" },
];
/** First grapheme of the org name, upper-cased — a stable, emoji-free monogram. */
function monogram(name: string): string {
return Array.from(name.trim())[0]?.toUpperCase() ?? "?";
}
type OrgSwitcherProps = {
organizations: Org[];
currentId: string;
onSelect: (id: string) => void;
onCreate: () => void;
onJoin: () => void;
};
/**
* OrgSwitcher — the reusable recipe. A `Popover` whose trigger is the current-org card
* and whose panel is a searchable `Command` list of orgs plus create/join footer actions.
* Copy this function into a consumer app; every import is a real `@godxjp/ui` primitive.
*/
function OrgSwitcher({ organizations, currentId, onSelect, onCreate, onJoin }: OrgSwitcherProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const current = organizations.find((org) => org.id === currentId) ?? organizations[0];
function choose(id: string) {
onSelect(id);
setOpen(false);
setQuery("");
}
return (
{/* `flush` — the Command list owns its own inset, so its rows and separators run edge to
edge inside the popover instead of being indented by the panel padding. */}
Không tìm thấy tổ chức.
{organizations.map((org) => {
const active = org.id === currentId;
return (
choose(org.id)}
>
{monogram(org.name)}
{org.name}
{org.role}
{active ? (
) : null}
{active ? (đang chọn) : null}
);
})}
{/* Menu-style footer: the two actions read as one list, so no seam between them. */}
{
onCreate();
setOpen(false);
}}
>
Tạo tổ chức mới
{
onJoin();
setOpen(false);
}}
>
Tham gia bằng mã mời
);
}
const SECTIONS: SidebarSectionProp[] = [
{
label: "Điều hành",
items: [
{ id: "overview", label: "Tổng quan", icon: LayoutDashboard },
{ id: "workspaces", label: "Không gian làm việc", icon: Boxes },
{ id: "members", label: "Thành viên", icon: Users },
],
},
{
label: "Quản trị",
items: [
{ id: "roles", label: "Vai trò", icon: Shield },
{ id: "settings", label: "Cài đặt", icon: Settings },
],
},
];
export default function OrgSwitcherShowcase() {
const [activeId, setActiveId] = React.useState("overview");
const [currentOrg, setCurrentOrg] = React.useState("acme-hq");
const [lastAction, setLastAction] = React.useState(null);
const current = ORGS.find((org) => org.id === currentOrg) ?? ORGS[0];
return (
{
setCurrentOrg(id);
setLastAction(null);
}}
onCreate={() => setLastAction("Mở luồng tạo tổ chức mới")}
onJoin={() => setLastAction("Mở luồng nhập mã mời")}
/>
}
footer={
Satoshi Yamamoto
Trực tuyến · Tokyo
}
/>
}
topbar={
Acme Console
}
end={
SY
}
/>
}
>
OrgSwitcher (mẫu tổ hợp)
Thẻ tổ chức ở đầu Sidebar mở một danh sách tìm kiếm bằng Command, kèm hành động tạo tổ
chức và tham gia bằng mã mời. Toàn bộ dựng từ primitive @godxjp/ui · không thêm
component framework.
Tổ chức đang chọn{current.name}
Đổi tổ chức từ thẻ ở góc trên Sidebar để cập nhật giá trị này.
{lastAction ? {lastAction} : null}
);
}