feat(org-chart): finalize chart features and AI generation rules
This commit is contained in:
1 parent
7614507583
commit
d0828a7ff4
80 files changed
+8152
-221
No files matched your search
@@ -0,0 +1,58 @@
|
||||
/** Story-only alignment of the entire transformed Workdesk stage in the visible
|
||||
* Content Wrapper's content box. Core retains its proportional width/height fit. */
|
||||
export function centerCompositionPreview(host: HTMLElement): () => void {
|
||||
let frame: number | undefined;
|
||||
let shadow: ShadowRoot | undefined;
|
||||
let stage: HTMLElement | undefined;
|
||||
const schedule = () => { frame ??= requestAnimationFrame(align); };
|
||||
const resize = new ResizeObserver(schedule);
|
||||
const mutation = new MutationObserver(schedule);
|
||||
const outer = new MutationObserver(schedule);
|
||||
const align = () => {
|
||||
frame = undefined;
|
||||
const nextShadow = host.querySelector('.hm-orgchart-host')?.shadowRoot;
|
||||
if (!nextShadow) return;
|
||||
if (shadow !== nextShadow) {
|
||||
mutation.disconnect();
|
||||
shadow = nextShadow;
|
||||
mutation.observe(shadow, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'data-layout'] });
|
||||
}
|
||||
const wrapper = shadow.querySelector<HTMLElement>('.hm-orgchart-composition');
|
||||
const nextStage = shadow.querySelector<HTMLElement>('.hm-orgchart-stage[data-workdesk-artwork]');
|
||||
if (!wrapper || !nextStage) return;
|
||||
if (stage !== nextStage) {
|
||||
if (stage) resize.unobserve(stage);
|
||||
stage = nextStage;
|
||||
resize.observe(stage);
|
||||
}
|
||||
const container = host.getBoundingClientRect();
|
||||
const css = getComputedStyle(host);
|
||||
const inset = (key: 'borderLeftWidth' | 'borderRightWidth' | 'paddingLeft' | 'paddingRight') => parseFloat(css[key]) || 0;
|
||||
const left = container.left + inset('borderLeftWidth') + inset('paddingLeft');
|
||||
const right = container.right - inset('borderRightWidth') - inset('paddingRight');
|
||||
const bounds = stage.getBoundingClientRect();
|
||||
if (right <= left || !bounds.width) return;
|
||||
// The stage keeps its unscaled layout width, so centering that width with
|
||||
// flex/grid would misplace Core's transformed rectangle. Measure the actual
|
||||
// rendered rectangle instead; translate it as one unit without changing the
|
||||
// transform, scale, 1920 x 914.5 canvas or any stage-relative coordinates.
|
||||
const difference = (left + right - bounds.left - bounds.right) / 2;
|
||||
// Fractional proportional scales can alternate the rounded correction by
|
||||
// 0.0001px. Keep the same content-box calculation without a feedback loop.
|
||||
if (Math.abs(difference) < 0.001) return;
|
||||
const translation = `${Number(((parseFloat(stage.style.translate) || 0) + difference).toFixed(4))}px`;
|
||||
if (stage.style.translate !== translation) stage.style.translate = translation;
|
||||
};
|
||||
resize.observe(host);
|
||||
// A padding redistribution can move the content center without resizing it.
|
||||
outer.observe(host, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });
|
||||
window.addEventListener('resize', schedule);
|
||||
document.fonts.addEventListener('loadingdone', schedule);
|
||||
schedule();
|
||||
return () => {
|
||||
resize.disconnect(); mutation.disconnect(); outer.disconnect();
|
||||
window.removeEventListener('resize', schedule);
|
||||
document.fonts.removeEventListener('loadingdone', schedule);
|
||||
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||
};
|
||||
}
|
||||
@@ -3,28 +3,39 @@ import { useLayoutEffect, useRef } from 'react';
|
||||
import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '@hm-design/org-chart';
|
||||
import engineUrl from '@hm-design/org-chart/engine.css?url';
|
||||
import coreUrl from '@hm-design/org-chart/style.css?url';
|
||||
import centerPreviewUrl from '../center/center-stories.css?url';
|
||||
import departmentPreviewUrl from './department-colors-preview.css?url';
|
||||
import { createEmployeeCard, type EmployeeCardOptions } from '../../packages/org-chart/src/core/employee-card';
|
||||
import { decorateNode, engineTree, type RenderContext } from '../../packages/org-chart/src/core/render';
|
||||
import { applyOrganizationStyle } from '../../packages/org-chart/src/core/style-presets';
|
||||
import { createPositionSummary } from '../../packages/org-chart/src/core/position-summary';
|
||||
import { createAffiliationSummary } from '../../packages/org-chart/src/core/affiliation-summary';
|
||||
import { createOrganizationTotals } from '../../packages/org-chart/src/core/organization-totals';
|
||||
import { createAffiliationBar } from '../../packages/org-chart/src/core/affiliation-bar';
|
||||
import { createStatusSummary } from '../../packages/org-chart/src/core/status-summary';
|
||||
import { createLogoLegend } from '../../packages/org-chart/src/core/logo-legend';
|
||||
import { createDepartmentColors, updateDepartmentColorValues } from '../../packages/org-chart/src/core/department-colors';
|
||||
import fixture from '../../examples/org-chart/data-p1.json';
|
||||
|
||||
type PreviewKind = 'Logo' | 'DepartmentColors' | 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary' | 'EmployeeCard' | 'OrganizationCards';
|
||||
interface Props { kind: PreviewKind; data?: unknown; variant?: number; cardKind?: EmployeeCardOptions['kind'] }
|
||||
interface Props { kind: PreviewKind; data?: unknown; variant?: number; cardKind?: EmployeeCardOptions['kind']; presentation?: 'standalone' | 'workdesk' | 'design' }
|
||||
|
||||
/** Story host only. Every pixel/card/count is rendered by the same Core modules
|
||||
* used by Vanilla HTML and the production React Wrapper, not JSX copies. */
|
||||
export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKind = 'employee' }: Props) {
|
||||
export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKind = 'employee', presentation = 'standalone' }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const validation = validateOrgChartDataset(data);
|
||||
const host = ref.current!;
|
||||
if (!validation.valid) { host.textContent = validation.issues.map(issue => issue.code).join(', '); return; }
|
||||
const dataset = validation.data;
|
||||
const compactDepartmentPreview = kind === 'DepartmentColors' && presentation === 'design';
|
||||
const sampleName = '홍길동';
|
||||
const displayedSampleName = [...sampleName].join(' ');
|
||||
// Department-only display data: preserve original identity/roles/membership.
|
||||
// Format before Core rendering so the shared overflow tooltip has the same name.
|
||||
const dataset = compactDepartmentPreview
|
||||
? { ...validation.data, employees: validation.data.employees.map(employee => ({ ...employee, name: displayedSampleName })) }
|
||||
: validation.data;
|
||||
const normalized = normalizeOrgChartDataset(dataset);
|
||||
const context: RenderContext = {
|
||||
data: dataset, summary: calculateSummary(dataset), orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization,
|
||||
@@ -35,17 +46,18 @@ export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKi
|
||||
const root = document.createElement('div');
|
||||
root.className = 'hm-orgchart-component-host';
|
||||
const shadow = root.attachShadow({ mode: 'open' });
|
||||
for (const href of [engineUrl, coreUrl]) {
|
||||
for (const href of [engineUrl, coreUrl, ...(compactDepartmentPreview ? [centerPreviewUrl, departmentPreviewUrl] : [])]) {
|
||||
const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; shadow.append(link);
|
||||
link.addEventListener('load', () => updateDepartmentColorValues(shell), { signal: controller.signal });
|
||||
if (!compactDepartmentPreview) link.addEventListener('load', () => updateDepartmentColorValues(shell), { signal: controller.signal });
|
||||
}
|
||||
const shell = document.createElement('section');
|
||||
shell.className = 'hm-orgchart-shell';
|
||||
// Share Fixed's passive presenters/styles without its stage coordinates.
|
||||
shell.className = presentation === 'workdesk' ? 'hm-orgchart-shell hm-orgchart-summary-preview' : 'hm-orgchart-shell';
|
||||
let component: HTMLElement;
|
||||
if (kind === 'Logo') component = createLogoLegend(document, context, controller.signal);
|
||||
else if (kind === 'DepartmentColors') component = createDepartmentColors(document, context, controller.signal);
|
||||
else if (kind === 'PositionSummary') component = createPositionSummary(document, context);
|
||||
else if (kind === 'AffiliationSummary') component = createAffiliationSummary(document, context);
|
||||
else if (kind === 'PositionSummary') component = presentation === 'workdesk' ? createOrganizationTotals(document, context) : createPositionSummary(document, context);
|
||||
else if (kind === 'AffiliationSummary') component = presentation === 'workdesk' ? createAffiliationBar(document, context, controller.signal) : createAffiliationSummary(document, context);
|
||||
else if (kind === 'StatusSummary') component = createStatusSummary(document, context, controller.signal);
|
||||
else if (kind === 'OrganizationCards') {
|
||||
const organization = normalized.roots[0]?.children[variant]?.organization ?? normalized.roots[0]!.organization;
|
||||
@@ -70,12 +82,42 @@ export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKi
|
||||
component = createEmployeeCard(document, employee ?? dataset.employees[0]!, { kind: cardKind, affiliationName: dataset.affiliations.find(item => item.id === employee?.affiliationId)?.name }, controller.signal);
|
||||
applyOrganizationStyle(component, variant);
|
||||
}
|
||||
if (compactDepartmentPreview) {
|
||||
// Keep the representative and department cards built by the shared Core.
|
||||
// The Tunnel review frame styles wrappers only; approved artwork stays intact.
|
||||
shell.classList.add('sb-center', 'hm-orgchart-department-preview');
|
||||
const representative = component.querySelector<HTMLElement>(':scope > .hm-orgchart-representative-preview');
|
||||
if (representative) {
|
||||
const card = representative.querySelector<HTMLElement>('[data-card-kind="topLeader"]')!;
|
||||
const header = document.createElement('header');
|
||||
const title = document.createElement('h2'); title.textContent = '대표자 카드';
|
||||
header.append(title);
|
||||
const stage = document.createElement('div'); stage.className = 'tunnel-reference__stage hm-orgchart-department-stage';
|
||||
stage.append(card);
|
||||
representative.classList.add('tunnel-reference__sample');
|
||||
representative.replaceChildren(header, stage);
|
||||
}
|
||||
const rows = Array.from(component.querySelectorAll<HTMLElement>(':scope > .hm-orgchart-color-preview'));
|
||||
for (const row of rows) {
|
||||
const heading = row.querySelector<HTMLElement>('.hm-orgchart-color-department-name')!;
|
||||
const cards = row.querySelector<HTMLElement>('.hm-orgchart-preview-grid')!;
|
||||
for (const caption of cards.querySelectorAll('.hm-orgchart-card-caption')) caption.remove();
|
||||
const header = document.createElement('header'); header.append(heading);
|
||||
const stage = document.createElement('div'); stage.className = 'tunnel-reference__stage hm-orgchart-department-stage';
|
||||
stage.append(cards);
|
||||
row.classList.add('tunnel-reference__sample');
|
||||
row.replaceChildren(header, stage);
|
||||
}
|
||||
component.replaceChildren(...(representative ? [representative] : []), ...rows);
|
||||
component.classList.add('hm-orgchart-department-colors-design', 'tunnel-reference__variant-grid');
|
||||
for (const name of component.querySelectorAll<HTMLElement>('.hm-orgchart-person-name')) name.setAttribute('aria-label', sampleName);
|
||||
}
|
||||
component.dataset.component = kind;
|
||||
shell.append(component); shadow.append(shell); host.replaceChildren(root);
|
||||
updateDepartmentColorValues(shell);
|
||||
const observer = kind === 'DepartmentColors' ? new ResizeObserver(() => updateDepartmentColorValues(shell)) : undefined;
|
||||
if (!compactDepartmentPreview) updateDepartmentColorValues(shell);
|
||||
const observer = kind === 'DepartmentColors' && !compactDepartmentPreview ? new ResizeObserver(() => updateDepartmentColorValues(shell)) : undefined;
|
||||
observer?.observe(root);
|
||||
return () => { observer?.disconnect(); controller.abort(); root.remove(); };
|
||||
}, [kind, data, variant, cardKind]);
|
||||
}, [kind, data, variant, cardKind, presentation]);
|
||||
return <div ref={ref} className="hm-orgchart-story-chart" />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Department Colors Storybook adapter only. The actual Tunnel sample/stage
|
||||
frame comes from center-stories.css; this sheet never selects employee cards. */
|
||||
.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sb-center-column-gap);
|
||||
}
|
||||
|
||||
.hm-orgchart-department-preview .hm-orgchart-color-preview {
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-row: auto;
|
||||
}
|
||||
|
||||
.hm-orgchart-department-preview .hm-orgchart-representative-preview {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Group titles are OrgChart artwork. Preserve their established typography,
|
||||
white text and organization gradient within the common review header. */
|
||||
.hm-orgchart-department-preview .hm-orgchart-color-preview > header > .hm-orgchart-color-department-name {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
color: var(--hm-orgchart-current-department-color);
|
||||
font-size: var(--hm-orgchart-title-font-size, 18px);
|
||||
line-height: var(--hm-orgchart-title-line-height, 26px);
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
}
|
||||
|
||||
.hm-orgchart-department-preview .hm-orgchart-department-stage {
|
||||
display: grid;
|
||||
justify-items: safe center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hm-orgchart-department-preview .hm-orgchart-preview-grid {
|
||||
margin-top: 0;
|
||||
gap: var(--sb-center-column-gap);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { StrictMode } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { DepartmentTypographyPreview } from './org-chart-page-previews';
|
||||
|
||||
const meta = { title: 'OrgChart/Department Colors', component: DepartmentTypographyPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof DepartmentTypographyPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const DepartmentColors: Story = { name: 'Department Colors', parameters: { __id: 'orgchart--department-colors' } };
|
||||
@@ -0,0 +1,66 @@
|
||||
/* Existing Expanded Story content layout; HM widget styles stay unchanged. */
|
||||
/* Reuse the existing Storybook review card surface through inherited inputs.
|
||||
Only the outer Auto preview consumes these; its controls/artwork stay intact. */
|
||||
.hm-orgchart-story-expanded .hm-orgchart-story-preview.sb-center {
|
||||
--hm-orgchart-expanded-preview-background: var(--sb-center-card-background);
|
||||
--hm-orgchart-expanded-preview-border: var(--sb-center-card-border);
|
||||
--hm-orgchart-expanded-preview-radius: var(--sb-center-card-radius);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hm-orgchart-story-expanded .hm-orgchart-story-settings {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
/* StorySection supplies the visible heading; keep each fieldset named for assistive technology. */
|
||||
.hm-orgchart-story-expanded .hm-orgchart-story-section-legend {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.hm-orgchart-story-expanded .hm-orgchart-story-controls {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.hm-orgchart-story-expanded .hm-orgchart-story-instance {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
.hm-orgchart-story-expanded .hm-orgchart-story-popup-setting {
|
||||
flex: 0 1 24rem;
|
||||
width: 100%;
|
||||
}
|
||||
.hm-orgchart-story-expanded .hm-orgchart-story-companies { margin: 0; }
|
||||
.hm-orgchart-story-expanded .hm-orgchart-story-company-trigger {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.hm-orgchart-story-expanded .hm-orgchart-story-company-summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
}
|
||||
/* Popover is portaled outside StoryPage. Only this Story's content layout is
|
||||
composed here; the HM panel surface, padding and widget states stay intact. */
|
||||
.hm-orgchart-story-company-popover {
|
||||
max-height: var(--radix-popover-content-available-height);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.hm-orgchart-story-company-options {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1,54 +1,254 @@
|
||||
import { StrictMode, useId, useState } from 'react';
|
||||
import { StrictMode, useEffect, useId, useMemo, useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button, Checkbox, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@hm-design/ui';
|
||||
import { OrgChart } from '@hm-design/org-chart-react';
|
||||
import { companyColorPresets, JsonOrgChartDataSource, type OrgChartConnectionDirection } from '@hm-design/org-chart';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import {
|
||||
Button, Checkbox, FilterBar, FilterBarActions, Label,
|
||||
Popover, PopoverContent, PopoverTrigger, Separator,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch,
|
||||
} from '@hm-design/ui';
|
||||
import { OrgChart, type OrgChartProps } from '@hm-design/org-chart-react';
|
||||
import {
|
||||
companyColorPresets, createOrgChartExampleDataset, JsonOrgChartDataSource,
|
||||
type CompanyId, type OrgChartAutoLayoutOptions, type OrgChartDataset, type OrgChartExampleSelection, type OrgChartInteractionOptions,
|
||||
} from '@hm-design/org-chart';
|
||||
import engineUrl from '@hm-design/org-chart/engine.css?url';
|
||||
import coreUrl from '@hm-design/org-chart/style.css?url';
|
||||
import small from '../../examples/org-chart/data-expanded-small.json';
|
||||
import medium from '../../examples/org-chart/data-expanded-medium.json';
|
||||
import { StoryPage, StoryPageHeader, StorySection } from '@/components/story-page';
|
||||
import large from '../../examples/org-chart/data-expanded-large.json';
|
||||
import './org-chart-stories.css';
|
||||
import '../center/center-stories.css';
|
||||
import './org-chart-expanded-story.css';
|
||||
|
||||
const fixtures = { small, medium, large };
|
||||
const sources = { small: new JsonOrgChartDataSource(small), medium: new JsonOrgChartDataSource(medium), large: new JsonOrgChartDataSource(large) };
|
||||
const template = large as OrgChartDataset;
|
||||
const companies = Object.values(companyColorPresets);
|
||||
const allCompanies = companies.map(company => company.companyId as CompanyId);
|
||||
const compositionStylesheets = { engine: engineUrl, core: coreUrl };
|
||||
const topNodes = { ptc: { title: '기술총괄', name: '가상책임자 5' } };
|
||||
const connections: Record<string, OrgChartConnectionDirection> = Object.fromEntries(large.organizations.map((org, index) => [org.id, (['top', 'left', 'right'] as const)[index % 3]! ]));
|
||||
type DataSettings = Required<Pick<OrgChartExampleSelection, 'employeeCount' | 'companyIds'>>;
|
||||
const initialSettings: DataSettings = { employeeCount: 20, companyIds: allCompanies };
|
||||
|
||||
/** Company state is passed to the shared Core without filtering/mutating JSON. */
|
||||
/** Existing Expanded Story only: public FilterBar layout/actions match data
|
||||
* conditions. The preview selects samples rather than searching employees. */
|
||||
function ExpandedPreview() {
|
||||
const id = useId();
|
||||
const [size, setSize] = useState<keyof typeof fixtures>('small');
|
||||
const [selected, setSelected] = useState<string[] | undefined>();
|
||||
const companies = fixtures[size].affiliations;
|
||||
const visible = selected ?? companies.map(company => company.id);
|
||||
return <section className="hm-orgchart-story" aria-label="Expanded OrgChart · P5-4">
|
||||
<p className="hm-orgchart-story-note"><strong>확장형 조직도</strong> — 회사별 독립 조직 · 타이틀 클릭으로 접기·펼치기 · 확대 및 드래그 이동. 모든 직원은 가상 데이터입니다.</p>
|
||||
<div className="hm-orgchart-story-controls">
|
||||
<div className="hm-orgchart-story-instance">
|
||||
<Label id={`${id}-data-label`} htmlFor={`${id}-data`}>가상 데이터</Label>
|
||||
<Select value={size} onValueChange={value => { setSize(value as keyof typeof fixtures); setSelected(undefined); }}>
|
||||
<SelectTrigger id={`${id}-data`} aria-labelledby={`${id}-data-label`} size="medium"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="small">Small · 20명</SelectItem><SelectItem value="medium">Medium · 100명</SelectItem><SelectItem value="large">Large · 300명</SelectItem></SelectContent>
|
||||
</Select>
|
||||
const [draft, setDraft] = useState<DataSettings>(initialSettings);
|
||||
const [applied, setApplied] = useState<DataSettings>(initialSettings);
|
||||
const [dataRevision, setDataRevision] = useState(0);
|
||||
const [layout, setLayout] = useState<OrgChartAutoLayoutOptions>({ type: 'tb', hybridStartDepth: 2, initialVisibleDepth: 'all' });
|
||||
const [popup, setPopup] = useState<OrgChartProps['employeePopup']>('hm-dialog');
|
||||
const [interactions, setInteractions] = useState<OrgChartInteractionOptions>({ collapse: true, zoom: true, pan: true });
|
||||
const [dragDropEnabled, setDragDropEnabled] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const dataset = useMemo(() => createOrgChartExampleDataset(template, {
|
||||
scope: 'per-company',
|
||||
employeeCount: applied.employeeCount,
|
||||
// Company selection is view state. Keep every company in the canonical
|
||||
// session source so hiding/revealing a company retains successful moves.
|
||||
companyIds: allCompanies,
|
||||
}), [applied.employeeCount, dataRevision]);
|
||||
const source = useMemo(() => new JsonOrgChartDataSource(dataset), [dataset]);
|
||||
// Core owns edits. This read model updates counts/options without replacing
|
||||
// dataSource and reloading the unchanged fixture over a successful move.
|
||||
const [session, setSession] = useState(() => ({ baseline: dataset, current: dataset }));
|
||||
const currentDataset = session.baseline === dataset ? session.current : dataset;
|
||||
const organizationCompanies = useMemo(() => {
|
||||
const byId = new Map(currentDataset.organizations.map(organization => [organization.id, organization]));
|
||||
const companyById = new Map<string, CompanyId>();
|
||||
for (const organization of currentDataset.organizations) {
|
||||
let current = organization;
|
||||
while (!current.companyId && current.parentId && byId.has(current.parentId)) current = byId.get(current.parentId)!;
|
||||
if (current.companyId) companyById.set(organization.id, current.companyId as CompanyId);
|
||||
}
|
||||
return companyById;
|
||||
}, [currentDataset]);
|
||||
const groupChoices = useMemo(() => {
|
||||
const parents = new Set(currentDataset.organizations.map(organization => organization.parentId));
|
||||
return currentDataset.organizations.filter(organization => parents.has(organization.id)).map(organization => {
|
||||
const companyId = organizationCompanies.get(organization.id);
|
||||
if (!companyId || !applied.companyIds.includes(companyId)) return null;
|
||||
const company = companies.find(item => item.companyId === companyId);
|
||||
const prefix = company ? `${company.companyName} · ` : '';
|
||||
return { id: organization.id, name: organization.name.startsWith(prefix) ? organization.name : `${prefix}${organization.name}` };
|
||||
}).filter((organization): organization is { id: string; name: string } => organization !== null);
|
||||
}, [currentDataset, organizationCompanies, applied.companyIds]);
|
||||
useEffect(() => {
|
||||
const valid = new Set(groupChoices.map(organization => organization.id));
|
||||
setLayout(current => {
|
||||
const previous = current.hybridOrganizationIds ?? [];
|
||||
const ids = previous.filter(organizationId => valid.has(organizationId));
|
||||
return ids.length === previous.length ? current : { ...current, hybridOrganizationIds: ids };
|
||||
});
|
||||
}, [groupChoices]);
|
||||
const predicted = draft.companyIds.length * draft.employeeCount;
|
||||
const displayedTotal = new Set(currentDataset.employees.filter(employee => {
|
||||
const companyId = organizationCompanies.get(employee.organizationId);
|
||||
return companyId !== undefined && applied.companyIds.includes(companyId);
|
||||
}).map(employee => employee.id)).size;
|
||||
const sessionTotal = new Set(currentDataset.employees.map(employee => employee.id)).size;
|
||||
const companyEmployeeCounts = useMemo(() => {
|
||||
const employeeIds = new Map<CompanyId, Set<string>>(allCompanies.map(companyId => [companyId, new Set<string>()]));
|
||||
for (const employee of currentDataset.employees) {
|
||||
const companyId = organizationCompanies.get(employee.organizationId);
|
||||
if (companyId) employeeIds.get(companyId)?.add(employee.id);
|
||||
}
|
||||
return new Map([...employeeIds].map(([companyId, ids]) => [companyId, ids.size]));
|
||||
}, [currentDataset, organizationCompanies]);
|
||||
const companyCountSummary = applied.companyIds.map(companyId => `${companies.find(company => company.companyId === companyId)!.companyName} ${companyEmployeeCounts.get(companyId) ?? 0}명`).join(' · ');
|
||||
const pending = draft.employeeCount !== applied.employeeCount
|
||||
|| draft.companyIds.join() !== applied.companyIds.join();
|
||||
const selectedCompanyCount = draft.companyIds.length;
|
||||
const companySummary = selectedCompanyCount === allCompanies.length ? `전체 회사 (${allCompanies.length}개)`
|
||||
: selectedCompanyCount === 1 ? companies.find(company => company.companyId === draft.companyIds[0])!.companyName
|
||||
: selectedCompanyCount > 1 ? `${selectedCompanyCount}개 회사 선택` : '회사를 선택하세요';
|
||||
const allCompaniesChecked = selectedCompanyCount === allCompanies.length ? true
|
||||
: selectedCompanyCount > 0 ? 'indeterminate' : false;
|
||||
const changeCompanies = (companyIds: readonly CompanyId[]) => {
|
||||
const selected = allCompanies.filter(companyId => companyIds.includes(companyId));
|
||||
setError(null);
|
||||
// Company filtering is immediate; pending employee-count changes still belong
|
||||
// to the existing Data Apply action and P7 dataset generation contract.
|
||||
setDraft(current => ({ ...current, companyIds: selected }));
|
||||
setApplied(current => ({ ...current, companyIds: selected }));
|
||||
};
|
||||
|
||||
return <StoryPage className="hm-orgchart-story-page hm-orgchart-story-expanded">
|
||||
<StoryPageHeader
|
||||
kicker="HM DESIGN SYSTEM / ORGCHART"
|
||||
title="Expanded OrgChart"
|
||||
description="데이터·회사·표시 유형을 선택하고 직원 카드를 눌러 정보를 확인하세요. 모든 직원은 가상 데이터입니다."
|
||||
/>
|
||||
<StorySection title="데이터 설정" description="회사 선택은 즉시 반영됩니다. 회사당 직원 수 변경은 데이터 적용을 눌러 반영합니다.">
|
||||
<fieldset className="hm-orgchart-story-settings">
|
||||
<legend className="hm-orgchart-story-section-legend">데이터 설정</legend>
|
||||
<FilterBar layout="list-extended">
|
||||
<div className="hm-orgchart-story-instance">
|
||||
<Label id={`${id}-companies-label`} htmlFor={`${id}-companies`}>회사 선택</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button id={`${id}-companies`} type="button" variant="cancel" appearance="outline"
|
||||
className="hm-orgchart-story-company-trigger" aria-labelledby={`${id}-companies-label ${id}-companies-summary`}>
|
||||
<span id={`${id}-companies-summary`} className="hm-orgchart-story-company-summary">{companySummary}</span>
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" aria-labelledby={`${id}-companies-label`} data-component="OrgChartCompanySelection" className="hm-orgchart-story-company-popover">
|
||||
<fieldset className="hm-orgchart-story-company-options">
|
||||
<legend className="sr-only">표시할 회사 · 복수 선택 가능</legend>
|
||||
<div className="hm-orgchart-story-company">
|
||||
<Checkbox id={`${id}-companies-all`} checked={allCompaniesChecked} onCheckedChange={checked => changeCompanies(checked === true ? allCompanies : [])} />
|
||||
<Label htmlFor={`${id}-companies-all`}>전체 선택</Label>
|
||||
</div>
|
||||
<Separator />
|
||||
{companies.map(company => <div key={company.companyId} className="hm-orgchart-story-company">
|
||||
<Checkbox id={`${id}-${company.companyId}`} checked={draft.companyIds.includes(company.companyId as CompanyId)} onCheckedChange={checked => {
|
||||
changeCompanies(checked === true ? [...draft.companyIds, company.companyId as CompanyId] : draft.companyIds.filter(value => value !== company.companyId));
|
||||
}} />
|
||||
<Label htmlFor={`${id}-${company.companyId}`}>{company.companyName}</Label>
|
||||
</div>)}
|
||||
</fieldset>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="hm-orgchart-story-instance">
|
||||
<Label id={`${id}-size-label`} htmlFor={`${id}-size`}>회사당 직원 수</Label>
|
||||
<Select value={String(draft.employeeCount)} onValueChange={value => setDraft(current => ({ ...current, employeeCount: Number(value) as DataSettings['employeeCount'] }))}>
|
||||
<SelectTrigger id={`${id}-size`} aria-labelledby={`${id}-size-label`} size="medium"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{[20, 100, 300].map(count => <SelectItem key={count} value={String(count)}>{count}명</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FilterBarActions>
|
||||
<Button variant="primary" appearance="solid" onClick={() => { setError(null); setApplied({ ...draft, companyIds: [...draft.companyIds] }); setDataRevision(current => current + 1); }}>데이터 적용</Button>
|
||||
<Button variant="cancel" appearance="outline" onClick={() => { setError(null); setDraft(initialSettings); setApplied(initialSettings); setDataRevision(current => current + 1); }}>초기화</Button>
|
||||
</FilterBarActions>
|
||||
</FilterBar>
|
||||
<p className="hm-orgchart-story-note" role="status" aria-live="polite">
|
||||
{pending ? '적용 예정' : '예시 생성 기준'}: {draft.companyIds.length}개 회사 · 회사당 {draft.employeeCount}명 · 총 {predicted.toLocaleString('ko-KR')}명{pending ? ' · 데이터 적용을 눌러 반영하세요.' : ''}
|
||||
</p>
|
||||
<p className="hm-orgchart-story-note">회사 숨김·다시 표시는 현재 소속 변경을 유지합니다. 데이터 적용은 선택한 조건의 예시를 새로 만들고, 초기화는 최초 데이터 설정과 소속으로 돌아갑니다.</p>
|
||||
</fieldset>
|
||||
</StorySection>
|
||||
<StorySection title="레이아웃 설정" description="TB·LR·Hybrid 표시 유형과 초기 표시 Depth를 설정합니다.">
|
||||
<fieldset className="hm-orgchart-story-settings">
|
||||
<legend className="hm-orgchart-story-section-legend">레이아웃 설정</legend>
|
||||
<div className="hm-orgchart-story-controls">
|
||||
<div className="hm-orgchart-story-instance">
|
||||
<Label id={`${id}-layout-label`} htmlFor={`${id}-layout`}>조직도 표시 유형</Label>
|
||||
<Select value={layout.type} onValueChange={value => setLayout(current => ({ ...current, type: value as OrgChartAutoLayoutOptions['type'], hybridOrganizationIds: [] }))}>
|
||||
<SelectTrigger id={`${id}-layout`} aria-labelledby={`${id}-layout-label`} size="medium"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="tb">세로형 TB</SelectItem><SelectItem value="lr">가로형 LR</SelectItem><SelectItem value="hybrid">혼합형 Hybrid</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="hm-orgchart-story-instance">
|
||||
<Label id={`${id}-hybrid-label`} htmlFor={`${id}-hybrid`}>혼합형 시작 Depth</Label>
|
||||
<Select value={String(layout.hybridStartDepth)} disabled={layout.type !== 'hybrid'} onValueChange={value => setLayout(current => ({ ...current, hybridStartDepth: Number(value) as 2 | 3 | 4 }))}>
|
||||
<SelectTrigger id={`${id}-hybrid`} aria-labelledby={`${id}-hybrid-label`} aria-describedby={`${id}-hybrid-help`} size="medium"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{[2, 3, 4].map(depth => <SelectItem key={depth} value={String(depth)}>Depth {depth}부터 세로 배치</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<span id={`${id}-hybrid-help`} className="hm-orgchart-story-note">Hybrid에만 적용합니다.</span>
|
||||
</div>
|
||||
<div className="hm-orgchart-story-instance">
|
||||
<Label id={`${id}-group-hybrid-label`} htmlFor={`${id}-group-hybrid`}>특정 그룹 세로 배치</Label>
|
||||
<Select value={layout.hybridOrganizationIds?.[0] ?? 'none'} disabled={layout.type !== 'tb' || !applied.companyIds.length} onValueChange={value => setLayout(current => ({ ...current, hybridOrganizationIds: value === 'none' ? [] : [value] }))}>
|
||||
<SelectTrigger id={`${id}-group-hybrid`} aria-labelledby={`${id}-group-hybrid-label`} aria-describedby={`${id}-group-hybrid-help`} size="medium"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="none">사용 안 함</SelectItem>{groupChoices.map(organization => <SelectItem key={organization.id} value={organization.id}>{organization.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<span id={`${id}-group-hybrid-help`} className="hm-orgchart-story-note">TB에서 선택한 그룹 아래만 적용합니다. LR·Hybrid 전환 시 해제됩니다.</span>
|
||||
</div>
|
||||
<div className="hm-orgchart-story-instance">
|
||||
<Label id={`${id}-depth-label`} htmlFor={`${id}-depth`}>초기 표시 Depth</Label>
|
||||
<Select value={String(layout.initialVisibleDepth)} onValueChange={value => setLayout(current => ({ ...current, initialVisibleDepth: value === 'all' ? 'all' : Number(value) as 1 | 2 | 3 }))}>
|
||||
<SelectTrigger id={`${id}-depth`} aria-labelledby={`${id}-depth-label`} size="medium"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{[1, 2, 3].map(depth => <SelectItem key={depth} value={String(depth)}>{depth}Depth까지</SelectItem>)}<SelectItem value="all">전체 펼침</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hm-orgchart-story-note">Compact: 미지원. 기존 직원 카드 디자인을 보존하며, 압축 배치 선택은 제공하지 않습니다.</p>
|
||||
</fieldset>
|
||||
</StorySection>
|
||||
<StorySection title="인터랙션·직원 정보" description="Drag & Drop과 탐색 기능을 개별 선택하고 직원 카드의 팝업 방식을 설정합니다.">
|
||||
<div className="hm-orgchart-story-settings">
|
||||
<fieldset className="hm-orgchart-story-companies">
|
||||
<legend>독립 기능 설정 · 모든 표시 유형에서 개별 선택</legend>
|
||||
<div className="hm-orgchart-story-company">
|
||||
<Switch id={`${id}-drag-drop`} checked={dragDropEnabled} onCheckedChange={setDragDropEnabled} aria-describedby={`${id}-drag-drop-state`} />
|
||||
<Label htmlFor={`${id}-drag-drop`}>Drag & Drop</Label>
|
||||
<span id={`${id}-drag-drop-state`}>{dragDropEnabled ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
{([
|
||||
['collapse', '접기·펼치기 사용'], ['zoom', 'Zoom 사용'], ['pan', 'Pan 사용'],
|
||||
] as const).map(([key, label]) => <div key={key} className="hm-orgchart-story-company">
|
||||
<Checkbox id={`${id}-interaction-${key}`} checked={interactions[key]} onCheckedChange={checked => setInteractions(current => ({ ...current, [key]: checked === true }))} />
|
||||
<Label htmlFor={`${id}-interaction-${key}`}>{label}</Label>
|
||||
</div>)}
|
||||
</fieldset>
|
||||
<div className="hm-orgchart-story-controls">
|
||||
<div className="hm-orgchart-story-instance hm-orgchart-story-popup-setting">
|
||||
<Label id={`${id}-popup-label`} htmlFor={`${id}-popup`}>직원 정보 팝업</Label>
|
||||
<Select value={popup} onValueChange={value => setPopup(value as OrgChartProps['employeePopup'])}>
|
||||
<SelectTrigger id={`${id}-popup`} aria-labelledby={`${id}-popup-label`} size="medium"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="hm-dialog">HMDesign Dialog</SelectItem><SelectItem value="core">Core 기본 팝업</SelectItem><SelectItem value="none">사용 안 함</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" appearance="outline" onClick={() => setSelected(undefined)}>전체 회사</Button>
|
||||
<Button variant="secondary" appearance="outline" onClick={() => setSelected([])}>선택 해제</Button>
|
||||
</div>
|
||||
<fieldset className="hm-orgchart-story-companies">
|
||||
<legend>표시할 회사 · 복수 선택 가능</legend>
|
||||
{companies.map(company => <div key={company.id} className="hm-orgchart-story-company">
|
||||
<Checkbox id={`${id}-${company.id}`} checked={visible.includes(company.id)} onCheckedChange={checked => setSelected(checked === true ? [...visible, company.id] : visible.filter(value => value !== company.id))} />
|
||||
<Label htmlFor={`${id}-${company.id}`}>{company.name}</Label>
|
||||
<span className="hm-orgchart-story-note">{companyColorPresets[company.id as keyof typeof companyColorPresets].primary}</span>
|
||||
</div>)}
|
||||
</fieldset>
|
||||
<OrgChart dataSource={sources[size]} stylesheetUrls={compositionStylesheets} displayMode="readOnly" layoutMode="auto" selectedCompanyIds={selected} companyTopNodes={topNodes} employeeConnections={connections} className="hm-orgchart-story-chart" />
|
||||
</section>;
|
||||
</StorySection>
|
||||
<StorySection title="조직도 미리보기" description="직원 카드를 눌러 정보를 확인하고, 활성화한 탐색 기능으로 조직도를 살펴봅니다.">
|
||||
<div className="hm-orgchart-story">
|
||||
<p className="hm-orgchart-story-note">빈 공간 드래그는 Pan입니다. {dragDropEnabled ? '직원 카드·그룹 타이틀 드래그로 회사 안팎의 소속을 변경합니다. 키보드: Alt+M → 방향키 → Enter / ESC 취소.' : '소속 변경은 Drag & Drop을 ON으로 설정하면 사용할 수 있습니다.'} 클릭은 직원 정보·그룹 접기입니다.</p>
|
||||
<p className="hm-orgchart-story-note" data-component="OrgChartExampleCounts" aria-live="polite">
|
||||
표시 중: {applied.companyIds.length}개 회사 · 현재 소속 기준 직원 합계 {displayedTotal.toLocaleString('ko-KR')}명 / 전체 {sessionTotal.toLocaleString('ko-KR')}명
|
||||
{companyCountSummary ? ` · ${companyCountSummary}` : ''}
|
||||
</p>
|
||||
{error ? <p role="alert" className="hm-orgchart-story-note">{error}</p> : null}
|
||||
<div className="hm-orgchart-story-preview sb-center">
|
||||
<OrgChart dataSource={source} stylesheetUrls={compositionStylesheets} displayMode="readOnly" layoutMode="auto" autoLayout={layout} interactions={interactions} dragDropEnabled={dragDropEnabled} selectedCompanyIds={applied.companyIds} companyTopNodes={topNodes} employeePopup={popup} className="hm-orgchart-story-chart" onMove={result => { if (result.moved) setSession({ baseline: dataset, current: result.data }); }} onLifecycleError={cause => setError(cause instanceof Error ? cause.message : '조직도 설정을 적용할 수 없습니다.')} onStateChange={state => { if (state.status === 'error') setError(state.issues.map(issue => issue.message).join(' / ')); }} />
|
||||
</div>
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>;
|
||||
}
|
||||
|
||||
const meta = { title: 'OrgChart', component: ExpandedPreview, parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof ExpandedPreview>;
|
||||
const meta = { title: 'OrgChart/Expanded OrgChart', component: ExpandedPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof ExpandedPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const ExpandedOrgChart: Story = { name: 'Expanded OrgChart' };
|
||||
export const ExpandedOrgChart: Story = { name: 'Expanded OrgChart', parameters: { __id: 'orgchart--expanded-org-chart' } };
|
||||
@@ -0,0 +1,8 @@
|
||||
import { StrictMode } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { CompositionPreview } from './org-chart-page-previews';
|
||||
|
||||
const meta = { title: 'OrgChart/Full Composition', component: CompositionPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CompositionPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const FullComposition: Story = { name: 'Full Composition', parameters: { __id: 'orgchart--full-composition' } };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { CoreComponentPreview } from './core-component-preview';
|
||||
import './org-chart-stories.css';
|
||||
|
||||
const meta = { title: 'OrgChart/Verification/Overview', component: CoreComponentPreview, tags: ['internal'], parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CoreComponentPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
// Literal IDs preserve every existing direct URL after moving the hidden CSF entries.
|
||||
export const Logo: Story = { name: 'Logo', args: { kind: 'Logo' }, parameters: { __id: 'orgchart--logo' }, render: () => <CoreComponentPreview kind="Logo" /> };
|
||||
export const PositionSummary: Story = { name: 'Position Summary', args: { kind: 'PositionSummary' }, parameters: { __id: 'orgchart--position-summary' }, render: () => <><p className="hm-orgchart-story-note">가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.</p><CoreComponentPreview kind="PositionSummary" /></> };
|
||||
export const AffiliationSummary: Story = { name: 'Affiliation Summary', args: { kind: 'AffiliationSummary' }, parameters: { __id: 'orgchart--affiliation-summary' }, render: () => <><p className="hm-orgchart-story-note">가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.</p><CoreComponentPreview kind="AffiliationSummary" /></> };
|
||||
@@ -1,56 +1,8 @@
|
||||
import { StrictMode, useMemo, useState } from 'react';
|
||||
import { StrictMode } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button } from '@hm-design/ui';
|
||||
import { OrgChart } from '@hm-design/org-chart-react';
|
||||
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
import engineUrl from '@hm-design/org-chart/engine.css?url';
|
||||
import coreUrl from '@hm-design/org-chart/style.css?url';
|
||||
import fixture from '../../examples/org-chart/data-p1.json';
|
||||
import './org-chart-stories.css';
|
||||
import { CoreComponentPreview } from './core-component-preview';
|
||||
import { OverviewPreview } from './org-chart-page-previews';
|
||||
|
||||
const compositionSource = new JsonOrgChartDataSource(fixture);
|
||||
const compositionStylesheets = { engine: engineUrl, core: coreUrl };
|
||||
|
||||
/** View-only Workdesk composition: the same Core/React Wrapper with
|
||||
* displayMode="readOnly". No Story notes, test controls or collapse buttons;
|
||||
* JSON update/lifecycle and collapse checks stay in the internal Content Stories. */
|
||||
function CompositionPreview() {
|
||||
return <section className="hm-orgchart-story hm-orgchart-story-full" aria-label="Full Composition">
|
||||
<OrgChart dataSource={compositionSource} stylesheetUrls={compositionStylesheets} displayMode="readOnly" className="hm-orgchart-story-chart" />
|
||||
</section>;
|
||||
}
|
||||
function DepartmentTypographyPreview() {
|
||||
const [longNames, setLongNames] = useState(false);
|
||||
const data = useMemo(() => {
|
||||
const data = structuredClone(fixture);
|
||||
if (longNames) for (const id of ['mock-leader-00', 'mock-leader-01', 'mock-employee-01']) {
|
||||
data.employees.find(item => item.id === id)!.name = '아주긴가상이름표시검증사례';
|
||||
}
|
||||
return data;
|
||||
}, [longNames]);
|
||||
return <section className="hm-orgchart-story">
|
||||
<p className="hm-orgchart-story-note">기본 이름은 2–3음절의 가상 이름입니다. 긴 이름 검증에서는 폰트·사진·카드 크기를 유지하며 말줄임된 이름에 Hover 또는 Tab Focus로 전체 이름을 확인하고 Escape로 닫을 수 있습니다.</p>
|
||||
<div className="hm-orgchart-story-controls"><Button appearance="outline" aria-pressed={longNames} onClick={() => setLongNames(value => !value)}>긴 이름 검증 {longNames ? '종료' : '시작'}</Button></div>
|
||||
<CoreComponentPreview kind="DepartmentColors" data={data} />
|
||||
</section>;
|
||||
}
|
||||
const meta = { title: 'OrgChart', component: CompositionPreview, parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CompositionPreview>;
|
||||
const meta = { title: 'OrgChart/Overview', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof OverviewPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const LogoPersonnelSummary: Story = {
|
||||
name: 'Logo · Personnel Summary',
|
||||
render: () => <section className="hm-orgchart-story" aria-label="Logo · Personnel Summary">
|
||||
<CoreComponentPreview kind="Logo" />
|
||||
<CoreComponentPreview kind="PositionSummary" />
|
||||
<CoreComponentPreview kind="AffiliationSummary" />
|
||||
</section>,
|
||||
};
|
||||
export const DepartmentColors: Story = { name: 'Department Colors', render: () => <DepartmentTypographyPreview /> };
|
||||
export const FullComposition: Story = { name: 'Full Composition' };
|
||||
|
||||
// Preserve existing standalone Story IDs for verification and direct links.
|
||||
// The default manager excludes "internal"; user-facing navigation has three pages.
|
||||
export const Logo: Story = { name: 'Logo', tags: ['internal'], render: () => <CoreComponentPreview kind="Logo" /> };
|
||||
export const PositionSummary: Story = { name: 'Position Summary', tags: ['internal'], render: () => <><p className="hm-orgchart-story-note">가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.</p><CoreComponentPreview kind="PositionSummary" /></> };
|
||||
export const AffiliationSummary: Story = { name: 'Affiliation Summary', tags: ['internal'], render: () => <><p className="hm-orgchart-story-note">가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.</p><CoreComponentPreview kind="AffiliationSummary" /></> };
|
||||
export const LogoPersonnelSummary: Story = { name: 'Overview', parameters: { __id: 'orgchart--logo-personnel-summary' } };
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
import { OrgChart } from '@hm-design/org-chart-react';
|
||||
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
|
||||
import engineUrl from '@hm-design/org-chart/engine.css?url';
|
||||
import coreUrl from '@hm-design/org-chart/style.css?url';
|
||||
import { StoryPage, StoryPageHeader, StorySection } from '@/components/story-page';
|
||||
import fixture from '../../examples/org-chart/data-p1.json';
|
||||
import './org-chart-stories.css';
|
||||
import { CoreComponentPreview } from './core-component-preview';
|
||||
import { centerCompositionPreview } from './center-composition-preview';
|
||||
|
||||
const compositionSource = new JsonOrgChartDataSource(fixture);
|
||||
const compositionStylesheets = { engine: engineUrl, core: coreUrl };
|
||||
|
||||
/** View-only Workdesk composition: the same Core/React Wrapper with
|
||||
* displayMode="readOnly". No Story notes, test controls or collapse buttons;
|
||||
* JSON update/lifecycle and collapse checks stay in the internal Content Stories. */
|
||||
export function CompositionPreview() {
|
||||
const host = useRef<HTMLElement>(null);
|
||||
useLayoutEffect(() => centerCompositionPreview(host.current!), []);
|
||||
return <StoryPage className="hm-orgchart-story-page">
|
||||
<StoryPageHeader kicker="HM DESIGN SYSTEM / ORGCHART" title="Full Composition" description="Workdesk 기반 고정형 조직도입니다. 대표자와 6개 조직, 협업 허브 및 인원 현황을 한 화면에서 확인합니다." />
|
||||
<StorySection title="고정형 조직도" description="직원 카드를 눌러 정보를 확인하세요. 미리보기 영역의 실제 폭에 맞춰 전체 구성을 비례 축소합니다.">
|
||||
<section ref={host} className="hm-orgchart-story hm-orgchart-story-full" aria-label="Full Composition">
|
||||
<OrgChart dataSource={compositionSource} stylesheetUrls={compositionStylesheets} displayMode="readOnly" className="hm-orgchart-story-chart" />
|
||||
</section>
|
||||
</StorySection>
|
||||
</StoryPage>;
|
||||
}
|
||||
|
||||
export function DepartmentTypographyPreview() {
|
||||
return <StoryPage className="hm-orgchart-story-page">
|
||||
<StoryPageHeader kicker="HM DESIGN SYSTEM / ORGCHART" title="Department Colors" />
|
||||
<StorySection title="조직별 컬러 · 카드 미리보기">
|
||||
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="DepartmentColors" presentation="design" /></div>
|
||||
</StorySection>
|
||||
</StoryPage>;
|
||||
}
|
||||
|
||||
export function OverviewPreview() {
|
||||
return <StoryPage className="hm-orgchart-story-page">
|
||||
<StoryPageHeader kicker="HM DESIGN SYSTEM / ORGCHART" title="Overview" description="회사 로고와 조직·직위별, 관계사별 인원 현황입니다. 인원 현황은 고정형 조직도와 같은 Core 집계·표시 디자인을 사용하며 기존 가상 데이터를 표시합니다." />
|
||||
<StorySection title="관계사·회사 로고" description="회사 로고와 기존 안내를 확인합니다.">
|
||||
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="Logo" /></div>
|
||||
</StorySection>
|
||||
<StorySection title="조직·직위별 인원 현황" description="기존 가상 직원 데이터를 조직·직위별로 집계합니다.">
|
||||
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="PositionSummary" presentation="workdesk" /></div>
|
||||
</StorySection>
|
||||
<StorySection title="관계사별 인원 현황" description="동일 데이터의 관계사별 인원 현황입니다. 실제 운영 인원수가 아닙니다.">
|
||||
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="AffiliationSummary" presentation="workdesk" /></div>
|
||||
</StorySection>
|
||||
</StoryPage>;
|
||||
}
|
||||
@@ -10,9 +10,13 @@
|
||||
|
||||
/* Full Composition scales itself to the available width; no host cap. */
|
||||
.hm-orgchart-story-full { max-width: none; }
|
||||
body:has(.hm-orgchart-story-full) { background: #F5F8FB; }
|
||||
.hm-orgchart-story-full { background: #F5F8FB; }
|
||||
|
||||
/* Reuse StoryPage's inner/gutters; specimens receive the measured content width.
|
||||
No page selector reaches into the Core's Shadow DOM. Fixed has no extra inset. */
|
||||
.hm-orgchart-story-page .hm-orgchart-story { max-width: none; }
|
||||
.hm-orgchart-story-page .hm-orgchart-story-preview { min-width: 0; max-width: 100%; }
|
||||
|
||||
.hm-orgchart-story-note {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
Reference in new issue
Block a user