feat(org-chart): support standalone React integration and update guides

This commit is contained in:
shkim4 committed 2026-10-13 16:03:05 +09:00
1 parent 5acaf8a295
commit b637f86631
19 files changed
+1455 -64

No files matched your search

+34 -5
View File
@@ -33,11 +33,39 @@ export function OrganizationContent({ data }: { data: OrgChartDataset }) {
}
```
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. Default Core rendering does not load `@hm-design/ui`; opting into `employeePopup="hm-dialog"` loads the optional peer and requires the consumer's HM Theme CSS. Bundlers may resolve that optional dynamic chunk during a build even for a default consumer. For independent Tree/Summary integration, use the [Core factories](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI.
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 default entry has no runtime or declaration dependency on HM UI. For independent Tree/Summary integration, use the [Core factories](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI.
## Explicit popup adapters
Minimum React installation: `@hm-design/org-chart`, `@hm-design/org-chart-react`, React 19 and the host renderer (usually `react-dom`). HM UI, HM Theme and Tailwind are unnecessary for the default entry and Core popup. Core + Vanilla requires only `@hm-design/org-chart` and its public CSS URLs.
For the existing HM Dialog, install the optional `@hm-design/ui@0.1.0` peer and its required peers, provide the host's real HM Theme/Tailwind CSS, and explicitly select both the entry and popup mode:
```tsx
import { OrgChart } from '@hm-design/org-chart-react/hm-dialog';
// Existing dataSource, stylesheetUrls and all other Props are unchanged.
<OrgChart dataSource={dataSource} stylesheetUrls={stylesheetUrls}
layoutMode="auto" employeePopup="hm-dialog" />;
```
Migration from the previous HM popup setup: change the `OrgChart` import to `/hm-dialog`; keep `employeePopup="hm-dialog"`. Installing UI alone never switches the popup. Even the HM entry defaults to the Core popup. The default entry can also use the exported `HMEmployeeDialog` as `employeePopupAdapter` explicitly.
For another design system, keep the default entry and implement a React component with the public `OrgChartEmployeePopupProps` contract:
```tsx
import { OrgChart, type OrgChartEmployeePopupProps } from '@hm-design/org-chart-react';
// MyEmployeePopup receives { selection, onClose }; selection is null when closed.
// Read selection.employee/context without mutating them. Own accessible modal
// semantics, Escape/close behavior and focus restoration to context.trigger.
<OrgChart dataSource={dataSource} stylesheetUrls={stylesheetUrls} layoutMode="auto"
employeePopup="adapter" employeePopupAdapter={MyEmployeePopup} />;
```
Neither Core nor the dataset changes. Supplying an adapter alone does not enable it. An explicit `onEmployeeClick` still takes precedence. If an adapter mode is requested without an adapter, the wrapper retains the Core popup and reports a configuration error through `onLifecycleError`. Fixed continues to use its existing Core popup. Department Colors and Overview remain Storybook-only pages and have no new exports.
## 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`.
Default named runtime export: `OrgChart`. `/hm-dialog` exports the same `OrgChart` Props and `HMEmployeeDialog` adapter. Type exports: `OrgChartProps`, `OrgChartEmployeeSelection`, `OrgChartEmployeePopupProps` 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'>`:
@@ -54,7 +82,8 @@ feedback and drag/keyboard activation are implemented in the shared Core.
- Optional `selectedCompanyIds`: undefined selects all, [] selects none. Changes call Core selection on the existing instance, preserving per-company collapse state and source counts.
- Optional `autoLayout`: `{ type: 'tb' | 'lr' | 'hybrid', hybridStartDepth: 2 | 3 | 4, initialVisibleDepth: 1 | 2 | 3 | 'all', hybridOrganizationIds?: readonly string[] }` (each field optional). Changes use Core `setAutoLayout` on the existing instance; TB/LR/Hybrid changes retain valid collapse state. Per-group hybrid IDs apply only to TB; clear them when selecting LR/global Hybrid. Omission follows Core defaults (TB, Hybrid depth 3, all visible).
- Optional `interactions`: `{ collapse?: boolean, zoom?: boolean, pan?: boolean }`, all enabled by default. Auto independently enables/disables group toggles, Zoom controls/wheel Zoom and drag Pan via Core `setInteractions` on the same instance. Omitted fields are enabled, and removing the prop restores all three defaults. Existing collapse and viewport state remain; TB/LR/Hybrid has no interaction prerequisite. Fixed ignores this option and keeps its existing behavior.
- Optional `employeePopup`: `'core'` (default), `'hm-dialog'`, or `'none'`. Applies only to Auto; Fixed retains its existing Core popup. HM Dialog requires the optional `@hm-design/ui@0.1.0` peer and the consumer's real HM Theme/Tailwind CSS entry. It is loaded lazily so default/Core-only consumers do not load HM UI. An explicit `onEmployeeClick` always takes precedence and suppresses either default popup. HM popup state closes on source/company changes and resolves the employee's replacement card in the original ShadowRoot when returning focus after a layout render. Auto uses Core `setEmployeeClickHandler` to change popup ownership on the same instance, retaining company selection, layout, collapse and viewport state.
- Optional `employeePopup`: `'core'` (default), `'hm-dialog'`, `'adapter'`, or `'none'`. Applies only to Auto; Fixed retains its existing Core popup. HM Dialog requires the explicit `/hm-dialog` entry or `employeePopupAdapter={HMEmployeeDialog}`, the optional `@hm-design/ui@0.1.0` peer and the consumer's real HM Theme/Tailwind CSS entry. An explicit `onEmployeeClick` always takes precedence and suppresses either default popup. Host popup state closes on source/company changes, adapter replacement and successful movement. HM focus restoration still resolves the employee's replacement card in the original ShadowRoot after a layout render. Auto uses Core `setEmployeeClickHandler` to change popup ownership on the same instance, retaining company selection, layout, collapse and viewport state.
- Optional `employeePopupAdapter`: React `ComponentType<OrgChartEmployeePopupProps>`. Receives the untouched employee/context selection and `onClose`; used only with an explicit adapter popup mode. No global registration or package-presence detection is performed.
- Optional `companyTopNodes`, `employeeConnections`, `zoom`: immutable Core configuration maps/range. Identity changes recreate the handle; memoize these objects. Names, positions and profile overrides affect only Auto presentation, not callback data.
- 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.
@@ -105,7 +134,7 @@ Committed callback publication, Core ownership and source updates use layout eff
## Package boundary and verification limits
The build emits ESM `dist/index.js`, a lazy HM Dialog chunk, and TypeScript declarations. It preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`), `@hm-design/org-chart`, and optional `@hm-design/ui` 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.
The build emits separate ESM `dist/index.js` and `dist/hm-dialog.js` entries, their shared UI-independent wrapper chunk, and TypeScript declarations. It preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`), `@hm-design/org-chart`, and optional `@hm-design/ui` external. Only the HM entry imports UI. 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. Building all entries, including the HM adapter, also needs HM UI declarations in the maintainer workspace; installing the packed default entry does not.
From the repository root:
@@ -114,4 +143,4 @@ 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. The adapter delegates Auto Pan/Zoom to Core; it adds no alternate layout, additional relation overlays or API client. SSR/hydration, full screen reader behavior, physical touch, 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.
Storybook is one React host, not proof of every consumer deployment. The adapter delegates Auto Pan/Zoom to Core; it adds no alternate layout, additional relation overlays or API client. SSR/hydration, full screen reader behavior, physical touch, complete heap/GC recovery, HM Dialog without its Theme, Workdesk reference visual approval and every deployment environment require their own evidence. Default/Core rendering requires no HM Theme. Public ESM import without a DOM does not establish SSR/hydration support. Consult the minimal-install verification report for actual packed-package consumer results.
+9 -3
View File
@@ -18,7 +18,10 @@ await build({
target: 'es2022',
outDir: 'dist',
minify: false,
lib: { entry: path.join(root, 'src/index.ts'), formats: ['es'], fileName: () => 'index.js' },
lib: {
entry: { index: path.join(root, 'src/index.ts'), 'hm-dialog': path.join(root, 'src/hm-dialog.tsx') },
formats: ['es'], fileName: (_format, name) => `${name}.js`,
},
rolldownOptions: {
external: id => externals.some(dependency => id === dependency || id.startsWith(`${dependency}/`)),
onwarn(warning, warn) {
@@ -37,5 +40,8 @@ execFileSync(process.execPath, [
'-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')}`);
for (const name of ['index', 'hm-dialog']) {
const entry = path.join(root, `dist/${name}.js`);
const content = await readFile(entry, 'utf8');
await writeFile(entry, content.startsWith('"use client";') ? content : `"use client";\n${content}`);
}
+5
View File
@@ -10,6 +10,11 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./hm-dialog": {
"types": "./dist/hm-dialog.d.ts",
"import": "./dist/hm-dialog.js",
"default": "./dist/hm-dialog.js"
}
},
"files": ["dist", "README.md"],
@@ -5,21 +5,13 @@ import {
Avatar, AvatarFallback, AvatarImage, Button, Dialog, DialogBody, DialogClose,
DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@hm-design/ui';
import type { Employee, OrgChartEmployeeClickContext } from '@hm-design/org-chart';
export interface EmployeeDialogSelection {
employee: Employee;
context: OrgChartEmployeeClickContext;
}
import type { OrgChartEmployeeSelection, OrgChartEmployeePopupProps } from './types.js';
const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const;
/** Lazy React-only adapter. The Core and Vanilla remain independent of HM UI. */
export default function HMEmployeeDialog({ selection, onClose }: {
selection: EmployeeDialogSelection | null;
onClose: () => void;
}) {
const returnTarget = useRef<EmployeeDialogSelection | null>(null);
/** Explicit React-only adapter. The Core and default entry stay UI-independent. */
export default function HMEmployeeDialog({ selection, onClose }: OrgChartEmployeePopupProps) {
const returnTarget = useRef<OrgChartEmployeeSelection | null>(null);
const returnRoot = useRef<ShadowRoot | null>(null);
const returnContainer = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
+22 -16
View File
@@ -1,12 +1,9 @@
"use client";
import { lazy, Suspense, useLayoutEffect, useRef, useState } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import { createOrgChart } from '@hm-design/org-chart';
import type { OrgChartAutoLayoutOptions, OrgChartDataSource, OrgChartHandle, OrgChartInteractionOptions, OrgChartOptions } from '@hm-design/org-chart';
import type { OrgChartProps } from './types.js';
import type { EmployeeDialogSelection } from './HMEmployeeDialog.js';
const HMEmployeeDialog = lazy(() => import('./HMEmployeeDialog.js'));
import type { OrgChartEmployeeSelection, OrgChartProps } from './types.js';
function annotateEmployeeCards(shadow: ShadowRoot, popup: NonNullable<OrgChartProps['employeePopup']>, customCallback: boolean) {
const actionable = customCallback || popup !== 'none';
@@ -33,7 +30,7 @@ interface Instance {
dragDropEnabled: boolean;
readonly employeeClick: NonNullable<OrgChartOptions['onEmployeeClick']>;
employeeCallbackEnabled: boolean;
popupMode: 'core' | 'hm-dialog' | 'none';
popupMode: NonNullable<OrgChartProps['employeePopup']>;
operation: number;
}
@@ -56,6 +53,7 @@ export function OrgChart({
onEmployeeClick,
onMove,
employeePopup = 'core',
employeePopupAdapter: EmployeePopupAdapter,
className,
style,
onStateChange,
@@ -64,19 +62,28 @@ export function OrgChart({
const containerRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<Instance | null>(null);
const generationRef = useRef(0);
const [employeeSelection, setEmployeeSelection] = useState<EmployeeDialogSelection | null>(null);
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup });
const [employeeSelection, setEmployeeSelection] = useState<OrgChartEmployeeSelection | null>(null);
const requestedAdapter = employeePopup === 'hm-dialog' || employeePopup === 'adapter';
const effectivePopup = requestedAdapter && !EmployeePopupAdapter ? 'core' : employeePopup;
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup: effectivePopup });
// 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, onMove, employeePopup };
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup]);
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup: effectivePopup };
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, effectivePopup]);
useLayoutEffect(() => {
if (layoutMode !== 'auto' || !requestedAdapter || EmployeePopupAdapter || onEmployeeClick) return;
try {
committedRef.current.onLifecycleError?.(new Error('OrgChart popup adapter is missing. Use @hm-design/org-chart-react/hm-dialog or supply employeePopupAdapter. Falling back to the Core popup.'));
} catch { /* Consumer error reporting cannot prevent Core rendering. */ }
}, [layoutMode, requestedAdapter, EmployeePopupAdapter, Boolean(onEmployeeClick)]);
const engineStylesheetUrl = stylesheetUrls.engine;
const coreStylesheetUrl = stylesheetUrls.core;
const hasEmployeeCallback = typeof onEmployeeClick === 'function';
const popupMode = layoutMode === 'auto' ? employeePopup : 'core';
const popupMode = layoutMode === 'auto' ? effectivePopup : 'core';
const adapterOwnsPopup = popupMode !== 'core';
const hasAdapterCallback = hasEmployeeCallback || popupMode !== 'core';
const fixedHasEmployeeCallback = layoutMode !== 'auto' && hasEmployeeCallback;
@@ -103,7 +110,7 @@ export function OrgChart({
if (generationRef.current !== generation) return;
const callback = committedRef.current.onEmployeeClick;
if (callback) callback(employee, context);
else if (committedRef.current.employeePopup === 'hm-dialog') setEmployeeSelection({ employee, context });
else if (committedRef.current.employeePopup === 'hm-dialog' || committedRef.current.employeePopup === 'adapter') setEmployeeSelection({ employee, context });
};
handle = createOrgChart({
dataSource: source,
@@ -197,7 +204,7 @@ export function OrgChart({
if (shadow && layoutMode === 'auto') {
annotateEmployeeCards(shadow, popupMode, hasEmployeeCallback);
}
}, [dataSource, selectedCompanyIds, popupMode, hasEmployeeCallback, layoutMode, adapterOwnsPopup]);
}, [dataSource, selectedCompanyIds, popupMode, hasEmployeeCallback, layoutMode, adapterOwnsPopup, EmployeePopupAdapter]);
useLayoutEffect(() => {
const instance = instanceRef.current;
@@ -259,8 +266,7 @@ export function OrgChart({
return <>
<div ref={containerRef} className={className} style={style} />
{popupMode === 'hm-dialog' && !hasEmployeeCallback ? <Suspense fallback={null}>
<HMEmployeeDialog selection={employeeSelection} onClose={() => setEmployeeSelection(null)} />
</Suspense> : null}
{(popupMode === 'hm-dialog' || popupMode === 'adapter') && EmployeePopupAdapter && !hasEmployeeCallback
? <EmployeePopupAdapter selection={employeeSelection} onClose={() => setEmployeeSelection(null)} /> : null}
</>;
}
@@ -0,0 +1,14 @@
"use client";
import { OrgChart as CoreOrgChart } from './OrgChart.js';
import HMEmployeeDialog from './HMEmployeeDialog.js';
import type { OrgChartProps } from './types.js';
/** HM integration is explicit at the import and popup setting boundaries. */
export function OrgChart(props: OrgChartProps) {
return <CoreOrgChart {...props} employeePopupAdapter={props.employeePopupAdapter ?? HMEmployeeDialog} />;
}
export { HMEmployeeDialog };
export type { OrgChartProps, OrgChartEmployeeSelection, OrgChartEmployeePopupProps } from './types.js';
export type * from '@hm-design/org-chart';
+1 -1
View File
@@ -1,5 +1,5 @@
"use client";
export { OrgChart } from './OrgChart.js';
export type { OrgChartProps } from './types.js';
export type { OrgChartProps, OrgChartEmployeeSelection, OrgChartEmployeePopupProps } from './types.js';
export type * from '@hm-design/org-chart';
+20 -6
View File
@@ -1,15 +1,29 @@
import type { CSSProperties } from 'react';
import type { OrgChartOptions, OrgChartState } from '@hm-design/org-chart';
import type { ComponentType, CSSProperties } from 'react';
import type { Employee, OrgChartEmployeeClickContext, OrgChartOptions, OrgChartState } from '@hm-design/org-chart';
export interface OrgChartEmployeeSelection {
employee: Employee;
context: OrgChartEmployeeClickContext;
}
/** Host-owned popup UI. Adapters own presentation and focus restoration. */
export interface OrgChartEmployeePopupProps {
selection: OrgChartEmployeeSelection | null;
onClose: () => void;
}
/** The Core owns data, organization rendering and interactions; React owns its
* dedicated container and the optional Portal-based HM employee information UI. */
* dedicated container and optional host-owned employee information UI. */
export interface OrgChartProps extends Omit<OrgChartOptions, 'container' | 'onStateChange'> {
className?: string;
style?: CSSProperties;
/** Auto only. Opt into the public HM Dialog (requires @hm-design/ui + its
* Theme), retain the Vanilla popup, or suppress the default information UI.
/** Auto only. Explicitly select an HM/custom adapter, retain the Vanilla
* popup, or suppress the default information UI.
* An explicit onEmployeeClick callback always owns the popup instead. */
employeePopup?: 'core' | 'hm-dialog' | 'none';
employeePopup?: 'core' | 'hm-dialog' | 'adapter' | 'none';
/** Explicit UI integration. Supplying an adapter does not enable it: select
* employeePopup="adapter" (or "hm-dialog" for the HM entry) as well. */
employeePopupAdapter?: ComponentType<OrgChartEmployeePopupProps>;
/** 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. */