@msflib/react-ux¶
Small, UI-agnostic React utilities for common product interactions.
This package intentionally does not ship visual components. It provides focused helpers that can be used with any design system, table component, card layout, modal, menu, or custom UI.
Installation¶
pnpm add @msflib/react-ux
Imports¶
Import everything from the package root:
import {
buildExplorerRows,
useExplorerRowClick,
useCloseOnOutsideInteraction,
} from '@msflib/react-ux';
Or import from focused subpaths:
import { buildExplorerRows } from '@msflib/react-ux/file-explorer';
import { useCloseOnOutsideInteraction } from '@msflib/react-ux/interactions';
import { useLazyTreeData } from '@msflib/react-ux/tree-view';
File Explorer Utilities¶
The file explorer utilities transform a flat backend list of files into rows that can represent folders and files.
The only required backend field is file_path.
type FileLike = {
file_path: string;
file_size?: string | number;
created_at?: string;
updated_at?: string;
[key: string]: unknown;
};
All other backend fields are preserved on file rows. The library only computes the fields it needs for explorer behavior.
buildExplorerRows¶
import { buildExplorerRows } from '@msflib/react-ux/file-explorer';
const files = [
{
id: 1,
file_path: '/kodecamp/cohort2/epic.png',
file_size: '20 KB',
created_at: '2025-01-04T00:00:00.000Z',
updated_at: '2025-01-05T00:00:00.000Z',
owner: 'Drew Cano',
},
{
id: 2,
file_path: '/kodecamp/onboard.pdf',
file_size: '720 KB',
created_at: '2025-01-02T00:00:00.000Z',
updated_at: '2025-01-04T00:00:00.000Z',
},
];
const rows = buildExplorerRows(files);
At the root path, this returns the first visible folder level:
[
{
id: 'folder:/kodecamp',
name: 'kodecamp',
file_path: '/kodecamp',
entryType: 'folder',
file_size: 757760,
created_at: '2025-01-02T00:00:00.000Z',
updated_at: '2025-01-05T00:00:00.000Z',
},
];
To read inside a folder, pass currentPath:
const kodecampRows = buildExplorerRows(files, {
currentPath: '/kodecamp',
});
This returns folders and files directly inside /kodecamp:
[
{
id: 'folder:/kodecamp/cohort2',
name: 'cohort2',
file_path: '/kodecamp/cohort2',
entryType: 'folder',
file_size: 20480,
created_at: '2025-01-04T00:00:00.000Z',
updated_at: '2025-01-05T00:00:00.000Z',
},
{
id: 2,
name: 'onboard.pdf',
file_path: '/kodecamp/onboard.pdf',
entryType: 'file',
extension: 'pdf',
file_size: '720 KB',
created_at: '2025-01-02T00:00:00.000Z',
updated_at: '2025-01-04T00:00:00.000Z',
source: files[1],
},
];
Folder Aggregation¶
Folder rows are computed from descendant files:
file_sizeis the sum of descendant file sizes, returned in bytes.created_atis the earliest descendantcreated_at.updated_atis the latest descendantupdated_at.
Supported size units:
b, kb, mb, gb, tb
If a file size cannot be parsed, it contributes 0 to the folder total.
File Rows¶
File rows preserve backend data and add explorer fields:
{
...backendFile,
name: 'epic.png',
file_path: '/kodecamp/cohort2/epic.png',
entryType: 'file',
extension: 'png',
source: backendFile,
}
This means consumers can decide which columns to render and how to map backend data into labels.
Rendering With Any Table¶
The package is not tied to a table implementation.
import { useMemo, useState } from 'react';
import {
buildExplorerRows,
useExplorerRowClick,
} from '@msflib/react-ux/file-explorer';
function FilesView({ files }) {
const [currentPath, setCurrentPath] = useState('/');
const rows = useMemo(
() => buildExplorerRows(files, { currentPath }),
[files, currentPath],
);
const handleExplorerRowClick = useExplorerRowClick({
onFolderClick: setCurrentPath,
onFileClick: (file) => {
console.log('open file', file);
},
});
return (
<TableWidget
rows={rows}
columns={columns}
onRowClick={({ row }) => handleExplorerRowClick(row)}
/>
);
}
Row Click Handling¶
useExplorerRowClick returns a generic row click handler.
const handleExplorerRowClick = useExplorerRowClick({
onFolderClick: (path, row) => {
setCurrentPath(path);
},
onFileClick: (file, row) => {
openPreview(file);
},
onRowClick: (row) => {
console.log('clicked any row', row);
},
});
Behavior:
- Folder rows call
onFolderClick(row.file_path, row). - File rows call
onFileClick(row.source, row). - Every row calls
onRowClick(row)when provided.
For non-React usage, use createExplorerRowClickHandler.
const handleExplorerRowClick = createExplorerRowClickHandler({
onFolderClick: setCurrentPath,
});
Icon Helpers¶
The package does not ship icons. Consumers provide their own icons, images, or React nodes.
import { buildExplorerIcon } from '@msflib/react-ux/file-explorer';
function FileIcon({ row }) {
return buildExplorerIcon(row, {
icons: {
folder: <FolderIcon />,
pdf: <PdfIcon />,
image: <ImageIcon />,
video: <VideoIcon />,
audio: <AudioIcon />,
document: <DocumentIcon />,
},
});
}
buildExplorerIcon uses:
folderwhenentryTypeisfolderpdffor.pdfimagefor.jpg,.jpeg,.png,.gif,.webp,.svgvideofor.mp4,.mov,.avi,.mkv,.webmaudiofor.mp3,.wav,.aac,.m4a,.oggdocumentas the fallback file type
Use getExplorerIconType directly if you only need the icon category:
const iconType = getExplorerIconType('pdf'); // "pdf"
Path Helpers¶
normalizeExplorerPath('kodecamp/cohort2/');
// "/kodecamp/cohort2"
joinExplorerPath('/kodecamp', 'cohort2');
// "/kodecamp/cohort2"
Outside Interaction Hook¶
useCloseOnOutsideInteraction closes menus, popovers, cards, and similar UI when the user clicks outside the referenced element or presses Escape.
import { useRef, useState } from 'react';
import { useCloseOnOutsideInteraction } from '@msflib/react-ux/interactions';
function WorkspaceMenu() {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useCloseOnOutsideInteraction({
ref: menuRef,
enabled: open,
onClose: () => setOpen(false),
});
return open ? <div ref={menuRef}>Workspace content</div> : null;
}
Options:
type UseCloseOnOutsideInteractionOptions<TNode extends Node> = {
ref: RefObject<TNode | null>;
enabled?: boolean;
onClose: (event: PointerEvent | KeyboardEvent) => void;
closeOnEscape?: boolean;
closeOnPointerDownOutside?: boolean;
};
Disable Escape handling:
useCloseOnOutsideInteraction({
ref,
enabled: open,
onClose,
closeOnEscape: false,
});
Disable pointer outside handling:
useCloseOnOutsideInteraction({
ref,
enabled: open,
onClose,
closeOnPointerDownOutside: false,
});
Lazy Tree Data¶
useLazyTreeData bridges a backend that returns nodes per parent (fetch root nodes, then fetch a folder's children only when it's opened — e.g. @msflib/react-drivelink's listNodes({ parent_id })) into a nested tree structure. It's not tied to MUI or any tree component: the shape it returns ({ id, label, expandable, children }) is a plain object that structurally matches what MUI X's RichTreeView items prop expects, so you can pass it straight through without this package depending on @mui/x-tree-view at all — same reasoning as the rest of this package staying UI-agnostic.
It solves the specific problem of not re-fetching a folder's children every time it's expanded: each parent's children are fetched once and cached; concurrent expand clicks on the same folder are deduped into a single request; already-loaded folders return instantly from cache.
import { useLazyTreeData } from '@msflib/react-ux/tree-view';
import { TreeView } from '@msflib/react-components';
import { useDrivelink } from '@msflib/react-drivelink';
import type { DrivelinkNode } from '@msflib/react-drivelink';
function DriveTree() {
const { listNodes } = useDrivelink();
const { items, isLoading, onItemExpansionToggle } = useLazyTreeData<DrivelinkNode>({
adapter: {
getId: (node) => String(node.id),
getLabel: (node) => node.name,
isExpandable: (node) => node.kind === 'folder',
getExtension: (node) => node.name.split('.').pop(),
},
fetchChildren: (parentId) =>
listNodes({ parent_id: parentId ? Number(parentId) : undefined }),
});
return (
<TreeView
items={items}
onItemExpansionToggle={onItemExpansionToggle}
isItemLoading={isLoading}
showIcons
/>
);
}
- On mount, it calls
fetchChildren(null)once to load the root/home level (disable withautoLoadRoot: falseif you want to trigger it yourself). - Wire
onItemExpansionToggledirectly to the tree view — when an item expands, its children are fetched (or served from cache if already loaded) and nested under it initems. isLoading(parentId)/getError(parentId)give per-folder loading and error state, e.g. to show a spinner or retry affordance for the folder currently being opened.refreshChildren(parentId)force-refetches a folder — useful after a mutation inside it (upload, delete, rename) invalidates what's cached.loadChildren(parentId)is the same thingonItemExpansionTogglecalls internally — use it directly if you need to trigger a fetch outside of an expansion event (e.g. programmatically opening a path).
adapter is how the hook stays generic across backends — getId/getLabel/isExpandable are the only required fields; everything else about the node is preserved on item.node for you to use in custom rendering. Two optional accessors feed @msflib/react-components's TreeView icon system with zero extra glue:
getExtension?: (node) => string | undefined— becomesitem.extension, used byTreeView'sshowIcons/fileIconMapto pick a default file-type icon.getIcon?: (node) => ReactNode— becomesitem.icon, a full per-item icon override.
Recommended pairing: @msflib/react-components's TreeView (built specifically to consume this hook's output — see its README) rather than MUI X's RichTreeView, since MUI's own dynamic/lazy-loading support (dataSource) is Pro-plan only — confirmed against MUI's own docs — while this hook + TreeView gets you the same capability in the MIT tier. items/onItemExpansionToggle are still plain enough to wire into MUI's RichTreeView too if you're already on a Pro license and prefer it; just note some @mui/x-tree-view versions only show the expand arrow once an item already has at least one child in items, so you may need a placeholder child for not-yet-loaded items — verify against your installed version's docs.
Design Principles¶
- Require only the data needed for computation.
- Preserve backend data instead of guessing display fields.
- Keep utilities UI-agnostic.
- Let consuming apps decide columns, cards, icons, and styling.
- Prefer small, direct APIs over broad abstractions.