diff --git a/.changeset/org-chart-block.md b/.changeset/org-chart-block.md
new file mode 100644
index 00000000..167be0e0
--- /dev/null
+++ b/.changeset/org-chart-block.md
@@ -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).
diff --git a/.claude/charters/p33-org-chart.md b/.claude/charters/p33-org-chart.md
index cde08b00..265f37d4 100644
--- a/.claude/charters/p33-org-chart.md
+++ b/.claude/charters/p33-org-chart.md
@@ -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
@@ -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 邊界
diff --git a/apps/docs/e2e/org-chart.spec.ts b/apps/docs/e2e/org-chart.spec.ts
new file mode 100644
index 00000000..169d9920
--- /dev/null
+++ b/apps/docs/e2e/org-chart.spec.ts
@@ -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();
+ 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');
+ });
+});
diff --git a/apps/docs/src/app/app.routes.ts b/apps/docs/src/app/app.routes.ts
index 5c5ff116..5bc77b70 100644
--- a/apps/docs/src/app/app.routes.ts
+++ b/apps/docs/src/app/app.routes.ts
@@ -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',
diff --git a/apps/docs/src/app/i18n/locales/en/pages/blocks.ts b/apps/docs/src/app/i18n/locales/en/pages/blocks.ts
index a045476f..733e596e 100644
--- a/apps/docs/src/app/i18n/locales/en/pages/blocks.ts
+++ b/apps/docs/src/app/i18n/locales/en/pages/blocks.ts
@@ -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;
diff --git a/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts b/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts
index d8940bb2..d3ee5d4f 100644
--- a/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts
+++ b/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts
@@ -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;
diff --git a/apps/docs/src/app/navigation/docs-navigation.ts b/apps/docs/src/app/navigation/docs-navigation.ts
index 6a11472e..2eecf446 100644
--- a/apps/docs/src/app/navigation/docs-navigation.ts
+++ b/apps/docs/src/app/navigation/docs-navigation.ts
@@ -102,8 +102,7 @@ export const docsSectionItems: DocsSidebarItem[] = [
export interface DocsBlockNavItem extends DocsSidebarItem {
id: string;
- path: '/blocks';
- fragment: string;
+ path: string;
descriptionKey: TranslationKey;
}
@@ -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,
},
diff --git a/apps/docs/src/app/navigation/menu-navigation.ts b/apps/docs/src/app/navigation/menu-navigation.ts
index 842eb94f..27cff5b8 100644
--- a/apps/docs/src/app/navigation/menu-navigation.ts
+++ b/apps/docs/src/app/navigation/menu-navigation.ts
@@ -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 },
];
diff --git a/apps/docs/src/app/pages/blocks/block-composition.component.ts b/apps/docs/src/app/pages/blocks/block-composition.component.ts
new file mode 100644
index 00000000..e23e82a8
--- /dev/null
+++ b/apps/docs/src/app/pages/blocks/block-composition.component.ts
@@ -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: `
+
+
+ @for (dep of componentDeps(); track dep) {
+ -
+
+ {{ dep }}
+
+
+ }
+
+
+ @if (peerEntries().length > 0) {
+
+
+ {{ peersLabel() }}
+
+
+ @for (peer of peerEntries(); track peer.name) {
+ -
+ {{ peer.name }}
+ {{ peer.version }}
+
+ }
+
+
+ }
+
+ `,
+})
+export class BlockCompositionComponent {
+ readonly componentDeps = input.required();
+ readonly peerDependencies = input> | undefined>();
+ readonly componentsLabel = input('Built from');
+ readonly peersLabel = input('Peer dependencies');
+
+ protected readonly peerEntries = () =>
+ Object.entries(this.peerDependencies() ?? {}).map(([name, version]) => ({ name, version }));
+}
diff --git a/apps/docs/src/app/pages/blocks/blocks-page.component.ts b/apps/docs/src/app/pages/blocks/blocks-page.component.ts
index 061d124c..9fd58462 100644
--- a/apps/docs/src/app/pages/blocks/blocks-page.component.ts
+++ b/apps/docs/src/app/pages/blocks/blocks-page.component.ts
@@ -1,50 +1,24 @@
-import { Component, effect, inject, signal } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-import { LucideLayoutDashboard, LucidePanelLeft } from '@lucide/angular';
-import {
- ButtonDirective,
- DescriptionDirective,
- FieldLabelDirective,
- InputDirective,
- LinkDirective,
- SANRING_ALERT_IMPORTS,
- SANRING_CARD_IMPORTS,
- SANRING_SIDEBAR_IMPORTS,
- SanringFieldComponent,
-} from '@sanring/ui';
+import { Component, effect, inject } from '@angular/core';
+import { RouterLink } from '@angular/router';
import { ComponentPageSectionDefinition } from '../../docs-schema/component-page.types';
import { I18nService } from '../../i18n/i18n.service';
import { SeoService } from '../../seo/seo.service';
import {
ComponentPageCodeBlock,
- ComponentPageCodePreviewer,
ComponentPageComponent,
ComponentPageSectionComponent,
DocsPageHeaderComponent,
} from '../../layouts/component-page';
-import { OrgChartComponent, type OrgLink, type OrgPerson } from '../../../registry-stage/org-chart';
+import { docsBlockCatalog } from './blocks.catalog';
@Component({
selector: 'app-blocks-page',
imports: [
- FormsModule,
- LucideLayoutDashboard,
- LucidePanelLeft,
- ButtonDirective,
- DescriptionDirective,
- FieldLabelDirective,
- InputDirective,
- LinkDirective,
- SANRING_ALERT_IMPORTS,
- SANRING_CARD_IMPORTS,
- SANRING_SIDEBAR_IMPORTS,
- SanringFieldComponent,
+ RouterLink,
ComponentPageCodeBlock,
- ComponentPageCodePreviewer,
ComponentPageComponent,
ComponentPageSectionComponent,
DocsPageHeaderComponent,
- OrgChartComponent,
],
template: `
@@ -62,129 +36,24 @@ import { OrgChartComponent, type OrgLink, type OrgPerson } from '../../../regist
-
- {{ i18n.t('blocks.dashboard.body') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- Overview
-
-
- Customers
-
-
-
-
-
-
-
Page content goes here.
-
-
-
-
-
-
-
-
-
- {{ i18n.t('blocks.login.body') }}
-
-
-
-
-
-
- Sign in
-
- Enter your email and password to continue.
-
-
-
- @if (loginError()) {
-
- Could not sign in
- {{ loginError() }}
-
- }
-
-
- No account yet?
- Create one
-
-
-
-
-
-
-
-
-
- {{ i18n.t('blocks.table.body') }}
-
-
-
-
-
-
- {{ i18n.t('blocks.org.body') }}
-
-
-
-
-
-
-
+
`,
@@ -192,8 +61,7 @@ import { OrgChartComponent, type OrgLink, type OrgPerson } from '../../../regist
export class BlocksPageComponent {
protected readonly i18n = inject(I18nService);
private readonly seo = inject(SeoService);
- protected email = '';
- protected readonly loginError = signal(null);
+ protected readonly blocks = docsBlockCatalog;
constructor() {
effect(() => {
@@ -206,63 +74,10 @@ export class BlocksPageComponent {
protected readonly sections: readonly ComponentPageSectionDefinition[] = [
{ id: 'overview', titleKey: 'blocks.overview.title' },
- { id: 'dashboard-shell', titleKey: 'blocks.dashboard.title' },
- { id: 'login', titleKey: 'blocks.login.title' },
- { id: 'table-page', titleKey: 'blocks.table.title' },
- { id: 'org-chart', titleKey: 'blocks.org.title' },
+ { id: 'catalog', titleKey: 'blocks.catalog.title' },
];
- protected readonly installAll = `npx @sanring/cli add block/dashboard-shell
-npx @sanring/cli add block/login
-npx @sanring/cli add block/table-page
-npx @sanring/cli add block/org-chart`;
- protected readonly installDashboard = `npx @sanring/cli add block/dashboard-shell`;
- protected readonly installLogin = `npx @sanring/cli add block/login`;
- protected readonly installTable = `npx @sanring/cli add block/table-page`;
- protected readonly installOrg = `npx @sanring/cli add block/org-chart`;
- protected readonly orgSnippet = ``;
- protected readonly orgSelected = signal(null);
- protected readonly orgPeople: OrgPerson[] = [
- { id: 'ada', name: 'Ada Lin', title: 'CEO' },
- { id: 'ben', name: 'Ben Ho', title: 'CTO', department: 'Eng' },
- { id: 'cora', name: 'Cora Wu', title: 'CFO', department: 'Finance' },
- { id: 'dan', name: 'Dan Chen', title: 'VP Sales', department: 'Sales' },
- { id: 'eve', name: 'Eve Tsai', title: 'Eng Manager', department: 'Eng' },
- { id: 'finn', name: 'Finn Kao', title: 'Eng Manager', department: 'Eng' },
- { id: 'gus', name: 'Gus Lee', title: 'Controller', department: 'Finance' },
- { id: 'hana', name: 'Hana Su', title: 'Sales Lead', department: 'Sales' },
- { id: 'jo', name: 'Jo Yang', title: 'Engineer', department: 'Eng' },
- { id: 'kai', name: 'Kai Lu', title: 'Engineer', department: 'Eng' },
- { id: 'mia', name: 'Mia Hsu', title: 'Solutions Eng', department: 'Eng' },
- { id: 'nia', name: 'Nia Lo', title: 'Accountant', department: 'Finance' },
- { id: 'oto', name: 'Oto Pan', title: 'Account Exec', department: 'Sales' },
- { id: 'quin', name: 'Quin Fang', title: 'Junior PM', department: 'Eng' },
- ];
- protected readonly orgLinks: OrgLink[] = [
- { source: 'ada', target: 'ben' },
- { source: 'ada', target: 'cora' },
- { source: 'ada', target: 'dan' },
- { source: 'ben', target: 'eve' },
- { source: 'ben', target: 'finn' },
- { source: 'cora', target: 'gus' },
- { source: 'dan', target: 'hana' },
- { source: 'eve', target: 'jo' },
- { source: 'eve', target: 'quin' },
- { source: 'finn', target: 'kai' },
- // Mia reports to both Finn and Hana.
- { source: 'finn', target: 'mia' },
- { source: 'hana', target: 'mia' },
- { source: 'gus', target: 'nia' },
- { source: 'hana', target: 'oto' },
- // The CEO sponsors Quin's project directly.
- { source: 'ada', target: 'quin', kind: 'dotted' },
- ];
- protected readonly dashboardSnippet = `
-
-`;
- protected readonly loginSnippet = ``;
-
- protected submitLogin(): void {
- this.loginError.set(this.email.includes('@') ? null : 'Enter a valid email to continue.');
- }
+ protected readonly installAll = docsBlockCatalog
+ .map((block) => `npx @sanring/cli@latest add ${block.installName}`)
+ .join('\n');
}
diff --git a/apps/docs/src/app/pages/blocks/blocks.catalog.ts b/apps/docs/src/app/pages/blocks/blocks.catalog.ts
new file mode 100644
index 00000000..e265ac4d
--- /dev/null
+++ b/apps/docs/src/app/pages/blocks/blocks.catalog.ts
@@ -0,0 +1,92 @@
+import { TranslationKey } from '../../i18n/translations';
+import { DocsComponentId } from '../../navigation/docs-navigation';
+
+export type DocsBlockId = 'dashboard-shell' | 'login' | 'table-page' | 'org-chart';
+
+export interface DocsBlockDefinition {
+ id: DocsBlockId;
+ /** CLI target, e.g. `block/org-chart`. */
+ installName: string;
+ titleKey: TranslationKey;
+ descriptionKey: TranslationKey;
+ scenarioKey: TranslationKey;
+ compositionKey: TranslationKey;
+ notesKey?: TranslationKey;
+ componentDeps: readonly DocsComponentId[];
+ peerDependencies?: Readonly>;
+ path: `/blocks/${DocsBlockId}`;
+}
+
+export const docsBlockCatalog: readonly DocsBlockDefinition[] = [
+ {
+ id: 'dashboard-shell',
+ installName: 'block/dashboard-shell',
+ titleKey: 'blocks.dashboard.title',
+ descriptionKey: 'blocks.dashboard.body',
+ scenarioKey: 'blocks.dashboard.scenario',
+ compositionKey: 'blocks.dashboard.composition',
+ componentDeps: ['sidebar', 'dropdown-menu', 'avatar', 'breadcrumb', 'badge'],
+ path: '/blocks/dashboard-shell',
+ },
+ {
+ id: 'login',
+ installName: 'block/login',
+ titleKey: 'blocks.login.title',
+ descriptionKey: 'blocks.login.body',
+ scenarioKey: 'blocks.login.scenario',
+ compositionKey: 'blocks.login.composition',
+ componentDeps: [
+ 'card',
+ 'field',
+ 'input',
+ 'label',
+ 'button',
+ 'checkbox',
+ 'link',
+ 'divider',
+ 'alert',
+ ],
+ path: '/blocks/login',
+ },
+ {
+ id: 'table-page',
+ installName: 'block/table-page',
+ titleKey: 'blocks.table.title',
+ descriptionKey: 'blocks.table.body',
+ scenarioKey: 'blocks.table.scenario',
+ compositionKey: 'blocks.table.composition',
+ componentDeps: [
+ 'table',
+ 'pagination',
+ 'input',
+ 'select',
+ 'dropdown-menu',
+ 'checkbox',
+ 'badge',
+ 'sheet',
+ 'skeleton',
+ 'toast',
+ 'field',
+ 'button',
+ ],
+ path: '/blocks/table-page',
+ },
+ {
+ id: 'org-chart',
+ installName: 'block/org-chart',
+ titleKey: 'blocks.org.title',
+ descriptionKey: 'blocks.org.body',
+ scenarioKey: 'blocks.org.scenario',
+ compositionKey: 'blocks.org.composition',
+ notesKey: 'blocks.org.notes',
+ componentDeps: ['avatar', 'badge', 'button', 'skeleton', 'tree'],
+ peerDependencies: { elkjs: '^0.12.0' },
+ path: '/blocks/org-chart',
+ },
+];
+
+export function getDocsBlock(id: DocsBlockId): DocsBlockDefinition {
+ const block = docsBlockCatalog.find((item) => item.id === id);
+ if (!block) throw new Error(`Unknown docs block: ${id}`);
+ return block;
+}
diff --git a/apps/docs/src/app/pages/blocks/dashboard-shell/dashboard-shell-block-page.component.ts b/apps/docs/src/app/pages/blocks/dashboard-shell/dashboard-shell-block-page.component.ts
new file mode 100644
index 00000000..77461013
--- /dev/null
+++ b/apps/docs/src/app/pages/blocks/dashboard-shell/dashboard-shell-block-page.component.ts
@@ -0,0 +1,123 @@
+import { Component, effect, inject } from '@angular/core';
+import { ComponentPageSectionDefinition } from '../../../docs-schema/component-page.types';
+import { I18nService } from '../../../i18n/i18n.service';
+import { SeoService } from '../../../seo/seo.service';
+import {
+ ComponentPageCodeBlock,
+ ComponentPageCodePreviewer,
+ ComponentPageComponent,
+ ComponentPageInstallationComponent,
+ ComponentPageSectionComponent,
+ DocsPageHeaderComponent,
+} from '../../../layouts/component-page';
+import { DashboardShellComponent } from '../../../../registry-stage/dashboard-shell';
+import { BlockCompositionComponent } from '../block-composition.component';
+import { getDocsBlock } from '../blocks.catalog';
+
+@Component({
+ selector: 'app-dashboard-shell-block-page',
+ imports: [
+ BlockCompositionComponent,
+ ComponentPageCodeBlock,
+ ComponentPageCodePreviewer,
+ ComponentPageComponent,
+ ComponentPageInstallationComponent,
+ ComponentPageSectionComponent,
+ DocsPageHeaderComponent,
+ DashboardShellComponent,
+ ],
+ template: `
+
+
+
+
+
+ {{ i18n.t(block.scenarioKey) }}
+
+
+
+
+
+ {{ i18n.t(block.compositionKey) }}
+
+
+ {{ i18n.t('blocks.section.compositionHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Page content goes here.
+
+
+
+
+
+ `,
+})
+export class DashboardShellBlockPageComponent {
+ protected readonly i18n = inject(I18nService);
+ private readonly seo = inject(SeoService);
+ protected readonly block = getDocsBlock('dashboard-shell');
+
+ constructor() {
+ effect(() => {
+ this.seo.setPage({
+ title: this.i18n.t(this.block.titleKey),
+ description: this.i18n.t(this.block.descriptionKey),
+ });
+ });
+ }
+
+ protected readonly sections: readonly ComponentPageSectionDefinition[] = [
+ { id: 'scenario', titleKey: 'blocks.section.scenario' },
+ { id: 'composition', titleKey: 'blocks.section.composition' },
+ { id: 'installation', titleKey: 'blocks.section.installation' },
+ { id: 'usage', titleKey: 'blocks.section.usage' },
+ { id: 'preview', titleKey: 'blocks.section.preview' },
+ ];
+
+ protected readonly usageTs = `import { Component } from '@angular/core';
+import { DashboardShellComponent } from './components/ui/dashboard-shell';
+
+@Component({
+ imports: [DashboardShellComponent],
+ templateUrl: './app.html',
+})
+export class App {}`;
+
+ protected readonly usageHtml = `
+
+`;
+}
diff --git a/apps/docs/src/app/pages/blocks/login/login-block-page.component.ts b/apps/docs/src/app/pages/blocks/login/login-block-page.component.ts
new file mode 100644
index 00000000..3d54901f
--- /dev/null
+++ b/apps/docs/src/app/pages/blocks/login/login-block-page.component.ts
@@ -0,0 +1,136 @@
+import { Component, effect, inject, signal } from '@angular/core';
+import { ComponentPageSectionDefinition } from '../../../docs-schema/component-page.types';
+import { I18nService } from '../../../i18n/i18n.service';
+import { SeoService } from '../../../seo/seo.service';
+import {
+ ComponentPageCodeBlock,
+ ComponentPageCodePreviewer,
+ ComponentPageComponent,
+ ComponentPageInstallationComponent,
+ ComponentPageSectionComponent,
+ DocsPageHeaderComponent,
+} from '../../../layouts/component-page';
+import { LoginComponent } from '../../../../registry-stage/login';
+import { BlockCompositionComponent } from '../block-composition.component';
+import { getDocsBlock } from '../blocks.catalog';
+
+@Component({
+ selector: 'app-login-block-page',
+ imports: [
+ BlockCompositionComponent,
+ ComponentPageCodeBlock,
+ ComponentPageCodePreviewer,
+ ComponentPageComponent,
+ ComponentPageInstallationComponent,
+ ComponentPageSectionComponent,
+ DocsPageHeaderComponent,
+ LoginComponent,
+ ],
+ template: `
+
+
+
+
+
+ {{ i18n.t(block.scenarioKey) }}
+
+
+
+
+
+ {{ i18n.t(block.compositionKey) }}
+
+
+ {{ i18n.t('blocks.section.compositionHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+})
+export class LoginBlockPageComponent {
+ protected readonly i18n = inject(I18nService);
+ private readonly seo = inject(SeoService);
+ protected readonly block = getDocsBlock('login');
+ protected readonly loginError = signal(null);
+
+ constructor() {
+ effect(() => {
+ this.seo.setPage({
+ title: this.i18n.t(this.block.titleKey),
+ description: this.i18n.t(this.block.descriptionKey),
+ });
+ });
+ }
+
+ protected readonly sections: readonly ComponentPageSectionDefinition[] = [
+ { id: 'scenario', titleKey: 'blocks.section.scenario' },
+ { id: 'composition', titleKey: 'blocks.section.composition' },
+ { id: 'installation', titleKey: 'blocks.section.installation' },
+ { id: 'usage', titleKey: 'blocks.section.usage' },
+ { id: 'preview', titleKey: 'blocks.section.preview' },
+ ];
+
+ protected readonly usageTs = `import { Component, signal } from '@angular/core';
+import { LoginComponent } from './components/ui/login';
+
+@Component({
+ imports: [LoginComponent],
+ templateUrl: './login.page.html',
+})
+export class LoginPage {
+ readonly error = signal(null);
+
+ onSubmitted(payload: { email: string; password: string; rememberMe: boolean }) {
+ // call your auth API, then:
+ // this.error.set('Invalid email or password');
+ }
+}`;
+
+ protected readonly usageHtml = ``;
+
+ protected onSubmitted(payload: { email: string; password: string; rememberMe: boolean }): void {
+ this.loginError.set(
+ payload.email.includes('@') ? null : 'Enter a valid email to continue.',
+ );
+ }
+}
diff --git a/apps/docs/src/app/pages/blocks/org-chart/org-chart-block-page.component.ts b/apps/docs/src/app/pages/blocks/org-chart/org-chart-block-page.component.ts
new file mode 100644
index 00000000..230810ed
--- /dev/null
+++ b/apps/docs/src/app/pages/blocks/org-chart/org-chart-block-page.component.ts
@@ -0,0 +1,261 @@
+import { Component, effect, inject, signal } from '@angular/core';
+import {
+ ComponentPageDefinition,
+ ComponentPageSectionDefinition,
+} from '../../../docs-schema/component-page.types';
+import { getComponentPageSection } from '../../../docs-schema/component-page.utils';
+import { I18nService } from '../../../i18n/i18n.service';
+import { SeoService } from '../../../seo/seo.service';
+import {
+ ComponentPageCodeBlock,
+ ComponentPageCodePreviewer,
+ ComponentPageComponent,
+ ComponentPageInstallationComponent,
+ ComponentPageSectionComponent,
+ DocsPageHeaderComponent,
+} from '../../../layouts/component-page';
+import { OrgChartComponent, type OrgLink, type OrgPerson } from '../../../../registry-stage/org-chart';
+import { BlockCompositionComponent } from '../block-composition.component';
+import { getDocsBlock } from '../blocks.catalog';
+
+const orgChartBlockPage = {
+ componentId: 'tree',
+ titleKey: 'blocks.org.title',
+ descriptionKey: 'blocks.org.body',
+ sections: [
+ { id: 'scenario', titleKey: 'blocks.section.scenario' },
+ { id: 'composition', titleKey: 'blocks.section.composition' },
+ { id: 'installation', titleKey: 'blocks.section.installation' },
+ { id: 'usage', titleKey: 'blocks.section.usage' },
+ {
+ id: 'preview',
+ titleKey: 'blocks.section.preview',
+ children: [
+ {
+ id: 'preview-with-tree',
+ titleKey: 'blocks.org.preview.withTree',
+ level: 3,
+ },
+ {
+ id: 'preview-canvas-only',
+ titleKey: 'blocks.org.preview.canvasOnly',
+ level: 3,
+ },
+ ],
+ },
+ { id: 'notes', titleKey: 'blocks.section.notes' },
+ ],
+} as const satisfies ComponentPageDefinition;
+
+@Component({
+ selector: 'app-org-chart-block-page',
+ imports: [
+ BlockCompositionComponent,
+ ComponentPageCodeBlock,
+ ComponentPageCodePreviewer,
+ ComponentPageComponent,
+ ComponentPageInstallationComponent,
+ ComponentPageSectionComponent,
+ DocsPageHeaderComponent,
+ OrgChartComponent,
+ ],
+ template: `
+
+
+
+
+
+ {{ i18n.t(block.scenarioKey) }}
+
+
+
+
+
+ {{ i18n.t(block.compositionKey) }}
+
+
+ {{ i18n.t('blocks.section.compositionHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.t('blocks.org.preview.withTreeDescription') }}
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.t('blocks.org.preview.canvasOnlyDescription') }}
+
+
+
+
+
+
+
+
+
+
+ @if (block.notesKey; as notesKey) {
+
+
+ {{ i18n.t(notesKey) }}
+
+
+ }
+
+ `,
+})
+export class OrgChartBlockPageComponent {
+ protected readonly i18n = inject(I18nService);
+ private readonly seo = inject(SeoService);
+ protected readonly block = getDocsBlock('org-chart');
+ protected readonly page = orgChartBlockPage;
+ protected readonly selected = signal(null);
+ protected readonly selectedCanvasOnly = signal(null);
+
+ constructor() {
+ effect(() => {
+ this.seo.setPage({
+ title: this.i18n.t(this.block.titleKey),
+ description: this.i18n.t(this.block.descriptionKey),
+ });
+ });
+ }
+
+ protected section(id: string): ComponentPageSectionDefinition {
+ return getComponentPageSection(this.page, id);
+ }
+
+ protected readonly usageTs = `import { Component, signal } from '@angular/core';
+import {
+ OrgChartComponent,
+ type OrgLink,
+ type OrgPerson,
+} from './components/ui/org-chart';
+
+@Component({
+ imports: [OrgChartComponent],
+ templateUrl: './org.page.html',
+})
+export class OrgPage {
+ readonly selected = signal(null);
+
+ readonly people: OrgPerson[] = [
+ { id: 'ada', name: 'Ada Lin', title: 'CEO' },
+ { id: 'ben', name: 'Ben Ho', title: 'CTO', department: 'Eng' },
+ { id: 'mia', name: 'Mia Hsu', title: 'Solutions Eng', department: 'Eng' },
+ ];
+
+ readonly links: OrgLink[] = [
+ { source: 'ada', target: 'ben' },
+ { source: 'ben', target: 'mia' },
+ { source: 'ada', target: 'mia', kind: 'dotted' },
+ ];
+}`;
+
+ protected readonly usageHtml = ``;
+
+ protected readonly usageCanvasOnlyHtml = ``;
+
+ protected readonly people: OrgPerson[] = [
+ { id: 'ada', name: 'Ada Lin', title: 'CEO' },
+ { id: 'ben', name: 'Ben Ho', title: 'CTO', department: 'Eng' },
+ { id: 'cora', name: 'Cora Wu', title: 'CFO', department: 'Finance' },
+ { id: 'dan', name: 'Dan Chen', title: 'VP Sales', department: 'Sales' },
+ { id: 'eve', name: 'Eve Tsai', title: 'Eng Manager', department: 'Eng' },
+ { id: 'finn', name: 'Finn Kao', title: 'Eng Manager', department: 'Eng' },
+ { id: 'gus', name: 'Gus Lee', title: 'Controller', department: 'Finance' },
+ { id: 'hana', name: 'Hana Su', title: 'Sales Lead', department: 'Sales' },
+ { id: 'jo', name: 'Jo Yang', title: 'Engineer', department: 'Eng' },
+ { id: 'kai', name: 'Kai Lu', title: 'Engineer', department: 'Eng' },
+ { id: 'mia', name: 'Mia Hsu', title: 'Solutions Eng', department: 'Eng' },
+ { id: 'nia', name: 'Nia Lo', title: 'Accountant', department: 'Finance' },
+ { id: 'oto', name: 'Oto Pan', title: 'Account Exec', department: 'Sales' },
+ { id: 'quin', name: 'Quin Fang', title: 'Junior PM', department: 'Eng' },
+ ];
+
+ protected readonly links: OrgLink[] = [
+ { source: 'ada', target: 'ben' },
+ { source: 'ada', target: 'cora' },
+ { source: 'ada', target: 'dan' },
+ { source: 'ben', target: 'eve' },
+ { source: 'ben', target: 'finn' },
+ { source: 'cora', target: 'gus' },
+ { source: 'dan', target: 'hana' },
+ { source: 'eve', target: 'jo' },
+ { source: 'eve', target: 'quin' },
+ { source: 'finn', target: 'kai' },
+ { source: 'finn', target: 'mia' },
+ { source: 'hana', target: 'mia' },
+ { source: 'gus', target: 'nia' },
+ { source: 'hana', target: 'oto' },
+ { source: 'ada', target: 'quin', kind: 'dotted' },
+ ];
+}
diff --git a/apps/docs/src/app/pages/blocks/table-page/table-page-block-page.component.ts b/apps/docs/src/app/pages/blocks/table-page/table-page-block-page.component.ts
new file mode 100644
index 00000000..7048bed6
--- /dev/null
+++ b/apps/docs/src/app/pages/blocks/table-page/table-page-block-page.component.ts
@@ -0,0 +1,118 @@
+import { Component, effect, inject } from '@angular/core';
+import { ComponentPageSectionDefinition } from '../../../docs-schema/component-page.types';
+import { I18nService } from '../../../i18n/i18n.service';
+import { SeoService } from '../../../seo/seo.service';
+import {
+ ComponentPageCodeBlock,
+ ComponentPageCodePreviewer,
+ ComponentPageComponent,
+ ComponentPageInstallationComponent,
+ ComponentPageSectionComponent,
+ DocsPageHeaderComponent,
+} from '../../../layouts/component-page';
+import { TablePageComponent } from '../../../../registry-stage/table-page';
+import { BlockCompositionComponent } from '../block-composition.component';
+import { getDocsBlock } from '../blocks.catalog';
+
+@Component({
+ selector: 'app-table-page-block-page',
+ imports: [
+ BlockCompositionComponent,
+ ComponentPageCodeBlock,
+ ComponentPageCodePreviewer,
+ ComponentPageComponent,
+ ComponentPageInstallationComponent,
+ ComponentPageSectionComponent,
+ DocsPageHeaderComponent,
+ TablePageComponent,
+ ],
+ template: `
+
+
+
+
+
+ {{ i18n.t(block.scenarioKey) }}
+
+
+
+
+
+ {{ i18n.t(block.compositionKey) }}
+
+
+ {{ i18n.t('blocks.section.compositionHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+})
+export class TablePageBlockPageComponent {
+ protected readonly i18n = inject(I18nService);
+ private readonly seo = inject(SeoService);
+ protected readonly block = getDocsBlock('table-page');
+
+ constructor() {
+ effect(() => {
+ this.seo.setPage({
+ title: this.i18n.t(this.block.titleKey),
+ description: this.i18n.t(this.block.descriptionKey),
+ });
+ });
+ }
+
+ protected readonly sections: readonly ComponentPageSectionDefinition[] = [
+ { id: 'scenario', titleKey: 'blocks.section.scenario' },
+ { id: 'composition', titleKey: 'blocks.section.composition' },
+ { id: 'installation', titleKey: 'blocks.section.installation' },
+ { id: 'usage', titleKey: 'blocks.section.usage' },
+ { id: 'preview', titleKey: 'blocks.section.preview' },
+ ];
+
+ protected readonly usageTs = `import { Component } from '@angular/core';
+import { TablePageComponent } from './components/ui/table-page';
+
+@Component({
+ imports: [TablePageComponent],
+ templateUrl: './invoices.page.html',
+})
+export class InvoicesPage {}`;
+
+ protected readonly usageHtml = ``;
+}
diff --git a/apps/docs/src/app/shell/header/feature-list.component.ts b/apps/docs/src/app/shell/header/feature-list.component.ts
index 9508fb4b..4b4bfe2d 100644
--- a/apps/docs/src/app/shell/header/feature-list.component.ts
+++ b/apps/docs/src/app/shell/header/feature-list.component.ts
@@ -252,7 +252,7 @@ export class FeatureListComponent {
.map((item) => ({
label: this.i18n.t(item.labelKey),
description: this.i18n.t(item.descriptionKey),
- path: `${item.path}#${item.fragment}`,
+ path: item.path,
}));
const componentItems = docsComponentItems
.filter((item) => !item.disabled)
diff --git a/packages/cli/src/commands/add.test.ts b/packages/cli/src/commands/add.test.ts
index 8535ddce..5dcb69b6 100644
--- a/packages/cli/src/commands/add.test.ts
+++ b/packages/cli/src/commands/add.test.ts
@@ -98,6 +98,15 @@ describe('collectPeerDeps', () => {
it('returns an empty object when nothing has peer dependencies', () => {
expect(collectPeerDeps([component({ name: 'a' })], [])).toEqual({});
});
+
+ it('keeps elkjs when collecting peers for an org-chart-like block', () => {
+ expect(
+ collectPeerDeps(
+ [component({ name: 'org-chart', peerDependencies: { elkjs: '^0.12.0' } })],
+ [],
+ ),
+ ).toEqual({ elkjs: '^0.12.0' });
+ });
});
describe('parseComponentRef', () => {
@@ -362,6 +371,40 @@ describe('addCommand (integration)', () => {
expect(existsSync(join(projectDir, 'src/app/components/ui/widget/index.ts'))).toBe(true);
});
+ it('dry-runs installing elkjs when adding block/org-chart with that peer', async () => {
+ const logs: string[] = [];
+ vi.mocked(console.log).mockImplementation((...args: unknown[]) => {
+ logs.push(args.join(' '));
+ });
+
+ writeRegistryFixture(registryDir, {
+ widget: 'export const widget = 1;\n',
+ block: 'export const org = true;\n',
+ });
+ const registryJsonPath = join(registryDir, 'registry.json');
+ const registryJson = JSON.parse(readFileSync(registryJsonPath, 'utf-8')) as Registry;
+ const login = registryJson.blocks?.[0];
+ if (!login) throw new Error('expected fixture block');
+ login.name = 'org-chart';
+ login.files = ['org-chart/index.ts'];
+ login.peerDependencies = { elkjs: '^0.12.0' };
+ mkdirSync(join(registryDir, 'blocks', 'org-chart'), { recursive: true });
+ writeFileSync(
+ join(registryDir, 'blocks', 'org-chart', 'index.ts'),
+ 'export const org = true;\n',
+ 'utf-8',
+ );
+ writeFileSync(registryJsonPath, JSON.stringify(registryJson, null, 2), 'utf-8');
+
+ await addCommand.parseAsync(['block/org-chart', '--registry', registryDir, '--dry-run'], {
+ from: 'user',
+ });
+
+ expect(logs.some((line) => line.includes('Would install: npm install elkjs@^0.12.0'))).toBe(
+ true,
+ );
+ });
+
it('installs a block by bare name when no component shares that name', async () => {
writeRegistryFixture(registryDir, {
widget: 'export const widget = 1;\n',
diff --git a/registry/blocks/org-chart/org-chart.component.ts b/registry/blocks/org-chart/org-chart.component.ts
index a2c7ac9d..69c8afbe 100644
--- a/registry/blocks/org-chart/org-chart.component.ts
+++ b/registry/blocks/org-chart/org-chart.component.ts
@@ -1,17 +1,24 @@
+import { NgTemplateOutlet } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
+ ElementRef,
computed,
+ effect,
inject,
input,
linkedSignal,
model,
resource,
+ signal,
+ viewChild,
} from '@angular/core';
import { SANRING_AVATAR_IMPORTS } from '../avatar';
import { BadgeDirective } from '../badge';
+import { ButtonDirective } from '../button';
import { SkeletonDirective } from '../skeleton';
+import { SANRING_TREE_IMPORTS } from '../tree';
import {
createOrgLayoutEngine,
layoutOrg,
@@ -32,78 +39,182 @@ export interface OrgPerson {
/** `solid` = line manager, `dotted` = matrix / dotted-line reporting. */
export type OrgLink = OrgEdge;
+interface OrgTreeNode {
+ id: string;
+ person: OrgPerson;
+ children: OrgTreeNode[];
+}
+
@Component({
selector: 'sanring-org-chart',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [SANRING_AVATAR_IMPORTS, BadgeDirective, SkeletonDirective],
+ imports: [
+ NgTemplateOutlet,
+ SANRING_AVATAR_IMPORTS,
+ BadgeDirective,
+ ButtonDirective,
+ SkeletonDirective,
+ SANRING_TREE_IMPORTS,
+ ],
host: {
class:
- 'block overflow-auto rounded-[var(--sanring-radius-lg)] border border-[var(--sanring-border)] bg-[var(--sanring-background)]',
+ 'flex min-h-0 overflow-hidden rounded-[var(--sanring-radius-lg)] border border-[var(--sanring-border)] bg-[var(--sanring-background)]',
},
template: `
- @if (layout.error()) {
- Could not lay out the org chart.
- } @else if (people().length === 0) {
- No people to show.
- } @else if (chart(); as c) {
-
-
- @for (node of c.nodes; track node.id) {
-