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

@@ -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. */