diff --git a/.changeset/sortable-timeline-mcp-spec.md b/.changeset/sortable-timeline-mcp-spec.md new file mode 100644 index 00000000..b522b285 --- /dev/null +++ b/.changeset/sortable-timeline-mcp-spec.md @@ -0,0 +1,5 @@ +--- +"@sanring/cli": minor +--- + +Add `sortable` for pointer and keyboard reordering. Timeline separators now draw the rail and default marker. MCP adds `get_component_spec` so agents get authoring contracts instead of inventing APIs. 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/public/avatars/lantern.jpg b/apps/docs/public/avatars/lantern.jpg new file mode 100644 index 00000000..de68f20a Binary files /dev/null and b/apps/docs/public/avatars/lantern.jpg differ diff --git a/apps/docs/public/avatars/night-lantern.jpg b/apps/docs/public/avatars/night-lantern.jpg new file mode 100644 index 00000000..7f8dd555 Binary files /dev/null and b/apps/docs/public/avatars/night-lantern.jpg differ diff --git a/apps/docs/public/avatars/path.jpg b/apps/docs/public/avatars/path.jpg new file mode 100644 index 00000000..4e6ad167 Binary files /dev/null and b/apps/docs/public/avatars/path.jpg differ diff --git a/apps/docs/public/avatars/stairs.jpg b/apps/docs/public/avatars/stairs.jpg new file mode 100644 index 00000000..710e7b5b Binary files /dev/null and b/apps/docs/public/avatars/stairs.jpg differ 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/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/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/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/en/components/timeline.ts b/apps/docs/src/app/i18n/locales/en/components/timeline.ts index d362ad40..cf0599ec 100644 --- a/apps/docs/src/app/i18n/locales/en/components/timeline.ts +++ b/apps/docs/src/app/i18n/locales/en/components/timeline.ts @@ -2,13 +2,14 @@ export const timelineTranslations = { 'timeline.description': 'Composable timeline primitives for chronological events, activity feeds, and process milestones.', 'timeline.examples.basic.description': - 'Use sanringTimeline with native list markup, then compose separators, markers, connectors, and content.', + 'A vertical list with a built-in rail. Leave the separator empty for a default marker, or project an icon or avatar.', 'timeline.usage.description': - 'Import the timeline directives and apply them to list or div-based activity markup.', + 'Each item is a separator plus content. The separator draws the connector; project a node only when the default marker is not enough.', 'timeline.installation.description': 'Install the timeline primitives and compose item, separator, and content directives where each event renders.', 'timeline.demo.horizontal': 'Horizontal', 'timeline.demo.divBased': 'Div-based timeline', + 'timeline.demo.reorder': 'Reorder', 'timeline.demo.releaseActivity': 'Release activity', 'timeline.demo.releaseActivityDescription': 'A compact activity trail for release notes and registry updates.', @@ -37,6 +38,8 @@ export const timelineTranslations = { 'timeline.demo.qaTitle': 'Quality review', 'timeline.demo.qaMeta': 'QA pass', 'timeline.demo.qaDescription': 'Visual spacing and empty states were reviewed before publishing.', + 'timeline.demo.reorder.description': + 'Compose with sortable and drag the grip to reorder. Timeline stays layout; sortable owns order. Also install sortable.', 'timeline.api.description': 'Inputs supported by the Timeline directives.', 'timeline.api.orientation.description': 'Controls whether items stack vertically or horizontally.', @@ -46,7 +49,7 @@ export const timelineTranslations = { 'Additional classes merged with each separator wrapper.', 'timeline.api.contentClass.description': 'Additional classes merged with each content container.', 'timeline.accessibility.description': - 'Timeline is a visual layout primitive with no built-in ARIA role. When timeline items represent an ordered sequence, wrap the content in a native
    or
      in your template so screen readers understand the list structure.', + 'The root sets role=list and each item sets role=listitem so Tailwind Preflight does not strip list semantics. The separator is aria-hidden. Prefer native ul or ol when the sequence is ordered.', 'timeline.keyboard.description': 'Not focusable unless interactive children are present.', 'timeline.stateModel.description': 'Stateless layout component — no value, selection, or event state.', 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/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/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/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/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/locales/zh/components/timeline.ts b/apps/docs/src/app/i18n/locales/zh/components/timeline.ts index b6e518ca..6877005a 100644 --- a/apps/docs/src/app/i18n/locales/zh/components/timeline.ts +++ b/apps/docs/src/app/i18n/locales/zh/components/timeline.ts @@ -1,12 +1,14 @@ export const timelineTranslations = { 'timeline.description': '可組合的時間軸 primitives,適合時間事件、活動紀錄與流程里程碑。', 'timeline.examples.basic.description': - '使用 sanringTimeline 搭配原生清單標記,再自行組合分隔、節點、連接線與內容。', - 'timeline.usage.description': '匯入 Timeline directives,並套用到清單或 div 型活動資料標記。', + '垂直列表,軌道由元件自己畫。Separator 留空就是預設圓點;需要時再投影 icon 或 avatar。', + 'timeline.usage.description': + '每個 item 是 separator 加 content。連接線由 separator 負責;只有預設圓點不夠時才投影節點。', 'timeline.installation.description': '安裝 Timeline primitives,並在每個事件中組合 item、separator 與 content directives。', 'timeline.demo.horizontal': '水平', 'timeline.demo.divBased': 'Div 型時間軸', + 'timeline.demo.reorder': '調整順序', 'timeline.demo.releaseActivity': '發布活動', 'timeline.demo.releaseActivityDescription': '適合發布紀錄與 registry 更新的精簡活動軌跡。', 'timeline.demo.today': '今天', @@ -31,6 +33,8 @@ export const timelineTranslations = { 'timeline.demo.qaTitle': '品質檢查', 'timeline.demo.qaMeta': 'QA 通過', 'timeline.demo.qaDescription': '發布前已檢查視覺間距與空狀態。', + 'timeline.demo.reorder.description': + '跟 sortable 組合,拖右側握把即可改順序。Timeline 只管排版,順序由 sortable 負責。這個範例另外需要安裝 sortable。', 'timeline.api.description': 'Timeline directives 支援的 Inputs。', 'timeline.api.orientation.description': '控制項目垂直堆疊或水平排列。', 'timeline.api.class.description': '與 timeline 根元素合併的額外 class。', @@ -38,7 +42,7 @@ export const timelineTranslations = { 'timeline.api.separatorClass.description': '與每個 separator wrapper 合併的額外 class。', 'timeline.api.contentClass.description': '與每個 content container 合併的額外 class。', 'timeline.accessibility.description': - 'Timeline 是視覺版面 primitive,沒有內建 ARIA 角色。當時間軸項目代表有序序列時,請在模板中用原生
        或
          包裹內容,讓螢幕閱讀器理解清單結構。', + '根元素會設 role=list,每個 item 設 role=listitem,避免 Tailwind Preflight 拿掉清單語意。Separator 為 aria-hidden。有序序列請優先用原生 ul 或 ol。', 'timeline.keyboard.description': '除非內部有互動子元素,否則不可聚焦。', 'timeline.stateModel.description': '無狀態版面元件——沒有值、選取或事件狀態。', } as const; 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/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/layouts/component-page/component-page-code-previewer.component.ts b/apps/docs/src/app/layouts/component-page/component-page-code-previewer.component.ts index b10148eb..42661428 100644 --- a/apps/docs/src/app/layouts/component-page/component-page-code-previewer.component.ts +++ b/apps/docs/src/app/layouts/component-page/component-page-code-previewer.component.ts @@ -18,7 +18,7 @@ import { template: `
          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 9d4b02d9..36040ba5 100644 --- a/apps/docs/src/app/pages/changelog/component-changelog.ts +++ b/apps/docs/src/app/pages/changelog/component-changelog.ts @@ -35,6 +35,50 @@ 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]`.', + }, + { + type: 'added', + notable: true, + text: '`sanring mcp` adds `get_component_spec` for selectors, anatomy, API, accessibility, and a canonical example. `get_component_info` stays install metadata; `search_components` also matches aliases such as modal → dialog.', + }, + { + type: 'changed', + notable: true, + componentIds: ['timeline'], + text: '`sanringTimelineSeparator` now draws the rail and a default marker, so items no longer need hand-rolled dots, cards, or connector spans.', + }, + { + type: 'changed', + componentIds: ['sortable'], + text: '`[sanringSortableItem]` no longer binds a host `class` string, so it can sit on the same node as `[sanringTimelineItem]`.', + }, + ], + }, + { + 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/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/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/apps/docs/src/app/pages/components/timeline/timeline-page.component.ts b/apps/docs/src/app/pages/components/timeline/timeline-page.component.ts index eac5895c..de87e1b8 100644 --- a/apps/docs/src/app/pages/components/timeline/timeline-page.component.ts +++ b/apps/docs/src/app/pages/components/timeline/timeline-page.component.ts @@ -1,12 +1,10 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; +import { LucideGripVertical } from '@lucide/angular'; import { - BadgeDirective, SANRING_AVATAR_IMPORTS, SANRING_CARD_IMPORTS, - TimelineContentDirective, - TimelineDirective, - TimelineItemDirective, - TimelineSeparatorDirective, + SANRING_SORTABLE_IMPORTS, + SANRING_TIMELINE_IMPORTS, } from '@sanring/ui'; import { getComponentPageSection } from '../../../docs-schema/component-page.utils'; import { I18nService } from '../../../i18n/i18n.service'; @@ -22,6 +20,16 @@ import { } from '../../../layouts/component-page'; import { timelinePage, timelinePageExamples } from './timeline.docs'; +interface TimelineReorderEvent { + id: string; + titleKey: 'timeline.demo.created' | 'timeline.demo.reviewed' | 'timeline.demo.shipped'; + descriptionKey: + | 'timeline.demo.createdDescription' + | 'timeline.demo.reviewedDescription' + | 'timeline.demo.shippedDescription'; + metaKey: 'timeline.demo.createdMeta' | 'timeline.demo.reviewedMeta' | 'timeline.demo.shippedMeta'; +} + @Component({ selector: 'app-timeline-page', imports: [ @@ -33,13 +41,11 @@ import { timelinePage, timelinePageExamples } from './timeline.docs'; ComponentPageInstallationComponent, ComponentPageUsageImportsComponent, ComponentPageSectionComponent, - BadgeDirective, + LucideGripVertical, SANRING_AVATAR_IMPORTS, SANRING_CARD_IMPORTS, - TimelineContentDirective, - TimelineDirective, - TimelineItemDirective, - TimelineSeparatorDirective, + SANRING_SORTABLE_IMPORTS, + SANRING_TIMELINE_IMPORTS, ], template: ` @@ -54,38 +60,24 @@ import { timelinePage, timelinePageExamples } from './timeline.docs'; [stateModelLabel]="i18n.t('component.header.stateless')" /> -
            - @for (event of events; track event.titleKey; let last = $last) { + @for (event of events; track event.titleKey) {
          • - - - - - @if (!last) { - - } - -
            - - -
            -

            - {{ i18n.t(event.titleKey) }} -

            -

            - {{ i18n.t(event.descriptionKey) }} -

            -
            - - {{ i18n.t(event.metaKey) }} - -
            -
            + +
            +
            +

            + {{ i18n.t(event.titleKey) }} +

            + +
            +

            + {{ i18n.t(event.descriptionKey) }} +

          • } @@ -95,7 +87,10 @@ import { timelinePage, timelinePageExamples } from './timeline.docs';
            - +
            @@ -103,39 +98,24 @@ import { timelinePage, timelinePageExamples } from './timeline.docs';
            -
            -
              - @for (event of compactEvents; track event.titleKey; let last = $last) { -
            • - - - - {{ $index + 1 }} - - - -
              +
                + @for (event of compactEvents; track event.titleKey) { +
              • + +

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

                -

                +

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

                @@ -146,14 +126,13 @@ import { timelinePage, timelinePageExamples } from './timeline.docs'; -
                - -
                + +
                @for (item of feedItems; track item.titleKey) { -
                +
                {{ item.initials }} @@ -168,7 +147,7 @@ import { timelinePage, timelinePageExamples } from './timeline.docs'; {{ i18n.t(item.metaKey) }}
                -

                +

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

                @@ -179,6 +158,47 @@ import { timelinePage, timelinePageExamples } from './timeline.docs';
                + + + +
                + + @for (event of reorderable(); track event.id) { +
                + +
                +
                +
                +

                + {{ i18n.t(event.titleKey) }} +

                + +
                +

                + {{ i18n.t(event.descriptionKey) }} +

                +
                + +
                +
                + } +
                +
                +
                +
                @@ -202,22 +222,44 @@ export class TimelinePageComponent { titleKey: 'timeline.demo.created', descriptionKey: 'timeline.demo.createdDescription', metaKey: 'timeline.demo.createdMeta', - dotClass: 'bg-[var(--docs-accent-strong)]', }, { titleKey: 'timeline.demo.reviewed', descriptionKey: 'timeline.demo.reviewedDescription', metaKey: 'timeline.demo.reviewedMeta', - dotClass: 'bg-emerald-500', }, { titleKey: 'timeline.demo.shipped', descriptionKey: 'timeline.demo.shippedDescription', metaKey: 'timeline.demo.shippedMeta', - dotClass: 'bg-amber-500', }, ] as const; + protected readonly reorderable = signal([ + { + id: 'created', + titleKey: 'timeline.demo.created', + descriptionKey: 'timeline.demo.createdDescription', + metaKey: 'timeline.demo.createdMeta', + }, + { + id: 'reviewed', + titleKey: 'timeline.demo.reviewed', + descriptionKey: 'timeline.demo.reviewedDescription', + metaKey: 'timeline.demo.reviewedMeta', + }, + { + id: 'shipped', + titleKey: 'timeline.demo.shipped', + descriptionKey: 'timeline.demo.shippedDescription', + metaKey: 'timeline.demo.shippedMeta', + }, + ]); + + protected onReordered(items: unknown[]): void { + this.reorderable.set(items as TimelineReorderEvent[]); + } + protected readonly compactEvents = [ { titleKey: 'timeline.demo.plan', diff --git a/apps/docs/src/app/pages/components/timeline/timeline.docs.ts b/apps/docs/src/app/pages/components/timeline/timeline.docs.ts index f80ec817..66fbd31e 100644 --- a/apps/docs/src/app/pages/components/timeline/timeline.docs.ts +++ b/apps/docs/src/app/pages/components/timeline/timeline.docs.ts @@ -43,6 +43,12 @@ export const timelinePage = { titleKey: 'timeline.demo.divBased', level: 3, }, + { + id: 'example-reorder', + titleKey: 'timeline.demo.reorder', + descriptionKey: 'timeline.demo.reorder.description', + level: 3, + }, ], }, { @@ -101,64 +107,61 @@ export const timelinePage = { export const timelinePageExamples = { basic: `
                • - - - - - - - -
                  -
                  -
                  -
                  -

                  Created project

                  -

                  Workspace and registry files are ready.

                  -
                  - 09:12 -
                  + +
                  +
                  +

                  Created project

                  +
                  +

                  + Workspace and registry files are ready. +

                `, - usageImport: `import { TimelineContentDirective, TimelineDirective, TimelineItemDirective, TimelineSeparatorDirective } from './components/ui/timeline';`, - usageMain: `
                  + usageImport: `import { Component } from '@angular/core'; +import { SANRING_TIMELINE_IMPORTS } from './components/ui/timeline'; + +@Component({ + imports: [SANRING_TIMELINE_IMPORTS], +}) +export class ExampleComponent {}`, + usageIndividualImports: `import { Component } from '@angular/core'; +import { + TimelineContentDirective, + TimelineDirective, + TimelineItemDirective, + TimelineSeparatorDirective, +} from './components/ui/timeline'; + +@Component({ + imports: [ + TimelineDirective, + TimelineItemDirective, + TimelineSeparatorDirective, + TimelineContentDirective, + ], +}) +export class ExampleComponent {}`, + usageMain: `
                  • Created project
                  `, - horizontal: ` -
                    -
                  • - - - - - 1 - - - -
                    Plan
                    + horizontal: `
                      +
                    • + +
                      Plan
                    • -
                    • - - - - 2 - - - - -
                      Build
                      +
                    • + +
                      Build
                    `, - divBased: ` - -
                    -
                    + divBased: ` +
                    +
                    UI @@ -168,4 +171,15 @@ export const timelinePageExamples = {
                    `, + reorder: ` + @for (item of items; track item.id) { +
                    + +
                    +
                    {{ item.title }}
                    + +
                    +
                    + } +
                    `, } as const; 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/apps/docs/src/styles.css b/apps/docs/src/styles.css index 5da2e6e1..504a2874 100644 --- a/apps/docs/src/styles.css +++ b/apps/docs/src/styles.css @@ -602,4 +602,13 @@ outline: none; box-shadow: 0 0 0 2px var(--docs-focus-ring); } + + .cdk-drag-placeholder { + opacity: 0.4; + } + + .sanring-sortable-preview { + box-sizing: border-box; + box-shadow: 0 10px 28px rgb(0 0 0 / 0.22); + } } 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/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/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..520630f0 --- /dev/null +++ b/packages/ui/src/lib/components/sortable/sortable-item.directive.ts @@ -0,0 +1,56 @@ +import { CdkDrag } from '@angular/cdk/drag-drop'; +import { Directive, booleanAttribute, computed, contentChild, effect, inject, input } from '@angular/core'; +import { SortableHandleDirective } from './sortable-handle.directive'; +import { SortableComponent } from './sortable.component'; + +@Directive({ + selector: '[sanringSortableItem]', + standalone: true, + hostDirectives: [CdkDrag], + host: { + role: 'listitem', + '[class.cursor-grab]': 'showGrabCursor()', + '[class.select-none]': '!isDisabled()', + '[attr.tabindex]': 'isDisabled() ? -1 : 0', + '[attr.data-disabled]': 'isDisabled() ? "" : null', + '(keydown)': 'onKeydown($event)', + }, +}) +export class SortableItemDirective { + 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 showGrabCursor = computed(() => !this.handle() && !this.isDisabled()); + + 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/lib/components/timeline/index.ts b/packages/ui/src/lib/components/timeline/index.ts index 789b58a5..0a0af3d7 100644 --- a/packages/ui/src/lib/components/timeline/index.ts +++ b/packages/ui/src/lib/components/timeline/index.ts @@ -3,3 +3,15 @@ export * from './timeline-content.directive'; export * from './timeline-item.directive'; export * from './timeline-separator.directive'; export * from './timeline-type'; + +import { TimelineContentDirective } from './timeline-content.directive'; +import { TimelineItemDirective } from './timeline-item.directive'; +import { TimelineSeparatorDirective } from './timeline-separator.directive'; +import { TimelineDirective } from './timeline.directive'; + +export const SANRING_TIMELINE_IMPORTS = [ + TimelineDirective, + TimelineItemDirective, + TimelineSeparatorDirective, + TimelineContentDirective, +]; diff --git a/packages/ui/src/lib/components/timeline/timeline-content.directive.ts b/packages/ui/src/lib/components/timeline/timeline-content.directive.ts index e0e8b8f7..abe5d62e 100644 --- a/packages/ui/src/lib/components/timeline/timeline-content.directive.ts +++ b/packages/ui/src/lib/components/timeline/timeline-content.directive.ts @@ -11,5 +11,5 @@ import { cn } from '../../utils'; export class TimelineContentDirective { readonly class = input(); - protected readonly contentClass = computed(() => cn('min-w-0 flex-1', this.class())); + protected readonly contentClass = computed(() => cn('min-w-0 flex-1 pt-px', this.class())); } diff --git a/packages/ui/src/lib/components/timeline/timeline-item.directive.ts b/packages/ui/src/lib/components/timeline/timeline-item.directive.ts index 415a40d8..b71e5d3a 100644 --- a/packages/ui/src/lib/components/timeline/timeline-item.directive.ts +++ b/packages/ui/src/lib/components/timeline/timeline-item.directive.ts @@ -17,8 +17,10 @@ export class TimelineItemDirective { protected readonly itemClass = computed(() => cn( - 'relative flex min-w-0 gap-4', - this.timeline?.orientation() === 'horizontal' ? 'flex-col' : 'flex-row', + 'group/timeline-item relative min-w-0', + this.timeline?.orientation() === 'horizontal' + ? 'flex flex-1 flex-col gap-3' + : "pb-8 pl-10 last:pb-0 before:absolute before:bottom-0 before:left-[15px] before:top-3 before:w-px before:bg-[var(--sanring-border)] before:content-[''] last:before:hidden", this.class(), ), ); diff --git a/packages/ui/src/lib/components/timeline/timeline-separator.directive.ts b/packages/ui/src/lib/components/timeline/timeline-separator.directive.ts index 2fc2e1f0..84b71ecf 100644 --- a/packages/ui/src/lib/components/timeline/timeline-separator.directive.ts +++ b/packages/ui/src/lib/components/timeline/timeline-separator.directive.ts @@ -1,10 +1,28 @@ -import { Directive, computed, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { cn } from '../../utils'; import { TimelineDirective } from './timeline.directive'; -@Directive({ +@Component({ selector: 'div[sanringTimelineSeparator], span[sanringTimelineSeparator]', standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (isHorizontal()) { + + } + + + + @if (isHorizontal()) { + + } + `, host: { '[class]': 'separatorClass()', '[attr.aria-hidden]': '"true"', @@ -15,10 +33,14 @@ export class TimelineSeparatorDirective { private readonly timeline = inject(TimelineDirective, { optional: true }); + protected readonly isHorizontal = computed(() => this.timeline?.orientation() === 'horizontal'); + protected readonly separatorClass = computed(() => cn( 'flex shrink-0 items-center', - this.timeline?.orientation() === 'horizontal' ? 'flex-row' : 'flex-col', + this.isHorizontal() + ? 'w-full flex-row self-stretch' + : 'absolute top-0.5 left-0 z-10 w-8 flex-col items-center', this.class(), ), ); diff --git a/packages/ui/src/lib/components/timeline/timeline.directive.spec.ts b/packages/ui/src/lib/components/timeline/timeline.directive.spec.ts index fe67b4ec..0563c713 100644 --- a/packages/ui/src/lib/components/timeline/timeline.directive.spec.ts +++ b/packages/ui/src/lib/components/timeline/timeline.directive.spec.ts @@ -20,10 +20,15 @@ import { TimelineDirective } from './timeline.directive';
                    First event
                  • +
                  • + +
                    Last event
                    +
                  +
                  Second event
                  @@ -79,15 +84,18 @@ describe('Timeline primitives', () => { expect(separator.classList).not.toContain('flex-col'); }); - it('lays out item content in a row and the separator in a column for vertical (default) orientation', () => { + it('offsets vertical items for an overlay rail', () => { const fixture = TestBed.createComponent(TimelineTestHost); fixture.detectChanges(); const timeline = fixture.nativeElement.querySelector('div[sanringTimeline]'); const item = timeline.querySelector('div[sanringTimelineItem]'); + const separator = item.querySelector('[sanringTimelineSeparator]'); - expect(item.classList).toContain('flex-row'); - expect(item.classList).not.toContain('flex-col'); + expect(item.classList).toContain('pl-10'); + expect(item.classList).toContain('last:before:hidden'); + expect(separator.classList).toContain('absolute'); + expect(separator.classList).toContain('flex-col'); }); it('has no axe-detectable a11y violations', async () => { diff --git a/packages/ui/src/lib/components/timeline/timeline.directive.ts b/packages/ui/src/lib/components/timeline/timeline.directive.ts index b48d3976..95392295 100644 --- a/packages/ui/src/lib/components/timeline/timeline.directive.ts +++ b/packages/ui/src/lib/components/timeline/timeline.directive.ts @@ -3,7 +3,7 @@ import { cn } from '../../utils'; import { TimelineOrientation } from './timeline-type'; @Directive({ - selector: 'ul[sanringTimeline], div[sanringTimeline]', + selector: 'ul[sanringTimeline], ol[sanringTimeline], div[sanringTimeline]', standalone: true, host: { '[class]': 'timelineClass()', @@ -16,10 +16,6 @@ export class TimelineDirective { readonly class = input(); protected readonly timelineClass = computed(() => - cn( - 'relative flex w-full', - this.orientation() === 'vertical' ? 'flex-col gap-4' : 'flex-row gap-6', - this.class(), - ), + cn('relative flex w-full', this.orientation() === 'vertical' ? 'flex-col' : 'flex-row', 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/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'; 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..520630f0 --- /dev/null +++ b/registry/components/sortable/sortable-item.directive.ts @@ -0,0 +1,56 @@ +import { CdkDrag } from '@angular/cdk/drag-drop'; +import { Directive, booleanAttribute, computed, contentChild, effect, inject, input } from '@angular/core'; +import { SortableHandleDirective } from './sortable-handle.directive'; +import { SortableComponent } from './sortable.component'; + +@Directive({ + selector: '[sanringSortableItem]', + standalone: true, + hostDirectives: [CdkDrag], + host: { + role: 'listitem', + '[class.cursor-grab]': 'showGrabCursor()', + '[class.select-none]': '!isDisabled()', + '[attr.tabindex]': 'isDisabled() ? -1 : 0', + '[attr.data-disabled]': 'isDisabled() ? "" : null', + '(keydown)': 'onKeydown($event)', + }, +}) +export class SortableItemDirective { + 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 showGrabCursor = computed(() => !this.handle() && !this.isDisabled()); + + 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/components/timeline/index.ts b/registry/components/timeline/index.ts index 789b58a5..0a0af3d7 100644 --- a/registry/components/timeline/index.ts +++ b/registry/components/timeline/index.ts @@ -3,3 +3,15 @@ export * from './timeline-content.directive'; export * from './timeline-item.directive'; export * from './timeline-separator.directive'; export * from './timeline-type'; + +import { TimelineContentDirective } from './timeline-content.directive'; +import { TimelineItemDirective } from './timeline-item.directive'; +import { TimelineSeparatorDirective } from './timeline-separator.directive'; +import { TimelineDirective } from './timeline.directive'; + +export const SANRING_TIMELINE_IMPORTS = [ + TimelineDirective, + TimelineItemDirective, + TimelineSeparatorDirective, + TimelineContentDirective, +]; diff --git a/registry/components/timeline/timeline-content.directive.ts b/registry/components/timeline/timeline-content.directive.ts index 416aeae7..517f4dd5 100644 --- a/registry/components/timeline/timeline-content.directive.ts +++ b/registry/components/timeline/timeline-content.directive.ts @@ -11,5 +11,5 @@ import { cn } from '../shared/utils'; export class TimelineContentDirective { readonly class = input(); - protected readonly contentClass = computed(() => cn('min-w-0 flex-1', this.class())); + protected readonly contentClass = computed(() => cn('min-w-0 flex-1 pt-px', this.class())); } diff --git a/registry/components/timeline/timeline-item.directive.ts b/registry/components/timeline/timeline-item.directive.ts index 6af1e07d..cf6e63f3 100644 --- a/registry/components/timeline/timeline-item.directive.ts +++ b/registry/components/timeline/timeline-item.directive.ts @@ -17,8 +17,10 @@ export class TimelineItemDirective { protected readonly itemClass = computed(() => cn( - 'relative flex min-w-0 gap-4', - this.timeline?.orientation() === 'horizontal' ? 'flex-col' : 'flex-row', + 'group/timeline-item relative min-w-0', + this.timeline?.orientation() === 'horizontal' + ? 'flex flex-1 flex-col gap-3' + : "pb-8 pl-10 last:pb-0 before:absolute before:bottom-0 before:left-[15px] before:top-3 before:w-px before:bg-[var(--sanring-border)] before:content-[''] last:before:hidden", this.class(), ), ); diff --git a/registry/components/timeline/timeline-separator.directive.ts b/registry/components/timeline/timeline-separator.directive.ts index 86c0e0b0..130e8a0f 100644 --- a/registry/components/timeline/timeline-separator.directive.ts +++ b/registry/components/timeline/timeline-separator.directive.ts @@ -1,10 +1,28 @@ -import { Directive, computed, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { cn } from '../shared/utils'; import { TimelineDirective } from './timeline.directive'; -@Directive({ +@Component({ selector: 'div[sanringTimelineSeparator], span[sanringTimelineSeparator]', standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (isHorizontal()) { + + } + + + + @if (isHorizontal()) { + + } + `, host: { '[class]': 'separatorClass()', '[attr.aria-hidden]': '"true"', @@ -15,10 +33,14 @@ export class TimelineSeparatorDirective { private readonly timeline = inject(TimelineDirective, { optional: true }); + protected readonly isHorizontal = computed(() => this.timeline?.orientation() === 'horizontal'); + protected readonly separatorClass = computed(() => cn( 'flex shrink-0 items-center', - this.timeline?.orientation() === 'horizontal' ? 'flex-row' : 'flex-col', + this.isHorizontal() + ? 'w-full flex-row self-stretch' + : 'absolute top-0.5 left-0 z-10 w-8 flex-col items-center', this.class(), ), ); diff --git a/registry/components/timeline/timeline.directive.ts b/registry/components/timeline/timeline.directive.ts index 348902df..fddc96ea 100644 --- a/registry/components/timeline/timeline.directive.ts +++ b/registry/components/timeline/timeline.directive.ts @@ -3,7 +3,7 @@ import { cn } from '../shared/utils'; import { TimelineOrientation } from './timeline-type'; @Directive({ - selector: 'ul[sanringTimeline], div[sanringTimeline]', + selector: 'ul[sanringTimeline], ol[sanringTimeline], div[sanringTimeline]', standalone: true, host: { '[class]': 'timelineClass()', @@ -16,10 +16,6 @@ export class TimelineDirective { readonly class = input(); protected readonly timelineClass = computed(() => - cn( - 'relative flex w-full', - this.orientation() === 'vertical' ? 'flex-col gap-4' : 'flex-row gap-6', - this.class(), - ), + cn('relative flex w-full', this.orientation() === 'vertical' ? 'flex-col' : 'flex-row', 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.", diff --git a/registry/specs.json b/registry/specs.json new file mode 100644 index 00000000..dec8b42d --- /dev/null +++ b/registry/specs.json @@ -0,0 +1,4380 @@ +{ + "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