Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/org-chart-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sanring/cli": minor
---

Add `block/org-chart`: elkjs worker layout, pan/zoom canvas, and a tree directory for keyboard access. Dual managers and dotted-line reporting are supported; `elkjs` is installed as a peer dependency (EPL-2.0).
18 changes: 9 additions & 9 deletions .claude/charters/p33-org-chart.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
schema_version: 1
charter_id: p33-org-chart
charter_name: P33 — Block org-chart(elkjs 排版 + 薄殼渲染)
status: draft
status: completed
charter_type: feature
parent_charter:
date: 2026-10-01
Expand Down Expand Up @@ -85,23 +85,23 @@ docs app 暫時頁 + 假資料:≥1 位雙主管員工、≥1 條 CEO → 基

### 批次 D:互動

- [ ] 平移、縮放(滑鼠 + 觸控板)、fit-to-view、捲到指定節點
- [x] 平移、縮放(滑鼠 + 觸控板)、fit-to-view、捲到指定節點

### 批次 E:a11y

- [ ] `tree` 側欄聯動:選取同步、焦點移到對應卡片
- [ ] axe 掃描零違規
- [x] `tree` 側欄聯動:選取同步、焦點移到對應卡片
- [x] axe 掃描零違規

### 批次 F:Registry + docs

- [ ] `registry.json` `blocks[]`、`/blocks` 頁示範
- [ ] docs 註明 EPL-2.0、worker 體積(~336 KB transfer)、`allowedCommonJsDependencies: ["elkjs"]`
- [x] `registry.json` `blocks[]`、`/blocks` 頁示範
- [x] docs 註明 EPL-2.0、worker 體積(~336 KB transfer)、`allowedCommonJsDependencies: ["elkjs"]`

### 批次 G:E2E + 發版

- [ ] Playwright:渲染、縮放、tree 聯動
- [ ] CLI e2e:`sanring add block/org-chart` 會裝 elkjs
- [ ] changeset
- [x] Playwright:渲染、縮放、tree 聯動
- [x] CLI e2e:`sanring add block/org-chart` 會裝 elkjs
- [x] changeset

## 5. Commit 邊界

Expand Down
62 changes: 62 additions & 0 deletions apps/docs/e2e/org-chart.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { expect, test, type Page } from '@playwright/test';
import axe from 'axe-core';

async function expectNoAxeViolations(page: Page, selector: string) {
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async (sel) => {
const root = document.querySelector(sel);
if (!root) throw new Error(`Missing ${sel}`);
const axeApi = (
window as Window & {
axe: {
run: (
context: Element,
options: object,
) => Promise<{
violations: Array<{ id: string; help: string; nodes: Array<{ target: string[] }> }>;
}>;
};
}
).axe;
const results = await axeApi.run(root, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
});
return results.violations.map((v) => ({
id: v.id,
help: v.help,
targets: v.nodes.map((n) => n.target),
}));
}, selector);
expect(violations, JSON.stringify(violations, null, 2)).toEqual([]);
}

