Grouped checkbox filter
Dropdown with grouped checkboxes for filtering a list by multiple criteria.
import { useCallback, useMemo, useRef, useState } from "react"; import { HvBaseDropdown, HvCheckBox, HvCheckBoxGroup, HvOverflowTooltip, HvPanel, HvSearchInput, HvTag, HvTypography, } from "@hitachivantara/uikit-react-core"; export default function Demo() { const [selected, setSelected] = useState<string[] | null>(null); const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(""); const searchRef = useRef<HTMLInputElement>(null); const filteredGroups = useMemo( () => filterGroups(searchValue), [searchValue], ); const handleSelect = useCallback( (groupValues: string[], checkedValues: string[]) => { setSelected((prev) => { if (!prev) return checkedValues; const withoutGroup = prev?.filter((id) => !groupValues.includes(id)); return [...(withoutGroup ?? []), ...checkedValues]; }); }, [], ); return ( <HvBaseDropdown expanded={open} onToggle={() => setOpen((p) => !p)} classes={{ root: "w-300px!", panel: "w-300px!", }} placeholder={ <div className="flex gap-xxs overflow-scroll"> {selected?.map((item) => ( <HvTag label={item} /> ))} </div> } onContainerCreation={() => searchRef.current?.focus()} > <HvPanel> <HvSearchInput ref={searchRef} placeholder="Search..." onChange={(_, val) => setSearchValue(val || "")} className="m-b-xxs p-l-0" /> <div> <div className="flex flex-col items-start gap-xxs"> {filteredGroups.map((group) => ( <div key={group.id} className="w-full flex flex-col gap-xxs [&:not(:last-child)]:b-b-1 [&:not(:last-child)]:b-b-border [&:not(:last-child)]:p-b-xs [&:not(:last-child)]:m-b-xs" > {group.items.length > 0 && ( <> <HvTypography variant="label" className="p-xs"> {group.label} </HvTypography> <HvCheckBoxGroup onChange={(_, value) => handleSelect( group.items.map((opt: Item) => opt.label), value, ) } > {group.items .filter((option: Item) => option.label .toLowerCase() .includes(searchValue.toLowerCase()), ) .map((option: Item) => ( <HvCheckBox key={option.id} value={option.label} checked={selected?.includes(option.label)} label={<HvOverflowTooltip data={option.label} />} /> ))} </HvCheckBoxGroup> </> )} </div> ))} </div> </div> </HvPanel> </HvBaseDropdown> ); } interface Item { id: string; label: string; } interface Group { id: string; label: string; items: Item[]; } const groups: Group[] = [ { id: "engineering", label: "Engineering", items: [ { id: "1", label: "Software Development" }, { id: "2", label: "Quality Assurance" }, { id: "3", label: "DevOps" }, ], }, { id: "finance", label: "Finance", items: [ { id: "4", label: "Accounting" }, { id: "5", label: "Budgeting" }, { id: "6", label: "Investments" }, { id: "7", label: "Payroll" }, ], }, ]; const filterGroups = (search: string) => { if (!search) return groups; return groups.map((group) => ({ ...group, items: group.items?.filter((item: Item) => { const labelText = item.label.toLowerCase(); return labelText.includes(search.toLowerCase()); }), })); };
Column header filters
Table with per-column filter dropdowns attached to each column header.
Name | status | severity | priority |
|---|---|---|---|
| Event 1 | Open | Critical | High |
| Event 2 | Closed | Major | Medium |
| Event 3 | Open | Average | Low |
| Event 4 | Closed | Minor | High |
| Event 5 | Open | Critical | Medium |
| Event 6 | Closed | Major | Low |
| Event 7 | Open | Average | High |
| Event 8 | Closed | Minor | Medium |
| Event 9 | Open | Critical | Low |
| Event 10 | Closed | Major | High |
| Event 11 | Open | Average | Medium |
| Event 12 | Closed | Minor | Low |
import { useMemo, useState } from "react"; import { HvBaseDropdown, HvButton, HvCheckBox, HvEmptyState, HvIconButton, HvIconContainer, HvPanel, HvTable, HvTableBody, HvTableCell, HvTableContainer, HvTableHead, HvTableHeader, HvTableRow, HvTableSection, } from "@hitachivantara/uikit-react-core"; import { Ban } from "@hitachivantara/uikit-react-icons"; const rows = [...Array(12).keys()].map((i) => ({ id: `${i + 1}`, name: `Event ${i + 1}`, status: (["Open", "Closed"] as const)[i % 2], severity: (["Critical", "Major", "Average", "Minor"] as const)[i % 4], priority: (["High", "Medium", "Low"] as const)[i % 3], })); type FilterKey = "status" | "severity" | "priority"; const uniqueValues = (key: FilterKey) => [...new Set(rows.map((r) => r[key]))] as string[]; interface ColumnFilterProps { options: string[]; activeFilters: string[]; onApply: (values: string[]) => void; } function ColumnFilter({ options, activeFilters, onApply }: ColumnFilterProps) { const [open, setOpen] = useState(false); const [pending, setPending] = useState<string[]>([]); const isActive = activeFilters.length > 0; const handleToggle = (_: unknown, isOpen: boolean) => { if (isOpen) setPending(activeFilters); setOpen(isOpen); }; const toggle = (val: string) => setPending((prev) => prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val], ); return ( <HvBaseDropdown expanded={open} onToggle={handleToggle} placement="right" variableWidth classes={{ root: "inline-flex! w-auto!", header: "border-transparent! bg-transparent!", arrowContainer: "hidden!", selection: "flex items-center pr-xs", }} placeholder={ <HvIconContainer> <HvIconButton title="Filter"> <div className={`i-ph-funnel ${isActive ? "color-primary" : "color-textSubtle"}`} /> </HvIconButton> </HvIconContainer> } > <HvPanel className="min-w-160px p-xs flex flex-col gap-xs"> {options.map((opt) => ( <HvCheckBox key={opt} label={opt} checked={pending.includes(opt)} onChange={() => toggle(opt)} /> ))} <div className="flex gap-xs mt-xs pt-xs border-t-1 border-borderSubtle"> <HvButton size="xs" onClick={() => { onApply(pending); setOpen(false); }} > Apply </HvButton> <HvButton size="xs" variant="secondaryGhost" onClick={() => { setPending([]); onApply([]); setOpen(false); }} > Clear </HvButton> </div> </HvPanel> </HvBaseDropdown> ); } const filterKeys: FilterKey[] = ["status", "severity", "priority"]; export default function Demo() { const [filters, setFilters] = useState<Record<FilterKey, string[]>>({ status: [], severity: [], priority: [], }); const filtered = useMemo( () => rows.filter( (r) => (!filters.status.length || filters.status.includes(r.status)) && (!filters.severity.length || filters.severity.includes(r.severity)) && (!filters.priority.length || filters.priority.includes(r.priority)), ), [filters], ); const setFilter = (key: FilterKey) => (values: string[]) => setFilters((prev) => ({ ...prev, [key]: values })); return ( <HvTableSection> <HvTableContainer> <HvTable> <HvTableHead> <HvTableRow> <HvTableHeader>Name</HvTableHeader> {filterKeys.map((key) => ( <HvTableHeader key={key}> <div className="flex items-center gap-xxs capitalize"> {key} <ColumnFilter options={uniqueValues(key)} activeFilters={filters[key]} onApply={setFilter(key)} /> </div> </HvTableHeader> ))} </HvTableRow> </HvTableHead> <HvTableBody> {filtered.map((row) => ( <HvTableRow key={row.id}> <HvTableCell>{row.name}</HvTableCell> <HvTableCell>{row.status}</HvTableCell> <HvTableCell>{row.severity}</HvTableCell> <HvTableCell>{row.priority}</HvTableCell> </HvTableRow> ))} {filtered.length === 0 && ( <HvTableRow> <HvTableCell colSpan={4}> <HvEmptyState message="No results match the current filters." icon={<Ban />} /> </HvTableCell> </HvTableRow> )} </HvTableBody> </HvTable> </HvTableContainer> </HvTableSection> ); }
Assignee picker
Dropdown for selecting a user from a list, with avatar and name display.
Select assignee...
import { useMemo, useRef, useState } from "react"; import { HvAvatar, HvBaseDropdown, HvLabel, HvPanel, HvSearchInput, HvTypography, } from "@hitachivantara/uikit-react-core"; const users = [ { id: "1", name: "Alice Chen", role: "Engineer", initials: "AC", bg: "cat1" }, { id: "2", name: "Bob Martinez", role: "QA Lead", initials: "BM", bg: "cat2", }, { id: "3", name: "Carol Smith", role: "Product Manager", initials: "CS", bg: "cat3", }, { id: "4", name: "David Lee", role: "DevOps", initials: "DL", bg: "cat4" }, { id: "5", name: "Elena Torres", role: "Designer", initials: "ET", bg: "cat5", }, { id: "6", name: "Frank Wang", role: "Engineer", initials: "FW", bg: "cat6" }, { id: "7", name: "Grace Kim", role: "Architect", initials: "GK", bg: "cat7" }, ] as const; type User = (typeof users)[number]; export default function Demo() { const searchRef = useRef<HTMLInputElement>(null); const [open, setOpen] = useState(false); const [selected, setSelected] = useState<User | null>(null); const [search, setSearch] = useState(""); const filtered = useMemo( () => users.filter( (u) => u.name.toLowerCase().includes(search.toLowerCase()) || u.role.toLowerCase().includes(search.toLowerCase()), ), [search], ); const handleSelect = (user: User) => { setSelected(user); setOpen(false); setSearch(""); }; return ( <div className="w-300px"> <HvLabel label="Assign to" showGutter /> <HvBaseDropdown expanded={open} onToggle={(_, isOpen) => { setOpen(isOpen); if (!isOpen) setSearch(""); }} onContainerCreation={() => searchRef.current?.focus()} placeholder={ selected ? ( <div className="flex items-center gap-xs"> <HvAvatar size="xs" backgroundColor={selected.bg}> {selected.initials} </HvAvatar> <span>{selected.name}</span> </div> ) : ( "Select assignee..." ) } > <HvPanel className="w-300px p-xs flex flex-col gap-xs"> <HvSearchInput ref={searchRef} placeholder="Search people..." value={search} onChange={(_, val) => setSearch(val ?? "")} className="pb-xs" /> <div className="flex flex-col"> {filtered.length === 0 ? ( <HvTypography variant="caption1" className="px-xs color-textSubtle" > No results </HvTypography> ) : ( filtered.map((user) => ( <button type="button" key={user.id} className="flex items-center gap-sm p-xs rounded cursor-pointer w-full text-left hover:bg-bgHover" onClick={() => handleSelect(user)} > <HvAvatar size="sm" backgroundColor={user.bg}> {user.initials} </HvAvatar> <div> <HvTypography variant="label">{user.name}</HvTypography> <HvTypography variant="caption1" className="color-textSubtle" > {user.role} </HvTypography> </div> </button> )) )} </div> </HvPanel> </HvBaseDropdown> </div> ); }
Inline status changer
Compact inline dropdown for changing an item’s status without leaving the current view.
Alert | Asset | Status |
|---|---|---|
| High CPU usage | Server-01 | Active |
| Disk space low | Storage-A | Acknowledged |
| Network timeout | Router-02 | Active |
| Temp threshold | Sensor-07 | Suppressed |
| Memory leak | App-Server | Resolved |
import { useState } from "react"; import { HvBaseDropdown, HvPanel, HvStatusIcon, HvTable, HvTableBody, HvTableCell, HvTableContainer, HvTableHead, HvTableHeader, HvTableRow, HvTableSection, HvTypography, type HvStatusIconVariant, } from "@hitachivantara/uikit-react-core"; type AlertStatus = "Active" | "Acknowledged" | "Resolved" | "Suppressed"; const statusConfig: Record< AlertStatus, { variant: HvStatusIconVariant; next: AlertStatus[] } > = { Active: { variant: "error", next: ["Acknowledged", "Suppressed"], }, Acknowledged: { variant: "warning", next: ["Resolved", "Active"], }, Resolved: { variant: "success", next: ["Active"], }, Suppressed: { variant: "info", next: ["Active"], }, }; const initialAlerts = [ { id: "1", name: "High CPU usage", asset: "Server-01", status: "Active" }, { id: "2", name: "Disk space low", asset: "Storage-A", status: "Acknowledged", }, { id: "3", name: "Network timeout", asset: "Router-02", status: "Active" }, { id: "4", name: "Temp threshold", asset: "Sensor-07", status: "Suppressed" }, { id: "5", name: "Memory leak", asset: "App-Server", status: "Resolved" }, ] as { id: string; name: string; asset: string; status: AlertStatus }[]; function StatusDropdown({ status, onChange, }: { status: AlertStatus; onChange: (next: AlertStatus) => void; }) { const [open, setOpen] = useState(false); const config = statusConfig[status]; return ( <HvBaseDropdown expanded={open} onToggle={(_, isOpen) => setOpen(isOpen)} placement="right" variableWidth classes={{ root: "inline-flex! w-auto!", header: "border-transparent! bg-transparent! min-h-0! p-2px! rounded! hover:bg-bgHover! shadow-none!", arrowContainer: "hidden!", selection: "flex items-center", }} placeholder={ <div className="flex items-center gap-xs cursor-pointer"> <HvStatusIcon variant={config.variant} type="simple" /> <HvTypography variant="label">{status}</HvTypography> <div className="i-ph-caret-down text-12px color-textSubtle" /> </div> } > <HvPanel className="min-w-160px p-xxs flex flex-col"> <HvTypography variant="caption2" className="color-textSubtle px-xs pt-xs pb-xxs" > Change status to </HvTypography> {config.next.map((next) => ( <button type="button" key={next} className="flex items-center gap-xs px-xs py-xs rounded hover:bg-bgHover w-full text-left" onClick={() => { onChange(next); setOpen(false); }} > <HvStatusIcon variant={statusConfig[next].variant} type="simple" /> <HvTypography variant="label">{next}</HvTypography> </button> ))} </HvPanel> </HvBaseDropdown> ); } export default function Demo() { const [alerts, setAlerts] = useState(initialAlerts); const updateStatus = (id: string, next: AlertStatus) => setAlerts((prev) => prev.map((a) => (a.id === id ? { ...a, status: next } : a)), ); return ( <HvTableSection> <HvTableContainer> <HvTable> <HvTableHead> <HvTableRow> <HvTableHeader>Alert</HvTableHeader> <HvTableHeader>Asset</HvTableHeader> <HvTableHeader>Status</HvTableHeader> </HvTableRow> </HvTableHead> <HvTableBody> {alerts.map((alert) => ( <HvTableRow key={alert.id}> <HvTableCell>{alert.name}</HvTableCell> <HvTableCell>{alert.asset}</HvTableCell> <HvTableCell> <StatusDropdown status={alert.status} onChange={(next) => updateStatus(alert.id, next)} /> </HvTableCell> </HvTableRow> ))} </HvTableBody> </HvTable> </HvTableContainer> </HvTableSection> ); }
Cascading location picker
Multi-level dropdown for hierarchical location selection, where each level filters the next.
Select asset location...
import { useState } from "react"; import { HvBaseDropdown, HvIconButton, HvLabel, HvPanel, HvTypography, } from "@hitachivantara/uikit-react-core"; type Asset = { id: string; label: string }; type Site = { id: string; label: string; assets: Asset[] }; type Region = { id: string; label: string; sites: Site[] }; const regions: Region[] = [ { id: "emea", label: "EMEA", sites: [ { id: "lisbon", label: "Lisbon Plant", assets: [ { id: "comp-a", label: "Compressor A" }, { id: "pump-b", label: "Pump B" }, { id: "valve-c", label: "Valve C" }, ], }, { id: "berlin", label: "Berlin Site", assets: [ { id: "motor-1", label: "Motor 1" }, { id: "turbine-2", label: "Turbine 2" }, ], }, ], }, { id: "amer", label: "AMER", sites: [ { id: "houston", label: "Houston Facility", assets: [ { id: "drill-01", label: "Drill Unit 01" }, { id: "pump-02", label: "Pump 02" }, { id: "sensor-03", label: "Sensor Array 03" }, ], }, { id: "toronto", label: "Toronto Office", assets: [ { id: "hvac-1", label: "HVAC Unit 1" }, { id: "ups-1", label: "UPS System" }, ], }, ], }, { id: "apac", label: "APAC", sites: [ { id: "singapore", label: "Singapore Hub", assets: [ { id: "chiller-a", label: "Chiller A" }, { id: "gen-b", label: "Generator B" }, ], }, ], }, ]; type Level = "region" | "site" | "asset"; interface Selection { region: Region | null; site: Site | null; asset: Asset | null; } export default function Demo() { const [open, setOpen] = useState(false); const [level, setLevel] = useState<Level>("region"); const [selection, setSelection] = useState<Selection>({ region: null, site: null, asset: null, }); const handleToggle = (_: unknown, isOpen: boolean) => { setOpen(isOpen); if (isOpen) { // re-open at the deepest confirmed level if (selection.asset) setLevel("asset"); else if (selection.site) setLevel("site"); else setLevel("region"); } }; const selectRegion = (region: Region) => { setSelection({ region, site: null, asset: null }); setLevel("site"); }; const selectSite = (site: Site) => { setSelection((prev) => ({ ...prev, site, asset: null })); setLevel("asset"); }; const selectAsset = (asset: Asset) => { setSelection((prev) => ({ ...prev, asset })); setOpen(false); }; const placeholderText = selection.asset ? `${selection.region!.label} / ${selection.site!.label} / ${selection.asset.label}` : selection.site ? `${selection.region!.label} / ${selection.site!.label}` : selection.region ? selection.region.label : "Select asset location..."; const listItems: { id: string; label: string }[] = level === "region" ? regions : level === "site" ? (selection.region?.sites ?? []) : (selection.site?.assets ?? []); const onSelect = (item: { id: string; label: string }) => { if (level === "region") selectRegion(regions.find((r) => r.id === item.id)!); else if (level === "site") selectSite(selection.region!.sites.find((s) => s.id === item.id)!); else selectAsset(item); }; const levelLabel: Record<Level, string> = { region: "Region", site: `${selection.region?.label ?? ""} › Site`, asset: `${selection.site?.label ?? ""} › Asset`, }; const canGoBack = level !== "region"; const goBack = () => { if (level === "asset") setLevel("site"); else if (level === "site") setLevel("region"); }; return ( <div className="w-320px"> <HvLabel label="Asset location" showGutter /> <HvBaseDropdown expanded={open} onToggle={handleToggle} placeholder={ <div className="flex items-center gap-xs overflow-hidden"> <div className="i-ph-map-pin-simple text-16px color-textSubtle shrink-0" /> <HvTypography noWrap className="flex-1"> {placeholderText} </HvTypography> </div> } > <HvPanel className="w-320px flex flex-col"> <div className="flex items-center gap-xs px-xs pt-xs pb-xxs border-b-1 border-borderSubtle"> {canGoBack && ( <HvIconButton title="Back" size="xs" onClick={goBack}> <div className="i-ph-arrow-left" /> </HvIconButton> )} <HvTypography variant="label" className="color-textSubtle"> {levelLabel[level]} </HvTypography> </div> <div className="flex flex-col py-xxs"> {listItems.map((item) => { const isSelected = (level === "region" && selection.region?.id === item.id) || (level === "site" && selection.site?.id === item.id) || (level === "asset" && selection.asset?.id === item.id); const hasChildren = level !== "asset"; return ( <button type="button" key={item.id} className={`flex items-center justify-between px-sm py-xs hover:bg-bgHover w-full text-left ${isSelected ? "color-primary" : ""}`} onClick={() => onSelect(item)} > <HvTypography variant={isSelected ? "label" : "body"}> {item.label} </HvTypography> {hasChildren && ( <div className="i-ph-caret-right text-14px color-textSubtle" /> )} </button> ); })} </div> </HvPanel> </HvBaseDropdown> </div> ); }
Output folder picker
Tree-based dropdown for selecting a destination folder from a file system structure.
Select output folder...
import { forwardRef, useState } from "react"; import { HvBaseDropdown, HvButton, HvIconContainer, HvLabel, HvPanel, HvTreeItem, HvTreeView, HvTypography, type HvTreeItemProps, } from "@hitachivantara/uikit-react-core"; type FileNode = { id: string; label: string; path: string; type: "folder" | "file"; children?: FileNode[]; }; const tree: FileNode = { id: "workspace", label: "workspace", path: "/workspace", type: "folder", children: [ { id: "projects", label: "projects", path: "/workspace/projects", type: "folder", children: [ { id: "alpha", label: "alpha", path: "/workspace/projects/alpha", type: "folder", children: [ { id: "alpha-output", label: "output", path: "/workspace/projects/alpha/output", type: "folder", }, { id: "alpha-input", label: "input", path: "/workspace/projects/alpha/input", type: "folder", }, { id: "alpha-config", label: "config.json", path: "/workspace/projects/alpha/config.json", type: "file", }, ], }, { id: "beta", label: "beta", path: "/workspace/projects/beta", type: "folder", children: [ { id: "beta-results", label: "results", path: "/workspace/projects/beta/results", type: "folder", }, { id: "beta-log", label: "run.log", path: "/workspace/projects/beta/run.log", type: "file", }, ], }, ], }, { id: "shared", label: "shared", path: "/workspace/shared", type: "folder", children: [ { id: "shared-exports", label: "exports", path: "/workspace/shared/exports", type: "folder", }, { id: "shared-reports", label: "reports", path: "/workspace/shared/reports", type: "folder", }, ], }, { id: "temp", label: "temp", path: "/workspace/temp", type: "folder", }, ], }; // Build a flat lookup: nodeId → FileNode const buildIndex = (node: FileNode, acc: Map<string, FileNode> = new Map()) => { acc.set(node.id, node); node.children?.forEach((child) => buildIndex(child, acc)); return acc; }; const nodeIndex = buildIndex(tree); const FolderIcon = () => ( <HvIconContainer> <div className="i-ph-folder-notch-fill color-info" /> </HvIconContainer> ); const FolderOpenIcon = () => ( <HvIconContainer> <div className="i-ph-folder-notch-open-fill color-info" /> </HvIconContainer> ); const FileIcon = () => ( <HvIconContainer> <div className="i-ph-file color-textSubtle" /> </HvIconContainer> ); const FolderTreeItem = forwardRef<HTMLLIElement, HvTreeItemProps>( function FolderTreeItem({ nodeId, label, children, disabled, ...rest }, ref) { const isFile = nodeIndex.get(nodeId)?.type === "file"; return ( <HvTreeItem ref={ref} nodeId={nodeId} disabled={disabled || isFile} icon={isFile ? <FileIcon /> : !children ? <FolderIcon /> : undefined} expandIcon={<FolderIcon />} collapseIcon={<FolderOpenIcon />} label={ <HvTypography variant="body" className={isFile ? "color-textDisabled" : undefined} > {label} </HvTypography> } {...rest} > {children} </HvTreeItem> ); }, ); const renderNode = (node: FileNode): React.ReactNode => ( <FolderTreeItem key={node.id} nodeId={node.id} label={node.label}> {node.children?.map(renderNode)} </FolderTreeItem> ); export default function Demo() { const [open, setOpen] = useState(false); const [pendingId, setPendingId] = useState<string | null>(null); const [selectedPath, setSelectedPath] = useState<string | null>(null); const pendingNode = pendingId ? nodeIndex.get(pendingId) : null; const handleConfirm = () => { if (pendingNode) { setSelectedPath(pendingNode.path); setOpen(false); setPendingId(null); } }; return ( <div className="w-340px"> <HvLabel label="Output folder" showGutter /> <HvBaseDropdown expanded={open} onToggle={(_, isOpen) => { setOpen(isOpen); if (isOpen) setPendingId(null); }} placeholder={ <div className="flex items-center gap-xs overflow-hidden"> <HvIconContainer className="shrink-0"> <div className={`i-ph-folder-notch-fill ${selectedPath ? "color-info" : "color-textSubtle"}`} /> </HvIconContainer> <HvTypography noWrap className="flex-1 text-left"> {selectedPath ?? "Select output folder..."} </HvTypography> </div> } > <HvPanel className="w-340px flex flex-col"> <div className="px-xs pt-xs pb-xxs border-b-1 border-borderSubtle"> <HvTypography variant="label" className="color-textSubtle"> Select a folder </HvTypography> </div> <div className="overflow-auto max-h-260px py-xs"> <HvTreeView multiSelect={false} defaultExpanded={["workspace", "projects"]} aria-label="Output folder selector" onNodeSelect={(_, nodeId) => { const node = nodeIndex.get(nodeId as string); if (node?.type === "folder") setPendingId(nodeId as string); }} > {renderNode(tree)} </HvTreeView> </div> <div className="flex items-center justify-between gap-xs px-xs py-xs border-t-1 border-borderSubtle"> <HvTypography variant="caption1" className="color-textSubtle truncate flex-1" > {pendingNode ? pendingNode.path : "No folder selected"} </HvTypography> <HvButton size="xs" disabled={!pendingId} onClick={handleConfirm}> Select </HvButton> </div> </HvPanel> </HvBaseDropdown> </div> ); }
Database field picker
Tree picker with cascading checkbox selection for choosing fields from a database schema.
import { forwardRef, useMemo, useRef, useState } from "react"; import { HvBadge, HvBaseDropdown, HvCheckBox, HvIconButton, HvIconContainer, HvPanel, HvTreeItem, HvTreeView, HvTypography, } from "@hitachivantara/uikit-react-core"; type NodeType = "server" | "schema" | "table" | "column" | "folder" | "cloud"; interface DbNode { id: string; label: string; type: NodeType; children?: DbNode[]; } const dbTree: DbNode[] = [ { id: "oracle-berlin", label: "Oracle Berlin", type: "server", children: [ { id: "ob-prod", label: "OB_PROD", type: "schema", children: [ { id: "ob-customers", label: "customers", type: "table", children: [ { id: "ob-cust-id", label: "customer_id", type: "column" }, { id: "ob-cust-name", label: "full_name", type: "column" }, { id: "ob-cust-email", label: "email", type: "column" }, ], }, ], }, ], }, { id: "postgres-eu", label: "Postgres EU", type: "server", children: [ { id: "cust-data", label: "CUST_DATA", type: "schema", children: [ { id: "contacts", label: "contacts", type: "table", children: [ { id: "contact-name", label: "name", type: "column" }, { id: "contact-email", label: "email", type: "column" }, { id: "contact-phone", label: "phone", type: "column" }, { id: "contact-city", label: "city", type: "column" }, ], }, { id: "transactions", label: "transactions", type: "table", children: [ { id: "tx-id", label: "tx_id", type: "column" }, { id: "tx-amount", label: "amount", type: "column" }, { id: "tx-date", label: "date", type: "column" }, ], }, ], }, ], }, { id: "postgres-us", label: "Postgres US", type: "server", children: [ { id: "us-data", label: "US_DATA", type: "schema", children: [ { id: "us-orders", label: "orders", type: "table", children: [ { id: "us-order-id", label: "order_id", type: "column" }, { id: "us-status", label: "status", type: "column" }, { id: "us-total", label: "total", type: "column" }, ], }, ], }, ], }, { id: "marketing", label: "Marketing", type: "cloud", children: [ { id: "mtg-q4", label: "MTG_Q4_2024", type: "folder", children: [ { id: "mtg-campaigns", label: "campaigns", type: "table", children: [ { id: "mtg-id", label: "campaign_id", type: "column" }, { id: "mtg-budget", label: "budget", type: "column" }, { id: "mtg-region", label: "region", type: "column" }, ], }, ], }, ], }, ]; /** Returns all IDs in a subtree (node + all descendants) */ const getAllIds = (node: DbNode): string[] => [ node.id, ...(node.children?.flatMap(getAllIds) ?? []), ]; /** Returns only the leaf IDs (nodes without children) in a subtree */ const getLeafIds = (node: DbNode): string[] => node.children?.length ? node.children.flatMap(getLeafIds) : [node.id]; /** * Returns the "topmost" selected nodes — the minimal set that represents * the selection. Fully-checked parents collapse their children into one entry. */ const getTopSelected = (nodes: DbNode[], checked: Set<string>): DbNode[] => nodes.flatMap((node) => { const leafIds = getLeafIds(node); const n = leafIds.filter((id) => checked.has(id)).length; if (n === 0) return []; if (n === leafIds.length) return [node]; return getTopSelected(node.children ?? [], checked); }); // Flat node index for O(1) lookups const buildIndex = ( nodes: DbNode[], acc = new Map<string, DbNode>(), ): Map<string, DbNode> => { nodes.forEach((n) => { acc.set(n.id, n); buildIndex(n.children ?? [], acc); }); return acc; }; const nodeIndex = buildIndex(dbTree); const typeIcon: Record<NodeType, string> = { server: "i-ph-database", schema: "i-ph-circles-three-plus", table: "i-ph-table", column: "i-ph-columns", folder: "i-ph-folder", cloud: "i-ph-cloud", }; // --- Header component --- // Must be a stable forwardRef component defined outside the main component // so HvBaseDropdown can forward the popper anchor ref to the DOM button. const HeaderComponent = forwardRef< HTMLButtonElement, React.HTMLAttributes<HTMLButtonElement> & { disabled?: boolean } >(function HeaderComponent(props, ref) { return ( <HvIconButton ref={ref} title="Toggle database picker" {...props}> <div className="i-ph-dots-three-vertical" /> </HvIconButton> ); }); // --- Tree item --- interface DbTreeItemProps { node: DbNode; checked: Set<string>; onToggle: (id: string) => void; } function DbTreeItem({ node, checked, onToggle }: DbTreeItemProps) { const leafIds = useMemo(() => getLeafIds(node), [node]); const checkedCount = leafIds.filter((id) => checked.has(id)).length; const isFullyChecked = checkedCount === leafIds.length; const isIndeterminate = !isFullyChecked && checkedCount > 0; return ( <HvTreeItem style={{ zIndex: 0 }} nodeId={node.id} label={ // stopPropagation prevents: // 1. bubbling to DefaultContent's wrapper → no expand/collapse on label click // 2. bubbling to ancestor <li> elements → no parent toggle triggered <div className="flex items-center gap-xs flex-1" role="button" tabIndex={-1} onClick={(e) => { e.stopPropagation(); onToggle(node.id); }} onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") { e.stopPropagation(); onToggle(node.id); } }} > <HvCheckBox style={{ zIndex: -1 }} checked={isFullyChecked} indeterminate={isIndeterminate} onChange={() => {}} className="pointer-events-none" /> <HvIconContainer> <div className={typeIcon[node.type]} /> </HvIconContainer> <HvTypography variant="body">{node.label}</HvTypography> </div> } > {node.children?.map((child) => ( <DbTreeItem key={child.id} node={child} checked={checked} onToggle={onToggle} /> ))} </HvTreeItem> ); } export default function DatabasePicker() { const [open, setOpen] = useState(false); const [checked, setChecked] = useState<Set<string>>(new Set()); const toggleNode = (nodeId: string) => { const node = nodeIndex.get(nodeId); if (!node) return; const allIds = getAllIds(node); const leafIds = getLeafIds(node); setChecked((prev) => { const next = new Set(prev); const allLeafsChecked = leafIds.every((id) => prev.has(id)); if (allLeafsChecked) { allIds.forEach((id) => next.delete(id)); } else { allIds.forEach((id) => next.add(id)); } return next; }); }; const selectedItems = useMemo( () => getTopSelected(dbTree, checked), [checked], ); const selectedLeafCount = useMemo( () => selectedItems.reduce((acc, node) => acc + getLeafIds(node).length, 0), [selectedItems], ); // Track focused node via ref (no re-render needed) for keyboard selection const focusedNodeRef = useRef<string | null>(null); return ( <div className="relative inline-flex"> <HvBaseDropdown headerComponent={HeaderComponent} expanded={open} onToggle={(_, isOpen) => setOpen(isOpen)} variableWidth onContainerCreation={(containerEl) => { containerEl?.querySelector<HTMLElement>('[role="tree"]')?.focus(); }} > <HvPanel className="w-[420px] max-h-[400px] overflow-y-auto"> <HvTreeView disableSelection onNodeFocus={(_, nodeId) => { focusedNodeRef.current = nodeId; }} onKeyDown={(e) => { if ( (e.key === " " || e.key === "Enter") && focusedNodeRef.current ) { toggleNode(focusedNodeRef.current); } }} defaultExpanded={[ "postgres-eu", "cust-data", "contacts", "marketing", ]} aria-label="Database structure" > {dbTree.map((node) => ( <DbTreeItem key={node.id} node={node} checked={checked} onToggle={toggleNode} /> ))} </HvTreeView> </HvPanel> </HvBaseDropdown> {selectedLeafCount > 0 && ( <HvBadge label={selectedLeafCount} showCount className="absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 pointer-events-none" /> )} </div> ); }