From 728dc829581fab7ee5f66aa74c73e34f0c3454ab Mon Sep 17 00:00:00 2001 From: jack755051 Date: Fri, 2 Oct 2026 17:01:36 +0800 Subject: [PATCH 1/6] feat(org-chart): add pan/zoom, fit-to-view, and tree directory Viewport supports wheel zoom and drag pan; Fit recenters the layout. A solid-line reporting tree syncs selection and moves focus to the matching card for keyboard access. Co-authored-by: Cursor --- .../blocks/org-chart/org-chart.component.ts | 413 +++++++++++++++--- 1 file changed, 355 insertions(+), 58 deletions(-) diff --git a/registry/blocks/org-chart/org-chart.component.ts b/registry/blocks/org-chart/org-chart.component.ts index a2c7ac9..69c8afb 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()) { - - } @else if (people().length === 0) { -
No people to show.
- } @else if (chart(); as c) { -
-

+ Directory +

+ - @for (edge of c.edges; track edge.id) { - + + + + @if (node.children.length > 0) { + + @for (child of node.children; track child.id) { + + } + + } + + + @for (root of treeRoots(); track root.id) { + } - - @for (node of c.nodes; track node.id) { - + + + + {{ zoomPercent() }}% + +
+ + @if (layout.error()) { + + } @else if (people().length === 0) { +
No people to show.
+ } @else if (chart(); as c) { +
+
- - @if (node.person.avatarUrl) { - - } - {{ initials(node.person.name) }} - - - {{ node.person.name }} - @if (node.person.title) { - {{ node.person.title }} + - @if (node.person.department) { - {{ node.person.department }} + + @for (node of c.nodes; track node.id) { + } - - } -
- } @else { -
-
-
-
-
+
+
+ } @else { +
+
+
+
+
+
-
- } + } + `, }) export class OrgChartComponent { @@ -111,10 +222,25 @@ export class OrgChartComponent { readonly links = input.required(); readonly nodeWidth = input(240); readonly nodeHeight = input(64); + /** When false, only the chart viewport is shown (no directory tree). */ + readonly showTree = input(true); /** Id of the highlighted person. */ readonly selected = model(null); + protected readonly zoom = signal(1); + protected readonly panX = signal(0); + protected readonly panY = signal(0); + protected readonly zoomPercent = computed(() => Math.round(this.zoom() * 100)); + protected readonly treeExpanded = linkedSignal(() => this.defaultExpandedIds()); + + private readonly viewport = viewChild>('viewport'); private engine?: OrgLayoutEngine & { terminateWorker?: () => void }; + private dragging = false; + private dragPointerId: number | null = null; + private lastPointerX = 0; + private lastPointerY = 0; + private didInitialFit = false; + private focusAfterSelect = false; protected readonly layout = resource({ params: () => ({ @@ -147,12 +273,129 @@ export class OrgChartComponent { const person = byId.get(node.id); return person ? [{ ...node, person }] : []; }), - edges: layout.edges.map((edge) => ({ id: edge.id, kind: edge.kind, d: orgEdgePath(edge.points) })), + edges: layout.edges.map((edge) => ({ + id: edge.id, + kind: edge.kind, + d: orgEdgePath(edge.points), + })), }; }); + /** Solid-line reporting tree for keyboard navigation (first parent wins on dual managers). */ + protected readonly treeRoots = computed(() => this.buildTree()); + constructor() { inject(DestroyRef).onDestroy(() => this.engine?.terminateWorker?.()); + + effect(() => { + const chart = this.chart(); + const vp = this.viewport(); + if (!chart || !vp || this.didInitialFit) return; + requestAnimationFrame(() => { + if (this.didInitialFit) return; + this.fitToView(); + this.didInitialFit = true; + }); + }); + + effect(() => { + const id = this.selected(); + if (!id || !this.focusAfterSelect) return; + this.focusAfterSelect = false; + queueMicrotask(() => this.focusCard(id)); + }); + } + + protected onTreeSelection(id: string | null): void { + if (!id) { + this.selected.set(null); + return; + } + this.focusAfterSelect = true; + this.selected.set(id); + this.scrollToNode(id); + } + + protected zoomBy(factor: number): void { + this.zoom.set(clampZoom(this.zoom() * factor)); + } + + fitToView(): void { + const chart = this.chart(); + const el = this.viewport()?.nativeElement; + if (!chart || !el || chart.width <= 0 || chart.height <= 0) return; + const pad = 24; + const scale = Math.min( + (el.clientWidth - pad) / chart.width, + (el.clientHeight - pad) / chart.height, + 1, + ); + this.zoom.set(clampZoom(scale)); + this.panX.set((el.clientWidth - chart.width * scale) / 2); + this.panY.set((el.clientHeight - chart.height * scale) / 2); + } + + scrollToNode(id: string): void { + const chart = this.chart(); + const el = this.viewport()?.nativeElement; + const node = chart?.nodes.find((item) => item.id === id); + if (!chart || !el || !node) return; + const z = this.zoom(); + this.panX.set(el.clientWidth / 2 - (node.x + node.width / 2) * z); + this.panY.set(el.clientHeight / 2 - (node.y + node.height / 2) * z); + } + + protected onWheel(event: WheelEvent): void { + event.preventDefault(); + const factor = event.deltaY > 0 ? 1 / 1.08 : 1.08; + const el = this.viewport()?.nativeElement; + if (!el) { + this.zoomBy(factor); + return; + } + const rect = el.getBoundingClientRect(); + const mx = event.clientX - rect.left; + const my = event.clientY - rect.top; + const prev = this.zoom(); + const next = clampZoom(prev * factor); + const worldX = (mx - this.panX()) / prev; + const worldY = (my - this.panY()) / prev; + this.zoom.set(next); + this.panX.set(mx - worldX * next); + this.panY.set(my - worldY * next); + } + + protected onPointerDown(event: PointerEvent): void { + if (event.button !== 0) return; + if ((event.target as HTMLElement | null)?.closest?.('[data-org-card]')) return; + const el = this.viewport()?.nativeElement; + if (!el) return; + this.dragging = true; + this.dragPointerId = event.pointerId; + this.lastPointerX = event.clientX; + this.lastPointerY = event.clientY; + el.setPointerCapture(event.pointerId); + } + + protected onPointerMove(event: PointerEvent): void { + if (!this.dragging || event.pointerId !== this.dragPointerId) return; + const dx = event.clientX - this.lastPointerX; + const dy = event.clientY - this.lastPointerY; + this.lastPointerX = event.clientX; + this.lastPointerY = event.clientY; + this.panX.update((x) => x + dx); + this.panY.update((y) => y + dy); + } + + protected onPointerUp(event: PointerEvent): void { + if (event.pointerId !== this.dragPointerId) return; + this.dragging = false; + this.dragPointerId = null; + try { + this.viewport()?.nativeElement.releasePointerCapture(event.pointerId); + } catch { + // Pointer may already be released. + } } protected initials(name: string): string { @@ -163,4 +406,58 @@ export class OrgChartComponent { .map((part) => part[0]!.toUpperCase()) .join(''); } + + private focusCard(id: string): void { + const card = this.viewport()?.nativeElement.querySelector( + `[data-org-card="${CSS.escape(id)}"]`, + ); + card?.focus({ preventScroll: true }); + } + + private buildTree(): OrgTreeNode[] { + const people = this.people(); + const byId = new Map(people.map((person) => [person.id, person])); + const children = new Map(); + const parentOf = new Map(); + + for (const link of this.links()) { + if ((link.kind ?? 'solid') !== 'solid') continue; + if (!byId.has(link.source) || !byId.has(link.target)) continue; + if (parentOf.has(link.target)) continue; + parentOf.set(link.target, link.source); + children.set(link.source, [...(children.get(link.source) ?? []), link.target]); + } + + const build = (id: string): OrgTreeNode | null => { + const person = byId.get(id); + if (!person) return null; + return { + id, + person, + children: (children.get(id) ?? []) + .map(build) + .filter((node): node is OrgTreeNode => node !== null), + }; + }; + + return people + .filter((person) => !parentOf.has(person.id)) + .map((person) => build(person.id)) + .filter((node): node is OrgTreeNode => node !== null); + } + + private defaultExpandedIds(): string[] { + const ids: string[] = []; + const visit = (node: OrgTreeNode) => { + if (node.children.length === 0) return; + ids.push(node.id); + for (const child of node.children) visit(child); + }; + for (const root of this.buildTree()) visit(root); + return ids; + } +} + +function clampZoom(value: number): number { + return Math.min(2.5, Math.max(0.35, value)); } From 733f94cbf61529dab8d0aef340b69f69e428e945 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Fri, 2 Oct 2026 17:01:36 +0800 Subject: [PATCH 2/6] feat(registry): register org-chart block and document elkjs notes Expose block/org-chart in registry.json with elkjs peer dependency, and document EPL-2.0, worker size, and allowedCommonJsDependencies on the docs blocks page. Co-authored-by: Cursor --- .../src/app/i18n/locales/en/pages/blocks.ts | 4 +++- .../src/app/i18n/locales/zh/pages/blocks.ts | 4 +++- .../app/pages/blocks/blocks-page.component.ts | 7 ++++-- registry/registry.json | 24 ++++++++++++++++++- 4 files changed, 34 insertions(+), 5 deletions(-) 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 a045476..d62a9c8 100644 --- a/apps/docs/src/app/i18n/locales/en/pages/blocks.ts +++ b/apps/docs/src/app/i18n/locales/en/pages/blocks.ts @@ -15,5 +15,7 @@ export const blocksTranslations = { 'A data table with search, status filter, row selection, a create sheet, loading skeletons, and toast.', '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: managers above reports, dual managers and dotted-line reporting, orthogonal connectors routed around cards. Pan and zoom the canvas, or use the directory tree for keyboard selection. Layout runs in a Web Worker via elkjs (EPL-2.0, ~336 KB transfer); the CLI installs elkjs as a peer. Add `allowedCommonJsDependencies: ["elkjs"]` to your Angular build options if the CLI warns about CommonJS.', + 'blocks.org.notes': + 'elkjs is EPL-2.0. The worker keeps layout off the main thread; expect ~336 KB transfer for the worker chunk.', } 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 d8940bb..a8fd594 100644 --- a/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts +++ b/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts @@ -15,5 +15,7 @@ export const blocksTranslations = { '含搜尋、狀態篩選、列選取、新增抽屜、loading skeleton 與 toast 的資料表頁。', 'blocks.org.title': '組織圖', 'blocks.org.body': - '經典組織圖:主管在上、下屬在下,支援雙主管與虛線匯報,直角連線自動繞開卡片。排版由 elkjs(EPL-2.0)在 Web Worker 中計算,CLI 會以 peer dependency 安裝。', + '經典組織圖:主管在上、下屬在下,支援雙主管與虛線匯報,直角連線自動繞開卡片。可平移縮放畫布,或用目錄樹鍵盤選取。排版由 elkjs(EPL-2.0,約 336 KB transfer)在 Web Worker 計算,CLI 會以 peer dependency 安裝。若 Angular 警告 CommonJS,在 build options 加上 `allowedCommonJsDependencies: ["elkjs"]`。', + 'blocks.org.notes': + 'elkjs 為 EPL-2.0。Worker 讓排版不阻塞主執行緒;worker chunk 約 336 KB transfer。', } as const; 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 061d124..d263f8a 100644 --- a/apps/docs/src/app/pages/blocks/blocks-page.component.ts +++ b/apps/docs/src/app/pages/blocks/blocks-page.component.ts @@ -174,11 +174,14 @@ import { OrgChartComponent, type OrgLink, type OrgPerson } from '../../../regist

{{ i18n.t('blocks.org.body') }}

+

+ {{ i18n.t('blocks.org.notes') }} +

- +
Date: Fri, 2 Oct 2026 17:01:36 +0800 Subject: [PATCH 3/6] test(org-chart): cover pan/zoom, tree sync, and elkjs peer install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Playwright coverage with axe, CLI dry-run assertion that block/org-chart installs elkjs, and a minor changeset. Mark P33 D–G complete in the charter. Co-authored-by: Cursor --- .changeset/org-chart-block.md | 5 +++ .claude/charters/p33-org-chart.md | 18 ++++---- apps/docs/e2e/org-chart.spec.ts | 62 +++++++++++++++++++++++++++ packages/cli/src/commands/add.test.ts | 43 +++++++++++++++++++ 4 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 .changeset/org-chart-block.md create mode 100644 apps/docs/e2e/org-chart.spec.ts diff --git a/.changeset/org-chart-block.md b/.changeset/org-chart-block.md new file mode 100644 index 0000000..167be0e --- /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 cde08b0..265f37d 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 0000000..dbe3e20 --- /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/packages/cli/src/commands/add.test.ts b/packages/cli/src/commands/add.test.ts index 8535ddc..5dcb69b 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', From a8641ab013bc2e3b204b363cd0b298cc13aadb44 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Fri, 2 Oct 2026 21:04:45 +0800 Subject: [PATCH 4/6] docs(blocks): split each block into its own page Move dashboard-shell, login, table-page, and org-chart off the single /blocks dump into /blocks/:id pages with scenario, composition links, install, TS/HTML usage, and live preview. Keep /blocks as the catalog index. Co-authored-by: Cursor --- apps/docs/e2e/org-chart.spec.ts | 2 +- apps/docs/src/app/app.routes.ts | 37 ++- .../src/app/i18n/locales/en/pages/blocks.ts | 35 ++- .../src/app/i18n/locales/zh/pages/blocks.ts | 35 ++- .../src/app/navigation/docs-navigation.ts | 15 +- .../blocks/block-composition.component.ts | 52 ++++ .../app/pages/blocks/blocks-page.component.ts | 242 ++---------------- .../src/app/pages/blocks/blocks.catalog.ts | 92 +++++++ .../dashboard-shell-block-page.component.ts | 123 +++++++++ .../login/login-block-page.component.ts | 136 ++++++++++ .../org-chart-block-page.component.ts | 189 ++++++++++++++ .../table-page-block-page.component.ts | 118 +++++++++ .../shell/header/feature-list.component.ts | 2 +- 13 files changed, 843 insertions(+), 235 deletions(-) create mode 100644 apps/docs/src/app/pages/blocks/block-composition.component.ts create mode 100644 apps/docs/src/app/pages/blocks/blocks.catalog.ts create mode 100644 apps/docs/src/app/pages/blocks/dashboard-shell/dashboard-shell-block-page.component.ts create mode 100644 apps/docs/src/app/pages/blocks/login/login-block-page.component.ts create mode 100644 apps/docs/src/app/pages/blocks/org-chart/org-chart-block-page.component.ts create mode 100644 apps/docs/src/app/pages/blocks/table-page/table-page-block-page.component.ts diff --git a/apps/docs/e2e/org-chart.spec.ts b/apps/docs/e2e/org-chart.spec.ts index dbe3e20..169d992 100644 --- a/apps/docs/e2e/org-chart.spec.ts +++ b/apps/docs/e2e/org-chart.spec.ts @@ -32,7 +32,7 @@ async function expectNoAxeViolations(page: Page, selector: string) { test.describe('org-chart block', () => { test('renders chart cards, pans/zooms, and syncs tree selection', async ({ page }) => { - await page.goto('/blocks#org-chart'); + 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({ diff --git a/apps/docs/src/app/app.routes.ts b/apps/docs/src/app/app.routes.ts index 5c5ff11..5bc77b7 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 d62a9c8..2af9164 100644 --- a/apps/docs/src/app/i18n/locales/en/pages/blocks.ts +++ b/apps/docs/src/app/i18n/locales/en/pages/blocks.ts @@ -4,18 +4,47 @@ 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. Pan and zoom the canvas, or use the directory tree for keyboard selection. Layout runs in a Web Worker via elkjs (EPL-2.0, ~336 KB transfer); the CLI installs elkjs as a peer. Add `allowedCommonJsDependencies: ["elkjs"]` to your Angular build options if the CLI warns about CommonJS.', + '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. The worker keeps layout off the main thread; expect ~336 KB transfer for the worker chunk.', + '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.', } 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 a8fd594..1b6b4aa 100644 --- a/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts +++ b/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts @@ -4,18 +4,47 @@ 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,約 336 KB transfer)在 Web Worker 計算,CLI 會以 peer dependency 安裝。若 Angular 警告 CommonJS,在 build options 加上 `allowedCommonJsDependencies: ["elkjs"]`。', + '經典組織圖:支援雙主管、虛線匯報、平移縮放,並以目錄樹提供鍵盤選取。', + '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 讓排版不阻塞主執行緒;worker chunk 約 336 KB transfer。', + 'elkjs 為 EPL-2.0(worker chunk 約 336 KB transfer)。若 Angular 警告 CommonJS,在 build options 加上 `allowedCommonJsDependencies: ["elkjs"]`。', } as const; diff --git a/apps/docs/src/app/navigation/docs-navigation.ts b/apps/docs/src/app/navigation/docs-navigation.ts index 6a11472..2eecf44 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/pages/blocks/block-composition.component.ts b/apps/docs/src/app/pages/blocks/block-composition.component.ts new file mode 100644 index 0000000..e23e82a --- /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: ` +
+ + + @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 d263f8a..9fd5846 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,132 +36,24 @@ import { OrgChartComponent, type OrgLink, type OrgPerson } from '../../../regist -

- {{ i18n.t('blocks.dashboard.body') }} -

- - -
-
- - - -
-
- -
-
Acme Inc
-
-
- - - - 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() }}

-
- } -
- - - - -

