118 lines
5.0 KiB
TypeScript
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 });
|
|
},
|
|
};
|
|
}
|