import type { Employee } from '../data/index.js'; import type { OrgChartEmployeeClickContext } from './types.js'; const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const; export interface EmployeeInfoDialog { show(employee: Employee, context: OrgChartEmployeeClickContext): void; close(): void; } /** The native modal lives in the unscaled shell and enters the browser's top * layer. It keeps the Core shared by HTML and React, including focus handling. */ export function createEmployeeInfoDialog( shell: HTMLElement, signal: AbortSignal, prefix: string, readOnly: boolean, ): EmployeeInfoDialog { const document = shell.ownerDocument; const dialog = document.createElement('dialog'); dialog.className = 'hm-orgchart-employee-dialog'; dialog.dataset.component = 'EmployeeInfoDialog'; dialog.setAttribute('aria-modal', 'true'); const header = document.createElement('div'); header.className = 'hm-orgchart-employee-dialog-header'; const title = document.createElement('h2'); title.id = `${prefix}-employee-dialog-title`; title.textContent = '직원 정보'; dialog.setAttribute('aria-labelledby', title.id); const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.className = 'hm-orgchart-employee-dialog-close'; closeButton.setAttribute('aria-label', '직원 정보 닫기'); // HM Dialog's Lucide XIcon, kept as native SVG for the shared Vanilla Core. const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); for (const [name, value] of Object.entries({ width: '24', height: '24', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false', })) closeIcon.setAttribute(name, value); for (const d of ['M18 6 6 18', 'm6 6 12 12']) { const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', d); closeIcon.append(path); } closeButton.append(closeIcon); const fields = document.createElement('dl'); fields.className = 'hm-orgchart-employee-dialog-fields'; header.append(title, closeButton); dialog.append(header, fields); shell.append(dialog); let trigger: HTMLElement | undefined; function restoreFocus(): void { const previous = trigger; // close() restores synchronously, while the native close event is queued. // Only a real activation owns a return target: a second close notification // (or routine render cleanup before the dialog ever opens) must not focus // the shell and displace the card's restored focus. if (!previous) return; trigger = undefined; if (signal.aborted || !shell.isConnected) return; (previous?.isConnected ? previous : shell).focus({ preventScroll: true }); } function close(): void { if (dialog.open) dialog.close(); restoreFocus(); } closeButton.addEventListener('click', close, { signal }); dialog.addEventListener('cancel', event => { event.preventDefault(); close(); }, { signal }); dialog.addEventListener('close', () => { if (!dialog.open) restoreFocus(); }, { signal }); dialog.addEventListener('keydown', event => { if (event.key !== 'Tab') return; const focusable = [...dialog.querySelectorAll('button:not(:disabled), a[href], [tabindex="0"]')]; const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = (dialog.getRootNode() as ShadowRoot).activeElement; if ((event.shiftKey && active === first) || (!event.shiftKey && active === last) || !dialog.contains(active)) { event.preventDefault(); (event.shiftKey ? last : first)?.focus(); } }, { signal }); signal.addEventListener('abort', () => { trigger = undefined; if (dialog.open) dialog.close(); dialog.remove(); }, { once: true }); return { close, show(employee, context) { if (signal.aborted) return; // Never synthesize contact details, dates or other personal information. const entries: [string, string | null | undefined][] = [ ['이름', readOnly ? context.trigger.querySelector('.hm-orgchart-person-name')?.textContent ?? employee.name : employee.name], ['직위', employee.position], ['소속', context.organization?.name], ['직책', employee.jobTitle], ['관계사', context.affiliation?.name], ['근무 상태', statusNames[employee.workStatus]], ]; fields.replaceChildren(); for (const [label, value] of entries) { if (!value) continue; const term = document.createElement('dt'); term.textContent = label; const detail = document.createElement('dd'); detail.textContent = value; fields.append(term, detail); } dialog.dataset.employeeId = employee.id; trigger = context.trigger; if (!dialog.open) dialog.showModal(); closeButton.focus({ preventScroll: true }); }, }; }