Use the email on your workspace invite.

-
- -
-

- No account yet? - Create one -

-
-
-
-
-
- - -

- {{ i18n.t('blocks.table.body') }} -

- -
- - -

- {{ i18n.t('blocks.org.body') }} -

-

- {{ i18n.t('blocks.org.notes') }} -

- - -
- -
-
+
`, @@ -195,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(() => { @@ -209,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 0000000..e265ac4 --- /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 0000000..7746101 --- /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 0000000..3d54901 --- /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 0000000..da57fd4 --- /dev/null +++ b/apps/docs/src/app/pages/blocks/org-chart/org-chart-block-page.component.ts @@ -0,0 +1,189 @@ +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 { OrgChartComponent, type OrgLink, type OrgPerson } from '../../../../registry-stage/org-chart'; +import { BlockCompositionComponent } from '../block-composition.component'; +import { getDocsBlock } from '../blocks.catalog'; + +@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') }} +

+
+ +
+
+ + + + + + +
+ + +
+
+ + + +
+ +
+
+
+ + @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 selected = 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' }, + { id: 'notes', titleKey: 'blocks.section.notes' }, + ]; + + 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 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 0000000..7048bed --- /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 9508fb4..4b4bfe2 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) From 85436d21c4ed316cf45c43c9188353a5033ed1c7 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Fri, 2 Oct 2026 21:09:10 +0800 Subject: [PATCH 5/6] docs(org-chart): add canvas-only preview with showTree=false Co-authored-by: Cursor --- .../src/app/i18n/locales/en/pages/blocks.ts | 6 + .../src/app/i18n/locales/zh/pages/blocks.ts | 6 + .../org-chart-block-page.component.ts | 124 ++++++++++++++---- 3 files changed, 110 insertions(+), 26 deletions(-) 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 2af9164..733e596 100644 --- a/apps/docs/src/app/i18n/locales/en/pages/blocks.ts +++ b/apps/docs/src/app/i18n/locales/en/pages/blocks.ts @@ -47,4 +47,10 @@ export const blocksTranslations = { '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 1b6b4aa..d3ee5d4 100644 --- a/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts +++ b/apps/docs/src/app/i18n/locales/zh/pages/blocks.ts @@ -47,4 +47,10 @@ export const blocksTranslations = { '卡片用 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/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 index da57fd4..230810e 100644 --- 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 @@ -1,5 +1,9 @@ import { Component, effect, inject, signal } from '@angular/core'; -import { ComponentPageSectionDefinition } from '../../../docs-schema/component-page.types'; +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 { @@ -14,6 +18,35 @@ import { OrgChartComponent, type OrgLink, type OrgPerson } from '../../../../reg 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: [ @@ -27,20 +60,20 @@ import { getDocsBlock } from '../blocks.catalog'; OrgChartComponent, ], template: ` - + - +

