feat(org-chart): support external CSS themes and align documentation

This commit is contained in:
shkim4 committed 2026-10-13 22:43:21 +09:00
1 parent dcfa4e0895
commit 346c648c09
27 files changed
+1189 -75

No files matched your search

@@ -0,0 +1,2 @@
@import './external-theme.css';
:host { --hm-orgchart-group-title-background: #613e8b; }
@@ -0,0 +1,44 @@
/* Served to stylesheetUrls.core: imports the complete default package stylesheet.
Vite resolves this public export; plain servers use their copied /assets/style.css.
To change a theme, edit only values in this file. No !important or inline colors. */
@import '@hm-design/org-chart/style.css';
:host {
--hm-orgchart-bg-surface: #edf4ff;
--hm-orgchart-bg-content: #dce8f8;
--hm-orgchart-fg-primary: #172b4d;
--hm-orgchart-stroke-default: #8b9bb0;
--hm-orgchart-company-hanmac: #2457a7;
--hm-orgchart-company-saman: #385ca8;
--hm-orgchart-company-jangheon-industry: #14786b;
--hm-orgchart-company-jangheon: #503a91;
--hm-orgchart-company-ptc: #755024;
--hm-orgchart-company-halla: #207879;
--hm-orgchart-company-baron: #4d6295;
--hm-orgchart-group-title-color: #fff4d6;
--hm-orgchart-card-background: #f4f8ff;
--hm-orgchart-leader-card-background: #e3eeff;
--hm-orgchart-top-leader-card-background: #d2e3ff;
--hm-orgchart-card-border: #527aa9;
--hm-orgchart-card-hover-background: #c1d8f8;
--hm-orgchart-card-focus-color: #7650a5;
--hm-orgchart-expanded-line-color: #249a79;
--hm-orgchart-fixed-line-color: #297ab0;
--hm-orgchart-business-management-gradient: linear-gradient(180deg, #2457a7, #123568);
--hm-orgchart-business-management-border: #2457a7;
--hm-orgchart-business-planning-gradient: linear-gradient(180deg, #385ca8, #23417f);
--hm-orgchart-business-planning-border: #385ca8;
--hm-orgchart-dispatched-card-gradient: linear-gradient(180deg, #e9f9f0, #a9d8bd);
--hm-orgchart-dispatched-card-border: #278857;
--hm-orgchart-dispatched-card-color: #145d39;
--hm-orgchart-dispatched-title-background: linear-gradient(180deg, #328c61, #1e5b3d);
--hm-orgchart-on-leave-card-gradient: linear-gradient(180deg, #f7efff, #d6bbeb);
--hm-orgchart-on-leave-card-border: #8050a1;
--hm-orgchart-on-leave-card-color: #603080;
--hm-orgchart-on-leave-title-background: linear-gradient(180deg, #8d66b3, #573771);
--hm-orgchart-dialog-background: #eef2ff;
--hm-orgchart-dialog-color: #283568;
--hm-orgchart-dialog-border: #6879ba;
--hm-orgchart-dialog-close-color: #36478a;
--hm-orgchart-dialog-close-hover-background: #dce2f9;
--hm-orgchart-dialog-focus-color: #8958ba;
}
+7
View File
@@ -0,0 +1,7 @@
/* Loaded by the host document. Colors inherit; Shadow selectors stay private. */
.orgchart-custom-theme {
--hm-orgchart-company-hanmac: #2457a7;
--hm-orgchart-company-saman: #385ca8;
--hm-orgchart-expanded-line-color: #249a79;
--hm-orgchart-bg-surface: #edf4ff;
}
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,"><title>OrgChart P11 theme verification</title><body><main id="root"></main><script type="module" src="./p11.tsx"></script></body></html>
+34
View File
@@ -0,0 +1,34 @@
// Isolated consumer fixture; no Storybook registration or production controls.
import { createRoot } from 'react-dom/client';
import { OrgChart } from '@hm-design/org-chart-react';
import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart';
import coreUrl from '@hm-design/org-chart/style.css?url';
import engineUrl from '@hm-design/org-chart/engine.css?url';
import externalThemeUrl from './external-theme.css?url';
import hostThemeUrl from './host-theme.css?url';
import groupThemeUrl from './external-group-theme.css?url';
import autoData from '../org-chart/data-expanded-small.json';
import allCompanies from '../org-chart/data-expanded-large.json';
import fixedData from '../org-chart/data-p1.json';
const query = new URLSearchParams(location.search);
const mode = query.get('mode') ?? 'auto';
const data = structuredClone(mode === 'auto' ? query.has('all') ? allCompanies : autoData : fixedData);
if (mode === 'auto' && query.has('statuses')) {
data.employees.filter(employee => !data.organizations.some(org => org.leaderId === employee.id)).slice(0, 2).forEach((employee, index) => { employee.workStatus = index ? 'onLeave' : 'dispatched'; });
}
const options = {
dataSource: new JsonOrgChartDataSource(data),
dragDropEnabled: query.has('drag'),
stylesheetUrls: { core: query.has('groups') ? groupThemeUrl : query.has('theme') ? externalThemeUrl : coreUrl, engine: engineUrl },
...(mode === 'auto' ? { layoutMode: 'auto' as const, presentationMode: 'chart-only' as const, initialView: 'fit' as const, wheelZoom: 'direct' as const } : { displayMode: mode === 'fixed' ? 'readOnly' as const : 'interactive' as const }),
};
document.body.style.margin = '0';
Object.assign(window, { p11HostThemeUrl: hostThemeUrl });
const container = document.getElementById('root')!;
container.style.height = '850px';
if (query.get('runtime') === 'vanilla') {
const chart = createOrgChart({ container, ...options });
void chart.mount();
Object.assign(window, { p11Chart: chart });
} else createRoot(container).render(<OrgChart {...options} />);
@@ -0,0 +1,35 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import assert from 'node:assert/strict';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const output = path.resolve('test-results/org-chart-p11');
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [], checks = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
try {
for (const [id, selector] of [
['full-composition', '.hm-orgchart-composition[data-display-mode="readOnly"]'],
['expanded-org-chart', '.hm-orgchart-expanded'],
['department-colors', '.hm-orgchart-department-colors'],
['logo-personnel-summary', '.hm-orgchart-affiliation-mark'],
]) {
await page.goto(`http://127.0.0.1:6034/iframe.html?id=orgchart--${id}&viewMode=story`);
await page.locator(selector).first().waitFor();
await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => setTimeout(resolve, 800)); });
if (id === 'department-colors') {
const gradients = await page.locator('.hm-orgchart-color-preview .hm-orgchart-color-department-name').evaluateAll(headings => headings.map(heading => getComputedStyle(heading).backgroundImage));
assert.equal(gradients.length, 6);
assert.ok(gradients.every(gradient => gradient !== 'none'));
assert.equal(new Set(gradients).size, 6);
}
await page.screenshot({ path: path.join(output, `storybook-${id}.png`) });
checks.push({ name: id, status: 'PASS' });
}
assert.deepEqual(errors, []);
} finally {
await fs.writeFile(path.join(output, 'storybook-result.json'), JSON.stringify({ checks, errors }, null, 2));
await browser.close();
}
console.log(`Storybook: ${checks.length} checks, ${errors.length} errors`);
+184
View File
@@ -0,0 +1,184 @@
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
import path from 'node:path';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const output = path.resolve('test-results/org-chart-p11');
await fs.mkdir(output, { recursive: true });
const browser = await launchChromium({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [], checks = [], measurements = {};
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
const baseline = process.argv.includes('--baseline');
const origin = process.env.ORGCHART_P11_ORIGIN ?? 'http://127.0.0.1:6026';
async function open(runtime, mode, theme = false, extra = '') {
await page.goto(`${origin}/p11.html?runtime=${runtime}&mode=${mode}${theme ? '&theme=1' : ''}${extra}`);
await page.locator('.hm-orgchart-employee').first().waitFor();
await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link')].every(link => link.sheet));
await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => setTimeout(resolve, 600)); });
}
async function styles() {
return page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const selectors = '.hm-orgchart-shell,.hm-orgchart-viewport,.hm-orgchart-organization-name,.hm-orgchart-employee,.hm-orgchart-status-group > .hm-orgchart-summary-title,.hm-orgchart-employee-dialog,.hm-orgchart-employee-dialog-close';
const fields = ['backgroundColor', 'backgroundImage', 'color', 'borderTopColor', 'borderRightColor', 'borderTopWidth', 'boxShadow', 'width', 'height', 'fontSize', 'padding', 'borderRadius'];
return [...shadow.querySelectorAll(selectors)].map(el => ({ tag: el.tagName, class: el.className, company: el.dataset.companyId ?? el.closest('[data-company-id]')?.dataset.companyId, kind: el.dataset.cardKind, status: el.dataset.workStatus, css: Object.fromEntries(fields.map(field => [field, getComputedStyle(el)[field]])) }));
});
}
async function check(name, run) { await run(); checks.push({ name, status: 'PASS' }); }
try {
for (const runtime of ['vanilla', 'react']) for (const mode of ['auto', 'fixed', 'interactive']) {
const key = `${runtime}-${mode}`;
await open(runtime, mode);
measurements[key] = await styles();
await page.screenshot({ path: path.join(output, `${baseline ? 'before' : 'after'}-${key}.png`) });
if (!baseline) {
const before = JSON.parse(await fs.readFile(path.join(output, 'baseline.json'), 'utf8'));
await check(`${key}: default colors and geometry identical`, () => assert.equal(JSON.stringify(measurements[key]), JSON.stringify(before[key])));
}
// Popup is captured separately so it cannot obscure the chart screenshot.
await page.locator('.hm-orgchart-employee').first().click();
await page.locator('.hm-orgchart-employee-dialog[open]').waitFor();
measurements[`${key}-popup`] = (await styles()).filter(row => row.class.includes('dialog'));
if (!baseline) {
const before = JSON.parse(await fs.readFile(path.join(output, 'baseline.json'), 'utf8'));
await check(`${key}: default popup identical`, () => assert.equal(JSON.stringify(measurements[`${key}-popup`]), JSON.stringify(before[`${key}-popup`])));
}
}
if (baseline) await fs.writeFile(path.join(output, 'baseline.json'), JSON.stringify(measurements, null, 2));
else {
const expectedCompanies = { hanmac: 'rgb(36, 87, 167)', saman: 'rgb(56, 92, 168)', 'jangheon-industry': 'rgb(20, 120, 107)' };
const value = (selector, property, pseudo = null) => page.locator(selector).first().evaluate((el, { property, pseudo }) => getComputedStyle(el, pseudo)[property], { property, pseudo });
for (const runtime of ['vanilla', 'react']) {
await open(runtime, 'auto', true);
for (const [id, color] of Object.entries(expectedCompanies)) {
await check(`${runtime}: external CSS ${id} title`, async () => assert.equal(await value(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`, 'backgroundColor'), color));
await check(`${runtime}: external CSS ${id} card accent`, async () => assert.equal(await value(`.hm-orgchart-employee[data-company-id="${id}"]`, 'borderTopColor'), color));
}
await check(`${runtime}: canvas and title text`, async () => {
assert.equal(await value('.hm-orgchart-viewport', 'backgroundColor'), 'rgb(237, 244, 255)');
assert.equal(await value('.hm-orgchart-organization-name', 'color'), 'rgb(255, 244, 214)');
});
await check(`${runtime}: independent card roles and border`, async () => {
assert.equal(await value('.hm-orgchart-employee[data-card-kind="employee"]', 'backgroundColor'), 'rgb(244, 248, 255)');
assert.equal(await value('.hm-orgchart-employee[data-card-kind="topLeader"]', 'backgroundColor'), 'rgb(210, 227, 255)');
assert.equal(await value('.hm-orgchart-employee', 'borderRightColor'), 'rgb(82, 122, 169)');
});
await check(`${runtime}: employee and organization connector theme`, async () => {
assert.equal(await value('.hm-orgchart-employee-connectors path', 'stroke'), 'rgb(36, 154, 121)');
assert.equal(await value('.hm-orgchart-engine > ul > li > ul li > .node', 'backgroundColor', '::before'), 'rgb(36, 154, 121)');
});
const employee = page.locator('.hm-orgchart-employee[data-card-kind="employee"]').first();
await employee.hover();
await check(`${runtime}: actual card hover`, async () => assert.equal(await value('.hm-orgchart-employee[data-card-kind="employee"]', 'backgroundColor'), 'rgb(193, 216, 248)'));
await page.mouse.move(0, 0); await employee.focus();
await check(`${runtime}: actual card focus`, async () => assert.equal(await value('.hm-orgchart-employee[data-card-kind="employee"]', 'outlineColor'), 'rgb(118, 80, 165)'));
await employee.press('Enter');
await page.locator('.hm-orgchart-employee-dialog[open]').waitFor();
await check(`${runtime}: Core popup background, text and border`, async () => {
assert.equal(await value('.hm-orgchart-employee-dialog', 'backgroundColor'), 'rgb(238, 242, 255)');
assert.equal(await value('.hm-orgchart-employee-dialog', 'color'), 'rgb(40, 53, 104)');
assert.equal(await value('.hm-orgchart-employee-dialog', 'borderTopColor'), 'rgb(104, 121, 186)');
});
const close = page.locator('.hm-orgchart-employee-dialog-close');
await close.hover(); await page.waitForTimeout(200);
await check(`${runtime}: popup hover`, async () => assert.equal(await value('.hm-orgchart-employee-dialog-close', 'backgroundColor'), 'rgb(220, 226, 249)'));
await page.mouse.move(0, 0); await close.focus(); await page.waitForTimeout(200);
await check(`${runtime}: popup focus`, async () => assert.equal(await value('.hm-orgchart-employee-dialog-close', 'borderTopColor'), 'rgb(137, 88, 186)'));
await page.keyboard.press('Escape');
await check(`${runtime}: popup Escape and focus restoration`, async () => assert.equal(await employee.evaluate(el => el.getRootNode().activeElement === el), true));
await page.screenshot({ path: path.join(output, `custom-${runtime}-auto.png`) });
await open(runtime, 'fixed', true);
await check(`${runtime}: Fixed headers and background`, async () => {
assert.match(await value('[data-group-variant="0"] .hm-orgchart-organization-name', 'backgroundImage'), /rgb\(36, 87, 167\)/);
assert.equal(await value('.hm-orgchart-composition', 'backgroundColor'), 'rgb(220, 232, 248)');
assert.equal(await value('.hm-orgchart-workdesk-overlay path[data-connector]', 'stroke'), 'rgb(41, 122, 176)');
});
for (const [status, color] of [['dispatched', 'rgb(39, 136, 87)'], ['onLeave', 'rgb(128, 80, 161)']]) {
await check(`${runtime}: ${status} external gradient and border`, async () => {
const selector = `.hm-orgchart-status-group[data-work-status="${status}"] .hm-orgchart-employee`;
assert.equal(await value(selector, 'borderTopColor'), color);
assert.notEqual(await value(selector, 'backgroundImage'), 'none');
});
}
await page.screenshot({ path: path.join(output, `custom-${runtime}-fixed.png`) });
await open(runtime, 'auto');
const unthemed = await styles();
await page.evaluate(() => {
const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = window.p11HostThemeUrl; link.id = 'host-theme'; document.head.append(link);
document.querySelector('#root').classList.add('orgchart-custom-theme');
});
await page.waitForFunction(() => document.querySelector('#host-theme').sheet);
await check(`${runtime}: host CSS crosses Shadow boundary without inline conflict`, async () => {
assert.equal(await value('.node[data-company-id="hanmac"] .hm-orgchart-organization-name', 'backgroundColor'), expectedCompanies.hanmac);
assert.equal(await value('.node[data-company-id="saman"] .hm-orgchart-organization-name', 'backgroundColor'), expectedCompanies.saman);
assert.equal(await value('.hm-orgchart-viewport', 'backgroundColor'), 'rgb(237, 244, 255)');
});
await page.evaluate(() => document.querySelector('#root').classList.remove('orgchart-custom-theme'));
await check(`${runtime}: removing host theme restores defaults immediately`, async () => assert.equal(JSON.stringify(await styles()), JSON.stringify(unthemed)));
await open(runtime, 'auto', false, '&all=1');
await check(`${runtime}: all seven approved default company colors`, async () => {
for (const [id, color] of Object.entries({ hanmac: 'rgb(203, 46, 40)', saman: 'rgb(240, 131, 0)', 'jangheon-industry': 'rgb(69, 128, 166)', jangheon: 'rgb(29, 65, 141)', ptc: 'rgb(132, 51, 142)', halla: 'rgb(0, 104, 183)', baron: 'rgb(12, 54, 127)' })) assert.equal(await value(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`, 'backgroundColor'), color);
});
await open(runtime, 'auto', true, '&all=1&statuses=1&drag=1');
for (const [id, color] of Object.entries({ ...expectedCompanies, jangheon: 'rgb(80, 58, 145)', ptc: 'rgb(117, 80, 36)', halla: 'rgb(32, 120, 121)', baron: 'rgb(77, 98, 149)' })) {
await check(`${runtime}: seven-company theme ${id}`, async () => assert.equal(await value(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`, 'backgroundColor'), color));
}
await check(`${runtime}: Auto status cards use external gradients and borders`, async () => {
assert.equal(await value('.hm-orgchart-employee[data-work-status="dispatched"]', 'borderRightColor'), 'rgb(39, 136, 87)');
assert.match(await value('.hm-orgchart-employee[data-work-status="onLeave"]', 'backgroundImage'), /rgb\(214, 187, 235\)/);
});
await check(`${runtime}: group leader has an independent background`, async () => assert.equal(await value('.hm-orgchart-employee[data-card-kind="leader"]', 'backgroundColor'), 'rgb(227, 238, 255)'));
await page.evaluate(() => {
const shadow = document.querySelector('.hm-orgchart-host').shadowRoot;
const removeHover = rules => { for (const rule of rules) { rule.style?.removeProperty('--hm-orgchart-card-hover-background'); if (rule.styleSheet) removeHover(rule.styleSheet.cssRules); if (rule.cssRules) removeHover(rule.cssRules); } };
for (const link of shadow.querySelectorAll('link')) removeHover(link.sheet.cssRules);
});
const statusCard = page.locator('.hm-orgchart-employee[data-work-status="dispatched"]').first();
const statusGradient = await value('.hm-orgchart-employee[data-work-status="dispatched"]', 'backgroundImage');
await statusCard.hover();
await check(`${runtime}: unspecified hover preserves status gradient`, async () => assert.equal(await value('.hm-orgchart-employee[data-work-status="dispatched"]', 'backgroundImage'), statusGradient));
await check(`${runtime}: Drag & Drop ON`, async () => assert.equal(await page.locator('.hm-orgchart-viewport').getAttribute('data-drag-drop-enabled'), 'true'));
await open(runtime, 'auto', true);
await check(`${runtime}: Drag & Drop OFF`, async () => assert.equal(await page.locator('.hm-orgchart-viewport').getAttribute('data-drag-drop-enabled'), 'false'));
await open(runtime, 'auto', true, '&groups=1');
await check(`${runtime}: independent group background via external CSS file`, async () => assert.equal(await value('.hm-orgchart-organization-name', 'backgroundColor'), 'rgb(97, 62, 139)'));
await open(runtime, 'auto', true);
const forest = page.locator('.hm-orgchart-forest');
const transform = await forest.evaluate(el => el.style.transform);
const viewport = await page.locator('.hm-orgchart-viewport').boundingBox();
const mouseX = viewport.x + viewport.width - 12, mouseY = viewport.y + viewport.height - 12;
await page.mouse.move(mouseX, mouseY); await page.mouse.wheel(0, -150); await page.waitForTimeout(150);
await check(`${runtime}: direct wheel zoom`, async () => assert.notEqual(await forest.evaluate(el => el.style.transform), transform));
const zoomed = await forest.evaluate(el => el.style.transform);
await page.mouse.move(mouseX, mouseY); await page.mouse.down(); await page.mouse.move(mouseX - 50, mouseY - 50, { steps: 5 }); await page.mouse.up();
await check(`${runtime}: pan`, async () => assert.notEqual(await forest.evaluate(el => el.style.transform), zoomed));
await open(runtime, 'auto', true);
const collapse = page.locator('.hm-orgchart-expanded-toggle').first();
await collapse.click();
await check(`${runtime}: collapse`, async () => assert.equal(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded'), 'false'));
await page.locator('.hm-orgchart-expanded-toggle').first().click();
await check(`${runtime}: expand`, async () => assert.equal(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded'), 'true'));
for (const width of [390, 768, 1280, 1440]) {
await page.setViewportSize({ width, height: 900 });
await open(runtime, 'auto', true);
await check(`${runtime}: ${width}px canvas fits and document does not overflow`, async () => {
const box = await page.locator('.hm-orgchart-viewport').boundingBox();
assert.equal(box.width, width);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
});
await open(runtime, 'fixed', true);
await check(`${runtime}: ${width}px Fixed document does not overflow`, async () => assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true));
}
}
}
assert.deepEqual(errors, [], 'No browser errors');
} finally {
await fs.writeFile(path.join(output, baseline ? 'baseline-result.json' : 'browser-result.json'), JSON.stringify({ checks, errors, measurements }, null, 2));
await browser.close();
}
console.log(`${baseline ? 'Baseline' : 'P11'}: ${checks.length} checks, ${errors.length} errors`);
+1 -1
View File
@@ -7,5 +7,5 @@ export default defineConfig({
base: './',
plugins: [react()],
server: { host: '127.0.0.1', port: 6026, strictPort: true },
build: { outDir: 'dist', emptyOutDir: true, rolldownOptions: { input: { index: path.resolve(import.meta.dirname, 'index.html'), p1: path.resolve(import.meta.dirname, 'p1.html') } } },
build: { outDir: 'dist', emptyOutDir: true, rolldownOptions: { input: { index: path.resolve(import.meta.dirname, 'index.html'), p1: path.resolve(import.meta.dirname, 'p1.html'), p11: path.resolve(import.meta.dirname, 'p11.html') } } },
});