feat(org-chart): implement expandable layout and multi-company navigation
Integrate P5-1B through P5-4 shared Core layout, collapse, zoom/pan/fit, company roots and keyboard accessibility. Include React/HTML examples, virtual 20/100/300 employee fixtures, focused checks and the additive data contract.
This commit is contained in:
1 parent
a92913d6bc
commit
064517ec46
30 files changed
+7532
-183
No files matched your search
@@ -1,47 +1,99 @@
|
||||
import { createEmployeeCard } from './employee-card.js';
|
||||
import { createHeading, type RenderContext } from './render.js';
|
||||
import { representativeCardAsset } from './representative-card-asset.js';
|
||||
import type { RenderEngineNode } from './engine.js';
|
||||
import { getCompanyColorPreset } from '../company-color-presets.js';
|
||||
import { alignExpandedLevels } from './expanded-layout.js';
|
||||
|
||||
/** P5-1A presenter boundary. The shared Core supplies the initial engine groups;
|
||||
* this presenter never assigns coordinates or interprets parentId. A later
|
||||
* layout adapter can replace group placement without duplicating card or data
|
||||
* rendering in React and Vanilla consumers. */
|
||||
export function createExpandedOrgChartContent(
|
||||
document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal,
|
||||
): HTMLElement {
|
||||
/** Auto-only presenter. The source and popup identity remain unchanged. */
|
||||
export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode, context: RenderContext, signal: AbortSignal): void {
|
||||
const organization = context.orderedOrganizations[Number(value.organizationIndex)]!;
|
||||
const companyId = context.organizationCompanies!.get(organization.id)!;
|
||||
const preset = getCompanyColorPreset(companyId)!;
|
||||
const document = node.ownerDocument;
|
||||
const article = node.querySelector<HTMLElement>('.hm-orgchart-organization')!;
|
||||
const collapsed = context.collapsedOrganizations!.has(organization.id);
|
||||
const top = organization.parentId === null ? context.companyTopNodes?.[companyId] : undefined;
|
||||
node.dataset.organizationId = organization.id;
|
||||
node.dataset.parentId = organization.parentId ?? '';
|
||||
node.dataset.depth = node.dataset.organizationDepth = String(value.depth);
|
||||
node.dataset.organizationType = organization.type;
|
||||
node.dataset.companyId = companyId;
|
||||
node.dataset.collapsed = String(collapsed);
|
||||
node.style.setProperty('--hm-orgchart-expanded-primary', preset.primary);
|
||||
node.style.setProperty('--hm-orgchart-expanded-title-ink', ['saman', 'jangheon-industry'].includes(companyId) ? 'var(--hm-orgchart-resolved-fg-primary)' : 'var(--hm-orgchart-resolved-on-action-primary)');
|
||||
preset.secondary.forEach((color, index) => node.style.setProperty(`--hm-orgchart-expanded-secondary-${index + 1}`, color));
|
||||
for (const edge of node.querySelectorAll('.edge')) edge.setAttribute('aria-hidden', 'true');
|
||||
const leaderId = top?.employeeId === undefined ? organization.leaderId : top.employeeId;
|
||||
const leader = context.data.employees.find(employee => employee.id === leaderId && employee.organizationId === organization.id);
|
||||
const staff = context.data.presentation?.groups.showEmployees === false ? [] : (context.employeesByOrganization.get(organization.id) ?? []).filter(employee => employee.id !== leaderId);
|
||||
const hasChildren = context.data.organizations.some(org => org.parentId === organization.id) || staff.length > 0;
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'hm-orgchart-organization-name';
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'hm-orgchart-expanded-toggle';
|
||||
button.dataset.organizationId = organization.id;
|
||||
button.textContent = `${top?.organizationName ?? organization.name} ${hasChildren ? collapsed ? '▸' : '▾' : '·'}`;
|
||||
button.setAttribute('aria-label', `${top?.organizationName ?? organization.name} 하위 조직·직원`);
|
||||
button.setAttribute('aria-expanded', String(!collapsed));
|
||||
button.disabled = !hasChildren;
|
||||
button.addEventListener('click', event => { event.stopPropagation(); context.toggleExpanded?.(organization.id); }, { signal });
|
||||
heading.append(button);
|
||||
const href = context.organizationLinks?.[organization.id];
|
||||
if (href) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'hm-orgchart-expanded-link';
|
||||
link.href = href;
|
||||
link.textContent = '↗';
|
||||
link.setAttribute('aria-label', `${organization.name} 페이지 이동`);
|
||||
link.addEventListener('click', event => event.stopPropagation(), { signal });
|
||||
heading.append(link);
|
||||
}
|
||||
article.append(heading);
|
||||
const colorCard = (card: HTMLElement, affiliationId: string) => {
|
||||
const employeePreset = getCompanyColorPreset(affiliationId) ?? preset;
|
||||
card.dataset.companyId = employeePreset.companyId;
|
||||
card.style.setProperty('--hm-orgchart-expanded-card-primary', `var(--hm-orgchart-company-${employeePreset.companyId})`);
|
||||
return card;
|
||||
};
|
||||
if (leader && top?.display !== 'hidden' && top?.display !== 'titleOnly') {
|
||||
const presentation = top ? { ...leader, position: top.title ?? leader.position, name: top.name ?? leader.name, profileImage: top.profileImage === undefined ? leader.profileImage : top.profileImage } : leader;
|
||||
const card = colorCard(createEmployeeCard(document, presentation, { kind: organization.parentId === null ? 'topLeader' : 'leader', expandedPresentation: true, hideStatusLabel: true }, signal), leader.affiliationId);
|
||||
card.dataset.connectionDirection = 'top';
|
||||
article.append(card);
|
||||
} else if (leader && top?.display === 'titleOnly') {
|
||||
const title = document.createElement('p');
|
||||
title.className = 'hm-orgchart-top-node-title';
|
||||
title.textContent = `${top.title ?? leader.position} · ${top.name ?? leader.name}`;
|
||||
article.append(title);
|
||||
}
|
||||
if (!collapsed && staff.length) {
|
||||
const employees = document.createElement('div');
|
||||
employees.className = 'hm-orgchart-employees';
|
||||
const direction = context.employeeConnections?.[organization.id] ?? 'top';
|
||||
employees.dataset.connectionDirection = direction;
|
||||
for (const employee of staff) {
|
||||
const card = colorCard(createEmployeeCard(document, employee, { kind: 'employee', expandedPresentation: true, hideStatusLabel: true }, signal), employee.affiliationId);
|
||||
card.dataset.connectionDirection = direction;
|
||||
employees.append(card);
|
||||
}
|
||||
article.append(employees);
|
||||
}
|
||||
}
|
||||
|
||||
export function createExpandedOrgChartContent(document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal, layoutReady: () => void): HTMLElement {
|
||||
const content = document.createElement('div');
|
||||
content.className = 'hm-orgchart-expanded';
|
||||
content.dataset.component = 'ExpandedOrgChartContent';
|
||||
content.dataset.layoutMode = 'auto';
|
||||
content.dataset.layoutStatus = 'preview';
|
||||
|
||||
const heading = createHeading(document, context);
|
||||
const status = document.createElement('p');
|
||||
status.className = 'hm-orgchart-expanded-stage-note';
|
||||
status.textContent = 'P5-1A · 확장형 준비 화면';
|
||||
const description = document.createElement('p');
|
||||
description.className = 'hm-orgchart-expanded-description';
|
||||
description.textContent = '대표자와 1Depth 예시 그룹을 표시합니다. 하위 계층 자동 배치와 접기·펼치기, Zoom/Pan은 후속 단계에서 구현합니다.';
|
||||
content.append(heading, status, description);
|
||||
|
||||
const leaders = document.createElement('section');
|
||||
leaders.className = 'hm-orgchart-expanded-top-leaders';
|
||||
leaders.setAttribute('aria-label', '최상위 조직 리더');
|
||||
for (const id of context.topLeaderIds) {
|
||||
const employee = context.data.employees.find(item => item.id === id);
|
||||
if (!employee) continue;
|
||||
leaders.append(createEmployeeCard(document, employee, {
|
||||
kind: 'topLeader',
|
||||
workdeskPresentation: true,
|
||||
cardAsset: representativeCardAsset,
|
||||
affiliationName: context.data.affiliations.find(item => item.id === employee.affiliationId)?.name,
|
||||
}, signal));
|
||||
content.dataset.layoutStatus = 'hierarchical';
|
||||
content.dataset.cardDesignStatus = 'current-presentation';
|
||||
for (const company of new Set([...context.organizationCompanies!.values(), ...context.data.employees.map(employee => employee.affiliationId)])) {
|
||||
const preset = getCompanyColorPreset(company);
|
||||
if (preset) content.style.setProperty(`--hm-orgchart-company-${company}`, preset.primary);
|
||||
}
|
||||
if (leaders.childElementCount) content.append(leaders);
|
||||
|
||||
// Keep the stable Core viewport for keyboard access and ordinary scrolling.
|
||||
// This is a flow preview, with no fixed-stage scale observer or artwork.
|
||||
tree.setAttribute('aria-label', '확장형 조직도 초기 그룹 영역');
|
||||
content.append(tree);
|
||||
tree.setAttribute('aria-label', '확장형 조직 계층도 · Ctrl 또는 Command + 휠로 확대 · 드래그로 이동');
|
||||
content.append(createHeading(document, context), tree);
|
||||
alignExpandedLevels(tree, signal, layoutReady);
|
||||
return content;
|
||||
}
|
||||
Reference in new issue
Block a user