/** * 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. */} ); } 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} ); }