test.describe('org-chart block', () => {
test('renders chart cards, pans/zooms, and syncs tree selection', async ({ page }) => {
await page.goto('/blocks/org-chart');

const preview = page.getByRole('group', { name: 'Preview' }).last();
await expect(preview.getByRole('button', { name: /Ada Lin/i }).first()).toBeVisible({
timeout: 30_000,
});

const fit = preview.getByRole('button', { name: 'Fit' });
await expect(fit).toBeVisible();
await fit.click();

const before = await preview.locator('[data-org-card="quin"]').boundingBox();
await preview.getByRole('button', { name: '+' }).click();
const afterZoom = await preview.locator('[data-org-card="quin"]').boundingBox();
expect(before && afterZoom).toBeTruthy();
if (before && afterZoom) {
expect(afterZoom.width).toBeGreaterThan(before.width);
}

const directory = preview.getByRole('tree', { name: 'People' });
await expect(directory).toBeVisible();

Check failure on line 55 in apps/docs/e2e/org-chart.spec.ts

View workflow job for this annotation

GitHub Actions / Docs E2E + visual regression

[desktop-chromium] › apps/docs/e2e/org-chart.spec.ts:34:7 › org-chart block › renders chart cards

2) [desktop-chromium] › apps/docs/e2e/org-chart.spec.ts:34:7 › org-chart block › renders chart cards, pans/zooms, and syncs tree selection Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByRole('group', { name: 'Preview' }).last().getByRole('tree', { name: 'People' }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByRole('group', { name: 'Preview' }).last().getByRole('tree', { name: 'People' }) 53 | 54 | const directory = preview.getByRole('tree', { name: 'People' }); > 55 | await expect(directory).toBeVisible(); | ^ 56 | await directory.getByRole('treeitem', { name: /Quin Fang/i }).click(); 57 | await expect(preview.locator('[data-org-card="quin"][data-selected="true"]')).toBeVisible(); 58 | await expect(preview.locator('[data-org-card="quin"]')).toBeFocused(); at /home/runner/work/ui/ui/apps/docs/e2e/org-chart.spec.ts:55:29

Check failure on line 55 in apps/docs/e2e/org-chart.spec.ts

View workflow job for this annotation

GitHub Actions / Docs E2E + visual regression

[desktop-chromium] › apps/docs/e2e/org-chart.spec.ts:34:7 › org-chart block › renders chart cards

2) [desktop-chromium] › apps/docs/e2e/org-chart.spec.ts:34:7 › org-chart block › renders chart cards, pans/zooms, and syncs tree selection Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: getByRole('group', { name: 'Preview' }).last().getByRole('tree', { name: 'People' }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByRole('group', { name: 'Preview' }).last().getByRole('tree', { name: 'People' }) 53 | 54 | const directory = preview.getByRole('tree', { name: 'People' }); > 55 | await expect(directory).toBeVisible(); | ^ 56 | await directory.getByRole('treeitem', { name: /Quin Fang/i }).click(); 57 | await expect(preview.locator('[data-org-card="quin"][data-selected="true"]')).toBeVisible(); 58 | await expect(preview.locator('[data-org-card="quin"]')).toBeFocused(); at /home/runner/work/ui/ui/apps/docs/e2e/org-chart.spec.ts:55:29

Check failure on line 55 in apps/docs/e2e/org-chart.spec.ts

View workflow job for this annotation

GitHub Actions / Docs E2E + visual regression

[desktop-chromium] › apps/docs/e2e/org-chart.spec.ts:34:7 › org-chart block › renders chart cards

2) [desktop-chromium] › apps/docs/e2e/org-chart.spec.ts:34:7 › org-chart block › renders chart cards, pans/zooms, and syncs tree selection Error: expect(locator).toBeVisible() failed Locator: getByRole('group', { name: 'Preview' }).last().getByRole('tree', { name: 'People' }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 5000ms - waiting for getByRole('group', { name: 'Preview' }).last().getByRole('tree', { name: 'People' }) 53 | 54 | const directory = preview.getByRole('tree', { name: 'People' }); > 55 | await expect(directory).toBeVisible(); | ^ 56 | await directory.getByRole('treeitem', { name: /Quin Fang/i }).click(); 57 | await expect(preview.locator('[data-org-card="quin"][data-selected="true"]')).toBeVisible(); 58 | await expect(preview.locator('[data-org-card="quin"]')).toBeFocused(); at /home/runner/work/ui/ui/apps/docs/e2e/org-chart.spec.ts:55:29
await directory.getByRole('treeitem', { name: /Quin Fang/i }).click();
await expect(preview.locator('[data-org-card="quin"][data-selected="true"]')).toBeVisible();
await expect(preview.locator('[data-org-card="quin"]')).toBeFocused();

await expectNoAxeViolations(page, 'sanring-org-chart');
});
});
37 changes: 35 additions & 2 deletions apps/docs/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,41 @@ export const routes: Routes = [
},
{
path: 'blocks',
loadComponent: () =>
import('./pages/blocks/blocks-page.component').then((m) => m.BlocksPageComponent),
children: [
{
path: '',
loadComponent: () =>
import('./pages/blocks/blocks-page.component').then((m) => m.BlocksPageComponent),
},
{
path: 'dashboard-shell',
loadComponent: () =>
import('./pages/blocks/dashboard-shell/dashboard-shell-block-page.component').then(
(m) => m.DashboardShellBlockPageComponent,
),
},
{
path: 'login',
loadComponent: () =>
import('./pages/blocks/login/login-block-page.component').then(
(m) => m.LoginBlockPageComponent,
),
},
{
path: 'table-page',
loadComponent: () =>
import('./pages/blocks/table-page/table-page-block-page.component').then(
(m) => m.TablePageBlockPageComponent,
),
},
{
path: 'org-chart',
loadComponent: () =>
import('./pages/blocks/org-chart/org-chart-block-page.component').then(
(m) => m.OrgChartBlockPageComponent,
),
},
],
},
{
path: 'changelog',
Expand Down
41 changes: 39 additions & 2 deletions apps/docs/src/app/i18n/locales/en/pages/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,53 @@ export const blocksTranslations = {
'blocks.overview.title': 'Overview',
'blocks.overview.body':
'Blocks are composed from existing Sanring components. They are not part of the UI package — the CLI copies them into your app so you can edit the source. Use the block/ prefix when you want to be explicit, or the bare name when it does not collide with a component.',
'blocks.install.title': 'Install',
'blocks.catalog.title': 'All blocks',
'blocks.section.scenario': 'When to use',
'blocks.section.composition': 'Composition',
'blocks.section.compositionHint': 'Installed with the block via componentDeps. Open a component page for its API.',
'blocks.section.peers': 'Peer dependencies',
'blocks.section.components': 'Components',
'blocks.section.installation': 'Installation',
'blocks.section.usage': 'Usage',
'blocks.section.preview': 'Preview',
'blocks.section.notes': 'Notes',

'blocks.dashboard.title': 'Dashboard shell',
'blocks.dashboard.body':
'Persistent app chrome: sidebar, breadcrumbs, and a user menu. Project your page into ng-content.',
'blocks.dashboard.scenario':
'Use this when you need a standard authenticated app frame — left nav, top breadcrumb trail, and account menu — and want the page body to stay in your router outlet.',
'blocks.dashboard.composition':
'Assembles sidebar for navigation, breadcrumb for location, avatar plus dropdown-menu for the user menu, and badge for optional nav counts.',

'blocks.login.title': 'Login',
'blocks.login.body': 'A sign-in card with email, password, remember-me, and an error alert.',
'blocks.login.scenario':
'Use this for a standalone sign-in screen before the app shell. Wire `(submitted)` to your auth API and pass `error` when credentials fail.',
'blocks.login.composition':
'Built as a card form: field + input + label for controls, checkbox for remember-me, alert for failures, link and divider for secondary actions, button to submit.',

'blocks.table.title': 'Table page',
'blocks.table.body':
'A data table with search, status filter, row selection, a create sheet, loading skeletons, and toast.',
'blocks.table.scenario':
'Use this as a starting CRUD list page: filterable rows, bulk-friendly selection, a sheet to create records, and toast feedback after mutations.',
'blocks.table.composition':
'Combines table and pagination for the grid, input/select/field for filters, checkbox for row selection, dropdown-menu for row actions, sheet for create/edit, skeleton while loading, badge for status, toast for feedback, and button for primary actions.',

'blocks.org.title': 'Org chart',
'blocks.org.body':
'A classic org chart: managers above reports, dual managers and dotted-line reporting, orthogonal connectors routed around cards. Layout runs in a Web Worker via elkjs (EPL-2.0), which the CLI installs as a peer dependency.',
'A classic org chart with dual managers, dotted-line reporting, pan/zoom, and a directory tree for keyboard access.',
'blocks.org.scenario':
'Use this when you need a 2D reporting graph — not an ARIA tree alone. Pass people and solid/dotted links; selection stays in sync between the canvas and the directory.',
'blocks.org.composition':
'Cards use avatar and badge; toolbar buttons and skeleton cover chrome and loading. tree provides keyboard navigation beside the canvas. Layout runs in a Web Worker via the elkjs peer.',
'blocks.org.notes':
'elkjs is EPL-2.0 (~336 KB transfer for the worker chunk). Add `allowedCommonJsDependencies: ["elkjs"]` to your Angular build options if the CLI warns about CommonJS.',
'blocks.org.preview.withTree': 'With directory tree',
'blocks.org.preview.withTreeDescription':
'Default. The tree is for keyboard access; selection stays in sync with the canvas.',
'blocks.org.preview.canvasOnly': 'Canvas only',
'blocks.org.preview.canvasOnlyDescription':
'Set `[showTree]="false"` when you only need the chart. Pan, zoom, and card selection still work.',
} as const;
41 changes: 39 additions & 2 deletions apps/docs/src/app/i18n/locales/zh/pages/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,53 @@ export const blocksTranslations = {
'blocks.overview.title': '概覽',
'blocks.overview.body':
'Blocks 由既有 Sanring 元件組裝而成,不是 UI 套件的一部分——CLI 會把原始碼複製進你的 app,方便直接改。需要明確指定時用 block/ 前綴;名稱不跟元件衝突時也可以直接寫名字。',
'blocks.install.title': '安裝',
'blocks.catalog.title': '所有 Blocks',
'blocks.section.scenario': '使用情境',
'blocks.section.composition': '構成',
'blocks.section.compositionHint': '會隨 block 透過 componentDeps 一併安裝。點名稱可進該元件頁看 API。',
'blocks.section.peers': 'Peer dependencies',
'blocks.section.components': '元件',
'blocks.section.installation': '安裝',
'blocks.section.usage': '使用方式',
'blocks.section.preview': '預覽',
'blocks.section.notes': '備註',

'blocks.dashboard.title': 'Dashboard shell',
'blocks.dashboard.body':
'持久的 app chrome:側欄、麵包屑、使用者選單。頁面內容用 ng-content 投影進去。',
'blocks.dashboard.scenario':
'適合已登入後的標準 app 外框:左側導覽、上方麵包屑、帳號選單;頁面本體放在 router outlet / ng-content。',
'blocks.dashboard.composition':
'用 sidebar 做導覽、breadcrumb 顯示位置、avatar + dropdown-menu 做使用者選單,badge 可選顯示導覽計數。',

'blocks.login.title': '登入頁',
'blocks.login.body': '含 email、密碼、記住我與錯誤提示的登入卡片。',
'blocks.login.scenario':
'適合進入 app shell 之前的獨立登入頁。把 `(submitted)` 接到 auth API,失敗時傳入 `error`。',
'blocks.login.composition':
'以 card 包表單:field + input + label 當控制項,checkbox 記住我,alert 顯示錯誤,link / divider 放次要操作,button 送出。',

'blocks.table.title': '資料表頁',
'blocks.table.body':
'含搜尋、狀態篩選、列選取、新增抽屜、loading skeleton 與 toast 的資料表頁。',
'blocks.table.scenario':
'適合 CRUD 列表起手:可篩選列、勾選、用 sheet 新增,mutation 後用 toast 回饋。',
'blocks.table.composition':
'table + pagination 做表格;input / select / field 做篩選;checkbox 列選取;dropdown-menu 列操作;sheet 新增/編輯;skeleton 載入;badge 狀態;toast 回饋;button 主操作。',

'blocks.org.title': '組織圖',
'blocks.org.body':
'經典組織圖:主管在上、下屬在下,支援雙主管與虛線匯報,直角連線自動繞開卡片。排版由 elkjs(EPL-2.0)在 Web Worker 中計算,CLI 會以 peer dependency 安裝。',
'經典組織圖:支援雙主管、虛線匯報、平移縮放,並以目錄樹提供鍵盤選取。',
'blocks.org.scenario':
'需要 2D 匯報圖時用(不是單純 ARIA tree)。傳入 people 與 solid/dotted links;畫布與目錄樹的選取會同步。',
'blocks.org.composition':
'卡片用 avatar、badge;工具列 button 與 skeleton 處理 chrome/載入。tree 在側邊提供鍵盤導覽。排版由 peer elkjs 在 Web Worker 計算。',
'blocks.org.notes':
'elkjs 為 EPL-2.0(worker chunk 約 336 KB transfer)。若 Angular 警告 CommonJS,在 build options 加上 `allowedCommonJsDependencies: ["elkjs"]`。',
'blocks.org.preview.withTree': '含目錄樹',
'blocks.org.preview.withTreeDescription':
'預設。目錄樹給鍵盤選取用;選取會與畫布同步。',
'blocks.org.preview.canvasOnly': '僅畫布',
'blocks.org.preview.canvasOnlyDescription':
'設 `[showTree]="false"` 就只顯示圖。平移、縮放與點選卡片仍可用。',
} as const;
15 changes: 5 additions & 10 deletions apps/docs/src/app/navigation/docs-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,7 @@ export const docsSectionItems: DocsSidebarItem[] = [

export interface DocsBlockNavItem extends DocsSidebarItem {
id: string;
path: '/blocks';
fragment: string;
path: string;
descriptionKey: TranslationKey;
}

Expand All @@ -112,32 +111,28 @@ export const docsBlockItems: DocsBlockNavItem[] = [
id: 'dashboard-shell',
labelKey: 'blocks.dashboard.title',
descriptionKey: 'blocks.dashboard.body',
path: '/blocks',
fragment: 'dashboard-shell',
path: '/blocks/dashboard-shell',
active: true,
},
{
id: 'login',
labelKey: 'blocks.login.title',
descriptionKey: 'blocks.login.body',
path: '/blocks',
fragment: 'login',
path: '/blocks/login',
active: true,
},
{
id: 'table-page',
labelKey: 'blocks.table.title',
descriptionKey: 'blocks.table.body',
path: '/blocks',
fragment: 'table-page',
path: '/blocks/table-page',
active: true,
},
{
id: 'org-chart',
labelKey: 'blocks.org.title',
descriptionKey: 'blocks.org.body',
path: '/blocks',
fragment: 'org-chart',
path: '/blocks/org-chart',
active: true,
badge: true,
},
Expand Down
1 change: 1 addition & 0 deletions apps/docs/src/app/navigation/menu-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export interface MenuItem {
export const menuItems: MenuItem[] = [
{ labelKey: 'nav.docs', path: '/introduction', exact: false },
{ labelKey: 'nav.components', path: '/components', exact: false },
{ labelKey: 'nav.blocks', path: '/blocks', exact: false },
];
52 changes: 52 additions & 0 deletions apps/docs/src/app/pages/blocks/block-composition.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Component, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DocsComponentId } from '../../navigation/docs-navigation';

@Component({
selector: 'app-block-composition',
standalone: true,
imports: [RouterLink],
template: `
<div class="grid gap-4">
<ul class="m-0 flex list-none flex-wrap gap-2 p-0" [attr.aria-label]="componentsLabel()">
@for (dep of componentDeps(); track dep) {
<li>
<a
class="inline-flex items-center rounded-[var(--sanring-radius-sm)] border border-[var(--docs-border)] bg-[var(--docs-surface)] px-2.5 py-1 font-mono text-xs font-medium text-[var(--docs-fg)] no-underline transition-colors hover:border-[var(--docs-border-strong)] hover:bg-[var(--docs-elevated)]"
[routerLink]="'/components/' + dep"
>
{{ dep }}
</a>
</li>
}
</ul>

@if (peerEntries().length > 0) {
<div>
<p class="m-0 mb-2 text-xs font-medium uppercase tracking-wide text-[var(--docs-muted)]">
{{ peersLabel() }}
</p>
<ul class="m-0 flex list-none flex-wrap gap-2 p-0">
@for (peer of peerEntries(); track peer.name) {
<li
class="inline-flex items-center gap-1.5 rounded-[var(--sanring-radius-sm)] border border-[var(--docs-border)] bg-[var(--docs-panel)] px-2.5 py-1 font-mono text-xs text-[var(--docs-fg)]"
>
<span>{{ peer.name }}</span>
<span class="text-[var(--docs-muted)]">{{ peer.version }}</span>
</li>
}
</ul>
</div>
}
</div>
`,
})
export class BlockCompositionComponent {
readonly componentDeps = input.required<readonly DocsComponentId[]>();
readonly peerDependencies = input<Readonly<Record<string, string>> | undefined>();
readonly componentsLabel = input('Built from');
readonly peersLabel = input('Peer dependencies');

protected readonly peerEntries = () =>
Object.entries(this.peerDependencies() ?? {}).map(([name, version]) => ({ name, version }));
}
Loading
Loading