From f95bc9aa9f5fca3100198498d374d9ffedf50012 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Sun, 27 Sep 2026 15:52:46 +0800 Subject: [PATCH 1/4] feat(sortable): add single-list reorder primitive Cards and other content compose with [sanringSortableItem] so a list can be reordered by pointer or keyboard without a first-class item API. Co-authored-by: Cursor --- README.md | 2 +- apps/docs/src/app/app.routes.ts | 7 + apps/docs/src/app/i18n/locales/en/common.ts | 1 + .../app/i18n/locales/en/components/index.ts | 2 + .../i18n/locales/en/components/sortable.ts | 40 ++++ apps/docs/src/app/i18n/locales/zh/common.ts | 1 + .../app/i18n/locales/zh/components/index.ts | 2 + .../i18n/locales/zh/components/sortable.ts | 38 +++ .../src/app/navigation/docs-navigation.ts | 8 + .../pages/changelog/component-changelog.ts | 12 + .../sortable/sortable-page.component.ts | 221 ++++++++++++++++++ .../components/sortable/sortable.docs.ts | 193 +++++++++++++++ .../ui/src/lib/components/sortable/index.ts | 13 ++ .../sortable/sortable-handle.directive.ts | 17 ++ .../sortable/sortable-item.directive.ts | 68 ++++++ .../sortable/sortable.component.spec.ts | 84 +++++++ .../components/sortable/sortable.component.ts | 75 ++++++ packages/ui/src/public-api.ts | 1 + registry/components/sortable/index.ts | 13 ++ .../sortable/sortable-handle.directive.ts | 17 ++ .../sortable/sortable-item.directive.ts | 68 ++++++ .../components/sortable/sortable.component.ts | 75 ++++++ registry/registry.json | 17 ++ 23 files changed, 974 insertions(+), 1 deletion(-) create mode 100644 apps/docs/src/app/i18n/locales/en/components/sortable.ts create mode 100644 apps/docs/src/app/i18n/locales/zh/components/sortable.ts create mode 100644 apps/docs/src/app/pages/components/sortable/sortable-page.component.ts create mode 100644 apps/docs/src/app/pages/components/sortable/sortable.docs.ts create mode 100644 packages/ui/src/lib/components/sortable/index.ts create mode 100644 packages/ui/src/lib/components/sortable/sortable-handle.directive.ts create mode 100644 packages/ui/src/lib/components/sortable/sortable-item.directive.ts create mode 100644 packages/ui/src/lib/components/sortable/sortable.component.spec.ts create mode 100644 packages/ui/src/lib/components/sortable/sortable.component.ts create mode 100644 registry/components/sortable/index.ts create mode 100644 registry/components/sortable/sortable-handle.directive.ts create mode 100644 registry/components/sortable/sortable-item.directive.ts create mode 100644 registry/components/sortable/sortable.component.ts diff --git a/README.md b/README.md index 49ab8131..0a3e6c6d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Components are copied directly into your source code — you own them and can mo ## Available Components -accordion · alert · alert-dialog · aspect-ratio · avatar · badge · breadcrumb · button · calendar · card · carousel · checkbox · collapsible · combobox · command · context-menu · date-picker · dialog · divider · dropdown-menu · field · file-upload · hover-card · input · label · link · navigation-menu · otp-input · pagination · popover · progress · radio · resizable · scroll-area · select · sheet · sidebar · skeleton · slider · spinner · stepper · switch · table · tabs · tag · textarea · timeline · toast · toggle · tooltip · transfer · tree +accordion · alert · alert-dialog · aspect-ratio · avatar · badge · breadcrumb · button · calendar · card · carousel · checkbox · collapsible · combobox · command · context-menu · date-picker · dialog · divider · dropdown-menu · field · file-upload · hover-card · input · label · link · navigation-menu · otp-input · pagination · popover · progress · radio · resizable · scroll-area · select · sheet · sidebar · skeleton · slider · sortable · spinner · stepper · switch · table · tabs · tag · textarea · timeline · toast · toggle · tooltip · transfer · tree --- diff --git a/apps/docs/src/app/app.routes.ts b/apps/docs/src/app/app.routes.ts index 6c0da791..1293e6a2 100644 --- a/apps/docs/src/app/app.routes.ts +++ b/apps/docs/src/app/app.routes.ts @@ -353,6 +353,13 @@ export const routes: Routes = [ (m) => m.SliderPageComponent, ), }, + { + path: 'sortable', + loadComponent: () => + import('./pages/components/sortable/sortable-page.component').then( + (m) => m.SortablePageComponent, + ), + }, { path: 'spinner', loadComponent: () => diff --git a/apps/docs/src/app/i18n/locales/en/common.ts b/apps/docs/src/app/i18n/locales/en/common.ts index acbc00a8..0c400beb 100644 --- a/apps/docs/src/app/i18n/locales/en/common.ts +++ b/apps/docs/src/app/i18n/locales/en/common.ts @@ -78,6 +78,7 @@ export const commonTranslations = { 'component.sidebar': 'Sidebar', 'component.skeleton': 'Skeleton', 'component.slider': 'Slider', + 'component.sortable': 'Sortable', 'component.stepper': 'Stepper', 'component.switch': 'Switch', 'component.table': 'Table', diff --git a/apps/docs/src/app/i18n/locales/en/components/index.ts b/apps/docs/src/app/i18n/locales/en/components/index.ts index 06e96de6..4778d891 100644 --- a/apps/docs/src/app/i18n/locales/en/components/index.ts +++ b/apps/docs/src/app/i18n/locales/en/components/index.ts @@ -37,6 +37,7 @@ import { sheetTranslations } from './sheet'; import { sidebarTranslations } from './sidebar'; import { skeletonTranslations } from './skeleton'; import { sliderTranslations } from './slider'; +import { sortableTranslations } from './sortable'; import { spinnerTranslations } from './spinner'; import { stepperTranslations } from './stepper'; import { switchTranslations } from './switch'; @@ -91,6 +92,7 @@ export const componentTranslations = { ...sidebarTranslations, ...skeletonTranslations, ...sliderTranslations, + ...sortableTranslations, ...spinnerTranslations, ...stepperTranslations, ...switchTranslations, diff --git a/apps/docs/src/app/i18n/locales/en/components/sortable.ts b/apps/docs/src/app/i18n/locales/en/components/sortable.ts new file mode 100644 index 00000000..2239d40c --- /dev/null +++ b/apps/docs/src/app/i18n/locales/en/components/sortable.ts @@ -0,0 +1,40 @@ +export const sortableTranslations = { + 'sortable.description': + 'A single-list reorder primitive. Pointer drag uses Angular CDK; keyboard arrows move the focused item. Cards and other content compose through the item directive.', + 'sortable.demo.handle': 'Drag handle', + 'sortable.demo.horizontal': 'Horizontal', + 'sortable.demo.alpha': 'Inbox digest', + 'sortable.demo.alphaDescription': 'Summarize unread threads each morning.', + 'sortable.demo.beta': 'Release checklist', + 'sortable.demo.betaDescription': 'Gate publish on review and smoke tests.', + 'sortable.demo.gamma': 'Weekly report', + 'sortable.demo.gammaDescription': 'Ship a compact status note on Fridays.', + 'sortable.demo.reorder': 'Reorder', + 'sortable.installation.description': + 'Add the component with the CLI, then import the list, item, and optional handle directives.', + 'sortable.usage.description': + 'Bind the same array to data and @for. On sorted, replace the array so Angular can reconcile the new order.', + 'sortable.composition.description': + 'The list owns order. Items are any host element. An optional handle limits pointer dragging to that control.', + 'sortable.api.description': 'Inputs, outputs, and directives for a single sortable list.', + 'sortable.api.class.description': 'Additional classes merged with the list host.', + 'sortable.api.data.description': + 'Required array to reorder in place. sorted emits a shallow copy after each move.', + 'sortable.api.orientation.description': + 'Layout and lock axis for pointer dragging. vertical uses Arrow Up/Down; horizontal uses Arrow Left/Right.', + 'sortable.api.disabled.description': 'Disables pointer and keyboard reordering for the whole list.', + 'sortable.api.sorted.description': 'Emits a new array after a successful reorder.', + 'sortable.api.itemDisabled.description': 'Disables one item without locking the rest of the list.', + 'sortable.api.handle.description': + 'Optional drag handle. When present, pointer dragging starts from the handle instead of the whole item.', + 'sortable.accessibility.description': + 'The list uses role=list and items use role=listitem. Disabled items leave the tab order. Prefer a named handle button when the item is a dense card.', + 'sortable.keyboard.description': 'Focus an item, then move it with arrows. Tab walks the list.', + 'sortable.keyboard.arrowsVertical': + 'Moves the focused item up or down in a vertical list.', + 'sortable.keyboard.arrowsHorizontal': + 'Moves the focused item left or right in a horizontal list.', + 'sortable.keyboard.tabShiftTab': 'Moves focus between sortable items in document order.', + 'sortable.stateModel.description': + 'data is the source of order. Reorder mutates that array and emits sorted with a copy. disabled on the list or an item blocks moves without clearing data.', +} as const; diff --git a/apps/docs/src/app/i18n/locales/zh/common.ts b/apps/docs/src/app/i18n/locales/zh/common.ts index 9ba6a0a5..e9b5ff4a 100644 --- a/apps/docs/src/app/i18n/locales/zh/common.ts +++ b/apps/docs/src/app/i18n/locales/zh/common.ts @@ -78,6 +78,7 @@ export const commonTranslations = { 'component.sidebar': 'Sidebar', 'component.skeleton': '骨架屏', 'component.slider': '滑桿', + 'component.sortable': '可排序列表', 'component.stepper': '步驟器', 'component.switch': '開關', 'component.table': '表格', diff --git a/apps/docs/src/app/i18n/locales/zh/components/index.ts b/apps/docs/src/app/i18n/locales/zh/components/index.ts index 06e96de6..4778d891 100644 --- a/apps/docs/src/app/i18n/locales/zh/components/index.ts +++ b/apps/docs/src/app/i18n/locales/zh/components/index.ts @@ -37,6 +37,7 @@ import { sheetTranslations } from './sheet'; import { sidebarTranslations } from './sidebar'; import { skeletonTranslations } from './skeleton'; import { sliderTranslations } from './slider'; +import { sortableTranslations } from './sortable'; import { spinnerTranslations } from './spinner'; import { stepperTranslations } from './stepper'; import { switchTranslations } from './switch'; @@ -91,6 +92,7 @@ export const componentTranslations = { ...sidebarTranslations, ...skeletonTranslations, ...sliderTranslations, + ...sortableTranslations, ...spinnerTranslations, ...stepperTranslations, ...switchTranslations, diff --git a/apps/docs/src/app/i18n/locales/zh/components/sortable.ts b/apps/docs/src/app/i18n/locales/zh/components/sortable.ts new file mode 100644 index 00000000..09fbbd7a --- /dev/null +++ b/apps/docs/src/app/i18n/locales/zh/components/sortable.ts @@ -0,0 +1,38 @@ +export const sortableTranslations = { + 'sortable.description': + '單一列表重排 primitive。指標拖曳走 Angular CDK,鍵盤方向鍵移動目前焦點項目。卡片與其他內容透過 item directive 組合。', + 'sortable.demo.handle': '拖曳手柄', + 'sortable.demo.horizontal': '水平', + 'sortable.demo.alpha': '收件摘要', + 'sortable.demo.alphaDescription': '每天早上彙整未讀討論串。', + 'sortable.demo.beta': '發布檢查清單', + 'sortable.demo.betaDescription': '發布前必須通過審核與煙霧測試。', + 'sortable.demo.gamma': '週報', + 'sortable.demo.gammaDescription': '每週五送出精簡狀態說明。', + 'sortable.demo.reorder': '調整順序', + 'sortable.installation.description': + '用 CLI 加入這個元件,再匯入 list、item,以及可選的 handle directive。', + 'sortable.usage.description': + '把同一個陣列綁到 data 與 @for。在 sorted 時換成新陣列,讓 Angular 對齊新順序。', + 'sortable.composition.description': + '列表負責順序。Item 可以是任何 host 元素。可選 handle 會把指標拖曳限制在該控制項上。', + 'sortable.api.description': '單一 sortable 列表的 Inputs、Outputs 與 directives。', + 'sortable.api.class.description': '與 list host 合併的額外 class。', + 'sortable.api.data.description': + '必填陣列,會就地重排。每次移動後 sorted 會送出淺拷貝。', + 'sortable.api.orientation.description': + '版面與指標拖曳的鎖定軸。vertical 用方向鍵上/下;horizontal 用左/右。', + 'sortable.api.disabled.description': '停用整份列表的指標與鍵盤重排。', + 'sortable.api.sorted.description': '成功重排後送出新陣列。', + 'sortable.api.itemDisabled.description': '停用單一項目,不鎖定整份列表。', + 'sortable.api.handle.description': + '可選拖曳手柄。有 handle 時,指標拖曳從手柄開始,而不是整個 item。', + 'sortable.accessibility.description': + '列表使用 role=list,項目使用 role=listitem。停用的項目會離開 Tab 順序。內容是密集卡片時,建議用有名稱的 handle 按鈕。', + 'sortable.keyboard.description': '先聚焦一個項目,再用方向鍵移動。Tab 會依序走過列表。', + 'sortable.keyboard.arrowsVertical': '在垂直列表中,把焦點項目往上或往下移。', + 'sortable.keyboard.arrowsHorizontal': '在水平列表中,把焦點項目往左或往右移。', + 'sortable.keyboard.tabShiftTab': '依文件順序在 sortable 項目之間移動焦點。', + 'sortable.stateModel.description': + 'data 是順序來源。重排會改這個陣列,並用拷貝觸發 sorted。list 或 item 的 disabled 只阻止移動,不會清空 data。', +} as const; diff --git a/apps/docs/src/app/navigation/docs-navigation.ts b/apps/docs/src/app/navigation/docs-navigation.ts index f423e6b9..05b6956a 100644 --- a/apps/docs/src/app/navigation/docs-navigation.ts +++ b/apps/docs/src/app/navigation/docs-navigation.ts @@ -63,6 +63,7 @@ export type DocsComponentId = | 'sidebar' | 'skeleton' | 'slider' + | 'sortable' | 'spinner' | 'stepper' | 'switch' @@ -371,6 +372,13 @@ export const docsComponentItems: DocsComponentNavItem[] = [ path: '/components/slider', active: true, }, + { + id: 'sortable', + labelKey: 'component.sortable', + descriptionKey: 'sortable.description', + path: '/components/sortable', + active: true, + }, { id: 'spinner', labelKey: 'component.spinner', diff --git a/apps/docs/src/app/pages/changelog/component-changelog.ts b/apps/docs/src/app/pages/changelog/component-changelog.ts index c6f396eb..f1084953 100644 --- a/apps/docs/src/app/pages/changelog/component-changelog.ts +++ b/apps/docs/src/app/pages/changelog/component-changelog.ts @@ -35,6 +35,18 @@ function isPatch(version: string): boolean { * - Keep each change to one sentence. Wrap identifiers in backticks. */ export const cliVersionChangelog: readonly CliVersionEntry[] = [ + { + version: '0.26.0', + date: '2026-09-27', + changes: [ + { + type: 'added', + notable: true, + componentIds: ['sortable'], + text: 'Add `sortable` for pointer and keyboard reordering of a single list. Cards and other content compose with `[sanringSortableItem]`.', + }, + ], + }, { version: '0.25.2', date: '2026-09-23', diff --git a/apps/docs/src/app/pages/components/sortable/sortable-page.component.ts b/apps/docs/src/app/pages/components/sortable/sortable-page.component.ts new file mode 100644 index 00000000..d8c2e42d --- /dev/null +++ b/apps/docs/src/app/pages/components/sortable/sortable-page.component.ts @@ -0,0 +1,221 @@ +import { Component, inject, signal } from '@angular/core'; +import { LucideGripVertical } from '@lucide/angular'; +import { SANRING_CARD_IMPORTS, SANRING_SORTABLE_IMPORTS } from '@sanring/ui'; +import { getComponentPageSection } from '../../../docs-schema/component-page.utils'; +import { I18nService } from '../../../i18n/i18n.service'; +import { + ComponentPageApiTableComponent, + ComponentPageCodeBlock, + ComponentPageCodePreviewer, + ComponentPageComponent, + ComponentPageHeaderComponent, + ComponentPageInstallationComponent, + ComponentPageKeyboardTableComponent, + ComponentPageSectionComponent, + ComponentPageUsageImportsComponent, +} from '../../../layouts/component-page'; +import { sortablePage, sortablePageExamples } from './sortable.docs'; + +interface SortableCard { + id: string; + titleKey: 'sortable.demo.alpha' | 'sortable.demo.beta' | 'sortable.demo.gamma'; + descriptionKey: + | 'sortable.demo.alphaDescription' + | 'sortable.demo.betaDescription' + | 'sortable.demo.gammaDescription'; +} + +@Component({ + selector: 'app-sortable-page', + imports: [ + ComponentPageApiTableComponent, + ComponentPageCodeBlock, + ComponentPageCodePreviewer, + ComponentPageComponent, + ComponentPageHeaderComponent, + ComponentPageInstallationComponent, + ComponentPageKeyboardTableComponent, + ComponentPageUsageImportsComponent, + ComponentPageSectionComponent, + LucideGripVertical, + SANRING_CARD_IMPORTS, + SANRING_SORTABLE_IMPORTS, + ], + template: ` + + + + + + + + +
+ + +
+
+ + + + + + +
+ + +
+ + @for (card of cards(); track card.id) { +
+ + +

{{ i18n.t(card.titleKey) }}

+

{{ i18n.t(card.descriptionKey) }}

+
+
+
+ } +
+
+
+
+ + + +
+ + @for (card of handled(); track card.id) { +
+ + + +

{{ i18n.t(card.titleKey) }}

+

{{ i18n.t(card.descriptionKey) }}

+
+
+
+ } +
+
+
+
+ + + +
+ + @for (chip of chips(); track chip) { +
+ {{ chip }} +
+ } +
+
+
+
+
+
+ + + + + + + + + + + + +
+ `, +}) +export class SortablePageComponent { + protected readonly page = sortablePage; + protected readonly examples = sortablePageExamples; + protected readonly i18n = inject(I18nService); + + protected readonly cards = signal([ + { + id: 'alpha', + titleKey: 'sortable.demo.alpha', + descriptionKey: 'sortable.demo.alphaDescription', + }, + { + id: 'beta', + titleKey: 'sortable.demo.beta', + descriptionKey: 'sortable.demo.betaDescription', + }, + { + id: 'gamma', + titleKey: 'sortable.demo.gamma', + descriptionKey: 'sortable.demo.gammaDescription', + }, + ]); + + protected readonly handled = signal([ + { + id: 'alpha', + titleKey: 'sortable.demo.alpha', + descriptionKey: 'sortable.demo.alphaDescription', + }, + { + id: 'beta', + titleKey: 'sortable.demo.beta', + descriptionKey: 'sortable.demo.betaDescription', + }, + { + id: 'gamma', + titleKey: 'sortable.demo.gamma', + descriptionKey: 'sortable.demo.gammaDescription', + }, + ]); + + protected readonly chips = signal(['Design', 'Build', 'Review']); + + protected onCardsSorted(items: unknown[]): void { + this.cards.set(items as SortableCard[]); + } + + protected onHandledSorted(items: unknown[]): void { + this.handled.set(items as SortableCard[]); + } + + protected onChipsSorted(items: unknown[]): void { + this.chips.set(items as string[]); + } + + protected section(id: string) { + return getComponentPageSection(this.page, id); + } +} diff --git a/apps/docs/src/app/pages/components/sortable/sortable.docs.ts b/apps/docs/src/app/pages/components/sortable/sortable.docs.ts new file mode 100644 index 00000000..58ee7fe1 --- /dev/null +++ b/apps/docs/src/app/pages/components/sortable/sortable.docs.ts @@ -0,0 +1,193 @@ +import { + ComponentPageApiRow, + ComponentPageKeyboardRow, + ComponentPageDefinition, +} from '../../../docs-schema/component-page.types'; + +export const sortablePage = { + componentId: 'sortable', + titleKey: 'component.sortable', + descriptionKey: 'sortable.description', + registryDeps: ['utils'], + ssrSafe: false, + sections: [ + { + id: 'installation', + titleKey: 'sidebar.installation', + descriptionKey: 'sortable.installation.description', + level: 2, + }, + { + id: 'usage', + titleKey: 'toc.usage', + descriptionKey: 'sortable.usage.description', + level: 2, + }, + { + id: 'composition', + titleKey: 'toc.composition', + descriptionKey: 'sortable.composition.description', + level: 2, + }, + { + id: 'examples', + titleKey: 'toc.examples', + level: 2, + children: [ + { + id: 'example-basic', + titleKey: 'toc.basic', + level: 3, + }, + { + id: 'example-handle', + titleKey: 'sortable.demo.handle', + level: 3, + }, + { + id: 'example-horizontal', + titleKey: 'sortable.demo.horizontal', + level: 3, + }, + ], + }, + { + id: 'api', + titleKey: 'toc.apiReference', + descriptionKey: 'sortable.api.description', + level: 2, + }, + { + id: 'accessibility', + titleKey: 'toc.accessibility', + descriptionKey: 'sortable.accessibility.description', + level: 2, + }, + { + id: 'keyboard', + titleKey: 'toc.keyboard', + descriptionKey: 'sortable.keyboard.description', + level: 2, + }, + { + id: 'stateModel', + titleKey: 'toc.stateModel', + descriptionKey: 'sortable.stateModel.description', + level: 2, + }, + ], + apiRows: [ + { + property: 'class', + type: 'string', + defaultValue: "''", + descriptionKey: 'sortable.api.class.description', + }, + { + property: 'data', + type: 'unknown[]', + defaultValue: '-', + descriptionKey: 'sortable.api.data.description', + }, + { + property: 'orientation', + type: "'vertical' | 'horizontal'", + defaultValue: "'vertical'", + descriptionKey: 'sortable.api.orientation.description', + }, + { + property: 'disabled', + type: 'boolean', + defaultValue: 'false', + descriptionKey: 'sortable.api.disabled.description', + }, + { + property: 'sorted', + type: 'OutputEmitterRef', + defaultValue: '-', + descriptionKey: 'sortable.api.sorted.description', + }, + { + property: '[sanringSortableItem].disabled', + type: 'boolean', + defaultValue: 'false', + descriptionKey: 'sortable.api.itemDisabled.description', + }, + { + property: '[sanringSortableHandle]', + type: 'directive', + defaultValue: '-', + descriptionKey: 'sortable.api.handle.description', + }, + ] satisfies readonly ComponentPageApiRow[], + keyboardRows: [ + { keys: 'Arrow Up / Arrow Down', descriptionKey: 'sortable.keyboard.arrowsVertical' }, + { keys: 'Arrow Left / Arrow Right', descriptionKey: 'sortable.keyboard.arrowsHorizontal' }, + { keys: 'Tab / Shift + Tab', descriptionKey: 'sortable.keyboard.tabShiftTab' }, + ] satisfies readonly ComponentPageKeyboardRow[], +} as const satisfies ComponentPageDefinition; + +export const sortablePageExamples = { + usageImport: `import { Component } from '@angular/core'; +import { SANRING_SORTABLE_IMPORTS } from './components/ui/sortable'; + +@Component({ + imports: [SANRING_SORTABLE_IMPORTS], +}) +export class ExampleComponent {}`, + usageMain: ` + @for (item of items; track item.id) { +
+ {{ item.label }} +
+ } +
`, + usageIndividualImports: `import { Component } from '@angular/core'; +import { + SortableComponent, + SortableHandleDirective, + SortableItemDirective, +} from './components/ui/sortable'; + +@Component({ + imports: [SortableComponent, SortableItemDirective, SortableHandleDirective], +}) +export class ExampleComponent {}`, + composition: `sanring-sortable +├── [sanringSortableItem] +│ └── [sanringSortableHandle] (optional) +└── …more items`, + basic: ` + @for (item of items; track item.id) { +
+ + +

{{ item.title }}

+

{{ item.description }}

+
+
+
+ } +
`, + handle: ` + @for (item of items; track item.id) { +
+ + + +

{{ item.title }}

+
+
+
+ } +
`, + horizontal: ` + @for (item of items; track item) { +
+ {{ item }} +
+ } +
`, +} as const; diff --git a/packages/ui/src/lib/components/sortable/index.ts b/packages/ui/src/lib/components/sortable/index.ts new file mode 100644 index 00000000..64523b16 --- /dev/null +++ b/packages/ui/src/lib/components/sortable/index.ts @@ -0,0 +1,13 @@ +export * from './sortable.component'; +export * from './sortable-item.directive'; +export * from './sortable-handle.directive'; + +import { SortableHandleDirective } from './sortable-handle.directive'; +import { SortableItemDirective } from './sortable-item.directive'; +import { SortableComponent } from './sortable.component'; + +export const SANRING_SORTABLE_IMPORTS = [ + SortableComponent, + SortableItemDirective, + SortableHandleDirective, +]; diff --git a/packages/ui/src/lib/components/sortable/sortable-handle.directive.ts b/packages/ui/src/lib/components/sortable/sortable-handle.directive.ts new file mode 100644 index 00000000..541ea821 --- /dev/null +++ b/packages/ui/src/lib/components/sortable/sortable-handle.directive.ts @@ -0,0 +1,17 @@ +import { CdkDragHandle } from '@angular/cdk/drag-drop'; +import { Directive, computed, input } from '@angular/core'; +import { cn } from '../../utils'; + +@Directive({ + selector: '[sanringSortableHandle]', + standalone: true, + hostDirectives: [CdkDragHandle], + host: { + '[class]': 'hostClass()', + }, +}) +export class SortableHandleDirective { + readonly class = input(); + + protected readonly hostClass = computed(() => cn('cursor-grab', this.class())); +} diff --git a/packages/ui/src/lib/components/sortable/sortable-item.directive.ts b/packages/ui/src/lib/components/sortable/sortable-item.directive.ts new file mode 100644 index 00000000..38d320ad --- /dev/null +++ b/packages/ui/src/lib/components/sortable/sortable-item.directive.ts @@ -0,0 +1,68 @@ +import { CdkDrag } from '@angular/cdk/drag-drop'; +import { + Directive, + booleanAttribute, + computed, + contentChild, + effect, + inject, + input, +} from '@angular/core'; +import { cn } from '../../utils'; +import { SortableHandleDirective } from './sortable-handle.directive'; +import { SortableComponent } from './sortable.component'; + +@Directive({ + selector: '[sanringSortableItem]', + standalone: true, + hostDirectives: [CdkDrag], + host: { + role: 'listitem', + '[class]': 'hostClass()', + '[attr.tabindex]': 'isDisabled() ? -1 : 0', + '[attr.data-disabled]': 'isDisabled() ? "" : null', + '(keydown)': 'onKeydown($event)', + }, +}) +export class SortableItemDirective { + readonly class = input(); + readonly disabled = input(false, { transform: booleanAttribute }); + + private readonly sortable = inject(SortableComponent); + private readonly drag = inject(CdkDrag, { self: true }); + private readonly handle = contentChild(SortableHandleDirective); + + constructor() { + this.drag.previewClass = 'sanring-sortable-preview'; + + effect(() => { + this.drag.disabled = this.isDisabled(); + }); + } + + protected readonly isDisabled = computed(() => this.disabled() || this.sortable.disabled()); + + protected readonly hostClass = computed(() => + cn(this.handle() ? null : 'cursor-grab', this.class()), + ); + + protected onKeydown(event: KeyboardEvent): void { + if (this.isDisabled()) return; + + const horizontal = this.sortable.orientation() === 'horizontal'; + const delta = + event.key === 'ArrowDown' || (horizontal && event.key === 'ArrowRight') + ? 1 + : event.key === 'ArrowUp' || (horizontal && event.key === 'ArrowLeft') + ? -1 + : 0; + if (delta === 0) return; + + const items = this.sortable.dropList.getSortedItems(); + const index = items.indexOf(this.drag); + if (index < 0) return; + + event.preventDefault(); + this.sortable.reorder(index, index + delta); + } +} diff --git a/packages/ui/src/lib/components/sortable/sortable.component.spec.ts b/packages/ui/src/lib/components/sortable/sortable.component.spec.ts new file mode 100644 index 00000000..ae24562b --- /dev/null +++ b/packages/ui/src/lib/components/sortable/sortable.component.spec.ts @@ -0,0 +1,84 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { expectNoA11yViolations } from '../../../testing/axe-a11y'; +import { SortableHandleDirective } from './sortable-handle.directive'; +import { SortableItemDirective } from './sortable-item.directive'; +import { SortableComponent } from './sortable.component'; + +@Component({ + imports: [SortableComponent, SortableItemDirective, SortableHandleDirective], + template: ` + + @for (item of items; track item) { +
+ + {{ item }} +
+ } +
+ + +
Locked
+
+ `, +}) +class SortableTestHost { + items: unknown[] = ['alpha', 'beta', 'gamma']; + locked: unknown[] = ['locked']; +} + +describe('SortableComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SortableTestHost], + }).compileComponents(); + }); + + it('renders without error', () => { + const fixture = TestBed.createComponent(SortableTestHost); + fixture.detectChanges(); + + expect(fixture.nativeElement).toBeTruthy(); + }); + + it('merges host class with consumer class', () => { + const fixture = TestBed.createComponent(SortableTestHost); + fixture.detectChanges(); + + const root = fixture.nativeElement.querySelector('sanring-sortable') as HTMLElement; + expect(root.classList.contains('custom-class')).toBe(true); + expect(root.getAttribute('role')).toBe('list'); + }); + + it('reorders items on ArrowDown', () => { + const fixture = TestBed.createComponent(SortableTestHost); + fixture.detectChanges(); + + const item = fixture.nativeElement.querySelector('[sanringSortableItem]') as HTMLElement; + item.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + fixture.detectChanges(); + + expect(fixture.componentInstance.items).toEqual(['beta', 'alpha', 'gamma']); + }); + + it('ignores keyboard reorder while disabled', () => { + const fixture = TestBed.createComponent(SortableTestHost); + fixture.detectChanges(); + + const disabled = fixture.nativeElement.querySelectorAll('sanring-sortable')[1] as HTMLElement; + const item = disabled.querySelector('[sanringSortableItem]') as HTMLElement; + item.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + fixture.detectChanges(); + + expect(fixture.componentInstance.locked).toEqual(['locked']); + expect(item.getAttribute('tabindex')).toBe('-1'); + }); + + it('has no axe-detectable a11y violations', async () => { + const fixture = TestBed.createComponent(SortableTestHost); + fixture.detectChanges(); + + await expectNoA11yViolations(fixture.nativeElement); + }); +}); diff --git a/packages/ui/src/lib/components/sortable/sortable.component.ts b/packages/ui/src/lib/components/sortable/sortable.component.ts new file mode 100644 index 00000000..7e6c8e5d --- /dev/null +++ b/packages/ui/src/lib/components/sortable/sortable.component.ts @@ -0,0 +1,75 @@ +import { CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; +import { + ChangeDetectionStrategy, + Component, + booleanAttribute, + computed, + effect, + inject, + input, + output, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { cn } from '../../utils'; + +export type SortableOrientation = 'vertical' | 'horizontal'; + +@Component({ + selector: 'sanring-sortable', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [CdkDropList], + template: ``, + host: { + role: 'list', + '[class]': 'hostClass()', + '[attr.data-orientation]': 'orientation()', + '[attr.data-disabled]': 'disabled() ? "" : null', + }, +}) +export class SortableComponent { + readonly class = input(); + readonly data = input.required(); + readonly orientation = input('vertical'); + readonly disabled = input(false, { transform: booleanAttribute }); + readonly sorted = output(); + + readonly dropList = inject(CdkDropList); + + constructor() { + this.dropList.dropped.pipe(takeUntilDestroyed()).subscribe((event) => { + this.reorder(event.previousIndex, event.currentIndex); + }); + + effect(() => { + this.dropList.data = this.data(); + this.dropList.disabled = this.disabled(); + this.dropList.orientation = this.orientation(); + this.dropList.lockAxis = this.orientation() === 'horizontal' ? 'x' : 'y'; + }); + } + + reorder(previousIndex: number, currentIndex: number): void { + if (this.disabled()) return; + const data = this.data(); + if (previousIndex === currentIndex) return; + if ( + previousIndex < 0 || + currentIndex < 0 || + previousIndex >= data.length || + currentIndex >= data.length + ) { + return; + } + moveItemInArray(data, previousIndex, currentIndex); + this.sorted.emit([...data]); + } + + protected readonly hostClass = computed(() => + cn( + 'flex gap-2', + this.orientation() === 'horizontal' ? 'flex-row' : 'flex-col', + this.class(), + ), + ); +} diff --git a/packages/ui/src/public-api.ts b/packages/ui/src/public-api.ts index f1d4a8be..67278b02 100644 --- a/packages/ui/src/public-api.ts +++ b/packages/ui/src/public-api.ts @@ -43,6 +43,7 @@ export * from './lib/components/sheet'; export * from './lib/components/sidebar'; export * from './lib/components/skeleton'; export * from './lib/components/slider'; +export * from './lib/components/sortable'; export * from './lib/components/spinner'; export * from './lib/components/stepper'; export * from './lib/components/switch'; diff --git a/registry/components/sortable/index.ts b/registry/components/sortable/index.ts new file mode 100644 index 00000000..64523b16 --- /dev/null +++ b/registry/components/sortable/index.ts @@ -0,0 +1,13 @@ +export * from './sortable.component'; +export * from './sortable-item.directive'; +export * from './sortable-handle.directive'; + +import { SortableHandleDirective } from './sortable-handle.directive'; +import { SortableItemDirective } from './sortable-item.directive'; +import { SortableComponent } from './sortable.component'; + +export const SANRING_SORTABLE_IMPORTS = [ + SortableComponent, + SortableItemDirective, + SortableHandleDirective, +]; diff --git a/registry/components/sortable/sortable-handle.directive.ts b/registry/components/sortable/sortable-handle.directive.ts new file mode 100644 index 00000000..c827b9cb --- /dev/null +++ b/registry/components/sortable/sortable-handle.directive.ts @@ -0,0 +1,17 @@ +import { CdkDragHandle } from '@angular/cdk/drag-drop'; +import { Directive, computed, input } from '@angular/core'; +import { cn } from '../shared/utils'; + +@Directive({ + selector: '[sanringSortableHandle]', + standalone: true, + hostDirectives: [CdkDragHandle], + host: { + '[class]': 'hostClass()', + }, +}) +export class SortableHandleDirective { + readonly class = input(); + + protected readonly hostClass = computed(() => cn('cursor-grab', this.class())); +} diff --git a/registry/components/sortable/sortable-item.directive.ts b/registry/components/sortable/sortable-item.directive.ts new file mode 100644 index 00000000..11dd6d8d --- /dev/null +++ b/registry/components/sortable/sortable-item.directive.ts @@ -0,0 +1,68 @@ +import { CdkDrag } from '@angular/cdk/drag-drop'; +import { + Directive, + booleanAttribute, + computed, + contentChild, + effect, + inject, + input, +} from '@angular/core'; +import { cn } from '../shared/utils'; +import { SortableHandleDirective } from './sortable-handle.directive'; +import { SortableComponent } from './sortable.component'; + +@Directive({ + selector: '[sanringSortableItem]', + standalone: true, + hostDirectives: [CdkDrag], + host: { + role: 'listitem', + '[class]': 'hostClass()', + '[attr.tabindex]': 'isDisabled() ? -1 : 0', + '[attr.data-disabled]': 'isDisabled() ? "" : null', + '(keydown)': 'onKeydown($event)', + }, +}) +export class SortableItemDirective { + readonly class = input(); + readonly disabled = input(false, { transform: booleanAttribute }); + + private readonly sortable = inject(SortableComponent); + private readonly drag = inject(CdkDrag, { self: true }); + private readonly handle = contentChild(SortableHandleDirective); + + constructor() { + this.drag.previewClass = 'sanring-sortable-preview'; + + effect(() => { + this.drag.disabled = this.isDisabled(); + }); + } + + protected readonly isDisabled = computed(() => this.disabled() || this.sortable.disabled()); + + protected readonly hostClass = computed(() => + cn(this.handle() ? null : 'cursor-grab', this.class()), + ); + + protected onKeydown(event: KeyboardEvent): void { + if (this.isDisabled()) return; + + const horizontal = this.sortable.orientation() === 'horizontal'; + const delta = + event.key === 'ArrowDown' || (horizontal && event.key === 'ArrowRight') + ? 1 + : event.key === 'ArrowUp' || (horizontal && event.key === 'ArrowLeft') + ? -1 + : 0; + if (delta === 0) return; + + const items = this.sortable.dropList.getSortedItems(); + const index = items.indexOf(this.drag); + if (index < 0) return; + + event.preventDefault(); + this.sortable.reorder(index, index + delta); + } +} diff --git a/registry/components/sortable/sortable.component.ts b/registry/components/sortable/sortable.component.ts new file mode 100644 index 00000000..06d8a12c --- /dev/null +++ b/registry/components/sortable/sortable.component.ts @@ -0,0 +1,75 @@ +import { CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; +import { + ChangeDetectionStrategy, + Component, + booleanAttribute, + computed, + effect, + inject, + input, + output, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { cn } from '../shared/utils'; + +export type SortableOrientation = 'vertical' | 'horizontal'; + +@Component({ + selector: 'sanring-sortable', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [CdkDropList], + template: ``, + host: { + role: 'list', + '[class]': 'hostClass()', + '[attr.data-orientation]': 'orientation()', + '[attr.data-disabled]': 'disabled() ? "" : null', + }, +}) +export class SortableComponent { + readonly class = input(); + readonly data = input.required(); + readonly orientation = input('vertical'); + readonly disabled = input(false, { transform: booleanAttribute }); + readonly sorted = output(); + + readonly dropList = inject(CdkDropList); + + constructor() { + this.dropList.dropped.pipe(takeUntilDestroyed()).subscribe((event) => { + this.reorder(event.previousIndex, event.currentIndex); + }); + + effect(() => { + this.dropList.data = this.data(); + this.dropList.disabled = this.disabled(); + this.dropList.orientation = this.orientation(); + this.dropList.lockAxis = this.orientation() === 'horizontal' ? 'x' : 'y'; + }); + } + + reorder(previousIndex: number, currentIndex: number): void { + if (this.disabled()) return; + const data = this.data(); + if (previousIndex === currentIndex) return; + if ( + previousIndex < 0 || + currentIndex < 0 || + previousIndex >= data.length || + currentIndex >= data.length + ) { + return; + } + moveItemInArray(data, previousIndex, currentIndex); + this.sorted.emit([...data]); + } + + protected readonly hostClass = computed(() => + cn( + 'flex gap-2', + this.orientation() === 'horizontal' ? 'flex-row' : 'flex-col', + this.class(), + ), + ); +} diff --git a/registry/registry.json b/registry/registry.json index ae86debc..ce408b27 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -68,6 +68,7 @@ "select", "sheet", "sidebar", + "sortable", "stepper", "table", "timeline", @@ -696,6 +697,22 @@ "slider/index.ts" ] }, + { + "name": "sortable", + "description": "A single-list reorder primitive built on Angular CDK drag-drop.", + "sharedDeps": [ + "utils" + ], + "peerDependencies": { + "@angular/cdk": "^22.0.0" + }, + "files": [ + "sortable/sortable.component.ts", + "sortable/sortable-item.directive.ts", + "sortable/sortable-handle.directive.ts", + "sortable/index.ts" + ] + }, { "name": "spinner", "description": "Displays an animated loading indicator.", From d869e13ab8b7e77ea80b5b36b9e0cfc83f27afe4 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Sun, 27 Sep 2026 15:52:50 +0800 Subject: [PATCH 2/4] feat(cli): expose get_component_spec over MCP Agents need real selectors and API contracts before writing templates; get_component_info stays install metadata, not authoring guidance. Co-authored-by: Cursor --- .../docs/src/app/i18n/locales/en/pages/mcp.ts | 2 +- .../docs/src/app/i18n/locales/zh/pages/mcp.ts | 2 +- .../src/app/pages/mcp/mcp-page.component.ts | 9 +- packages/cli/README.md | 2 +- packages/cli/package.json | 1 + packages/cli/scripts/check-registry-sync.mjs | 25 + packages/cli/scripts/generate-specs.mjs | 225 + packages/cli/src/commands/mcp.test.ts | 45 + packages/cli/src/commands/mcp.ts | 71 +- packages/cli/src/component-spec.test.ts | 72 + packages/cli/src/component-spec.ts | 107 + registry/specs.json | 4379 +++++++++++++++++ 12 files changed, 4927 insertions(+), 13 deletions(-) create mode 100644 packages/cli/scripts/generate-specs.mjs create mode 100644 packages/cli/src/component-spec.test.ts create mode 100644 packages/cli/src/component-spec.ts create mode 100644 registry/specs.json diff --git a/apps/docs/src/app/i18n/locales/en/pages/mcp.ts b/apps/docs/src/app/i18n/locales/en/pages/mcp.ts index c646f6bb..af3a9c17 100644 --- a/apps/docs/src/app/i18n/locales/en/pages/mcp.ts +++ b/apps/docs/src/app/i18n/locales/en/pages/mcp.ts @@ -3,7 +3,7 @@ export const mcpTranslations = { 'Starts an MCP server over stdio so AI coding agents — Claude Code, Cursor, Windsurf — can query and install Sanring UI components directly, without shelling out.', 'mcp.overview.title': 'Overview', 'mcp.overview.body': - 'The sanring mcp command starts an MCP server over stdio. It reads from the same component registry as the sanring CLI and this documentation site, and exposes five tools an AI agent can call to inspect, plan, and install components in your Angular project.', + 'The sanring mcp command starts an MCP server over stdio. It reads from the same component registry as the sanring CLI and this documentation site, and exposes tools an AI agent can call to look up authoring contracts, then plan and install components in your Angular project.', 'mcp.tools.title': 'Tools', 'mcp.tools.body': 'All tool handlers validate their input at runtime and return an MCP error result — rather than throwing — when a component name is not found or a required argument is missing.', diff --git a/apps/docs/src/app/i18n/locales/zh/pages/mcp.ts b/apps/docs/src/app/i18n/locales/zh/pages/mcp.ts index 89500424..3328b55a 100644 --- a/apps/docs/src/app/i18n/locales/zh/pages/mcp.ts +++ b/apps/docs/src/app/i18n/locales/zh/pages/mcp.ts @@ -3,7 +3,7 @@ export const mcpTranslations = { '以 stdio transport 啟動 MCP server,讓 Claude Code、Cursor、Windsurf 等 AI coding agent 能直接查詢、安裝 Sanring UI 元件,不用手動下 shell 指令。', 'mcp.overview.title': '概覽', 'mcp.overview.body': - 'sanring mcp 指令會以 stdio transport 啟動 MCP server,讀取跟 sanring CLI 及這個文件站相同的元件 registry,並曝露五個 tool,讓 AI agent 能查詢、預覽、安裝你 Angular 專案裡的元件。', + 'sanring mcp 指令會以 stdio transport 啟動 MCP server,讀取跟 sanring CLI 及這個文件站相同的元件 registry,並曝露 tools 讓 AI agent 先拿到寫程式用的 API 契約,再預覽、安裝你 Angular 專案裡的元件。', 'mcp.tools.title': '可用的 Tool', 'mcp.tools.body': '所有 tool 都有 runtime input validation;找不到元件或缺少必要參數時,會回傳 MCP error result,而不是直接丟出例外。', diff --git a/apps/docs/src/app/pages/mcp/mcp-page.component.ts b/apps/docs/src/app/pages/mcp/mcp-page.component.ts index f009960f..7340def6 100644 --- a/apps/docs/src/app/pages/mcp/mcp-page.component.ts +++ b/apps/docs/src/app/pages/mcp/mcp-page.component.ts @@ -31,7 +31,7 @@ const INLINE_CODE_CLASS =

AGENT TOOL MAP

Discover first. Plan before anything writes.

safe by default
-
read→plan→write

READ

list / search / info

understand the registry

PLAN

preview install

files and deps first

WRITE

add component

only after approval

+
read→plan→write

READ

list / search / spec

understand the registry

PLAN

preview install

files and deps first

WRITE

add component

only after approval

@@ -62,13 +62,18 @@ const INLINE_CODE_CLASS =
  • search_components - — search components by name or description + — search by name, description, or alias (modal → dialog / alert-dialog)
  • get_component_info — show files, auto-installed component dependencies, shared utilities, and peer dependencies
  • +
  • + get_component_spec + — authoring contract: selectors, anatomy, API, accessibility, keyboard, and a + canonical example. Call this before writing templates. +
  • plan_component_install — preview files, component deps, and peer packages that would be installed, diff --git a/packages/cli/README.md b/packages/cli/README.md index 2053aef3..51095210 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -51,7 +51,7 @@ Every command accepts `--registry ` to point at a custom registry. - Requires Node.js >= 18, Angular >= 22, Tailwind CSS. - `init` creates `sanring.config.json` and writes `src/sanring-theme.css`. Add the theme file to your global CSS once: `@import './sanring-theme.css';` - `add` records a content hash per file so `diff`/`update` can tell untouched files from customized ones — untouched files update silently, customized files always prompt first. -- `mcp` starts an MCP server over stdio for AI coding agents (`npx @sanring/cli@latest mcp`). +- `mcp` starts an MCP server over stdio for AI coding agents (`npx @sanring/cli@latest mcp`). `get_component_spec` returns selectors, anatomy, API, and accessibility; `get_component_info` is install metadata. - `build` generates a `registry.json` from your own Angular component source tree for publishing a private registry (`npx @sanring/cli@latest build --help`). ## Links diff --git a/packages/cli/package.json b/packages/cli/package.json index 27b7ea06..1f4e5654 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,6 +18,7 @@ ], "scripts": { "sync-registry": "node scripts/sync-registry.mjs", + "generate-specs": "node scripts/generate-specs.mjs", "build": "npm run sync-registry && tsc && tsc -p tsconfig.schematics.json && node scripts/copy-schematics-assets.mjs", "dev": "tsc --watch", "test": "vitest run", diff --git a/packages/cli/scripts/check-registry-sync.mjs b/packages/cli/scripts/check-registry-sync.mjs index e3cca282..e5f9108f 100644 --- a/packages/cli/scripts/check-registry-sync.mjs +++ b/packages/cli/scripts/check-registry-sync.mjs @@ -15,6 +15,7 @@ // `sanring add menu` would have installed source for a component that was // never actually part of the library. import { readFileSync, existsSync, readdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -24,6 +25,7 @@ const DOCS_NAV_PATH = join(REPO_ROOT, 'apps/docs/src/app/navigation/docs-navigat const DOCS_ROUTES_PATH = join(REPO_ROOT, 'apps/docs/src/app/app.routes.ts'); const DOCS_PAGES_DIR = join(REPO_ROOT, 'apps/docs/src/app/pages/components'); const REGISTRY_JSON_PATH = join(REPO_ROOT, 'registry/registry.json'); +const REGISTRY_SPECS_PATH = join(REPO_ROOT, 'registry/specs.json'); const REGISTRY_COMPONENTS_DIR = join(REPO_ROOT, 'registry/components'); const REGISTRY_BLOCKS_DIR = join(REPO_ROOT, 'registry/blocks'); const UI_COMPONENTS_DIR = join(REPO_ROOT, 'packages/ui/src/lib/components'); @@ -175,6 +177,9 @@ const publicApiNames = getPublicApiExportedNames(); const docsIds = getDocsComponentIds(); const docsPageDirs = getDocsPageDirNames(); const routedIds = getRoutedComponentIds(); +const specNames = existsSync(REGISTRY_SPECS_PATH) + ? Object.keys(JSON.parse(readFileSync(REGISTRY_SPECS_PATH, 'utf-8'))) + : []; // 1. registry.json entries must point at files that actually exist — // otherwise `sanring add ` fails at install time, not at CI time. @@ -293,6 +298,26 @@ diffSurfaces({ gapKey: 'docsVsRoutes', }); +diffSurfaces({ + a: docsIds, + aLabel: 'docs-navigation.ts', + b: specNames, + bLabel: 'registry/specs.json', + onlyInAFails: true, + onlyInBFails: false, + gapKey: 'docsVsSpecs', +}); + +const specCheck = spawnSync(process.execPath, [join(__dirname, 'generate-specs.mjs'), '--check'], { + encoding: 'utf8', +}); +if (specCheck.status !== 0) { + fail( + 'registry/specs.json is stale (docs changed without regenerating specs)', + [specCheck.stderr.trim() || specCheck.stdout.trim() || 'pnpm --filter @sanring/cli generate-specs'], + ); +} + if (hasError) process.exit(1); console.log( diff --git a/packages/cli/scripts/generate-specs.mjs b/packages/cli/scripts/generate-specs.mjs new file mode 100644 index 00000000..edb69ca8 --- /dev/null +++ b/packages/cli/scripts/generate-specs.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +// Builds registry/specs.json from apps/docs component pages + English copy. +// MCP get_component_spec reads this file; do not hand-edit it. +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '../../..'); +const DOCS_PAGES = join(REPO_ROOT, 'apps/docs/src/app/pages/components'); +const EN_LOCALES = join(REPO_ROOT, 'apps/docs/src/app/i18n/locales/en'); +const OUT_PATH = join(REPO_ROOT, 'registry/specs.json'); + +const ALIASES = { + 'alert-dialog': ['confirm', 'confirmation', 'delete', 'modal'], + dialog: ['modal', 'overlay'], + 'dropdown-menu': ['dropdown', 'menu'], + select: ['dropdown'], + combobox: ['autocomplete', 'typeahead'], + popover: ['popup', 'flyout'], + sheet: ['drawer', 'slide-over'], + toast: ['snackbar', 'notification'], + tooltip: ['hint'], + command: ['command palette', 'cmdk'], + 'context-menu': ['right-click', 'right click'], + 'date-picker': ['datepicker'], + calendar: ['datepicker'], + 'otp-input': ['pin', 'verification code'], + 'file-upload': ['uploader'], + radio: ['radio group'], + switch: ['toggle switch'], + slider: ['range'], + sortable: ['drag', 'reorder', 'drag list', 'sortable list'], + sidebar: ['sidenav'], + table: ['datagrid', 'data table'], + transfer: ['shuttle'], + stepper: ['wizard', 'steps'], + resizable: ['split pane'], + 'scroll-area': ['scrollbar'], + skeleton: ['placeholder'], + spinner: ['loading'], + tag: ['chip'], + badge: ['chip'], + accordion: ['collapse'], +}; + +function walkTs(dir, acc = []) { + if (!existsSync(dir)) return acc; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) walkTs(path, acc); + else if (entry.name.endsWith('.ts')) acc.push(path); + } + return acc; +} + +function readQuoted(source, quoteIndex) { + const quote = source[quoteIndex]; + let i = quoteIndex + 1; + let value = ''; + if (quote === '`') { + while (i < source.length) { + if (source[i] === '\\' && i + 1 < source.length) { + value += source[i + 1]; + i += 2; + continue; + } + if (source[i] === '`') return { value, end: i + 1 }; + value += source[i++]; + } + return null; + } + while (i < source.length) { + if (source[i] === '\\' && i + 1 < source.length) { + value += source[i + 1]; + i += 2; + continue; + } + if (source[i] === quote) return { value, end: i + 1 }; + value += source[i++]; + } + return null; +} + +function loadTranslations() { + const map = {}; + for (const file of walkTs(EN_LOCALES)) { + const source = readFileSync(file, 'utf-8'); + const re = /['"]([^'"]+)['"]\s*:/g; + let match; + while ((match = re.exec(source))) { + let i = match.index + match[0].length; + while (i < source.length && /\s/.test(source[i])) i++; + if (source[i] !== "'" && source[i] !== '"' && source[i] !== '`') continue; + const parsed = readQuoted(source, i); + if (!parsed) continue; + map[match[1]] = parsed.value; + re.lastIndex = parsed.end; + } + } + return map; +} + +function extractField(source, field) { + const re = new RegExp(`\\b${field}\\s*:\\s*`); + const match = re.exec(source); + if (!match) return ''; + let i = match.index + match[0].length; + while (i < source.length && /\s/.test(source[i])) i++; + if (source[i] !== "'" && source[i] !== '"' && source[i] !== '`') return ''; + return readQuoted(source, i)?.value ?? ''; +} + +function extractApiRows(source) { + const rows = []; + const re = + /property:\s*(['"`])([\s\S]*?)\1[\s\S]*?type:\s*(['"`])([\s\S]*?)\3[\s\S]*?defaultValue:\s*(['"`])([\s\S]*?)\5[\s\S]*?descriptionKey:\s*(['"`])([\s\S]*?)\7/g; + let match; + while ((match = re.exec(source))) { + rows.push({ + property: match[2], + type: match[4], + defaultValue: match[6], + descriptionKey: match[8], + }); + } + return rows; +} + +function extractKeyboardRows(source) { + const rows = []; + const re = /keys:\s*(['"`])([\s\S]*?)\1[\s\S]*?descriptionKey:\s*(['"`])([\s\S]*?)\3/g; + let match; + while ((match = re.exec(source))) { + rows.push({ keys: match[2], descriptionKey: match[4] }); + } + return rows; +} + +function splitSentences(text) { + const parts = text + .match(/[^。!?.!?]+[。!?.!?]?/g) + ?.map((part) => part.trim()) + .filter(Boolean); + return parts?.length ? parts : text ? [text] : []; +} + +function titleFromId(id) { + return id + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function buildSpec(docsPath, translations) { + const source = readFileSync(docsPath, 'utf-8'); + const name = extractField(source, 'componentId'); + if (!name) return null; + + const descriptionKey = extractField(source, 'descriptionKey'); + const accessibilityKey = source.match(/id:\s*'accessibility'[\s\S]*?descriptionKey:\s*'([^']+)'/)?.[1]; + const stateModelKey = source.match(/id:\s*'stateModel'[\s\S]*?descriptionKey:\s*'([^']+)'/)?.[1]; + + const titleKey = extractField(source, 'titleKey'); + const spec = { + name, + title: translations[titleKey] || titleFromId(name), + description: translations[descriptionKey] || '', + aliases: ALIASES[name] ?? [], + anatomy: extractField(source, 'composition').trim(), + example: (extractField(source, 'basic') || extractField(source, 'usageMain')).trim(), + usageImport: extractField(source, 'usageImport').trim(), + api: extractApiRows(source).map((row) => ({ + property: row.property, + type: row.type, + defaultValue: row.defaultValue, + description: translations[row.descriptionKey] || row.descriptionKey, + })), + keyboard: extractKeyboardRows(source).map((row) => ({ + keys: row.keys, + action: translations[row.descriptionKey] || row.descriptionKey, + })), + accessibility: splitSentences(translations[accessibilityKey] || ''), + stateModel: splitSentences(translations[stateModelKey] || ''), + }; + + for (const key of Object.keys(spec)) { + const value = spec[key]; + if (value === '' || (Array.isArray(value) && value.length === 0)) { + delete spec[key]; + } + } + return spec; +} + +function main() { + const translations = loadTranslations(); + const specs = {}; + const pages = readdirSync(DOCS_PAGES, { withFileTypes: true }).filter((entry) => entry.isDirectory()); + + for (const page of pages) { + const docsPath = join(DOCS_PAGES, page.name, `${page.name}.docs.ts`); + if (!existsSync(docsPath)) continue; + const spec = buildSpec(docsPath, translations); + if (spec) specs[spec.name] = spec; + } + + const ordered = Object.fromEntries(Object.keys(specs).sort().map((name) => [name, specs[name]])); + const next = `${JSON.stringify(ordered, null, 2)}\n`; + + if (process.argv.includes('--check')) { + const current = existsSync(OUT_PATH) ? readFileSync(OUT_PATH, 'utf-8') : ''; + if (current !== next) { + console.error('✖ registry/specs.json is stale. Run: pnpm --filter @sanring/cli generate-specs'); + process.exit(1); + } + console.log(`✔ Component specs are up to date (${Object.keys(ordered).length})`); + return; + } + + writeFileSync(OUT_PATH, next, 'utf-8'); + console.log(`✔ Wrote ${Object.keys(ordered).length} component specs to ${OUT_PATH}`); +} + +main(); diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index 05a36a79..da72d4ab 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -82,6 +82,7 @@ describe('mcp server', () => { 'migration_status', 'search_components', 'get_component_info', + 'get_component_spec', 'plan_component_install', 'add_component', ]); @@ -265,4 +266,48 @@ describe('mcp server', () => { expect(textContent(result)).toContain('Successfully added "widget"'); expect(textContent(result)).toContain('installed widget'); }); + + it('resolves modal via aliases and returns an authoring spec', async () => { + writeFileSync( + join(registryDir, 'specs.json'), + JSON.stringify({ + widget: { + name: 'widget', + title: 'Widget', + aliases: ['modal'], + anatomy: 'sanring-widget', + example: '', + accessibility: ["role='group'"], + }, + }), + 'utf-8', + ); + + const testClient = await connect(); + const searchResult = await testClient.callTool({ + name: 'search_components', + arguments: { query: 'modal' }, + }); + const specResult = await testClient.callTool({ + name: 'get_component_spec', + arguments: { name: 'widget' }, + }); + + expect(textContent(searchResult)).toContain('widget'); + expect(textContent(specResult)).toContain('sanring-widget'); + expect(textContent(specResult)).toContain("role='group'"); + expect(textContent(specResult)).toContain('Do not invent APIs'); + expect((specResult as { isError?: boolean }).isError).toBeUndefined(); + }); + + it('returns isError when get_component_spec has no specs.json', async () => { + const testClient = await connect(); + const result = await testClient.callTool({ + name: 'get_component_spec', + arguments: { name: 'widget' }, + }); + + expect((result as { isError?: boolean }).isError).toBe(true); + expect(textContent(result)).toContain('No authoring spec'); + }); }); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 1fb9d055..1d4982d8 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -22,6 +22,7 @@ import { } from '../registry.js'; import { findRegistryReferenceIssues } from '../registry-integrity.js'; import { isAngularProject, readConfig, resolveComponentBasePath, resolveRegistrySource, semverLte } from '../utils.js'; +import { fetchComponentSpecs, formatComponentSpec, rankRegistryItems } from '../component-spec.js'; import { resolveInstallSet, collectPeerDeps } from './add.js'; import { registryRelativePath } from './diff.js'; @@ -168,15 +169,21 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { }); let cachedRegistry: Registry | null = null; + let cachedSpecs: Awaited> | null = null; let cachedAt = 0; const CACHE_TTL_MS = 30_000; const getRegistry = async (forceRefresh = false): Promise => { if (forceRefresh || !cachedRegistry || Date.now() - cachedAt > CACHE_TTL_MS) { cachedRegistry = await fetchRegistry(registryUrl); + cachedSpecs = await fetchComponentSpecs(registryUrl); cachedAt = Date.now(); } return cachedRegistry; }; + const getSpecs = async (): Promise> => { + await getRegistry(); + return cachedSpecs ?? {}; + }; const server = new Server( { name: 'sanring', version: getCliVersion() }, @@ -228,7 +235,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { { name: 'search_components', description: - 'Search Sanring UI components by name or description. Returns matching components ranked by relevance (name matches first).', + 'Search Sanring UI components by name, description, or alias (e.g. modal → dialog / alert-dialog). Ranked by relevance. Call get_component_spec next before writing templates.', inputSchema: { type: 'object' as const, properties: { @@ -240,7 +247,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { { name: 'get_component_info', description: - 'Get detailed information about a specific Sanring UI component: files, component dependencies that will be auto-installed, and required peer dependencies.', + 'Install details for a Sanring UI component: files, auto-installed component dependencies, shared utilities, and peer dependencies. For selectors, anatomy, API, and accessibility, call get_component_spec.', inputSchema: { type: 'object' as const, properties: { @@ -252,6 +259,21 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { required: ['name'], }, }, + { + name: 'get_component_spec', + description: + 'Authoring contract for a Sanring UI component: real selectors, anatomy, API, accessibility, keyboard, and a canonical example. Call this before writing Angular templates. Do not invent APIs.', + inputSchema: { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: "Component name (e.g. 'dialog', 'alert-dialog', 'select')", + }, + }, + required: ['name'], + }, + }, { name: 'plan_component_install', description: @@ -398,12 +420,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { const registry = await getRegistry(); const q = query.toLowerCase(); const items = registryInstallables(registry); - const nameMatches = items.filter((c) => c.name.toLowerCase().includes(q)); - const descMatches = items.filter( - (c) => - !c.name.toLowerCase().includes(q) && c.description.toLowerCase().includes(q), - ); - const matches = [...nameMatches, ...descMatches]; + const matches = rankRegistryItems(items, q, await getSpecs()); if (matches.length === 0) { return { @@ -459,6 +476,44 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { }; } + case 'get_component_spec': { + const validated = requireStrings(args, ['name']); + if ('isError' in validated) return validated; + const { name: componentName } = validated.values; + const registry = await getRegistry(); + const component = findRegistryItem(registry, componentName); + + if (!component) { + const available = registryInstallables(registry).map((c) => c.name).join(', '); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Component "${componentName}" not found.\n\nAvailable: ${available}`, + }, + ], + }; + } + + const spec = (await getSpecs())[component.name]; + if (!spec) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `No authoring spec for "${component.name}". Call get_component_info for install details, or generate registry/specs.json.`, + }, + ], + }; + } + + return { + content: [{ type: 'text' as const, text: formatComponentSpec(spec) }], + }; + } + case 'plan_component_install': { const validated = requireStrings(args, ['name']); if ('isError' in validated) return validated; diff --git a/packages/cli/src/component-spec.test.ts b/packages/cli/src/component-spec.test.ts new file mode 100644 index 00000000..d6b152bf --- /dev/null +++ b/packages/cli/src/component-spec.test.ts @@ -0,0 +1,72 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { formatComponentSpec, rankRegistryItems, fetchComponentSpecs, type ComponentSpec } from './component-spec.js'; + +describe('rankRegistryItems', () => { + const items = [ + { name: 'dialog', description: 'An overlay primitive for modal tasks' }, + { name: 'alert-dialog', description: 'A dialog that requires an explicit choice' }, + { name: 'button', description: 'A clickable button' }, + ]; + const specs = { + dialog: { name: 'dialog', aliases: ['modal'] }, + 'alert-dialog': { name: 'alert-dialog', aliases: ['confirm', 'modal'] }, + }; + + it('maps modal to dialog and alert-dialog', () => { + expect(rankRegistryItems(items, 'modal', specs).map((item) => item.name)).toEqual([ + 'dialog', + 'alert-dialog', + ]); + }); + + it('maps confirm to alert-dialog', () => { + expect(rankRegistryItems(items, 'confirm', specs)[0]?.name).toBe('alert-dialog'); + }); + + it('still ranks an exact name first', () => { + expect(rankRegistryItems(items, 'dialog', specs)[0]?.name).toBe('dialog'); + }); +}); + +describe('formatComponentSpec', () => { + it('lists anatomy, API, and accessibility', () => { + const spec: ComponentSpec = { + name: 'dialog', + title: 'Dialog', + description: 'Overlay primitive.', + anatomy: 'sanring-dialog-content', + example: '', + api: [{ property: 'showClose', type: 'boolean', defaultValue: 'true', description: 'Show X' }], + accessibility: ["role='dialog'"], + }; + const text = formatComponentSpec(spec); + expect(text).toContain('sanring-dialog-content'); + expect(text).toContain('showClose'); + expect(text).toContain("role='dialog'"); + expect(text).toContain('Do not invent APIs'); + }); +}); + +describe('fetchComponentSpecs', () => { + let dir: string; + + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('returns an empty catalog when specs.json is missing', async () => { + dir = mkdtempSync(join(tmpdir(), 'sanring-specs-missing-')); + writeFileSync(join(dir, 'registry.json'), '{"name":"x","shared":[],"components":[]}', 'utf-8'); + expect(await fetchComponentSpecs(dir)).toEqual({}); + }); + + it('reads specs.json next to the registry', async () => { + dir = mkdtempSync(join(tmpdir(), 'sanring-specs-present-')); + writeFileSync(join(dir, 'registry.json'), '{"name":"x","shared":[],"components":[]}', 'utf-8'); + writeFileSync(join(dir, 'specs.json'), '{"dialog":{"name":"dialog","aliases":["modal"]}}', 'utf-8'); + expect(await fetchComponentSpecs(dir)).toEqual({ dialog: { name: 'dialog', aliases: ['modal'] } }); + }); +}); diff --git a/packages/cli/src/component-spec.ts b/packages/cli/src/component-spec.ts new file mode 100644 index 00000000..4f223752 --- /dev/null +++ b/packages/cli/src/component-spec.ts @@ -0,0 +1,107 @@ +import { fetchFile } from './registry.js'; + +export interface ComponentSpecApiRow { + property: string; + type: string; + defaultValue: string; + description: string; +} + +export interface ComponentSpecKeyboardRow { + keys: string; + action: string; +} + +export interface ComponentSpec { + name: string; + title?: string; + description?: string; + aliases?: string[]; + anatomy?: string; + example?: string; + usageImport?: string; + api?: ComponentSpecApiRow[]; + keyboard?: ComponentSpecKeyboardRow[]; + accessibility?: string[]; + stateModel?: string[]; +} + +export type ComponentSpecCatalog = Record; + +export async function fetchComponentSpecs(source?: string): Promise { + try { + const raw = await fetchFile('specs.json', source); + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as ComponentSpecCatalog; + } catch { + return {}; + } +} + +export function rankRegistryItems( + items: readonly T[], + query: string, + specs: ComponentSpecCatalog, +): T[] { + const q = query.toLowerCase().trim(); + const tokens = q.split(/[^a-z0-9]+/).filter(Boolean); + + return items + .map((item) => { + const name = item.name.toLowerCase(); + const description = item.description.toLowerCase(); + const aliases = (specs[item.name]?.aliases ?? []).map((alias) => alias.toLowerCase()); + let score = 0; + if (name === q) score += 100; + if (name.startsWith(q)) score += 40; + if (name.includes(q)) score += 20; + if (description.includes(q)) score += 8; + if (aliases.some((alias) => alias === q || alias.includes(q))) score += 50; + for (const token of tokens) { + if (name.includes(token)) score += 10; + if (description.includes(token)) score += 3; + if (aliases.some((alias) => alias.includes(token))) score += 15; + } + return { item, score }; + }) + .filter((entry) => entry.score > 0) + .sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)) + .map((entry) => entry.item); +} + +export function formatComponentSpec(spec: ComponentSpec): string { + const lines: string[] = [ + `${spec.name}${spec.title && spec.title !== spec.name ? ` — ${spec.title}` : ''}`, + ]; + if (spec.description) lines.push('', spec.description); + if (spec.aliases?.length) lines.push('', `Aliases: ${spec.aliases.join(', ')}`); + if (spec.anatomy) lines.push('', 'Anatomy:', spec.anatomy); + if (spec.usageImport) lines.push('', 'Import:', spec.usageImport); + if (spec.example) lines.push('', 'Example:', spec.example); + if (spec.api?.length) { + lines.push('', 'API:'); + for (const row of spec.api) { + lines.push(` ${row.property}: ${row.type} (default ${row.defaultValue}) — ${row.description}`); + } + } + if (spec.keyboard?.length) { + lines.push('', 'Keyboard:'); + for (const row of spec.keyboard) { + lines.push(` ${row.keys}: ${row.action}`); + } + } + if (spec.accessibility?.length) { + lines.push('', 'Accessibility:'); + for (const item of spec.accessibility) lines.push(` - ${item}`); + } + if (spec.stateModel?.length) { + lines.push('', 'State model:'); + for (const item of spec.stateModel) lines.push(` - ${item}`); + } + lines.push( + '', + 'Use only the selectors, inputs, and nesting shown above. Do not invent APIs.', + ); + return lines.join('\n'); +} diff --git a/registry/specs.json b/registry/specs.json new file mode 100644 index 00000000..4e556793 --- /dev/null +++ b/registry/specs.json @@ -0,0 +1,4379 @@ +{ + "accordion": { + "name": "accordion", + "title": "Accordion", + "description": "A vertically stacked set of interactive headings that each reveal a section of content.", + "aliases": [ + "collapse" + ], + "anatomy": "sanring-accordion\n└── sanring-accordion-item\n ├── sanring-accordion-trigger\n └── sanring-accordion-content", + "example": "\n \n \n Shipping options\n \n \n We ship domestically and internationally.\n \n \n", + "usageImport": "import { Component } from '@angular/core';\nimport { SANRING_ACCORDION_IMPORTS } from './components/ui/accordion';\n\n@Component({\n imports: [SANRING_ACCORDION_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "multi", + "type": "boolean", + "defaultValue": "false", + "description": "Allows multiple accordion items to stay open at the same time." + }, + { + "property": "expanded", + "type": "boolean", + "defaultValue": "false", + "description": "Controls whether an accordion item is open." + }, + { + "property": "disabled", + "type": "boolean", + "defaultValue": "false", + "description": "Disables interaction for an accordion item." + }, + { + "property": "variant", + "type": "'default' | 'underline'", + "defaultValue": "'default'", + "description": "Sets the trigger visual variant. The default uses a hover background; underline uses text underline." + }, + { + "property": "openAll()", + "type": "method", + "defaultValue": "-", + "description": "Opens all enabled items when multi is enabled." + }, + { + "property": "closeAll()", + "type": "method", + "defaultValue": "-", + "description": "Closes all enabled items in the accordion." + }, + { + "property": "opened", + "type": "OutputEmitterRef", + "defaultValue": "-", + "description": "Emits when an accordion item opens." + }, + { + "property": "closed", + "type": "OutputEmitterRef", + "defaultValue": "-", + "description": "Emits when an accordion item closes." + }, + { + "property": "expandedChange", + "type": "OutputEmitterRef", + "defaultValue": "-", + "description": "Emits when the expanded state changes." + } + ], + "keyboard": [ + { + "keys": "Enter / Space", + "action": "Toggle the focused accordion item open or closed." + }, + { + "keys": "↓", + "action": "Move focus to the next accordion trigger." + }, + { + "keys": "↑", + "action": "Move focus to the previous accordion trigger." + }, + { + "keys": "Home / End", + "action": "Move focus to the first / last accordion trigger." + } + ], + "accessibility": [ + "WAI-ARIA Accordion pattern via @angular/aria/accordion.", + "Each trigger receives role='button', aria-expanded, and aria-controls linking to its content panel.", + "The content panel has a matching id and aria-labelledby pointing back to its trigger." + ], + "stateModel": [ + "Not CVA.", + "Each sanring-accordion-item tracks its own expanded state via expanded / expandedChange.", + "Enable multi on the group to allow multiple open items simultaneously.", + "The openAll() and closeAll() methods on the group provide programmatic control." + ] + }, + "alert": { + "name": "alert", + "title": "Alert", + "description": "A persistent inline message for important state, warnings, and guidance inside the document flow.", + "example": "\n \n
    系統維護通知
    \n

    \n 系統將於本週日凌晨 02:00 進行升級,屆時將暫停服務 30 分鐘。\n

    \n
    ", + "usageImport": "import { Component } from '@angular/core';\nimport { SANRING_ALERT_IMPORTS } from './components/ui/alert';\n\n@Component({\n imports: [SANRING_ALERT_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the base alert styles." + }, + { + "property": "variant", + "type": "AlertVariant", + "defaultValue": "'default'", + "description": "Controls the visual tone. Available variants are default and destructive." + } + ], + "accessibility": [ + "role='alert' on the host, which carries an implicit aria-live='assertive'.", + "Screen readers announce the content immediately when the element is inserted into the DOM.", + "For non-urgent messages, wrap or replace with role='status' (aria-live='polite')." + ], + "stateModel": [ + "Stateless — insert or remove with @if to trigger or clear the live-region announcement.", + "Content is driven by ng-content." + ] + }, + "alert-dialog": { + "name": "alert-dialog", + "title": "Alert Dialog", + "description": "A modal confirmation dialog for destructive or important actions. Unlike Dialog, it cannot be dismissed by clicking the backdrop or pressing Escape.", + "aliases": [ + "confirm", + "confirmation", + "delete", + "modal" + ], + "anatomy": "[sanringAlertDialogTrigger]\nsanring-alert-dialog-content\n├── sanring-dialog-header\n│ ├── sanring-dialog-media (optional)\n│ ├── [sanringDialogTitle]\n│ └── [sanringDialogDescription]\n└── sanring-dialog-footer\n ├── [sanringAlertDialogCancel]\n └── [sanringAlertDialogAction]", + "example": "\n\n\n \n \n

    Delete account?

    \n

    This action cannot be undone.

    \n
    \n \n \n \n \n
    \n
    ", + "usageImport": "import { Component } from '@angular/core';\nimport { ButtonDirective } from './components/ui/button';\nimport { SANRING_ALERT_DIALOG_IMPORTS } from './components/ui/alert-dialog';\n\n@Component({\n imports: [ButtonDirective, SANRING_ALERT_DIALOG_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "sanringAlertDialogTrigger", + "type": "TemplateRef", + "defaultValue": "required", + "description": "Template rendered inside the alert dialog when the trigger is activated." + }, + { + "property": "sanringAlertDialogConfig", + "type": "DialogConfig", + "defaultValue": "undefined", + "description": "Optional CDK `DialogConfig` merged into the opened dialog. `role` and `disableClose` are always locked regardless of what is passed here." + }, + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with `AlertDialogContent` layout styles." + }, + { + "property": "showClose", + "type": "boolean", + "defaultValue": "false", + "description": "Controls whether the built-in close button is rendered. Defaults to `false`, unlike Dialog." + }, + { + "property": "ariaLabel", + "type": "string", + "defaultValue": "'Alert dialog' fallback", + "description": "Accessible-name fallback used when no sanringDialogTitle is projected. Untitled content defaults to “Alert dialog”." + }, + { + "property": "ariaLabelledBy", + "type": "string", + "defaultValue": "undefined", + "description": "Ids of external elements that label the alert dialog. Takes precedence over the projected title and ariaLabel." + }, + { + "property": "ariaDescribedBy", + "type": "string", + "defaultValue": "undefined", + "description": "Ids of external elements that describe the alert dialog. Takes precedence over sanringDialogDescription." + }, + { + "property": "sanringAlertDialogAction", + "type": "unknown", + "defaultValue": "true", + "description": "Optional result value passed to `DialogRef.close()` when clicked. Defaults to `true`." + }, + { + "property": "sanringAlertDialogCancel", + "type": "unknown", + "defaultValue": "false", + "description": "Optional result value passed to `DialogRef.close()` when clicked. Defaults to `false`." + } + ], + "keyboard": [ + { + "keys": "Tab", + "action": "Move focus to the next focusable element within the dialog." + }, + { + "keys": "Shift + Tab", + "action": "Move focus to the previous focusable element within the dialog." + }, + { + "keys": "Escape", + "action": "Has no effect — backdrop dismiss and Escape are disabled by default to prevent accidental dismissal." + } + ], + "accessibility": [ + "The dialog container receives role='alertdialog', aria-modal='true', and an accessible name from its title, ariaLabel, or the 'Alert dialog' fallback.", + "Backdrop click and Escape are both disabled, requiring an explicit choice." + ], + "stateModel": [ + "Service-based.", + "Call AlertDialogService.", + "open(template, config) to open an alert dialog programmatically, or use [sanringAlertDialogTrigger] for template-driven use.", + "The service always forces role='alertdialog' and disableClose:true — callers cannot opt out.", + "Not a form control." + ] + }, + "aspect-ratio": { + "name": "aspect-ratio", + "title": "Aspect Ratio", + "description": "A layout directive that keeps media, embeds, and preview frames locked to a consistent aspect ratio.", + "example": "
    \n \n
    ", + "usageImport": "import { AspectRatioDirective } from './components/ui/aspect-ratio';", + "api": [ + { + "property": "sanringAspectRatio", + "type": "string | number", + "defaultValue": "'1 / 1'", + "description": "Aspect ratio value applied to the host element. Accepts CSS ratio strings such as 16 / 9 or numbers such as 1.777." + }, + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the base relative w-full container styles." + } + ], + "accessibility": [ + "Adds no ARIA of its own and preserves the semantics of the projected content.", + "Images, iframes, and video still need alt text, titles, or captions based on the content." + ], + "stateModel": [ + "Stateless.", + "sanringAspectRatio only applies ratio styles to the host element; it does not store loading, selection, or interaction state." + ] + }, + "avatar": { + "name": "avatar", + "title": "Avatar", + "description": "A composable avatar primitive for profile images, fallbacks, status badges, and stacked groups.", + "anatomy": "sanring-avatar-group\n├── sanring-avatar\n│ ├── img[sanringAvatarImage]\n│ ├── sanring-avatar-fallback\n│ └── [sanringAvatarBadge]\n└── sanring-avatar-group-count", + "example": "\n \n AL\n", + "usageImport": "import { Component } from '@angular/core';\nimport { SANRING_AVATAR_IMPORTS } from './components/ui/avatar';\n\n@Component({\n imports: [SANRING_AVATAR_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the selected avatar primitive." + }, + { + "property": "size", + "type": "AvatarSize", + "defaultValue": "'md'", + "description": "Controls avatar density: sm, md, or lg." + }, + { + "property": "ariaLabel", + "type": "string", + "defaultValue": "undefined", + "description": "Accessible label for standalone avatars or avatar groups without visible text." + }, + { + "property": "delayMs", + "type": "number", + "defaultValue": "0", + "description": "Delay in milliseconds before the fallback is shown." + }, + { + "property": "status", + "type": "AvatarBadgeStatus", + "defaultValue": "'default'", + "description": "Controls badge color: online, offline, away, busy, or default." + }, + { + "property": "placement", + "type": "'start' | 'end' | 'top' | 'bottom'", + "defaultValue": "'end' / 'top'", + "description": "Places the badge at start, end, top, or bottom. Status badges default to end (bottom-end); count badges default to top (top-end). start/end follow RTL." + }, + { + "property": "count (AvatarBadge)", + "type": "number", + "defaultValue": "undefined", + "description": "Unread count on [sanringAvatarBadge]. Hidden at 0 or below; values above 99 render as 99+." + }, + { + "property": "overlap", + "type": "number", + "defaultValue": "0.75", + "description": "Stack overlap amount in rem for avatar groups." + }, + { + "property": "count", + "type": "number", + "defaultValue": "undefined", + "description": "Number displayed by the avatar group count item." + }, + { + "property": "clickable (AvatarGroupCountComponent)", + "type": "boolean", + "defaultValue": "false", + "description": "Gives the group count button semantics and enables pointer and keyboard activation." + }, + { + "property": "disabled (AvatarGroupCountComponent)", + "type": "boolean", + "defaultValue": "false", + "description": "Makes a clickable group count unavailable and removes it from the tab sequence." + }, + { + "property": "(clicked) (AvatarGroupCountComponent)", + "type": "void", + "defaultValue": "—", + "description": "Emitted when an enabled clickable group count is activated." + } + ], + "accessibility": [ + "role='img' on the host.", + "Provide ariaLabel or ariaLabelledBy to name a non-decorative avatar.", + "For purely decorative use — such as next to a user name already present in text — add aria-hidden='true' on to suppress redundant announcements.", + "A count badge is role='status'; give it an ariaLabel such as '3 unread'.", + "A clickable group count exposes button semantics and reflects disabled state." + ], + "stateModel": [ + "Stateless.", + "src loads the image; on failure the fallback slot renders; initials are a last resort.", + "No internal selection or value state." + ] + }, + "badge": { + "name": "badge", + "title": "Badge", + "description": "A compact label for status, counts, metadata, and inline categories.", + "aliases": [ + "chip" + ], + "example": "\n Default\n", + "usageImport": "import { BadgeDirective } from './components/ui/badge';", + "api": [ + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the base badge styles." + }, + { + "property": "variant", + "type": "BadgeVariant", + "defaultValue": "'default'", + "description": "Controls visual emphasis: default, secondary, destructive, outline, or ghost." + } + ], + "accessibility": [ + "Rendered as an inline with no added ARIA.", + "If the badge conveys meaning not present in surrounding text — such as an isolated notification count or status — add aria-label directly on the element." + ], + "stateModel": [ + "Stateless — variant and class inputs only.", + "No value or event state." + ] + }, + "breadcrumb": { + "name": "breadcrumb", + "title": "Breadcrumb", + "description": "A navigation aid showing the user's current location within a hierarchy — built from composable primitives with full accessibility support.", + "anatomy": "sanring-breadcrumb \n└── sanring-breadcrumb-list \n ├── sanring-breadcrumb-item \n │ └── sanring-breadcrumb-link \n ├── sanring-breadcrumb-divider \n ├── sanring-breadcrumb-item\n │ └── sanring-breadcrumb-ellipsis \n ├── sanring-breadcrumb-divider\n └── sanring-breadcrumb-item\n └── sanring-breadcrumb-page ", + "example": "\n \n \n Home\n \n\n \n\n \n Components\n \n\n \n\n \n Breadcrumb\n \n \n", + "usageImport": "import { Component } from '@angular/core';\nimport { SANRING_BREADCRUMB_IMPORTS } from './components/ui/breadcrumb';\n\n@Component({\n imports: [SANRING_BREADCRUMB_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "ariaLabel (BreadcrumbComponent)", + "type": "string", + "defaultValue": "'breadcrumb'", + "description": "Accessible name for the breadcrumb navigation; override it for localization or page-specific context." + }, + { + "property": "type (BreadcrumbDividerComponent)", + "type": "'chevron' | 'dot'", + "defaultValue": "'chevron'", + "description": "Divider icon: 'chevron' (default, ›) or 'dot' (·). Pass custom content via ng-content to override entirely." + }, + { + "property": "routerLink (BreadcrumbLinkComponent)", + "type": "string | unknown[] | null", + "defaultValue": "undefined", + "description": "Angular RouterLink value passed to the inner anchor element." + }, + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged onto the host element." + } + ], + "accessibility": [ + "The host uses role='navigation'; set ariaLabel to a localized or context-specific accessible name.", + "Mark the current page with aria-current='page' on so screen readers announce the user's current location." + ], + "stateModel": [ + "Stateless — items are rendered via template projection.", + "No internal selection state." + ] + }, + "button": { + "name": "button", + "title": "Button", + "description": "A flexible action primitive for commands, navigation triggers, and compact icon controls.", + "example": "import { ButtonDirective } from './components/ui/button';\n\n\n\n", + "usageImport": "import { ButtonDirective } from './components/ui/button';", + "api": [ + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the base button styles." + }, + { + "property": "variant", + "type": "ButtonVariant", + "defaultValue": "'default'", + "description": "Controls visual emphasis: default, secondary, outline, ghost, destructive, or link." + }, + { + "property": "size", + "type": "ButtonSize", + "defaultValue": "'md'", + "description": "Controls button density: sm, md, icon, toolbar, or toolbarIcon." + } + ], + "accessibility": [ + "Applied to native button or anchor elements, preserving their native semantics.", + "Icon-only buttons must provide an aria-label or readable text." + ], + "stateModel": [ + "Stateless.", + "variant, size, and class affect styling only; disabled, type, href, and click behavior are owned by the host element or Angular bindings." + ] + }, + "calendar": { + "name": "calendar", + "title": "Calendar", + "description": "A date grid built on the headless @sanring/date-picker-core engine, supporting single, range, and multi-month selection.", + "aliases": [ + "datepicker" + ], + "example": "", + "usageImport": "import { Component } from '@angular/core';\nimport { CALENDAR_LOCALE } from '@sanring/date-picker-core';\nimport { CalendarComponent } from './components/ui/calendar';\n\n@Component({\n imports: [CalendarComponent],\n providers: [\n {\n provide: CALENDAR_LOCALE,\n useValue: {\n weekStartsOn: 0,\n weekdayLabels: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],\n monthLabels: [\n 'January', 'February', 'March', 'April', 'May', 'June',\n 'July', 'August', 'September', 'October', 'November', 'December',\n ],\n },\n },\n ],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "id", + "type": "string", + "defaultValue": "auto-generated", + "description": "Host id used for field association and imperative focus targeting. Generated when omitted." + }, + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the component base styles." + }, + { + "property": "size", + "type": "'sm' | 'md' | 'lg'", + "defaultValue": "'md'", + "description": "Size of each day cell." + }, + { + "property": "locale", + "type": "CalendarLocale | undefined", + "defaultValue": "undefined", + "description": "Overrides the injected CALENDAR_LOCALE; falls back to the injected token when omitted." + }, + { + "property": "mode", + "type": "'single' | 'range'", + "defaultValue": "'single'", + "description": "Single or range selection mode; switching resets the current selection." + }, + { + "property": "monthsToDisplay", + "type": "number", + "defaultValue": "1", + "description": "Number of months rendered together." + }, + { + "property": "orientation", + "type": "'horizontal' | 'vertical'", + "defaultValue": "'horizontal'", + "description": "Layout direction for multiple months (monthsToDisplay > 1). Purely presentational — the engine only guarantees array order, not screen position." + }, + { + "property": "disabled", + "type": "DisabledInput | undefined", + "defaultValue": "undefined", + "description": "Disabled-date matcher: a single date, an array, an interval, or a predicate function." + }, + { + "property": "allowDeselect", + "type": "boolean", + "defaultValue": "true", + "description": "Whether re-clicking the selected date clears the selection." + }, + { + "property": "required", + "type": "boolean", + "defaultValue": "false", + "description": "Marks the calendar as required for field integration and aria-required." + }, + { + "property": "ariaDescribedBy", + "type": "string | undefined", + "defaultValue": "undefined", + "description": "ID of helper text that describes the calendar; merged with Field-provided description ids." + }, + { + "property": "prevMonthLabel", + "type": "string", + "defaultValue": "'上一月'", + "description": "aria-label for the previous-month button, overridable for i18n." + }, + { + "property": "nextMonthLabel", + "type": "string", + "defaultValue": "'下一月'", + "description": "aria-label for the next-month button, overridable for i18n." + }, + { + "property": "jumpMonthLabel", + "type": "string", + "defaultValue": "'選擇月份'", + "description": "aria-label for the month select in the jump popover." + }, + { + "property": "jumpYearLabel", + "type": "string", + "defaultValue": "'選擇年份'", + "description": "aria-label for the year select in the jump popover." + }, + { + "property": "selectedDateChange", + "type": "EventEmitter", + "defaultValue": "—", + "description": "Emits when the selected date changes in single mode." + }, + { + "property": "selectedRangeChange", + "type": "EventEmitter", + "defaultValue": "—", + "description": "Emits when the selected range changes in range mode." + }, + { + "property": "isDraftActive", + "type": "Signal", + "defaultValue": "—", + "description": "In range mode, whether the first endpoint is picked and a second one is pending." + }, + { + "property": "clear()", + "type": "(): void", + "defaultValue": "—", + "description": "Clears the current selection (also discards an in-progress range draft)." + }, + { + "property": "abortRangeDraft()", + "type": "(): void", + "defaultValue": "—", + "description": "Aborts an in-progress range draft without touching the committed range." + }, + { + "property": "focus()", + "type": "(options?: FocusOptions): void", + "defaultValue": "—", + "description": "Moves focus to the calendar host element." + } + ], + "keyboard": [ + { + "keys": "↑ / ↓ / ← / →", + "action": "Move focus between day cells, crossing month boundaries seamlessly when monthsToDisplay > 1." + }, + { + "keys": "Page Up / Page Down", + "action": "Previous / next month." + }, + { + "keys": "Home / End", + "action": "First / last day of the visible month." + }, + { + "keys": "Enter", + "action": "Select or toggle the focused day." + } + ], + "accessibility": [ + "role='grid' on each month grid, aria-label on the grid from the month/year header, role='gridcell' on each day cell.", + "The host element receives aria-required, aria-invalid, and aria-describedby, auto-wired when nested in sanring-field." + ], + "stateModel": [ + "Implements ControlValueAccessor.", + "Use [(ngModel)] or formControl.", + "Value type: Date | null (single mode) or { start: Date; end: Date } | null (range mode).", + "The abortRangeDraft() and clear() methods are available for programmatic control." + ] + }, + "card": { + "name": "card", + "title": "Card", + "description": "A composable surface primitive for forms, metrics, media, lists, and any structured content.", + "anatomy": "sanring-card\n├── sanring-card-header\n│ ├── [sanringCardTitle]\n│ └── [sanringCardDescription]\n├── sanring-card-content\n└── sanring-card-footer", + "example": "\n \n

    Card title

    \n

    Card description

    \n
    \n \n

    \n Compose any content with native HTML.\n

    \n
    \n
    ", + "usageImport": "import { Component } from '@angular/core';\nimport { SANRING_CARD_IMPORTS } from './components/ui/card';\n\n@Component({\n imports: [SANRING_CARD_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with each Card primitive." + } + ], + "accessibility": [ + "Card is a layout container with no built-in ARIA role.", + "Use semantic HTML inside — headings (h2–h4 via sanringCardTitle), paragraphs, lists.", + "Add role=\"article\" on the root manually for standalone content cards that need an independent landmark." + ], + "stateModel": [ + "Stateless layout container.", + "No value, selection, or event state of its own." + ] + }, + "carousel": { + "name": "carousel", + "title": "Carousel", + "description": "A composable carousel built on Embla for horizontal or vertical slide navigation.", + "anatomy": "sanring-carousel\n├── sanring-carousel-content\n│ └── sanring-carousel-item\n├── button[sanringCarouselPrevious]\n└── button[sanringCarouselNext]", + "example": "\n
    \n \n\n \n Slide 1\n Slide 2\n Slide 3\n \n\n \n
    \n
    ", + "usageImport": "import { SANRING_CAROUSEL_IMPORTS } from './components/ui/carousel';\n\n@Component({\n imports: [SANRING_CAROUSEL_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "orientation", + "type": "'horizontal' | 'vertical'", + "defaultValue": "'horizontal'", + "description": "Sets the carousel axis. Horizontal maps to Embla x-axis, vertical maps to y-axis." + }, + { + "property": "opts", + "type": "EmblaOptionsType", + "defaultValue": "{}", + "description": "Embla options passed to the underlying carousel instance, such as loop or align." + }, + { + "property": "ariaLabel", + "type": "string", + "defaultValue": "undefined", + "description": "Accessible label applied to the carousel region." + }, + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged onto the carousel root." + }, + { + "property": "sanringCarouselPrevious", + "type": "Directive", + "defaultValue": "-", + "description": "Directive for a button that scrolls to the previous slide and disables itself at the start." + }, + { + "property": "sanringCarouselNext", + "type": "Directive", + "defaultValue": "-", + "description": "Directive for a button that scrolls to the next slide and disables itself at the end." + } + ], + "keyboard": [ + { + "keys": "← / →", + "action": "Previous / next slide (horizontal orientation)." + }, + { + "keys": "↑ / ↓", + "action": "Previous / next slide (vertical orientation)." + } + ], + "accessibility": [ + "role='region' and aria-roledescription='carousel' on the host.", + "Provide an ariaLabel input to name the carousel region — required for screen readers to identify it.", + "Navigation buttons (sanring-carousel-previous / sanring-carousel-next) each need a descriptive aria-label attribute." + ], + "stateModel": [ + "Slide position is managed by the Embla engine internally.", + "Pass Embla options via the opts input (loop, align, etc.", + ").", + "No CVA." + ] + }, + "checkbox": { + "name": "checkbox", + "title": "Checkbox", + "description": "A control for toggling a boolean or indeterminate state, compatible with Angular forms via ControlValueAccessor.", + "example": "", + "usageImport": "import { CheckboxComponent } from './components/ui/checkbox';", + "api": [ + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the checkbox button styles." + }, + { + "property": "id", + "type": "string", + "defaultValue": "generated", + "description": "Native id applied to the checkbox button, useful for label association." + }, + { + "property": "name", + "type": "string", + "defaultValue": "undefined", + "description": "Native name attribute forwarded to the button for form submission." + }, + { + "property": "value", + "type": "string", + "defaultValue": "undefined", + "description": "Native value attribute forwarded to the button for form submission." + }, + { + "property": "disabled", + "type": "boolean", + "defaultValue": "false", + "description": "Disables interaction and reduces visual prominence." + }, + { + "property": "required", + "type": "boolean", + "defaultValue": "false", + "description": "Sets aria-required on the button to signal a mandatory field." + }, + { + "property": "checked", + "type": "CheckedState", + "defaultValue": "false", + "description": "Controlled checked state. Supports true, false, or \"indeterminate\". Use with [(checked)] for two-way binding without Angular forms." + }, + { + "property": "checkedChange", + "type": "EventEmitter", + "defaultValue": "—", + "description": "Emits the new CheckedState whenever the checkbox is toggled." + }, + { + "property": "tabIndex", + "type": "number", + "defaultValue": "0", + "description": "Tab order index. Automatically set to -1 when disabled." + }, + { + "property": "ariaLabel", + "type": "string", + "defaultValue": "undefined", + "description": "Accessible label for standalone checkboxes not associated with a visible