From edc3cc8f6c985ed1586001afe1c5ca7757063b99 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Wed, 23 Sep 2026 21:46:24 +0800 Subject: [PATCH 1/5] feat(avatar): add badge counts and local docs portraits Unread counts belong on the existing avatar badge, and tabs show the same pattern by composing Badge. Docs portraits now use local photos instead of pravatar. Co-authored-by: Cursor --- .changeset/avatar-tab-notification-count.md | 5 ++ apps/docs/public/avatars/lantern.jpg | Bin 0 -> 34397 bytes apps/docs/public/avatars/night-lantern.jpg | Bin 0 -> 12517 bytes apps/docs/public/avatars/path.jpg | Bin 0 -> 39176 bytes apps/docs/public/avatars/stairs.jpg | Bin 0 -> 30401 bytes .../app/i18n/locales/en/components/avatar.ts | 9 ++- .../app/i18n/locales/en/components/tabs.ts | 10 ++- .../app/i18n/locales/zh/components/avatar.ts | 10 ++- .../app/i18n/locales/zh/components/tabs.ts | 10 ++- apps/docs/src/app/i18n/translations.ts | 2 +- .../pages/changelog/component-changelog.ts | 16 ++++ .../avatar/avatar-page.component.ts | 28 ++++++- .../pages/components/avatar/avatar.docs.ts | 35 +++++++-- .../components/tabs/tabs-page.component.ts | 38 +++++++++- .../app/pages/components/tabs/tabs.docs.ts | 18 +++++ .../avatar/avatar-badge.directive.ts | 69 ++++++++++++++---- .../avatar/avatar.component.spec.ts | 64 ++++++++++++++++ .../src/lib/components/avatar/avatar.types.ts | 2 +- .../avatar/avatar-badge.directive.ts | 69 ++++++++++++++---- registry/components/avatar/avatar.types.ts | 2 +- 20 files changed, 340 insertions(+), 47 deletions(-) create mode 100644 .changeset/avatar-tab-notification-count.md create mode 100644 apps/docs/public/avatars/lantern.jpg create mode 100644 apps/docs/public/avatars/night-lantern.jpg create mode 100644 apps/docs/public/avatars/path.jpg create mode 100644 apps/docs/public/avatars/stairs.jpg diff --git a/.changeset/avatar-tab-notification-count.md b/.changeset/avatar-tab-notification-count.md new file mode 100644 index 00000000..cd0a1ab3 --- /dev/null +++ b/.changeset/avatar-tab-notification-count.md @@ -0,0 +1,5 @@ +--- +"@sanring/cli": patch +--- + +Avatar badge accepts `count` (hidden at 0, capped at 99+) and `top`/`bottom` placement. Tabs docs add a Badge composition recipe for unread counts. diff --git a/apps/docs/public/avatars/lantern.jpg b/apps/docs/public/avatars/lantern.jpg new file mode 100644 index 0000000000000000000000000000000000000000..de68f20a37ecdd3797382303c231fb2a698cc213 GIT binary patch literal 34397 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

