Skip to content

@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_size is the sum of descendant file sizes, returned in bytes.
  • created_at is the earliest descendant created_at.
  • updated_at is the latest descendant updated_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:

  • folder when entryType is folder
  • pdf for .pdf
  • image for .jpg, .jpeg, .png, .gif, .webp, .svg
  • video for .mp4, .mov, .avi, .mkv, .webm
  • audio for .mp3, .wav, .aac, .m4a, .ogg
  • document as 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 with autoLoadRoot: false if you want to trigger it yourself).
  • Wire onItemExpansionToggle directly to the tree view — when an item expands, its children are fetched (or served from cache if already loaded) and nested under it in items.
  • 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 thing onItemExpansionToggle calls 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 — becomes item.extension, used by TreeView's showIcons/fileIconMap to pick a default file-type icon.
  • getIcon?: (node) => ReactNode — becomes item.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.