{{ i18n.t(block.scenarioKey) }}

- +

{{ i18n.t(block.compositionKey) }}

@@ -57,35 +90,69 @@ import { getDocsBlock } from '../blocks.catalog';
- + - +
- - -
- -
-
+ +
+ +

+ {{ i18n.t('blocks.org.preview.withTreeDescription') }} +

+ +
+ +
+
+
+ + +

+ {{ i18n.t('blocks.org.preview.canvasOnlyDescription') }} +

+ +
+ +
+
+
+
@if (block.notesKey; as notesKey) { - +

{{ i18n.t(notesKey) }}

@@ -98,7 +165,9 @@ 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(() => { @@ -109,14 +178,9 @@ export class OrgChartBlockPageComponent { }); } - 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' }, - { id: 'notes', titleKey: 'blocks.section.notes' }, - ]; + protected section(id: string): ComponentPageSectionDefinition { + return getComponentPageSection(this.page, id); + } protected readonly usageTs = `import { Component, signal } from '@angular/core'; import { @@ -152,6 +216,14 @@ export class OrgPage { [(selected)]="selected" />`; + protected readonly usageCanvasOnlyHtml = ``; + protected readonly people: OrgPerson[] = [ { id: 'ada', name: 'Ada Lin', title: 'CEO' }, { id: 'ben', name: 'Ben Ho', title: 'CTO', department: 'Eng' }, From 3e05d6f4422db8c5291c2c7a90be384242137e7e Mon Sep 17 00:00:00 2001 From: jack755051 Date: Fri, 2 Oct 2026 22:28:27 +0800 Subject: [PATCH 6/6] docs: add Blocks link to the primary header nav Co-authored-by: Cursor --- apps/docs/src/app/navigation/menu-navigation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/src/app/navigation/menu-navigation.ts b/apps/docs/src/app/navigation/menu-navigation.ts index 842eb94..27cff5b 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 }, ];