?`ffX_M9X?xYr?Y8 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 N>P}@y^s)ON{{ie+P>KKm literal 0 HcmV?d00001 diff --git a/apps/docs/public/avatars/path.jpg b/apps/docs/public/avatars/path.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4e6ad1670151dc4608cac135dfea9a2f2ec849b7 GIT binary patch literal 39176 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

BiChX|w?|9E&(3+Ji<`KEA{sLRqgwV#GAKV`Op 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 TNa{W7C@CkR({qOQpL74&WES5Q literal 0 HcmV?d00001 diff --git a/apps/docs/public/avatars/stairs.jpg b/apps/docs/public/avatars/stairs.jpg new file mode 100644 index 0000000000000000000000000000000000000000..710e7b5b72bea78d8b4dfaa99cd091342f45f77f GIT binary patch literal 30401 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 Wl=5U53$cg;9mh4&+W1l&S^wE=w)pS> literal 0 HcmV?d00001 diff --git a/apps/docs/src/app/i18n/locales/en/components/avatar.ts b/apps/docs/src/app/i18n/locales/en/components/avatar.ts index fc34f50d..7add5678 100644 --- a/apps/docs/src/app/i18n/locales/en/components/avatar.ts +++ b/apps/docs/src/app/i18n/locales/en/components/avatar.ts @@ -4,6 +4,9 @@ export const avatarTranslations = { 'avatar.demo.sizes': 'Sizes', 'avatar.demo.statusBadge': 'Status badge', 'avatar.demo.badgeWithIcon': 'Badge with icon', + 'avatar.demo.badgeCount': 'Notification count', + 'avatar.examples.badgeCount.description': + 'Pass count to render a numeric pill. It defaults to the top-end corner so it can sit with a status dot at the bottom. 0 hides the pill; values above 99 render as 99+.', 'avatar.demo.group': 'Avatar group', 'avatar.demo.groupWithIcon': 'Avatar group with icon', 'avatar.examples.description': @@ -24,7 +27,9 @@ export const avatarTranslations = { 'avatar.api.delayMs.description': 'Delay in milliseconds before the fallback is shown.', 'avatar.api.status.description': 'Controls badge color: online, offline, away, busy, or default.', 'avatar.api.placement.description': - 'Places the badge at the visual start or end edge, respecting RTL direction.', + '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.', + 'avatar.api.badgeCount.description': + 'Unread count on [sanringAvatarBadge]. Hidden at 0 or below; values above 99 render as 99+.', 'avatar.api.overlap.description': 'Stack overlap amount in rem for avatar groups.', 'avatar.api.count.description': 'Number displayed by the avatar group count item.', 'avatar.api.clickable.description': @@ -33,7 +38,7 @@ export const avatarTranslations = { 'Makes a clickable group count unavailable and removes it from the tab sequence.', 'avatar.api.clicked.description': 'Emitted when an enabled clickable group count is activated.', 'avatar.accessibility.description': - "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 clickable group count exposes button semantics and reflects disabled state.", + "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.", 'avatar.keyboard.description': 'Avatars are not focusable by default. A clickable group count responds to Enter and Space.', 'avatar.stateModel.description': diff --git a/apps/docs/src/app/i18n/locales/en/components/tabs.ts b/apps/docs/src/app/i18n/locales/en/components/tabs.ts index 4345ffd5..ffdab608 100644 --- a/apps/docs/src/app/i18n/locales/en/components/tabs.ts +++ b/apps/docs/src/app/i18n/locales/en/components/tabs.ts @@ -11,6 +11,14 @@ export const tabsTranslations = { 'tabs.demo.settings': 'Settings', 'tabs.demo.horizontal': 'Horizontal', 'tabs.demo.withIcon': 'With icon', + 'tabs.demo.withCount': 'With count', + 'tabs.demo.inbox': 'Inbox', + 'tabs.demo.drafts': 'Drafts', + 'tabs.demo.inboxCountLabel': 'Inbox, 3 unread', + 'tabs.demo.inboxContent': 'Unread messages stay in the inbox.', + 'tabs.demo.draftsContent': 'Drafts have no unread count, so the badge is omitted.', + 'tabs.examples.withCount.description': + 'Compose sanringBadge inside a trigger. Put the count in the trigger aria-label. Omit the badge when the count is 0.', 'tabs.demo.vertical': 'Vertical', 'tabs.demo.line': 'Line variant', 'tabs.demo.disabled': 'Disabled tab', @@ -37,7 +45,7 @@ export const tabsTranslations = { 'tabs.api.disabled.description': 'Prevents a trigger from being selected or focused by keyboard navigation.', 'tabs.api.valueChange.description': 'Emits when the selected tab value changes.', - 'tabs.accessibility.description': "WAI-ARIA Tabs pattern via @angular/aria/tabs. role='tablist' on sanring-tabs-list, role='tab' on each trigger, role='tabpanel' on each content panel, linked with aria-controls and aria-labelledby. aria-selected reflects the active tab.", + 'tabs.accessibility.description': "WAI-ARIA Tabs pattern via @angular/aria/tabs. role='tablist' on sanring-tabs-list, role='tab' on each trigger, role='tabpanel' on each content panel, linked with aria-controls and aria-labelledby. aria-selected reflects the active tab. If a trigger shows a count, set aria-label on the trigger so the count is announced (for example 'Inbox, 3 unread').", 'tabs.keyboard.description': 'Arrow navigation within the trigger list; Tab moves into the active panel.', 'tabs.keyboard.arrowLeftRight': 'Navigate between tab triggers (horizontal orientation).', 'tabs.keyboard.arrowUpDown': 'Navigate between tab triggers (vertical orientation).', diff --git a/apps/docs/src/app/i18n/locales/zh/components/avatar.ts b/apps/docs/src/app/i18n/locales/zh/components/avatar.ts index 426c5f24..d16773f3 100644 --- a/apps/docs/src/app/i18n/locales/zh/components/avatar.ts +++ b/apps/docs/src/app/i18n/locales/zh/components/avatar.ts @@ -3,6 +3,9 @@ export const avatarTranslations = { 'avatar.demo.sizes': '尺寸', 'avatar.demo.statusBadge': '狀態徽章', 'avatar.demo.badgeWithIcon': '包含圖示的徽章', + 'avatar.demo.badgeCount': '通知計數', + 'avatar.examples.badgeCount.description': + '傳入 count 會渲染數字 pill。預設在右上角,可與右下角狀態點同時存在。0 會隱藏 pill;超過 99 顯示為 99+。', 'avatar.demo.group': '頭像群組', 'avatar.demo.groupWithIcon': '包含圖示的頭像群組', 'avatar.examples.description': '常見頭像模式,包含 fallback、在線狀態與精簡成員群組。', @@ -20,14 +23,17 @@ export const avatarTranslations = { 'avatar.api.ariaLabel.description': '無可見文字時,提供單一頭像或頭像群組的無障礙標籤。', 'avatar.api.delayMs.description': 'fallback 顯示前的延遲毫秒數。', 'avatar.api.status.description': '控制徽章顏色,可使用 online、offline、away、busy 或 default。', - 'avatar.api.placement.description': '將徽章放在視覺起點或終點,並尊重 RTL 方向。', + 'avatar.api.placement.description': + '將徽章放在 start、end、top 或 bottom。狀態徽章預設 end(右下);計數徽章預設 top(右上)。start/end 會跟隨 RTL。', + 'avatar.api.badgeCount.description': + '[sanringAvatarBadge] 的未讀計數。小於等於 0 時隱藏;超過 99 顯示為 99+。', 'avatar.api.overlap.description': '頭像群組的堆疊重疊量,單位為 rem。', 'avatar.api.count.description': '頭像群組數量項目顯示的數字。', 'avatar.api.clickable.description': '讓群組數量項目具備按鈕語意,並可由滑鼠與鍵盤操作。', 'avatar.api.disabled.description': '停用可點擊的群組數量項目,並將它移出 Tab 序列。', 'avatar.api.clicked.description': '啟用中的可點擊群組數量項目被觸發時送出。', 'avatar.accessibility.description': - "宿主具有 role='img'。為非裝飾性的頭像提供 ariaLabel 或 ariaLabelledBy。若頭像純屬裝飾性用途(例如緊鄰已出現在文字中的使用者名稱),請在 上加 aria-hidden='true' 以避免重複播報。可點擊的群組數量項目會提供 button 語意並反映停用狀態。", + "宿主具有 role='img'。為非裝飾性的頭像提供 ariaLabel 或 ariaLabelledBy。若頭像純屬裝飾性用途(例如緊鄰已出現在文字中的使用者名稱),請在 上加 aria-hidden='true' 以避免重複播報。計數徽章為 role='status',請給它 ariaLabel,例如「3 則未讀」。可點擊的群組數量項目會提供 button 語意並反映停用狀態。", 'avatar.keyboard.description': '頭像預設不可聚焦;可點擊的群組數量項目支援 Enter 與 Space。', 'avatar.stateModel.description': '無狀態。src 載入圖片;失敗時顯示 fallback 插槽;縮寫字母作為最後備援。沒有內部選取或值狀態。', diff --git a/apps/docs/src/app/i18n/locales/zh/components/tabs.ts b/apps/docs/src/app/i18n/locales/zh/components/tabs.ts index 462f3a51..c39ca129 100644 --- a/apps/docs/src/app/i18n/locales/zh/components/tabs.ts +++ b/apps/docs/src/app/i18n/locales/zh/components/tabs.ts @@ -10,6 +10,14 @@ export const tabsTranslations = { 'tabs.demo.settings': '設定', 'tabs.demo.horizontal': '水平', 'tabs.demo.withIcon': '包含圖示', + 'tabs.demo.withCount': '包含計數', + 'tabs.demo.inbox': '收件匣', + 'tabs.demo.drafts': '草稿', + 'tabs.demo.inboxCountLabel': '收件匣,3 則未讀', + 'tabs.demo.inboxContent': '未讀訊息會留在收件匣。', + 'tabs.demo.draftsContent': '草稿沒有未讀數,因此不放徽章。', + 'tabs.examples.withCount.description': + '在 trigger 內組合 sanringBadge。把數字寫進 trigger 的 aria-label。計數為 0 時不要渲染徽章。', 'tabs.demo.vertical': '垂直', 'tabs.demo.line': '底線變體', 'tabs.demo.disabled': '停用頁籤', @@ -33,7 +41,7 @@ export const tabsTranslations = { 'tabs.api.value.description': '用來配對 trigger 與 content panel 的必要值。', 'tabs.api.disabled.description': '避免 trigger 被選取,並從鍵盤導覽中略過。', 'tabs.api.valueChange.description': '選取的 tab value 變更時觸發。', - 'tabs.accessibility.description': "透過 @angular/aria/tabs 實作 WAI-ARIA Tabs 模式。sanring-tabs-list 具有 role='tablist',每個觸發器具有 role='tab',每個內容面板具有 role='tabpanel',透過 aria-controls 與 aria-labelledby 互相關聯。aria-selected 反映目前作用中的分頁。", + 'tabs.accessibility.description': "透過 @angular/aria/tabs 實作 WAI-ARIA Tabs 模式。sanring-tabs-list 具有 role='tablist',每個觸發器具有 role='tab',每個內容面板具有 role='tabpanel',透過 aria-controls 與 aria-labelledby 互相關聯。aria-selected 反映目前作用中的分頁。若 trigger 顯示計數,請在 trigger 上設 aria-label,讓螢幕閱讀器唸出數字(例如「收件匣,3 則未讀」)。", 'tabs.keyboard.description': '在觸發器清單中以方向鍵導覽;Tab 鍵移入作用中的面板。', 'tabs.keyboard.arrowLeftRight': '在分頁觸發器之間導覽(水平方向)。', 'tabs.keyboard.arrowUpDown': '在分頁觸發器之間導覽(垂直方向)。', diff --git a/apps/docs/src/app/i18n/translations.ts b/apps/docs/src/app/i18n/translations.ts index 4abf1dcc..a779fd11 100644 --- a/apps/docs/src/app/i18n/translations.ts +++ b/apps/docs/src/app/i18n/translations.ts @@ -1,7 +1,7 @@ import { en } from './locales/en/index'; import { zh } from './locales/zh/index'; -/** Docs locale catalogs. TranslationKey is keyof typeof en. */ +/** Docs locale catalogs. TranslationKey is inferred from the English catalog. */ export const supportedLocales = ['en', 'zh'] as const; diff --git a/apps/docs/src/app/pages/changelog/component-changelog.ts b/apps/docs/src/app/pages/changelog/component-changelog.ts index 9d4b02d9..c6f396eb 100644 --- a/apps/docs/src/app/pages/changelog/component-changelog.ts +++ b/apps/docs/src/app/pages/changelog/component-changelog.ts @@ -35,6 +35,22 @@ function isPatch(version: string): boolean { * - Keep each change to one sentence. Wrap identifiers in backticks. */ export const cliVersionChangelog: readonly CliVersionEntry[] = [ + { + version: '0.25.2', + date: '2026-09-23', + changes: [ + { + type: 'added', + componentIds: ['avatar'], + text: '`[sanringAvatarBadge]` accepts `count` (hidden at 0, capped at `99+`) and `placement` `top` / `bottom`. Count defaults to top-end so it can sit with a status dot at the bottom.', + }, + { + type: 'changed', + componentIds: ['tabs'], + text: 'Docs add a Badge-inside-trigger recipe for unread counts, including an `aria-label` that names the count.', + }, + ], + }, { version: '0.25.1', date: '2026-09-23', diff --git a/apps/docs/src/app/pages/components/avatar/avatar-page.component.ts b/apps/docs/src/app/pages/components/avatar/avatar-page.component.ts index 504a3a1c..3398e49e 100644 --- a/apps/docs/src/app/pages/components/avatar/avatar-page.component.ts +++ b/apps/docs/src/app/pages/components/avatar/avatar-page.component.ts @@ -47,7 +47,7 @@ import { avatarPage, avatarPageExamples } from './avatar.docs';

- Ada Lovelace + Ada Lovelace AL
@@ -97,7 +97,7 @@ import { avatarPage, avatarPageExamples } from './avatar.docs';
- Online user + Online user OU @@ -113,6 +113,23 @@ import { avatarPage, avatarPageExamples } from './avatar.docs'; + + +
+ + Ada Lovelace + AL + + + + + 99 + + +
+
+
+ Verified user VU @@ -139,12 +156,15 @@ import { avatarPage, avatarPageExamples } from './avatar.docs';
+ Ada Lovelace AL + Grace Hopper GH + Katherine Johnson KJ @@ -161,9 +181,11 @@ import { avatarPage, avatarPageExamples } from './avatar.docs';
+ Ada Lovelace AL + Grace Hopper GH diff --git a/apps/docs/src/app/pages/components/avatar/avatar.docs.ts b/apps/docs/src/app/pages/components/avatar/avatar.docs.ts index ad8ee708..19c4319e 100644 --- a/apps/docs/src/app/pages/components/avatar/avatar.docs.ts +++ b/apps/docs/src/app/pages/components/avatar/avatar.docs.ts @@ -54,6 +54,12 @@ export const avatarPage = { titleKey: 'avatar.demo.badgeWithIcon', level: 3, }, + { + id: 'example-badge-count', + titleKey: 'avatar.demo.badgeCount', + descriptionKey: 'avatar.examples.badgeCount.description', + level: 3, + }, { id: 'example-group', titleKey: 'avatar.demo.group', @@ -118,10 +124,16 @@ export const avatarPage = { }, { property: 'placement', - type: 'AvatarBadgePlacement', - defaultValue: "'end'", + type: "'start' | 'end' | 'top' | 'bottom'", + defaultValue: "'end' / 'top'", descriptionKey: 'avatar.api.placement.description', }, + { + property: 'count (AvatarBadge)', + type: 'number', + defaultValue: 'undefined', + descriptionKey: 'avatar.api.badgeCount.description', + }, { property: 'overlap', type: 'number', @@ -159,7 +171,7 @@ export const avatarPageExamples = { basic: ` Ada Lovelace AL @@ -172,7 +184,7 @@ import { SANRING_AVATAR_IMPORTS } from './components/ui/avatar'; }) export class ExampleComponent {}`, usageMain: ` - Ada Lovelace + Ada Lovelace AL `, usageIndividualImports: `import { Component } from '@angular/core'; @@ -207,12 +219,18 @@ export class ExampleComponent {}`, LG `, badge: ` - Online user + Online user OU +`, + badgeCount: ` + Ada Lovelace + AL + + `, badgeWithIcon: ` - Verified user + Verified user VU @@ -220,21 +238,26 @@ export class ExampleComponent {}`, `, group: ` + Ada Lovelace AL + Grace Hopper GH + Katherine Johnson KJ `, groupWithIcon: ` + Ada Lovelace AL + Grace Hopper GH diff --git a/apps/docs/src/app/pages/components/tabs/tabs-page.component.ts b/apps/docs/src/app/pages/components/tabs/tabs-page.component.ts index d4784cf9..f3a3f862 100644 --- a/apps/docs/src/app/pages/components/tabs/tabs-page.component.ts +++ b/apps/docs/src/app/pages/components/tabs/tabs-page.component.ts @@ -1,6 +1,6 @@ import { Component, inject } from '@angular/core'; import { LucideActivity, LucideKey, LucideSettings } from '@lucide/angular'; -import { SANRING_TABS_IMPORTS } from '@sanring/ui'; +import { BadgeDirective, SANRING_TABS_IMPORTS } from '@sanring/ui'; import { getComponentPageSection } from '../../../docs-schema/component-page.utils'; import { I18nService } from '../../../i18n/i18n.service'; import { @@ -20,6 +20,7 @@ import { tabsPage, tabsPageExamples } from './tabs.docs'; selector: 'app-tabs-page', imports: [ ComponentPageApiTableComponent, + BadgeDirective, SANRING_TABS_IMPORTS, ComponentPageCodeBlock, ComponentPageCodePreviewer, @@ -192,6 +193,41 @@ import { tabsPage, tabsPageExamples } from './tabs.docs'; + + +
+ + + + {{ i18n.t('tabs.demo.inbox') }} + 3 + + + {{ i18n.t('tabs.demo.drafts') }} + + + +
+ {{ i18n.t('tabs.demo.inboxContent') }} +
+
+ +
+ {{ i18n.t('tabs.demo.draftsContent') }} +
+
+
+
+
+
+
diff --git a/apps/docs/src/app/pages/components/tabs/tabs.docs.ts b/apps/docs/src/app/pages/components/tabs/tabs.docs.ts index 704d86b6..0743f2d7 100644 --- a/apps/docs/src/app/pages/components/tabs/tabs.docs.ts +++ b/apps/docs/src/app/pages/components/tabs/tabs.docs.ts @@ -49,6 +49,12 @@ export const tabsPage = { titleKey: 'tabs.demo.withIcon', level: 3, }, + { + id: 'example-with-count', + titleKey: 'tabs.demo.withCount', + descriptionKey: 'tabs.examples.withCount.description', + level: 3, + }, { id: 'example-line', titleKey: 'tabs.demo.line', @@ -204,6 +210,18 @@ export class ExampleComponent {}`, Overview content Analytics content Reports content +`, + withCount: ` + + + Inbox + 3 + + Drafts + + + Inbox content + Drafts content `, withIcon: ` diff --git a/packages/ui/src/lib/components/avatar/avatar-badge.directive.ts b/packages/ui/src/lib/components/avatar/avatar-badge.directive.ts index 902b336f..29827322 100644 --- a/packages/ui/src/lib/components/avatar/avatar-badge.directive.ts +++ b/packages/ui/src/lib/components/avatar/avatar-badge.directive.ts @@ -1,43 +1,84 @@ import { Directionality } from '@angular/cdk/bidi'; -import { Directive, computed, inject, input } from '@angular/core'; +import { coerceNumberProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { cn } from '../../utils'; import { AvatarBadgePlacement, AvatarBadgeStatus } from './avatar.types'; -@Directive({ +const STATUS_COLORS: Record = { + online: 'bg-[var(--sanring-badge-online)]', + offline: 'bg-[var(--sanring-badge-offline)]', + away: 'bg-[var(--sanring-badge-away)]', + busy: 'bg-[var(--sanring-badge-busy)]', + default: 'bg-[var(--sanring-badge-default)]', +}; + +@Component({ selector: '[sanringAvatarBadge]', standalone: true, + template: `{{ countLabel() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class]': 'badgeClass()', role: 'status', '[attr.aria-label]': 'resolvedAriaLabel()', + '[attr.aria-hidden]': 'isHidden() ? "true" : null', + '[hidden]': 'isHidden()', }, }) export class AvatarBadgeDirective { readonly class = input(); readonly status = input('default'); - readonly placement = input('end'); + readonly placement = input(); readonly ariaLabel = input(); + readonly count = input(undefined, { + transform: (value: unknown) => + value === undefined || value === null || value === '' + ? undefined + : coerceNumberProperty(value), + }); private readonly directionality = inject(Directionality, { optional: true }); - protected readonly resolvedAriaLabel = computed(() => this.ariaLabel() ?? this.status()); + protected readonly countLabel = computed(() => { + const count = this.count(); + if (typeof count !== 'number' || count <= 0) return ''; + return count > 99 ? '99+' : String(count); + }); + + protected readonly isCount = computed(() => this.countLabel() !== ''); + protected readonly isHidden = computed(() => { + const count = this.count(); + return typeof count === 'number' && count <= 0; + }); + + protected readonly resolvedPlacement = computed( + () => this.placement() ?? (this.isCount() ? 'top' : 'end'), + ); + + protected readonly resolvedAriaLabel = computed( + () => this.ariaLabel() ?? (this.countLabel() || this.status()), + ); protected readonly badgeClass = computed(() => { - const statusColors: Record = { - online: 'bg-[var(--sanring-badge-online)]', - offline: 'bg-[var(--sanring-badge-offline)]', - away: 'bg-[var(--sanring-badge-away)]', - busy: 'bg-[var(--sanring-badge-busy)]', - default: 'bg-[var(--sanring-badge-default)]', - }; + const placement = this.resolvedPlacement(); const isRtl = this.directionality?.value === 'rtl'; - const isVisualEnd = this.placement() === 'end'; + const isVisualEnd = placement !== 'start'; const sideClass = isVisualEnd !== isRtl ? 'right-0' : 'left-0'; + const verticalClass = placement === 'top' ? 'top-0' : 'bottom-0'; + const status = this.status(); + const colorClass = + this.isCount() && status === 'default' + ? 'bg-[var(--sanring-error-50)]' + : (STATUS_COLORS[status] ?? STATUS_COLORS['default']); return cn( - 'absolute bottom-0 z-10 flex size-3 items-center justify-center rounded-full text-white ring-2 ring-[var(--sanring-background)]', + 'absolute z-10 flex items-center justify-center rounded-full text-white ring-2 ring-[var(--sanring-background)]', + this.isCount() + ? 'h-4 min-w-4 px-1 text-[10px] font-semibold leading-none' + : 'size-3', + verticalClass, sideClass, - statusColors[this.status()], + colorClass, this.class(), ); }); diff --git a/packages/ui/src/lib/components/avatar/avatar.component.spec.ts b/packages/ui/src/lib/components/avatar/avatar.component.spec.ts index 5d859076..8c58dd66 100644 --- a/packages/ui/src/lib/components/avatar/avatar.component.spec.ts +++ b/packages/ui/src/lib/components/avatar/avatar.component.spec.ts @@ -35,11 +35,24 @@ import { AvatarComponent } from './avatar.component'; (clicked)="clicks = clicks + 1" /> + + + MB + + + `, }) class AvatarTestHost { clicks = 0; countDisabled = false; + badgeCount: number | undefined = 3; + badgePlacement: 'start' | 'end' | 'top' | 'bottom' | undefined; } describe('AvatarComponent', () => { @@ -100,6 +113,57 @@ describe('AvatarComponent', () => { expect(fixture.componentInstance.clicks).toBe(0); }); + it('renders a notification count on the top edge and keeps the status badge at the bottom', () => { + const fixture = TestBed.createComponent(AvatarTestHost); + fixture.detectChanges(); + + const avatar = fixture.nativeElement.querySelectorAll( + 'sanring-avatar', + )[2] as HTMLElement; + const badges = avatar.querySelectorAll('[sanringAvatarBadge]'); + const countBadge = badges[0] as HTMLElement; + const statusBadge = badges[1] as HTMLElement; + + expect(countBadge.textContent?.trim()).toBe('3'); + expect(countBadge.className).toContain('top-0'); + expect(countBadge.className).toContain('right-0'); + expect(countBadge.getAttribute('aria-label')).toBe('3 unread'); + expect(statusBadge.className).toContain('bottom-0'); + expect(statusBadge.className).toContain('size-3'); + }); + + it('hides a zero count', () => { + const fixture = TestBed.createComponent(AvatarTestHost); + fixture.componentInstance.badgeCount = 0; + fixture.detectChanges(); + + const avatar = fixture.nativeElement.querySelectorAll( + 'sanring-avatar', + )[2] as HTMLElement; + const countBadge = avatar.querySelectorAll('[sanringAvatarBadge]')[0] as HTMLElement; + + expect(countBadge.hidden).toBe(true); + expect(countBadge.getAttribute('aria-hidden')).toBe('true'); + expect(countBadge.textContent?.trim()).toBe(''); + }); + + it('caps large counts at 99+ and honors an explicit bottom placement', () => { + const fixture = TestBed.createComponent(AvatarTestHost); + fixture.componentInstance.badgeCount = 128; + fixture.componentInstance.badgePlacement = 'bottom'; + fixture.detectChanges(); + + const avatar = fixture.nativeElement.querySelectorAll( + 'sanring-avatar', + )[2] as HTMLElement; + const countBadge = avatar.querySelectorAll('[sanringAvatarBadge]')[0] as HTMLElement; + + expect(countBadge.hidden).toBe(false); + expect(countBadge.textContent?.trim()).toBe('99+'); + expect(countBadge.className).toContain('bottom-0'); + expect(countBadge.className).not.toContain('top-0'); + }); + it('has no axe-detectable a11y violations', async () => { const fixture = TestBed.createComponent(AvatarTestHost); fixture.detectChanges(); diff --git a/packages/ui/src/lib/components/avatar/avatar.types.ts b/packages/ui/src/lib/components/avatar/avatar.types.ts index b65d82af..e2adbc16 100644 --- a/packages/ui/src/lib/components/avatar/avatar.types.ts +++ b/packages/ui/src/lib/components/avatar/avatar.types.ts @@ -1,4 +1,4 @@ export type AvatarStatus = 'idle' | 'loading' | 'loaded' | 'error'; export type AvatarSize = 'sm' | 'md' | 'lg'; export type AvatarBadgeStatus = 'online' | 'offline' | 'away' | 'busy' | 'default' | (string & {}); -export type AvatarBadgePlacement = 'start' | 'end'; +export type AvatarBadgePlacement = 'start' | 'end' | 'top' | 'bottom'; diff --git a/registry/components/avatar/avatar-badge.directive.ts b/registry/components/avatar/avatar-badge.directive.ts index 76b3e528..c9a21af7 100644 --- a/registry/components/avatar/avatar-badge.directive.ts +++ b/registry/components/avatar/avatar-badge.directive.ts @@ -1,43 +1,84 @@ import { Directionality } from '@angular/cdk/bidi'; -import { Directive, computed, inject, input } from '@angular/core'; +import { coerceNumberProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { cn } from '../shared/utils'; import { AvatarBadgePlacement, AvatarBadgeStatus } from './avatar.types'; -@Directive({ +const STATUS_COLORS: Record = { + online: 'bg-[var(--sanring-badge-online)]', + offline: 'bg-[var(--sanring-badge-offline)]', + away: 'bg-[var(--sanring-badge-away)]', + busy: 'bg-[var(--sanring-badge-busy)]', + default: 'bg-[var(--sanring-badge-default)]', +}; + +@Component({ selector: '[sanringAvatarBadge]', standalone: true, + template: `{{ countLabel() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class]': 'badgeClass()', role: 'status', '[attr.aria-label]': 'resolvedAriaLabel()', + '[attr.aria-hidden]': 'isHidden() ? "true" : null', + '[hidden]': 'isHidden()', }, }) export class AvatarBadgeDirective { readonly class = input(); readonly status = input('default'); - readonly placement = input('end'); + readonly placement = input(); readonly ariaLabel = input(); + readonly count = input(undefined, { + transform: (value: unknown) => + value === undefined || value === null || value === '' + ? undefined + : coerceNumberProperty(value), + }); private readonly directionality = inject(Directionality, { optional: true }); - protected readonly resolvedAriaLabel = computed(() => this.ariaLabel() ?? this.status()); + protected readonly countLabel = computed(() => { + const count = this.count(); + if (typeof count !== 'number' || count <= 0) return ''; + return count > 99 ? '99+' : String(count); + }); + + protected readonly isCount = computed(() => this.countLabel() !== ''); + protected readonly isHidden = computed(() => { + const count = this.count(); + return typeof count === 'number' && count <= 0; + }); + + protected readonly resolvedPlacement = computed( + () => this.placement() ?? (this.isCount() ? 'top' : 'end'), + ); + + protected readonly resolvedAriaLabel = computed( + () => this.ariaLabel() ?? (this.countLabel() || this.status()), + ); protected readonly badgeClass = computed(() => { - const statusColors: Record = { - online: 'bg-[var(--sanring-badge-online)]', - offline: 'bg-[var(--sanring-badge-offline)]', - away: 'bg-[var(--sanring-badge-away)]', - busy: 'bg-[var(--sanring-badge-busy)]', - default: 'bg-[var(--sanring-badge-default)]', - }; + const placement = this.resolvedPlacement(); const isRtl = this.directionality?.value === 'rtl'; - const isVisualEnd = this.placement() === 'end'; + const isVisualEnd = placement !== 'start'; const sideClass = isVisualEnd !== isRtl ? 'right-0' : 'left-0'; + const verticalClass = placement === 'top' ? 'top-0' : 'bottom-0'; + const status = this.status(); + const colorClass = + this.isCount() && status === 'default' + ? 'bg-[var(--sanring-error-50)]' + : (STATUS_COLORS[status] ?? STATUS_COLORS['default']); return cn( - 'absolute bottom-0 z-10 flex size-3 items-center justify-center rounded-full text-white ring-2 ring-[var(--sanring-background)]', + 'absolute z-10 flex items-center justify-center rounded-full text-white ring-2 ring-[var(--sanring-background)]', + this.isCount() + ? 'h-4 min-w-4 px-1 text-[10px] font-semibold leading-none' + : 'size-3', + verticalClass, sideClass, - statusColors[this.status()], + colorClass, this.class(), ); }); diff --git a/registry/components/avatar/avatar.types.ts b/registry/components/avatar/avatar.types.ts index b65d82af..e2adbc16 100644 --- a/registry/components/avatar/avatar.types.ts +++ b/registry/components/avatar/avatar.types.ts @@ -1,4 +1,4 @@ export type AvatarStatus = 'idle' | 'loading' | 'loaded' | 'error'; export type AvatarSize = 'sm' | 'md' | 'lg'; export type AvatarBadgeStatus = 'online' | 'offline' | 'away' | 'busy' | 'default' | (string & {}); -export type AvatarBadgePlacement = 'start' | 'end'; +export type AvatarBadgePlacement = 'start' | 'end' | 'top' | 'bottom'; From 58c519ff787e6a5240c4e87e56eb0de78afaa9e5 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Sun, 27 Sep 2026 15:52:46 +0800 Subject: [PATCH 2/5] 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 93d760caa6a10eca6860e9be90128a1f2aad9f61 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Sun, 27 Sep 2026 15:52:50 +0800 Subject: [PATCH 3/5] 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 15ea47fe..08e41f2e 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
  • \n\n \n Actions\n \n Back\n ⌘[\n \n \n Forward\n ⌘]\n \n \n Reload\n ⌘R\n \n \n Delete\n \n", + "usageImport": "import { Component } from '@angular/core';\nimport { SANRING_CONTEXT_MENU_IMPORTS } from './components/ui/context-menu';\n\n@Component({\n imports: [SANRING_CONTEXT_MENU_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "ContextMenuComponent.itemSelected", + "type": "Output", + "defaultValue": "-", + "description": "Emits the value of whichever item was activated (click, Enter, or Space), right before the whole menu — including any open submenu — closes. Declared on sanring-context-menu (the root), so it fires no matter how deep the selected item is nested." + }, + { + "property": "ContextMenuItemComponent.value", + "type": "unknown", + "defaultValue": "required", + "description": "The value reported to itemSelected when this item is activated. Required." + }, + { + "property": "ContextMenuItemComponent.disabled", + "type": "boolean", + "defaultValue": "false", + "description": "Disables the item and removes it from keyboard and click activation." + }, + { + "property": "ContextMenuItemComponent.variant", + "type": "'default' | 'destructive'", + "defaultValue": "'default'", + "description": "Controls item tone. Use destructive for actions that remove data or have serious consequences." + }, + { + "property": "ContextMenuCheckboxItemComponent.checked", + "type": "boolean", + "defaultValue": "false", + "description": "The checkbox item's checked state. Supports two-way binding with [(checked)]." + }, + { + "property": "ContextMenuRadioGroupComponent.value", + "type": "string | undefined", + "defaultValue": "undefined", + "description": "The radio group's currently selected value. Supports two-way binding with [(value)]." + }, + { + "property": "class", + "type": "string", + "defaultValue": "undefined", + "description": "Additional classes merged with the corresponding context menu primitive." + } + ], + "keyboard": [ + { + "keys": "↑ / ↓", + "action": "Move focus between menu items, skipping disabled items (wraps)." + }, + { + "keys": "Enter", + "action": "Activate the focused menu item." + }, + { + "keys": "Tab / Shift+Tab", + "action": "Close the menu and move to the previous or next control beside its trigger." + }, + { + "keys": "Escape", + "action": "Close the context menu." + } + ], + "accessibility": [ + "The panel has role='menu'.", + "Items receive role='menuitem', role='menuitemcheckbox', or role='menuitemradio'.", + "The trigger zone ([sanringContextMenuTrigger]) does not expose ARIA state — consider pairing it with a visible affordance or keyboard shortcut hint for accessibility." + ], + "stateModel": [ + "Stateless.", + "Items emit events on activation.", + "The trigger context is set by the [sanringContextMenuTrigger] directive on any host element.", + "Open/close is driven by right-click events or programmatically via the open(x, y) method on the ContextMenuComponent." + ] + }, + "date-picker": { + "name": "date-picker", + "title": "Date Picker", + "description": "A calendar-driven input for picking a single date, a range, or multiple dates.", + "aliases": [ + "datepicker" + ], + "example": "", + "usageImport": "import { Component } from '@angular/core';\nimport { CALENDAR_LOCALE } from '@sanring/date-picker-core';\nimport { DatePickerComponent } from './components/ui/date-picker';\n\n@Component({\n imports: [DatePickerComponent],\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 grid cell." + }, + { + "property": "locale", + "type": "CalendarLocale | undefined", + "defaultValue": "undefined", + "description": "Overrides the injected CALENDAR_LOCALE for this instance; only monthLabels is consulted (for month-granularity cell text)." + }, + { + "property": "granularity", + "type": "'month' | 'quarter' | 'year'", + "defaultValue": "'month'", + "description": "Selection unit rendered by the grid." + }, + { + "property": "mode", + "type": "'single' | 'range' | 'multi'", + "defaultValue": "'single'", + "description": "Selection behavior: single value, a range, or multiple independent values." + }, + { + "property": "quarterLabels", + "type": "readonly [string, string, string, string]", + "defaultValue": "['Q1', 'Q2', 'Q3', 'Q4']", + "description": "Display text for the four quarter cells, in fiscal-quarter order." + }, + { + "property": "yearsToDisplay", + "type": "number", + "defaultValue": "12", + "description": "Number of cells in the year-granularity sliding window." + }, + { + "property": "gridColumns", + "type": "number | undefined", + "defaultValue": "undefined", + "description": "Grid column count used for arrow-key focus math; defaults to 3 for month/year and 4 for quarter." + }, + { + "property": "disabled", + "type": "DisabledInput | boolean | undefined", + "defaultValue": "undefined", + "description": "Pass a date matcher (or matcher array) to disable individual periods, or a boolean to disable the entire picker without clearing its value." + }, + { + "property": "allowDeselect", + "type": "boolean", + "defaultValue": "true", + "description": "Whether re-picking a selected value in single mode clears it." + }, + { + "property": "required", + "type": "boolean", + "defaultValue": "false", + "description": "Marks the picker as required for field integration and aria-required." + }, + { + "property": "ariaLabel", + "type": "string | undefined", + "defaultValue": "undefined", + "description": "Accessible name for the picker host when no visible label names it." + }, + { + "property": "ariaLabelledBy", + "type": "string | undefined", + "defaultValue": "undefined", + "description": "ID of visible text that provides the accessible name for the picker host." + }, + { + "property": "ariaDescribedBy", + "type": "string | undefined", + "defaultValue": "undefined", + "description": "ID of helper text that describes the picker; merged with Field-provided description ids." + }, + { + "property": "rangePeriodCountLimit", + "type": "RangePeriodCountLimit | undefined", + "defaultValue": "undefined", + "description": "Optional min/max period-count bound on range selections." + }, + { + "property": "prevYearLabel", + "type": "string", + "defaultValue": "'上一年'", + "description": "Accessible label for the previous-year navigation button." + }, + { + "property": "nextYearLabel", + "type": "string", + "defaultValue": "'下一年'", + "description": "Accessible label for the next-year navigation button." + }, + { + "property": "selectedDateChange", + "type": "EventEmitter", + "defaultValue": "—", + "description": "Emits the selected date whenever it changes (single mode)." + }, + { + "property": "selectedRangeChange", + "type": "EventEmitter", + "defaultValue": "—", + "description": "Emits the selected range whenever it changes (range mode)." + }, + { + "property": "selectedDatesChange", + "type": "EventEmitter", + "defaultValue": "—", + "description": "Emits the selected dates whenever they change (multi mode)." + }, + { + "property": "isDraftActive", + "type": "Signal", + "defaultValue": "—", + "description": "Whether a range selection draft is currently open." + }, + { + "property": "clear()", + "type": "(): void", + "defaultValue": "—", + "description": "Clears the current selection without changing the visible window." + }, + { + "property": "abortRangeDraft()", + "type": "(): void", + "defaultValue": "—", + "description": "Discards an in-progress range draft without committing it." + }, + { + "property": "removeDate(date)", + "type": "(date: Date): void", + "defaultValue": "—", + "description": "Removes a single date from the selection (multi mode only)." + }, + { + "property": "focus()", + "type": "(options?: FocusOptions): void", + "defaultValue": "—", + "description": "Moves focus to the date-picker host element." + } + ], + "keyboard": [ + { + "keys": "↑ / ↓ / ← / →", + "action": "Navigate between cells in the calendar grid." + }, + { + "keys": "Page Up / Page Down", + "action": "Previous / next page (month, quarter, or year depending on granularity)." + }, + { + "keys": "Home / End", + "action": "Jump to the first / last cell in the current view." + }, + { + "keys": "Enter", + "action": "Select the focused cell." + }, + { + "keys": "Escape", + "action": "Close the date-picker popover without committing a selection." + } + ], + "accessibility": [ + "The host group accepts aria-label or aria-labelledby and receives aria-invalid, aria-disabled, and aria-describedby.", + "The calendar panel has role='grid' with an aria-label derived from its header; each role='gridcell' receives its selection, disabled, and required states.", + "Field descriptions are wired automatically." + ], + "stateModel": [ + "Implements ControlValueAccessor.", + "Use [(ngModel)] or formControl.", + "Value type: DatePickerValue (single date, a range pair, or an array of dates depending on mode).", + "mode and granularity can be changed at runtime." + ] + }, + "dialog": { + "name": "dialog", + "title": "Dialog", + "description": "An overlay primitive built on Angular CDK Dialog for modal tasks and focused decisions.", + "aliases": [ + "modal", + "overlay" + ], + "anatomy": "[sanringDialogTrigger]\nsanring-dialog-content\n├── sanring-dialog-header\n│ ├── sanring-dialog-media\n│ ├── [sanringDialogTitle]\n│ └── [sanringDialogDescription]\n└── sanring-dialog-footer", + "example": "\n\n\n \n \n

    Edit profile

    \n

    Make changes to your profile here.

    \n
    \n
    \n
    ", + "usageImport": "import { Component } from '@angular/core';\nimport { ButtonDirective } from './components/ui/button';\nimport { SANRING_DIALOG_IMPORTS } from './components/ui/dialog';\n\n@Component({\n imports: [ButtonDirective, SANRING_DIALOG_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with DialogContent layout styles." + }, + { + "property": "showClose", + "type": "boolean", + "defaultValue": "true", + "description": "Controls whether the built-in close button is rendered." + }, + { + "property": "ariaLabel", + "type": "string", + "defaultValue": "'Dialog' fallback", + "description": "Accessible-name fallback used when no sanringDialogTitle is projected. DialogConfig ariaLabel remains supported." + }, + { + "property": "ariaLabelledBy", + "type": "string", + "defaultValue": "undefined", + "description": "Ids of external elements that label the dialog. Takes precedence over the projected title and ariaLabel." + }, + { + "property": "ariaDescribedBy", + "type": "string", + "defaultValue": "undefined", + "description": "Ids of external elements that describe the dialog. Takes precedence over sanringDialogDescription." + }, + { + "property": "sanringDialogConfig", + "type": "DialogConfig", + "defaultValue": "undefined", + "description": "CDK DialogConfig passed when sanringDialogTrigger opens the template." + }, + { + "property": "sanringDialogClose", + "type": "unknown", + "defaultValue": "undefined", + "description": "Optional result value emitted when sanringDialogClose closes the dialog." + }, + { + "property": "sanring-dialog-media.class", + "type": "string", + "defaultValue": "undefined", + "description": "Additional classes merged with the dialog media container." + }, + { + "property": "DialogHeaderComponent.align", + "type": "'start' | 'center'", + "defaultValue": "responsive", + "description": "Header text alignment. start is left, center is centered at every breakpoint. The default stays centered on small screens and left-aligned from sm up." + }, + { + "property": "DialogHeaderComponent.class", + "type": "string", + "defaultValue": "undefined", + "description": "Additional classes merged with the header layout. Use this to give the header a different background from sanring-dialog-content, for example bg-[var(--sanring-surface-strong)]. Pair with overflow-hidden p-0 on content so the fill reaches the panel edges." + }, + { + "property": "DialogTitleDirective.class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the title styles. Use this to change title color, for example text-[var(--sanring-primary-70)]." + } + ], + "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": "Close the dialog. Blocked when disableClose is set in the trigger config." + } + ], + "accessibility": [ + "The CDK Dialog container receives role='dialog' and aria-modal='true'.", + "Projected titles and descriptions are wired automatically; ariaLabel provides a fallback name for untitled content.", + "Angular CDK's FocusTrap keeps Tab and Shift+Tab cycling within the open dialog." + ], + "stateModel": [ + "Trigger-based.", + "Bind [sanringDialogTrigger] to an ng-template reference to open the dialog.", + "Pass [sanringDialogConfig] to configure CDK options (e.", + "g.", + "{ disableClose: true }).", + "Inside the template, bind [sanringDialogClose]='result' to close with an optional typed result.", + "Dialog is not a form control — there is no CVA integration." + ] + }, + "divider": { + "name": "divider", + "title": "Divider", + "description": "A simple separator for grouping content and actions in a layout.", + "example": "import { DividerComponent } from './components/ui/divider';\n\n", + "usageImport": "import { DividerComponent } from './components/ui/divider';", + "api": [ + { + "property": "vertical", + "type": "boolean", + "defaultValue": "false", + "description": "Switches the separator from horizontal to vertical." + }, + { + "property": "inset", + "type": "DividerInset", + "defaultValue": "'none'", + "description": "Offsets the horizontal divider: none, start, end, or both." + } + ], + "accessibility": [ + "Rendered with separator semantics.", + "If the divider is purely decorative, add aria-hidden=\"true\" at the usage site." + ], + "stateModel": [ + "Stateless.", + "vertical and inset only control orientation and offset; no value or interaction state is stored." + ] + }, + "dropdown-menu": { + "name": "dropdown-menu", + "title": "Dropdown Menu", + "description": "A floating menu for contextual actions opened from a trigger.", + "aliases": [ + "dropdown", + "menu" + ], + "example": "\n \n\n \n Actions\n \n \n \n \n \n", + "usageImport": "import { Component } from '@angular/core';\nimport { ButtonDirective } from './components/ui/button';\nimport { SANRING_DROPDOWN_MENU_IMPORTS } from './components/ui/dropdown-menu';\n\n@Component({\n imports: [ButtonDirective, SANRING_DROPDOWN_MENU_IMPORTS],\n})\nexport class ExampleComponent {}", + "api": [ + { + "property": "DropdownMenuTriggerDirective.menu", + "type": "Menu | undefined", + "defaultValue": "required", + "description": "The menu to open, bound to the content's exported reference (#ref=\"sanringDropdownMenuContent\", then [menu]=\"ref.menu\"). Selecting any item closes the menu automatically." + }, + { + "property": "DropdownMenuContentComponent.itemSelected", + "type": "Output", + "defaultValue": "-", + "description": "Emits the value of whichever item was activated (click, Enter, or Space), right before the menu closes." + }, + { + "property": "DropdownMenuContentComponent.id", + "type": "string", + "defaultValue": "generated by @angular/aria", + "description": "ID forwarded to the underlying @angular/aria menu content." + }, + { + "property": "DropdownMenuContentComponent.wrap", + "type": "boolean", + "defaultValue": "true", + "description": "Whether keyboard navigation wraps from the last enabled item back to the first." + }, + { + "property": "DropdownMenuContentComponent.typeaheadDelay", + "type": "number", + "defaultValue": "500", + "description": "Delay, in milliseconds, used by @angular/aria menu typeahead before resetting the typed search buffer." + }, + { + "property": "DropdownMenuItemDirective.value", + "type": "unknown", + "defaultValue": "required", + "description": "The value reported to itemSelected when this item is activated. Required by the underlying ARIA menu pattern." + }, + { + "property": "DropdownMenuItemDirective.disabled", + "type": "boolean", + "defaultValue": "false", + "description": "Disables a menu item and removes it from keyboard activation." + }, + { + "property": "DropdownMenuItemDirective.variant", + "type": "'default' | 'destructive'", + "defaultValue": "'default'", + "description": "Controls item tone. Use destructive for actions that remove data or have serious consequences." + }, + { + "property": "class", + "type": "string", + "defaultValue": "undefined", + "description": "Additional classes merged with the corresponding dropdown menu primitive." + }, + { + "property": "DropdownMenuSubTriggerComponent.submenu", + "type": "Menu | undefined", + "defaultValue": "required", + "description": "The nested menu to open, bound to the sub-content export (#ref=\"sanringDropdownMenuSubContent\", then [submenu]=\"ref.menu\"). Arrow Right opens it; Arrow Left closes it." + }, + { + "property": "DropdownMenuSubTriggerComponent.value", + "type": "unknown", + "defaultValue": "required", + "description": "Required by the underlying ARIA menu item. Use a distinct value from sibling items." + } + ], + "keyboard": [ + { + "keys": "Enter / Space", + "action": "Open the menu (from trigger) or activate the focused item." + }, + { + "keys": "↑ / ↓", + "action": "Navigate between menu items (wrap is configurable)." + }, + { + "keys": "→ (Arrow Right)", + "action": "Open the focused submenu." + }, + { + "keys": "← (Arrow Left)", + "action": "Close the active submenu." + }, + { + "keys": "Escape", + "action": "Close the menu or active submenu and return focus to the trigger." + } + ], + "accessibility": [ + "Built on @angular/aria/menu.", + "The panel has role='menu'; each item has role='menuitem', role='menuitemcheckbox', or role='menuitemradio' as appropriate.", + "The trigger button has aria-haspopup='menu' and aria-expanded managed by the underlying MenuTrigger directive." + ], + "stateModel": [ + "Stateless.", + "DropdownMenuItemDirective emits (itemSelected) on activation.", + "Nest sanring-dropdown-menu-sub with a sub-trigger and sub-content for a real submenu.", + "Checkbox and radio examples compose icons onto ordinary items.", + "Open/closed state is managed internally by @angular/aria/menu." + ] + }, + "field": { + "name": "field", + "title": "Field", + "description": "Form composition primitives — sanring-field wraps a label, control, description, and error message with the layout, ARIA wiring, and Angular Forms validation state they share.", + "anatomy": "sanring-field\n├── label[sanringLabel]\n├── [sanringInput] (or any SanringFieldControl)\n├── [sanringDescription]\n└── sanring-error-message", + "example": "\n \n \n

    We'll only use this for account notifications.

    \n
    ", + "usageImport": "import { DescriptionDirective, ErrorMessageComponent, FieldLabelDirective, SanringFieldComponent } from './components/ui/field';", + "api": [ + { + "property": "id", + "type": "string", + "defaultValue": "generated", + "description": "Stable ID applied to the field root and used to derive the fallback label target when the projected control does not expose its own ID." + }, + { + "property": "floating", + "type": "boolean", + "defaultValue": "false", + "description": "Floats the label above the control instead of stacking it on top." + }, + { + "property": "label[sanringLabel].class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the label's base styles." + }, + { + "property": "[sanringDescription].class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the description's base styles." + }, + { + "property": "sanring-error-message.class", + "type": "string", + "defaultValue": "''", + "description": "Additional classes merged with the error message's base styles." + } + ], + "accessibility": [ + "Wraps a label, control, error text, and helper text with automatic ARIA wiring.", + "CVA controls that inject FieldContext receive aria-labelledby and aria-describedby IDs automatically, without extra attributes in the template." + ], + "stateModel": [ + "Not a ControlValueAccessor.", + "Provides FieldContext so nested CVA controls (input, checkbox, radio-group, switch, slider, otp-input, file-upload, date-picker, calendar) can auto-wire aria IDs and validation state.", + "No value or selection state." + ] + }, + "file-upload": { + "name": "file-upload", + "title": "File Upload", + "description": "A headless file selection control with drag-and-drop, validation, and ControlValueAccessor support.", + "aliases": [ + "uploader" + ], + "example": "\n\n \n \n \n Browse files\n \n

    or drag and drop here

    \n
    \n
    ", + "usageImport": "import { FileDropzoneComponent, FileTriggerDirective, FileUploadComponent } from './components/ui/file-upload';", + "api": [ + { + "property": "accept", + "type": "string", + "defaultValue": "'*'", + "description": "Comma-separated list of accepted file types or extensions (e.g. \"image/*,.pdf\"). Defaults to accepting any file." + }, + { + "property": "multiple", + "type": "boolean", + "defaultValue": "false", + "description": "Allows selecting or dropping more than one file at a time." + }, + { + "property": "disabled", + "type": "boolean", + "defaultValue": "false", + "description": "Disables the dropzone and trigger, and reflects setDisabledState() from reactive forms." + }, + { + "property": "required", + "type": "boolean", + "defaultValue": "false", + "description": "Marks the control as required for aria-required and Field error state." + }, + { + "property": "maxSize", + "type": "number | null", + "defaultValue": "null", + "description": "Maximum file size in bytes. Files exceeding this are rejected and reported via rejectedFiles." + }, + { + "property": "maxFiles", + "type": "number | null", + "defaultValue": "null", + "description": "Maximum number of files allowed when multiple is enabled. Extra files are rejected." + }, + { + "property": "files", + "type": "File[]", + "defaultValue": "[]", + "description": "Selected files. Two-way bindable with [(files)], or via ngModel/formControl." + }, + { + "property": "sanring-file-item [progress]", + "type": "number | null", + "defaultValue": "null", + "description": "sanring-file-item accepts a standalone [progress] input (0-100). FileUploadComponent never performs the actual upload, so this value always comes from your own upload call — pass null to fall back to showing the file size." + } + ], + "keyboard": [ + { + "keys": "Tab", + "action": "Focus the upload trigger or dropzone." + }, + { + "keys": "Enter / Space", + "action": "Open the OS file picker." + } + ], + "accessibility": [ + "aria-invalid, aria-required, and aria-describedby are wired to the surrounding sanring-field.", + "The native inside the dropzone handles its own accessible label via the visible button text or a linked