feat(org-chart): add core cards and fixed-layout integration

This commit is contained in:
shkim4 committed 2026-10-12 17:58:30 +09:00
1 parent d18e3c3380
commit 61c6f8d1f1
161 files changed
+13044 -1

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
# @hm-design/org-chart-react v0.1
React 19 lifecycle adapter for `@hm-design/org-chart@0.1.0`. The wrapper renders one empty host container and delegates all organization DOM, engine work, validation, summaries, interactions and Shadow DOM styles to the existing Vanilla Core. It has no navigation, page layout, fetch client or separate node design.
## Usage
```tsx
import { useMemo } from 'react';
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
import { OrgChart } from '@hm-design/org-chart-react';
import type { OrgChartDataset, OrgChartState } from '@hm-design/org-chart-react';
import engineCssUrl from '@hm-design/org-chart/engine.css?url';
import coreCssUrl from '@hm-design/org-chart/style.css?url';
// Core P1 supplies standalone OrgChart design; no HM Theme/font import needed.
const stylesheetUrls = { engine: engineCssUrl, core: coreCssUrl };
export function OrganizationContent({ data }: { data: OrgChartDataset }) {
const dataSource = useMemo(() => new JsonOrgChartDataSource(data), [data]);
const onStateChange = (state: OrgChartState) => {
// Data/source/validation/style failures are state.status === 'error'.
// A ready state contains the Core's shared summary and dataVersion.
void state;
};
return (
<OrgChart
dataSource={dataSource}
stylesheetUrls={stylesheetUrls}
onStateChange={onStateChange}
onLifecycleError={error => { console.error('OrgChart lifecycle failed', error); }}
/>
);
}
```
The `?url` imports above are Vite asset imports, not a universal browser feature. Other hosts resolve the two Core public CSS entries to actual deployment URLs. Importing CSS only into the document does not style the Core's Shadow DOM. Engine CSS is linked first, then standalone Core CSS. P1 owns its colors, typography and geometry without depending on HM Theme. Set public `--hm-orgchart-*` properties on the wrapper container to override only this instance. No HM common variables are redefined. The wrapper does not depend on `@hm-design/ui`; P3 adds lookup Props without replacing the Core renderer. For independent Tree/Summary integration, use the [Core factories](../../docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI.
## Public API
Named runtime export: `OrgChart`. Type exports: `OrgChartProps` and the Core's public types, reexported as types only. Runtime helpers such as `JsonOrgChartDataSource` remain available from `@hm-design/org-chart`.
`OrgChartProps` reuses `Omit<OrgChartOptions, 'container' | 'onStateChange'>`:
- Required `dataSource`: the same Core `OrgChartDataSource` interface, `load(options?: { signal?: AbortSignal }): Promise<unknown>`.
- Required `stylesheetUrls`: `{ engine: string; core: string }`, the exact Core asset contract.
- Optional `displayMode`: `'interactive'` (default) or `'readOnly'`, passed to the Core composition. Changing it recreates the Core handle.
- Optional `organizationLinks`: existing organization-ID-to-URL map, applied only to original 1Depth titles. Missing/blank values retain plain titles. Core validates HTTP(S)/relative URLs and rejects invalid URLs, other schemes and credentials. Treat the map as immutable and memoize it; a new map recreates the handle.
- Optional `onEmployeeClick(employee, context)`: host-owned information UI, replacing the Core's built-in popup. `context` contains the existing `organization`, `affiliation`, focusable `trigger` card and `originalEvent`. It works in both modes and receives untouched Employee data.
- Optional `aggregationPolicy`: the Core's `ApprovedAggregationPolicy`.
- Optional `className` and `style`: applied only to React's outer container.
- Optional `onStateChange(state: OrgChartState)`: latest committed callback receives actual Core state changes.
- Optional `onLifecycleError(error: unknown)`: reports unexpected Core creation or rejected mount/reload calls and unexpected cleanup errors. Controlled data, source, validation, rendering and stylesheet failures use the Core's `error` state instead.
The container is owned by React; its dedicated children are owned by Core. There are no React children, imperative handle, title, employee or engine manipulation Props. Titles, descriptions, presentation groups, organization order and metadata use the common data contract. An omitted lifecycle error callback leaves unexpected operation errors handled but does not produce a second error UI or console log. Consumer state callback errors follow the Core's isolation behavior; lifecycle error callback exceptions are also contained.
Without `onEmployeeClick`, all employee cards open the same Core read-only information dialog on click, Enter or Space. It uses only existing employee/organization/affiliation fields and omits missing values. readOnly names match the displayed `홍 길 동` card while source data remains unchanged. The native modal sits outside the scaled Stage and supports Close, ESC, focus trapping and return to the card. Group-link events remain separate; a visible name tooltip closes before popup activation. A custom callback owns its popup's accessibility and cleanup.
```tsx
<OrgChart
dataSource={dataSource}
stylesheetUrls={stylesheetUrls}
displayMode="readOnly"
organizationLinks={configuredOrganizationLinks}
onEmployeeClick={(employee, context) => openEmployeeInformation(employee, context)}
/>
```
Both map and popup function above are supplied by the host; no real destination URL or employee information is embedded in the package. Omit the callback for the default popup.
## Lifecycle and updates
Mount creates one new Core handle, subscribes to its state and calls `mount(container)`. Mount already loads the source; the wrapper does not perform an additional initial reload. The wrapper does not synthesize `idle`, `destroyed` or `empty` callbacks. Current Core emits `loading`, then `ready`/`error`; employees `[]` is ready, organizations `[]` is error. An `empty` type member does not establish a supported Empty State.
A new `dataSource` object triggers the existing handle's `reload(nextSource)`. This starts Core's latest revision and aborts or ignores older responses. Updating fields inside the same source object is not a reload signal. `JsonOrgChartDataSource` captures inline input at its construction; create a new source for a new immutable snapshot. Memoize sources rather than constructing one on every render.
Changing callback identity updates the latest committed callback without reload or recreation. Adding/removing `onEmployeeClick` switches built-in/custom popup ownership and recreates the handle. Abandoned concurrent renders do not update callbacks. Policy or `organizationLinks` identity changes, displayMode changes, or either stylesheet URL string value changes dispose the previous handle and create a new one because the Core offers no configuration setter. Replacing the stylesheet URL object with the same two strings does not recreate. Treat policy and link-map objects as immutable and memoize them; changing an object internally is not a configuration update signal.
Cleanup invalidates the wrapper generation, unsubscribes, calls Core's terminal `destroy()` and clears its handle reference. React Strict Mode's setup → cleanup → setup uses a fresh handle. Old generations cannot deliver state/lifecycle callbacks to the new instance; superseded operation rejection handlers are also ignored. Cleanup runs before replacement, leaving at most one active Core per wrapper container. The wrapper does not repeat engine disposal or DOM removal itself.
Core regeneration resets expansion/scroll state according to the existing Core contract. Source errors can recover by replacing the source; Core validation/summary/node behavior remains identical to Vanilla. Invalid runtime Props that prevent Core construction are lifecycle usage errors: fix them and remount the component (for example by changing its React key). No rendered fallback silently replaces an invalid configuration.
Committed callback publication, Core ownership and source updates use layout effects in that order. Cleanup/reload runs synchronously with React commit, so a pending old response cannot notify in a gap before passive cleanup. Server rendering produces only the outer container; SSR/hydration remains outside the verified scope.
## Package boundary and verification limits
The build emits ESM `dist/index.js` plus TypeScript declarations, preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`) and `@hm-design/org-chart` external. React is a `^19.0.0` peer, matching the repository's existing React package convention; Core `0.1.0` is a runtime dependency. React DOM is supplied by the consuming app and is not imported by this package. Core must be built before wrapper typecheck/build in this workspace because dependency resolution uses its public declarations.
From the repository root:
```sh
npm run typecheck --workspace @hm-design/org-chart-react
npm run build --workspace @hm-design/org-chart-react
```
Storybook is one React host, not proof of every consumer deployment. This adapter does not add pan/zoom, advanced mobile reflow, additional relation overlays or reserved Core presentation sections. SSR/hydration, full screen reader behavior, complete heap/GC recovery, Theme-less rendering, Workdesk reference visual approval and production consumers require their own evidence. Public ESM import without a DOM does not establish SSR/hydration support. Consult the final integration report for the actual typecheck/build/browser/consumer results.
+41
View File
@@ -0,0 +1,41 @@
import { build } from 'vite';
import { readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
const root = fileURLToPath(new URL('./', import.meta.url));
const require = createRequire(import.meta.url);
const manifest = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
const externals = Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies });
await build({
configFile: false,
root,
publicDir: false,
build: {
target: 'es2022',
outDir: 'dist',
minify: false,
lib: { entry: path.join(root, 'src/index.ts'), formats: ['es'], fileName: () => 'index.js' },
rolldownOptions: {
external: id => externals.some(dependency => id === dependency || id.startsWith(`${dependency}/`)),
onwarn(warning, warn) {
// Restore the client boundary on the final public entry below.
if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return;
warn(warning);
},
},
},
});
const typescriptManifest = require.resolve('typescript/package.json');
const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8'));
execFileSync(process.execPath, [
path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc),
'-p', path.join(root, 'tsconfig.build.json'),
], { stdio: 'inherit' });
const entry = path.join(root, 'dist/index.js');
await writeFile(entry, `"use client";\n${await readFile(entry, 'utf8')}`);
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@hm-design/org-chart-react",
"version": "0.1.0",
"description": "React lifecycle adapter for the framework-independent HM OrgChart Core.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": ["dist", "README.md"],
"sideEffects": false,
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"build": "node build.mjs",
"prepack": "npm run build"
},
"dependencies": { "@hm-design/org-chart": "0.1.0" },
"peerDependencies": { "react": "^19.0.0" }
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useLayoutEffect, useRef } from 'react';
import { createOrgChart } from '@hm-design/org-chart';
import type { OrgChartDataSource, OrgChartHandle } from '@hm-design/org-chart';
import type { OrgChartProps } from './types.js';
interface Instance {
readonly handle: OrgChartHandle;
readonly generation: number;
source: OrgChartDataSource;
operation: number;
}
/** A React host for the exact same Core used by Vanilla HTML. */
export function OrgChart({
dataSource,
aggregationPolicy,
stylesheetUrls,
displayMode,
organizationLinks,
onEmployeeClick,
className,
style,
onStateChange,
onLifecycleError,
}: OrgChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<Instance | null>(null);
const generationRef = useRef(0);
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick });
// Publish committed values before Core effects run, without letting callback
// identity changes or abandoned concurrent renders alter Core ownership.
useLayoutEffect(() => {
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick };
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick]);
const engineStylesheetUrl = stylesheetUrls.engine;
const coreStylesheetUrl = stylesheetUrls.core;
const hasEmployeeCallback = typeof onEmployeeClick === 'function';
// Ownership changes are synchronous with the commit: an old request cannot
// notify between a committed unmount/source change and a passive cleanup.
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const generation = ++generationRef.current;
let handle: OrgChartHandle | undefined;
let unsubscribe: (() => void) | undefined;
const reportError = (error: unknown) => {
if (generationRef.current !== generation) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Consumer errors must not leave rejected lifecycle promises. */ }
};
try {
const source = committedRef.current.dataSource;
handle = createOrgChart({
dataSource: source,
aggregationPolicy,
stylesheetUrls: { engine: engineStylesheetUrl, core: coreStylesheetUrl },
displayMode,
organizationLinks,
onEmployeeClick: hasEmployeeCallback ? (employee, context) => {
if (generationRef.current === generation) committedRef.current.onEmployeeClick?.(employee, context);
} : undefined,
});
const instance: Instance = { handle, source, generation, operation: 1 };
instanceRef.current = instance;
unsubscribe = handle.subscribe(state => {
if (instanceRef.current === instance && generationRef.current === generation) {
committedRef.current.onStateChange?.(state);
}
});
// Core mount performs the first load. The source effect below compares
// identity against this record and does not load the initial source again.
const operation = instance.operation;
void handle.mount(container).catch(error => {
if (instanceRef.current === instance && instance.operation === operation) reportError(error);
});
} catch (error) {
reportError(error);
}
return () => {
// Invalidate callbacks and promise continuations before terminal disposal.
if (generationRef.current === generation) ++generationRef.current;
if (instanceRef.current?.generation === generation) instanceRef.current = null;
try { unsubscribe?.(); }
catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
catch { /* A consumer callback cannot prevent terminal Core disposal. */ }
}
finally {
try { handle?.destroy(); }
catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Cleanup still completes when the consumer callback throws. */ }
}
}
};
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, organizationLinks, hasEmployeeCallback]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || instance.source === dataSource) return;
instance.source = dataSource;
const operation = ++instance.operation;
// Core reload synchronously starts a new revision, aborting/ignoring earlier
// source requests even when their Promise or engine import is still pending.
void instance.handle.reload(dataSource).catch(error => {
if (instanceRef.current !== instance || generationRef.current !== instance.generation || instance.operation !== operation) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Consumer errors must not become unhandled Promise rejections. */ }
});
}, [dataSource]);
return <div ref={containerRef} className={className} style={style} />;
}
+5
View File
@@ -0,0 +1,5 @@
"use client";
export { OrgChart } from './OrgChart.js';
export type { OrgChartProps } from './types.js';
export type * from '@hm-design/org-chart';
+13
View File
@@ -0,0 +1,13 @@
import type { CSSProperties } from 'react';
import type { OrgChartOptions, OrgChartState } from '@hm-design/org-chart';
/** The Core owns data, rendering, organization links and employee information
* interactions; React owns its dedicated container. */
export interface OrgChartProps extends Omit<OrgChartOptions, 'container' | 'onStateChange'> {
className?: string;
style?: CSSProperties;
/** Latest committed callback. Controlled data/style failures arrive as error states. */
onStateChange?: (state: OrgChartState) => void;
/** Unexpected create/mount/reload/cleanup errors, rather than controlled error states. */
onLifecycleError?: (error: unknown) => void;
}
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"rootDir": "src",
"outDir": "dist"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"types": ["react"]
},
"include": ["src"]
}