feat(org-chart): finalize chart features and AI generation rules

This commit is contained in:
shkim4 committed 2026-10-13 14:39:42 +09:00
1 parent 7614507583
commit d0828a7ff4
80 files changed
+8152 -221

No files matched your search

@@ -0,0 +1,82 @@
"use client";
import { useLayoutEffect, useRef } from 'react';
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;
}
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);
const returnRoot = useRef<ShadowRoot | null>(null);
const returnContainer = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (!selection) return;
returnTarget.current = selection;
const root = selection.context.trigger.getRootNode();
const Shadow = selection.context.trigger.ownerDocument.defaultView?.ShadowRoot;
returnRoot.current = Shadow && root instanceof Shadow ? root : null;
returnContainer.current = returnRoot.current?.host.parentElement ?? null;
}, [selection]);
function restoreFocus(event: Event) {
event.preventDefault();
const previous = returnTarget.current;
returnTarget.current = null;
if (!previous) return;
const { trigger } = previous.context;
// A mode/selection render can replace the card while the Portal is open.
// Retain its ShadowRoot before replacement and resolve the same employee.
const shadow = returnRoot.current;
const currentRoot = shadow?.host.isConnected ? shadow : returnContainer.current?.firstElementChild?.shadowRoot;
const replacement = [...currentRoot?.querySelectorAll<HTMLElement>('.hm-orgchart-employee') ?? []]
.find(card => card.dataset.employeeId === previous.employee.id);
const target = trigger.isConnected ? trigger : replacement
?? currentRoot?.querySelector<HTMLElement>('.hm-orgchart-shell');
target?.focus({ preventScroll: true });
}
const displayed = selection ?? returnTarget.current;
const employee = displayed?.employee;
const context = displayed?.context;
const image = employee?.profileImage
?? context?.trigger.querySelector<HTMLImageElement>('.hm-orgchart-avatar img')?.src;
const fields: [string, string | null | undefined][] = employee ? [
['이름', employee.name], ['직위', employee.position],
['소속 회사', context?.affiliation?.name], ['소속 조직', context?.organization?.name],
['직책', employee.jobTitle], ['근무 상태', statusNames[employee.workStatus]],
] : [];
return <Dialog open={selection !== null} onOpenChange={open => { if (!open) onClose(); }}>
<DialogContent onCloseAutoFocus={restoreFocus} data-component="HMOrgChartEmployeeDialog" data-employee-id={employee?.id}>
<DialogHeader>
<DialogTitle>직원 정보</DialogTitle>
<DialogDescription>조직도에서 선택한 직원의 정보를 확인합니다.</DialogDescription>
</DialogHeader>
<DialogBody>
<Avatar size="lg">
{image ? <AvatarImage src={image} alt="" referrerPolicy="no-referrer" /> : null}
<AvatarFallback>{employee?.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<dl style={{ display: 'grid', gap: '0.75rem', margin: '1rem 0 0' }}>{fields.filter(([, value]) => Boolean(value)).map(([label, value]) => <div key={label} style={{ display: 'grid', gridTemplateColumns: '5rem minmax(0, 1fr)', gap: '0.75rem' }}>
<dt style={{ fontWeight: 600 }}>{label}</dt><dd style={{ margin: 0, minWidth: 0, overflowWrap: 'anywhere' }}>{value}</dd>
</div>)}</dl>
</DialogBody>
<DialogFooter>
<DialogClose asChild><Button variant="cancel" appearance="solid">닫기</Button></DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>;
}
+134 -11
View File
@@ -1,14 +1,39 @@
"use client";
import { useLayoutEffect, useRef } from 'react';
import { lazy, Suspense, useLayoutEffect, useRef, useState } from 'react';
import { createOrgChart } from '@hm-design/org-chart';
import type { OrgChartDataSource, OrgChartHandle } 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'));
function annotateEmployeeCards(shadow: ShadowRoot, popup: NonNullable<OrgChartProps['employeePopup']>, customCallback: boolean) {
const actionable = customCallback || popup !== 'none';
for (const card of shadow.querySelectorAll<HTMLElement>('.hm-orgchart-employee')) {
const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? '';
const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? '';
// Auto cards also provide keyboard movement when the information popup is
// disabled; popup ownership must not remove that Core keyboard entry point.
card.tabIndex = 0;
if (actionable) card.setAttribute('role', 'button');
else card.removeAttribute('role');
card.setAttribute('aria-label', `${name} ${position}${actionable ? ' 직원 정보 보기' : ''}`.trim());
if (actionable && !customCallback) card.setAttribute('aria-haspopup', 'dialog');
else card.removeAttribute('aria-haspopup');
}
}
interface Instance {
readonly handle: OrgChartHandle;
readonly generation: number;
source: OrgChartDataSource;
autoLayout: OrgChartAutoLayoutOptions | undefined;
interactions: OrgChartInteractionOptions | undefined;
dragDropEnabled: boolean;
readonly employeeClick: NonNullable<OrgChartOptions['onEmployeeClick']>;
employeeCallbackEnabled: boolean;
popupMode: 'core' | 'hm-dialog' | 'none';
operation: number;
}
@@ -19,6 +44,9 @@ export function OrgChart({
stylesheetUrls,
displayMode,
layoutMode,
autoLayout,
interactions,
dragDropEnabled = false,
companyId,
selectedCompanyIds,
companyTopNodes,
@@ -26,6 +54,8 @@ export function OrgChart({
zoom,
organizationLinks,
onEmployeeClick,
onMove,
employeePopup = 'core',
className,
style,
onStateChange,
@@ -34,17 +64,22 @@ export function OrgChart({
const containerRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<Instance | null>(null);
const generationRef = useRef(0);
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick });
const [employeeSelection, setEmployeeSelection] = useState<EmployeeDialogSelection | null>(null);
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup });
// 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]);
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup };
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup]);
const engineStylesheetUrl = stylesheetUrls.engine;
const coreStylesheetUrl = stylesheetUrls.core;
const hasEmployeeCallback = typeof onEmployeeClick === 'function';
const popupMode = layoutMode === 'auto' ? employeePopup : 'core';
const adapterOwnsPopup = popupMode !== 'core';
const hasAdapterCallback = hasEmployeeCallback || popupMode !== 'core';
const fixedHasEmployeeCallback = layoutMode !== 'auto' && hasEmployeeCallback;
// Ownership changes are synchronous with the commit: an old request cannot
// notify between a committed unmount/source change and a passive cleanup.
@@ -54,6 +89,7 @@ export function OrgChart({
const generation = ++generationRef.current;
let handle: OrgChartHandle | undefined;
let unsubscribe: (() => void) | undefined;
let popupObserver: MutationObserver | undefined;
const reportError = (error: unknown) => {
if (generationRef.current !== generation) return;
@@ -63,23 +99,35 @@ export function OrgChart({
try {
const source = committedRef.current.dataSource;
const employeeClick: NonNullable<OrgChartOptions['onEmployeeClick']> = (employee, context) => {
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 });
};
handle = createOrgChart({
dataSource: source,
aggregationPolicy,
stylesheetUrls: { engine: engineStylesheetUrl, core: coreStylesheetUrl },
displayMode,
layoutMode,
autoLayout,
interactions,
dragDropEnabled,
companyId,
selectedCompanyIds,
companyTopNodes,
employeeConnections,
zoom,
organizationLinks,
onEmployeeClick: hasEmployeeCallback ? (employee, context) => {
if (generationRef.current === generation) committedRef.current.onEmployeeClick?.(employee, context);
} : undefined,
onEmployeeClick: hasAdapterCallback ? employeeClick : undefined,
onMove: result => {
if (generationRef.current !== generation) return;
if (result.moved) setEmployeeSelection(null);
committedRef.current.onMove?.(result);
},
});
const instance: Instance = { handle, source, generation, operation: 1 };
const instance: Instance = { handle, source, autoLayout, interactions, dragDropEnabled, employeeClick, employeeCallbackEnabled: hasAdapterCallback, popupMode, generation, operation: 1 };
instanceRef.current = instance;
unsubscribe = handle.subscribe(state => {
if (instanceRef.current === instance && generationRef.current === generation) {
@@ -92,6 +140,18 @@ export function OrgChart({
void handle.mount(container).catch(error => {
if (instanceRef.current === instance && instance.operation === operation) reportError(error);
});
const shadow = container.firstElementChild?.shadowRoot;
if (shadow && layoutMode === 'auto') {
const annotateCards = () => {
annotateEmployeeCards(shadow, committedRef.current.employeePopup, Boolean(committedRef.current.onEmployeeClick));
};
const Observer = container.ownerDocument.defaultView?.MutationObserver;
if (Observer) {
popupObserver = new Observer(annotateCards);
popupObserver.observe(shadow, { childList: true, subtree: true });
}
annotateCards();
}
} catch (error) {
reportError(error);
}
@@ -100,6 +160,7 @@ export function OrgChart({
// Invalidate callbacks and promise continuations before terminal disposal.
if (generationRef.current === generation) ++generationRef.current;
if (instanceRef.current?.generation === generation) instanceRef.current = null;
popupObserver?.disconnect();
try { unsubscribe?.(); }
catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
@@ -113,7 +174,64 @@ export function OrgChart({
}
}
};
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, companyTopNodes, employeeConnections, zoom, organizationLinks, hasEmployeeCallback]);
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, companyTopNodes, employeeConnections, zoom, organizationLinks, fixedHasEmployeeCallback]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || layoutMode !== 'auto' || (instance.employeeCallbackEnabled === hasAdapterCallback && instance.popupMode === popupMode)) return;
try {
instance.handle.setEmployeeClickHandler(hasAdapterCallback ? instance.employeeClick : undefined);
instance.employeeCallbackEnabled = hasAdapterCallback;
instance.popupMode = popupMode;
} catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Match Core's callback ownership isolation. */ }
}
}, [hasAdapterCallback, popupMode, layoutMode]);
useLayoutEffect(() => {
// Close a host-owned popup before committing a changed source/company view;
// no asynchronous fixture request can leave a stale employee displayed.
setEmployeeSelection(null);
const shadow = containerRef.current?.firstElementChild?.shadowRoot;
if (shadow && layoutMode === 'auto') {
annotateEmployeeCards(shadow, popupMode, hasEmployeeCallback);
}
}, [dataSource, selectedCompanyIds, popupMode, hasEmployeeCallback, layoutMode, adapterOwnsPopup]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || layoutMode !== 'auto' || instance.autoLayout === autoLayout) return;
instance.autoLayout = autoLayout;
void instance.handle.setAutoLayout(autoLayout ?? { type: 'tb', hybridStartDepth: 3, initialVisibleDepth: 'all', hybridOrganizationIds: [] }).catch(error => {
if (instanceRef.current !== instance) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Keep layout setter failures in the lifecycle error contract. */ }
});
}, [autoLayout, layoutMode]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || layoutMode !== 'auto' || instance.dragDropEnabled === dragDropEnabled) return;
try {
instance.handle.setDragDropEnabled(dragDropEnabled);
instance.dragDropEnabled = dragDropEnabled;
} catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Keep option changes in the lifecycle error contract. */ }
}
}, [dragDropEnabled, layoutMode]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || layoutMode !== 'auto' || instance.interactions === interactions) return;
instance.interactions = interactions;
void instance.handle.setInteractions({ collapse: true, zoom: true, pan: true, ...interactions }).catch(error => {
if (instanceRef.current !== instance) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Interaction controls use the existing lifecycle error contract. */ }
});
}, [interactions, layoutMode]);
useLayoutEffect(() => {
const instance = instanceRef.current;
@@ -139,5 +257,10 @@ export function OrgChart({
});
}, [dataSource]);
return <div ref={containerRef} className={className} style={style} />;
return <>
<div ref={containerRef} className={className} style={style} />
{popupMode === 'hm-dialog' && !hasEmployeeCallback ? <Suspense fallback={null}>
<HMEmployeeDialog selection={employeeSelection} onClose={() => setEmployeeSelection(null)} />
</Suspense> : null}
</>;
}
+6 -2
View File
@@ -1,11 +1,15 @@
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. */
/** The Core owns data, organization rendering and interactions; React owns its
* dedicated container and the optional Portal-based HM 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.
* An explicit onEmployeeClick callback always owns the popup instead. */
employeePopup?: 'core' | 'hm-dialog' | 'none';
/** 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. */