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.
134 lines
6.7 KiB
TypeScript
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;
|
|
}
|