Files
HMDesign/packages/org-chart/src/core/employee-info-dialog.ts
T

118 lines
5.0 KiB
TypeScript

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<HTMLElement>('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 });
},
};
}