Files
HMDesign/packages/org-chart/src/core/employee-card.ts
T
shkim4 064517ec46 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.
2026-10-12 21:59:09 +09:00

134 lines
6.7 KiB
TypeScript

import type { Employee } from '../data/index.js';
import personPlaceholder from '../assets/person-placeholder.svg?inline';
import { createAffiliationMark } from './affiliation-mark.js';
import type { DepartmentCardAsset } from './department-card-assets.js';
import type { RepresentativeCardAsset } from './representative-card-asset.js';
import { observePersonNameOverflow } from './person-name-overflow.js';
const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const;
export interface EmployeeCardOptions {
kind: 'topLeader' | 'leader' | 'employee';
affiliationName?: string;
cardAsset?: DepartmentCardAsset | RepresentativeCardAsset;
/** Full Composition presentation only; the employee dataset is untouched. */
workdeskPresentation?: boolean;
/** Auto content presentation, independent of Fixed SVG geometry. */
expandedPresentation?: boolean;
/** Compact read-only status groups already convey this label in their header. */
hideStatusLabel?: boolean;
}
function text(document: Document, tag: string, className: string, value: string): HTMLElement {
const element = document.createElement(tag);
element.className = className;
element.textContent = value;
return element;
}
/** Content renderer shared by the hierarchy and supplementary status groups. */
export function createEmployeeCard(
document: Document, employee: Employee, options: EmployeeCardOptions, signal: AbortSignal,
): HTMLElement {
const leader = options.kind !== 'employee';
const representative = options.kind === 'topLeader' || options.cardAsset?.size === 'representative';
const compactStatus = Boolean(options.workdeskPresentation && options.hideStatusLabel && !options.cardAsset);
const styledCard = Boolean(options.cardAsset) || compactStatus || options.expandedPresentation;
const presentationName = options.workdeskPresentation || (representative && !options.expandedPresentation);
const displayName = presentationName ? '홍길동' : employee.name;
const displayedName = presentationName ? '홍 길 동' : options.expandedPresentation && /^[가-힣]{3}$/u.test(displayName) ? [...displayName].join(' ') : displayName;
const card = document.createElement('div');
card.className = `hm-orgchart-employee${leader ? ' hm-orgchart-leader' : ''}`;
card.dataset.employeeId = employee.id;
card.dataset.cardKind = options.kind;
card.dataset.workStatus = employee.workStatus;
if (options.expandedPresentation) card.dataset.cardPresentation = 'expanded';
if (options.workdeskPresentation) card.dataset.workdeskPresentation = 'true';
if (compactStatus) card.dataset.cardLayout = 'compact';
if (options.cardAsset) {
const asset = options.cardAsset;
card.dataset.cardAsset = 'departmentId' in asset ? asset.departmentId : 'representative';
card.dataset.cardAssetSize = asset.size;
const background = document.createElement('img');
background.className = 'hm-orgchart-card-background';
background.alt = '';
background.setAttribute('aria-hidden', 'true');
background.width = asset.width;
background.height = asset.height;
background.src = asset.url;
card.append(background);
}
// One neutral icon is shared by every card type and both runtime adapters.
// The approved rectangular photo slots keep their existing dimensions.
const avatar = document.createElement('span');
avatar.className = 'hm-orgchart-avatar';
avatar.setAttribute('aria-hidden', 'true');
const image = document.createElement('img');
image.alt = '';
image.loading = 'lazy';
image.decoding = 'async';
image.referrerPolicy = 'no-referrer';
const showPlaceholder = () => {
card.dataset.photoState = 'placeholder';
image.dataset.photoPlaceholder = 'true';
image.src = personPlaceholder;
};
if (employee.profileImage) {
card.dataset.photoState = 'image';
image.addEventListener('error', showPlaceholder, { once: true, signal });
// Images have already passed the dataset URL validation. No HTML parsing.
image.src = employee.profileImage;
} else showPlaceholder();
avatar.append(image);
const details = document.createElement('div');
details.className = 'hm-orgchart-employee-details';
const displayedPosition = representative && !options.expandedPresentation ? '대표이사' : employee.position.replace(/\s+/gu, '');
const position = text(document, 'span', 'hm-orgchart-person-role', displayedPosition);
const name = text(document, 'strong', 'hm-orgchart-person-name', displayedName);
if (presentationName) {
position.setAttribute('aria-label', representative ? displayedPosition : employee.position);
name.setAttribute('aria-label', displayName);
}
if (options.expandedPresentation) position.setAttribute('aria-label', employee.position);
const inlineRoleBadge = styledCard && !representative && !options.expandedPresentation;
const positionRow = inlineRoleBadge ? document.createElement('div') : null;
if (positionRow) {
positionRow.className = 'hm-orgchart-position-row';
positionRow.append(position);
}
if (styledCard) {
const personText = document.createElement('div'); personText.className = 'hm-orgchart-person-text';
personText.append(positionRow ?? position, name); details.append(personText);
observePersonNameOverflow(card, name, signal);
} else details.append(position, name);
const attributes = document.createElement('div');
attributes.className = 'hm-orgchart-person-attributes';
// Representative cards show only position/name in every consumer. Raw employee
// roles remain available to dialogs/statistics; other cards keep their policy.
const hideExecutiveBadge = options.workdeskPresentation && employee.jobTitle?.trim() === '경영진';
if (!representative && !options.expandedPresentation && !hideExecutiveBadge && (employee.jobTitle || (leader && !options.cardAsset))) {
const rawBadge = employee.jobTitle || '조직장';
const badge = text(document, 'span', 'hm-orgchart-job-title', options.workdeskPresentation ? rawBadge.replace(/\s+/gu, '') : rawBadge);
if (options.workdeskPresentation) badge.setAttribute('aria-label', employee.jobTitle || '조직장');
badge.title = badge.textContent!;
(positionRow ?? attributes).append(badge);
}
if (!representative && !options.hideStatusLabel) attributes.append(text(document, 'span', 'hm-orgchart-status-label', statusNames[employee.workStatus]));
if (styledCard) {
card.append(avatar, details);
if (attributes.childElementCount) card.append(attributes);
} else {
if (attributes.childElementCount) details.append(attributes);
card.append(avatar, details);
}
if (options.affiliationName) {
const affiliation = createAffiliationMark(document, employee.affiliationId, options.affiliationName, signal);
card.dataset.hasAffiliation = 'true';
card.append(affiliation);
}
return card;
}