diff --git a/.changeset/avatar-tab-notification-count.md b/.changeset/avatar-tab-notification-count.md new file mode 100644 index 00000000..cd0a1ab3 --- /dev/null +++ b/.changeset/avatar-tab-notification-count.md @@ -0,0 +1,5 @@ +--- +"@sanring/cli": patch +--- + +Avatar badge accepts `count` (hidden at 0, capped at 99+) and `top`/`bottom` placement. Tabs docs add a Badge composition recipe for unread counts. diff --git a/apps/docs/public/avatars/lantern.jpg b/apps/docs/public/avatars/lantern.jpg new file mode 100644 index 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/i18n/locales/en/components/avatar.ts b/apps/docs/src/app/i18n/locales/en/components/avatar.ts index fc34f50d..7add5678 100644 --- a/apps/docs/src/app/i18n/locales/en/components/avatar.ts +++ b/apps/docs/src/app/i18n/locales/en/components/avatar.ts @@ -4,6 +4,9 @@ export const avatarTranslations = { 'avatar.demo.sizes': 'Sizes', 'avatar.demo.statusBadge': 'Status badge', 'avatar.demo.badgeWithIcon': 'Badge with icon', + 'avatar.demo.badgeCount': 'Notification count', + 'avatar.examples.badgeCount.description': + 'Pass count to render a numeric pill. It defaults to the top-end corner so it can sit with a status dot at the bottom. 0 hides the pill; values above 99 render as 99+.', 'avatar.demo.group': 'Avatar group', 'avatar.demo.groupWithIcon': 'Avatar group with icon', 'avatar.examples.description': @@ -24,7 +27,9 @@ export const avatarTranslations = { 'avatar.api.delayMs.description': 'Delay in milliseconds before the fallback is shown.', 'avatar.api.status.description': 'Controls badge color: online, offline, away, busy, or default.', 'avatar.api.placement.description': - 'Places the badge at the visual start or end edge, respecting RTL direction.', + 'Places the badge at start, end, top, or bottom. Status badges default to end (bottom-end); count badges default to top (top-end). start/end follow RTL.', + 'avatar.api.badgeCount.description': + 'Unread count on [sanringAvatarBadge]. Hidden at 0 or below; values above 99 render as 99+.', 'avatar.api.overlap.description': 'Stack overlap amount in rem for avatar groups.', 'avatar.api.count.description': 'Number displayed by the avatar group count item.', 'avatar.api.clickable.description': @@ -33,7 +38,7 @@ export const avatarTranslations = { 'Makes a clickable group count unavailable and removes it from the tab sequence.', 'avatar.api.clicked.description': 'Emitted when an enabled clickable group count is activated.', 'avatar.accessibility.description': - "role='img' on the host. Provide ariaLabel or ariaLabelledBy to name a non-decorative avatar. For purely decorative use — such as next to a user name already present in text — add aria-hidden='true' on to suppress redundant announcements. A clickable group count exposes button semantics and reflects disabled state.", + "role='img' on the host. Provide ariaLabel or ariaLabelledBy to name a non-decorative avatar. For purely decorative use — such as next to a user name already present in text — add aria-hidden='true' on to suppress redundant announcements. A count badge is role='status'; give it an ariaLabel such as '3 unread'. A clickable group count exposes button semantics and reflects disabled state.", 'avatar.keyboard.description': 'Avatars are not focusable by default. A clickable group count responds to Enter and Space.', 'avatar.stateModel.description': diff --git a/apps/docs/src/app/i18n/locales/en/components/tabs.ts b/apps/docs/src/app/i18n/locales/en/components/tabs.ts index 4345ffd5..ffdab608 100644 --- a/apps/docs/src/app/i18n/locales/en/components/tabs.ts +++ b/apps/docs/src/app/i18n/locales/en/components/tabs.ts @@ -11,6 +11,14 @@ export const tabsTranslations = { 'tabs.demo.settings': 'Settings', 'tabs.demo.horizontal': 'Horizontal', 'tabs.demo.withIcon': 'With icon', + 'tabs.demo.withCount': 'With count', + 'tabs.demo.inbox': 'Inbox', + 'tabs.demo.drafts': 'Drafts', + 'tabs.demo.inboxCountLabel': 'Inbox, 3 unread', + 'tabs.demo.inboxContent': 'Unread messages stay in the inbox.', + 'tabs.demo.draftsContent': 'Drafts have no unread count, so the badge is omitted.', + 'tabs.examples.withCount.description': + 'Compose sanringBadge inside a trigger. Put the count in the trigger aria-label. Omit the badge when the count is 0.', 'tabs.demo.vertical': 'Vertical', 'tabs.demo.line': 'Line variant', 'tabs.demo.disabled': 'Disabled tab', @@ -37,7 +45,7 @@ export const tabsTranslations = { 'tabs.api.disabled.description': 'Prevents a trigger from being selected or focused by keyboard navigation.', 'tabs.api.valueChange.description': 'Emits when the selected tab value changes.', - 'tabs.accessibility.description': "WAI-ARIA Tabs pattern via @angular/aria/tabs. role='tablist' on sanring-tabs-list, role='tab' on each trigger, role='tabpanel' on each content panel, linked with aria-controls and aria-labelledby. aria-selected reflects the active tab.", + 'tabs.accessibility.description': "WAI-ARIA Tabs pattern via @angular/aria/tabs. role='tablist' on sanring-tabs-list, role='tab' on each trigger, role='tabpanel' on each content panel, linked with aria-controls and aria-labelledby. aria-selected reflects the active tab. If a trigger shows a count, set aria-label on the trigger so the count is announced (for example 'Inbox, 3 unread').", 'tabs.keyboard.description': 'Arrow navigation within the trigger list; Tab moves into the active panel.', 'tabs.keyboard.arrowLeftRight': 'Navigate between tab triggers (horizontal orientation).', 'tabs.keyboard.arrowUpDown': 'Navigate between tab triggers (vertical orientation).', diff --git a/apps/docs/src/app/i18n/locales/zh/components/avatar.ts b/apps/docs/src/app/i18n/locales/zh/components/avatar.ts index 426c5f24..d16773f3 100644 --- a/apps/docs/src/app/i18n/locales/zh/components/avatar.ts +++ b/apps/docs/src/app/i18n/locales/zh/components/avatar.ts @@ -3,6 +3,9 @@ export const avatarTranslations = { 'avatar.demo.sizes': '尺寸', 'avatar.demo.statusBadge': '狀態徽章', 'avatar.demo.badgeWithIcon': '包含圖示的徽章', + 'avatar.demo.badgeCount': '通知計數', + 'avatar.examples.badgeCount.description': + '傳入 count 會渲染數字 pill。預設在右上角,可與右下角狀態點同時存在。0 會隱藏 pill;超過 99 顯示為 99+。', 'avatar.demo.group': '頭像群組', 'avatar.demo.groupWithIcon': '包含圖示的頭像群組', 'avatar.examples.description': '常見頭像模式,包含 fallback、在線狀態與精簡成員群組。', @@ -20,14 +23,17 @@ export const avatarTranslations = { 'avatar.api.ariaLabel.description': '無可見文字時,提供單一頭像或頭像群組的無障礙標籤。', 'avatar.api.delayMs.description': 'fallback 顯示前的延遲毫秒數。', 'avatar.api.status.description': '控制徽章顏色,可使用 online、offline、away、busy 或 default。', - 'avatar.api.placement.description': '將徽章放在視覺起點或終點,並尊重 RTL 方向。', + 'avatar.api.placement.description': + '將徽章放在 start、end、top 或 bottom。狀態徽章預設 end(右下);計數徽章預設 top(右上)。start/end 會跟隨 RTL。', + 'avatar.api.badgeCount.description': + '[sanringAvatarBadge] 的未讀計數。小於等於 0 時隱藏;超過 99 顯示為 99+。', 'avatar.api.overlap.description': '頭像群組的堆疊重疊量,單位為 rem。', 'avatar.api.count.description': '頭像群組數量項目顯示的數字。', 'avatar.api.clickable.description': '讓群組數量項目具備按鈕語意,並可由滑鼠與鍵盤操作。', 'avatar.api.disabled.description': '停用可點擊的群組數量項目,並將它移出 Tab 序列。', 'avatar.api.clicked.description': '啟用中的可點擊群組數量項目被觸發時送出。', 'avatar.accessibility.description': - "宿主具有 role='img'。為非裝飾性的頭像提供 ariaLabel 或 ariaLabelledBy。若頭像純屬裝飾性用途(例如緊鄰已出現在文字中的使用者名稱),請在 上加 aria-hidden='true' 以避免重複播報。可點擊的群組數量項目會提供 button 語意並反映停用狀態。", + "宿主具有 role='img'。為非裝飾性的頭像提供 ariaLabel 或 ariaLabelledBy。若頭像純屬裝飾性用途(例如緊鄰已出現在文字中的使用者名稱),請在 上加 aria-hidden='true' 以避免重複播報。計數徽章為 role='status',請給它 ariaLabel,例如「3 則未讀」。可點擊的群組數量項目會提供 button 語意並反映停用狀態。", 'avatar.keyboard.description': '頭像預設不可聚焦;可點擊的群組數量項目支援 Enter 與 Space。', 'avatar.stateModel.description': '無狀態。src 載入圖片;失敗時顯示 fallback 插槽;縮寫字母作為最後備援。沒有內部選取或值狀態。', diff --git a/apps/docs/src/app/i18n/locales/zh/components/tabs.ts b/apps/docs/src/app/i18n/locales/zh/components/tabs.ts index 462f3a51..c39ca129 100644 --- a/apps/docs/src/app/i18n/locales/zh/components/tabs.ts +++ b/apps/docs/src/app/i18n/locales/zh/components/tabs.ts @@ -10,6 +10,14 @@ export const tabsTranslations = { 'tabs.demo.settings': '設定', 'tabs.demo.horizontal': '水平', 'tabs.demo.withIcon': '包含圖示', + 'tabs.demo.withCount': '包含計數', + 'tabs.demo.inbox': '收件匣', + 'tabs.demo.drafts': '草稿', + 'tabs.demo.inboxCountLabel': '收件匣,3 則未讀', + 'tabs.demo.inboxContent': '未讀訊息會留在收件匣。', + 'tabs.demo.draftsContent': '草稿沒有未讀數,因此不放徽章。', + 'tabs.examples.withCount.description': + '在 trigger 內組合 sanringBadge。把數字寫進 trigger 的 aria-label。計數為 0 時不要渲染徽章。', 'tabs.demo.vertical': '垂直', 'tabs.demo.line': '底線變體', 'tabs.demo.disabled': '停用頁籤', @@ -33,7 +41,7 @@ export const tabsTranslations = { 'tabs.api.value.description': '用來配對 trigger 與 content panel 的必要值。', 'tabs.api.disabled.description': '避免 trigger 被選取,並從鍵盤導覽中略過。', 'tabs.api.valueChange.description': '選取的 tab value 變更時觸發。', - 'tabs.accessibility.description': "透過 @angular/aria/tabs 實作 WAI-ARIA Tabs 模式。sanring-tabs-list 具有 role='tablist',每個觸發器具有 role='tab',每個內容面板具有 role='tabpanel',透過 aria-controls 與 aria-labelledby 互相關聯。aria-selected 反映目前作用中的分頁。", + 'tabs.accessibility.description': "透過 @angular/aria/tabs 實作 WAI-ARIA Tabs 模式。sanring-tabs-list 具有 role='tablist',每個觸發器具有 role='tab',每個內容面板具有 role='tabpanel',透過 aria-controls 與 aria-labelledby 互相關聯。aria-selected 反映目前作用中的分頁。若 trigger 顯示計數,請在 trigger 上設 aria-label,讓螢幕閱讀器唸出數字(例如「收件匣,3 則未讀」)。", 'tabs.keyboard.description': '在觸發器清單中以方向鍵導覽;Tab 鍵移入作用中的面板。', 'tabs.keyboard.arrowLeftRight': '在分頁觸發器之間導覽(水平方向)。', 'tabs.keyboard.arrowUpDown': '在分頁觸發器之間導覽(垂直方向)。', diff --git a/apps/docs/src/app/i18n/translations.ts b/apps/docs/src/app/i18n/translations.ts index 4abf1dcc..a779fd11 100644 --- a/apps/docs/src/app/i18n/translations.ts +++ b/apps/docs/src/app/i18n/translations.ts @@ -1,7 +1,7 @@ import { en } from './locales/en/index'; import { zh } from './locales/zh/index'; -/** Docs locale catalogs. TranslationKey is keyof typeof en. */ +/** Docs locale catalogs. TranslationKey is inferred from the English catalog. */ export const supportedLocales = ['en', 'zh'] as const; diff --git a/apps/docs/src/app/pages/changelog/component-changelog.ts b/apps/docs/src/app/pages/changelog/component-changelog.ts index 9d4b02d9..c6f396eb 100644 --- a/apps/docs/src/app/pages/changelog/component-changelog.ts +++ b/apps/docs/src/app/pages/changelog/component-changelog.ts @@ -35,6 +35,22 @@ function isPatch(version: string): boolean { * - Keep each change to one sentence. Wrap identifiers in backticks. */ export const cliVersionChangelog: readonly CliVersionEntry[] = [ + { + version: '0.25.2', + date: '2026-09-23', + changes: [ + { + type: 'added', + componentIds: ['avatar'], + text: '`[sanringAvatarBadge]` accepts `count` (hidden at 0, capped at `99+`) and `placement` `top` / `bottom`. Count defaults to top-end so it can sit with a status dot at the bottom.', + }, + { + type: 'changed', + componentIds: ['tabs'], + text: 'Docs add a Badge-inside-trigger recipe for unread counts, including an `aria-label` that names the count.', + }, + ], + }, { version: '0.25.1', date: '2026-09-23', diff --git a/apps/docs/src/app/pages/components/avatar/avatar-page.component.ts b/apps/docs/src/app/pages/components/avatar/avatar-page.component.ts index 504a3a1c..3398e49e 100644 --- a/apps/docs/src/app/pages/components/avatar/avatar-page.component.ts +++ b/apps/docs/src/app/pages/components/avatar/avatar-page.component.ts @@ -47,7 +47,7 @@ import { avatarPage, avatarPageExamples } from './avatar.docs';
- Ada Lovelace + Ada Lovelace AL
@@ -97,7 +97,7 @@ import { avatarPage, avatarPageExamples } from './avatar.docs';
- Online user + Online user OU @@ -113,6 +113,23 @@ import { avatarPage, avatarPageExamples } from './avatar.docs'; + + +
+ + Ada Lovelace + AL + + + + + 99 + + +
+
+
+ Verified user VU @@ -139,12 +156,15 @@ import { avatarPage, avatarPageExamples } from './avatar.docs';
+ Ada Lovelace AL + Grace Hopper GH + Katherine Johnson KJ @@ -161,9 +181,11 @@ import { avatarPage, avatarPageExamples } from './avatar.docs';
+ Ada Lovelace AL + Grace Hopper GH diff --git a/apps/docs/src/app/pages/components/avatar/avatar.docs.ts b/apps/docs/src/app/pages/components/avatar/avatar.docs.ts index ad8ee708..19c4319e 100644 --- a/apps/docs/src/app/pages/components/avatar/avatar.docs.ts +++ b/apps/docs/src/app/pages/components/avatar/avatar.docs.ts @@ -54,6 +54,12 @@ export const avatarPage = { titleKey: 'avatar.demo.badgeWithIcon', level: 3, }, + { + id: 'example-badge-count', + titleKey: 'avatar.demo.badgeCount', + descriptionKey: 'avatar.examples.badgeCount.description', + level: 3, + }, { id: 'example-group', titleKey: 'avatar.demo.group', @@ -118,10 +124,16 @@ export const avatarPage = { }, { property: 'placement', - type: 'AvatarBadgePlacement', - defaultValue: "'end'", + type: "'start' | 'end' | 'top' | 'bottom'", + defaultValue: "'end' / 'top'", descriptionKey: 'avatar.api.placement.description', }, + { + property: 'count (AvatarBadge)', + type: 'number', + defaultValue: 'undefined', + descriptionKey: 'avatar.api.badgeCount.description', + }, { property: 'overlap', type: 'number', @@ -159,7 +171,7 @@ export const avatarPageExamples = { basic: ` Ada Lovelace AL @@ -172,7 +184,7 @@ import { SANRING_AVATAR_IMPORTS } from './components/ui/avatar'; }) export class ExampleComponent {}`, usageMain: ` - Ada Lovelace + Ada Lovelace AL `, usageIndividualImports: `import { Component } from '@angular/core'; @@ -207,12 +219,18 @@ export class ExampleComponent {}`, LG `, badge: ` - Online user + Online user OU +`, + badgeCount: ` + Ada Lovelace + AL + + `, badgeWithIcon: ` - Verified user + Verified user VU @@ -220,21 +238,26 @@ export class ExampleComponent {}`, `, group: ` + Ada Lovelace AL + Grace Hopper GH + Katherine Johnson KJ `, groupWithIcon: ` + Ada Lovelace AL + Grace Hopper GH diff --git a/apps/docs/src/app/pages/components/tabs/tabs-page.component.ts b/apps/docs/src/app/pages/components/tabs/tabs-page.component.ts index d4784cf9..f3a3f862 100644 --- a/apps/docs/src/app/pages/components/tabs/tabs-page.component.ts +++ b/apps/docs/src/app/pages/components/tabs/tabs-page.component.ts @@ -1,6 +1,6 @@ import { Component, inject } from '@angular/core'; import { LucideActivity, LucideKey, LucideSettings } from '@lucide/angular'; -import { SANRING_TABS_IMPORTS } from '@sanring/ui'; +import { BadgeDirective, SANRING_TABS_IMPORTS } from '@sanring/ui'; import { getComponentPageSection } from '../../../docs-schema/component-page.utils'; import { I18nService } from '../../../i18n/i18n.service'; import { @@ -20,6 +20,7 @@ import { tabsPage, tabsPageExamples } from './tabs.docs'; selector: 'app-tabs-page', imports: [ ComponentPageApiTableComponent, + BadgeDirective, SANRING_TABS_IMPORTS, ComponentPageCodeBlock, ComponentPageCodePreviewer, @@ -192,6 +193,41 @@ import { tabsPage, tabsPageExamples } from './tabs.docs'; + + +
+ + + + {{ i18n.t('tabs.demo.inbox') }} + 3 + + + {{ i18n.t('tabs.demo.drafts') }} + + + +
+ {{ i18n.t('tabs.demo.inboxContent') }} +
+
+ +
+ {{ i18n.t('tabs.demo.draftsContent') }} +
+
+
+
+
+
+
diff --git a/apps/docs/src/app/pages/components/tabs/tabs.docs.ts b/apps/docs/src/app/pages/components/tabs/tabs.docs.ts index 704d86b6..0743f2d7 100644 --- a/apps/docs/src/app/pages/components/tabs/tabs.docs.ts +++ b/apps/docs/src/app/pages/components/tabs/tabs.docs.ts @@ -49,6 +49,12 @@ export const tabsPage = { titleKey: 'tabs.demo.withIcon', level: 3, }, + { + id: 'example-with-count', + titleKey: 'tabs.demo.withCount', + descriptionKey: 'tabs.examples.withCount.description', + level: 3, + }, { id: 'example-line', titleKey: 'tabs.demo.line', @@ -204,6 +210,18 @@ export class ExampleComponent {}`, Overview content Analytics content Reports content +`, + withCount: ` + + + Inbox + 3 + + Drafts + + + Inbox content + Drafts content `, withIcon: ` diff --git a/packages/ui/src/lib/components/avatar/avatar-badge.directive.ts b/packages/ui/src/lib/components/avatar/avatar-badge.directive.ts index 902b336f..29827322 100644 --- a/packages/ui/src/lib/components/avatar/avatar-badge.directive.ts +++ b/packages/ui/src/lib/components/avatar/avatar-badge.directive.ts @@ -1,43 +1,84 @@ import { Directionality } from '@angular/cdk/bidi'; -import { Directive, computed, inject, input } from '@angular/core'; +import { coerceNumberProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { cn } from '../../utils'; import { AvatarBadgePlacement, AvatarBadgeStatus } from './avatar.types'; -@Directive({ +const STATUS_COLORS: Record = { + online: 'bg-[var(--sanring-badge-online)]', + offline: 'bg-[var(--sanring-badge-offline)]', + away: 'bg-[var(--sanring-badge-away)]', + busy: 'bg-[var(--sanring-badge-busy)]', + default: 'bg-[var(--sanring-badge-default)]', +}; + +@Component({ selector: '[sanringAvatarBadge]', standalone: true, + template: `{{ countLabel() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class]': 'badgeClass()', role: 'status', '[attr.aria-label]': 'resolvedAriaLabel()', + '[attr.aria-hidden]': 'isHidden() ? "true" : null', + '[hidden]': 'isHidden()', }, }) export class AvatarBadgeDirective { readonly class = input(); readonly status = input('default'); - readonly placement = input('end'); + readonly placement = input(); readonly ariaLabel = input(); + readonly count = input(undefined, { + transform: (value: unknown) => + value === undefined || value === null || value === '' + ? undefined + : coerceNumberProperty(value), + }); private readonly directionality = inject(Directionality, { optional: true }); - protected readonly resolvedAriaLabel = computed(() => this.ariaLabel() ?? this.status()); + protected readonly countLabel = computed(() => { + const count = this.count(); + if (typeof count !== 'number' || count <= 0) return ''; + return count > 99 ? '99+' : String(count); + }); + + protected readonly isCount = computed(() => this.countLabel() !== ''); + protected readonly isHidden = computed(() => { + const count = this.count(); + return typeof count === 'number' && count <= 0; + }); + + protected readonly resolvedPlacement = computed( + () => this.placement() ?? (this.isCount() ? 'top' : 'end'), + ); + + protected readonly resolvedAriaLabel = computed( + () => this.ariaLabel() ?? (this.countLabel() || this.status()), + ); protected readonly badgeClass = computed(() => { - const statusColors: Record = { - online: 'bg-[var(--sanring-badge-online)]', - offline: 'bg-[var(--sanring-badge-offline)]', - away: 'bg-[var(--sanring-badge-away)]', - busy: 'bg-[var(--sanring-badge-busy)]', - default: 'bg-[var(--sanring-badge-default)]', - }; + const placement = this.resolvedPlacement(); const isRtl = this.directionality?.value === 'rtl'; - const isVisualEnd = this.placement() === 'end'; + const isVisualEnd = placement !== 'start'; const sideClass = isVisualEnd !== isRtl ? 'right-0' : 'left-0'; + const verticalClass = placement === 'top' ? 'top-0' : 'bottom-0'; + const status = this.status(); + const colorClass = + this.isCount() && status === 'default' + ? 'bg-[var(--sanring-error-50)]' + : (STATUS_COLORS[status] ?? STATUS_COLORS['default']); return cn( - 'absolute bottom-0 z-10 flex size-3 items-center justify-center rounded-full text-white ring-2 ring-[var(--sanring-background)]', + 'absolute z-10 flex items-center justify-center rounded-full text-white ring-2 ring-[var(--sanring-background)]', + this.isCount() + ? 'h-4 min-w-4 px-1 text-[10px] font-semibold leading-none' + : 'size-3', + verticalClass, sideClass, - statusColors[this.status()], + colorClass, this.class(), ); }); diff --git a/packages/ui/src/lib/components/avatar/avatar.component.spec.ts b/packages/ui/src/lib/components/avatar/avatar.component.spec.ts index 5d859076..8c58dd66 100644 --- a/packages/ui/src/lib/components/avatar/avatar.component.spec.ts +++ b/packages/ui/src/lib/components/avatar/avatar.component.spec.ts @@ -35,11 +35,24 @@ import { AvatarComponent } from './avatar.component'; (clicked)="clicks = clicks + 1" /> + + + MB + + + `, }) class AvatarTestHost { clicks = 0; countDisabled = false; + badgeCount: number | undefined = 3; + badgePlacement: 'start' | 'end' | 'top' | 'bottom' | undefined; } describe('AvatarComponent', () => { @@ -100,6 +113,57 @@ describe('AvatarComponent', () => { expect(fixture.componentInstance.clicks).toBe(0); }); + it('renders a notification count on the top edge and keeps the status badge at the bottom', () => { + const fixture = TestBed.createComponent(AvatarTestHost); + fixture.detectChanges(); + + const avatar = fixture.nativeElement.querySelectorAll( + 'sanring-avatar', + )[2] as HTMLElement; + const badges = avatar.querySelectorAll('[sanringAvatarBadge]'); + const countBadge = badges[0] as HTMLElement; + const statusBadge = badges[1] as HTMLElement; + + expect(countBadge.textContent?.trim()).toBe('3'); + expect(countBadge.className).toContain('top-0'); + expect(countBadge.className).toContain('right-0'); + expect(countBadge.getAttribute('aria-label')).toBe('3 unread'); + expect(statusBadge.className).toContain('bottom-0'); + expect(statusBadge.className).toContain('size-3'); + }); + + it('hides a zero count', () => { + const fixture = TestBed.createComponent(AvatarTestHost); + fixture.componentInstance.badgeCount = 0; + fixture.detectChanges(); + + const avatar = fixture.nativeElement.querySelectorAll( + 'sanring-avatar', + )[2] as HTMLElement; + const countBadge = avatar.querySelectorAll('[sanringAvatarBadge]')[0] as HTMLElement; + + expect(countBadge.hidden).toBe(true); + expect(countBadge.getAttribute('aria-hidden')).toBe('true'); + expect(countBadge.textContent?.trim()).toBe(''); + }); + + it('caps large counts at 99+ and honors an explicit bottom placement', () => { + const fixture = TestBed.createComponent(AvatarTestHost); + fixture.componentInstance.badgeCount = 128; + fixture.componentInstance.badgePlacement = 'bottom'; + fixture.detectChanges(); + + const avatar = fixture.nativeElement.querySelectorAll( + 'sanring-avatar', + )[2] as HTMLElement; + const countBadge = avatar.querySelectorAll('[sanringAvatarBadge]')[0] as HTMLElement; + + expect(countBadge.hidden).toBe(false); + expect(countBadge.textContent?.trim()).toBe('99+'); + expect(countBadge.className).toContain('bottom-0'); + expect(countBadge.className).not.toContain('top-0'); + }); + it('has no axe-detectable a11y violations', async () => { const fixture = TestBed.createComponent(AvatarTestHost); fixture.detectChanges(); diff --git a/packages/ui/src/lib/components/avatar/avatar.types.ts b/packages/ui/src/lib/components/avatar/avatar.types.ts index b65d82af..e2adbc16 100644 --- a/packages/ui/src/lib/components/avatar/avatar.types.ts +++ b/packages/ui/src/lib/components/avatar/avatar.types.ts @@ -1,4 +1,4 @@ export type AvatarStatus = 'idle' | 'loading' | 'loaded' | 'error'; export type AvatarSize = 'sm' | 'md' | 'lg'; export type AvatarBadgeStatus = 'online' | 'offline' | 'away' | 'busy' | 'default' | (string & {}); -export type AvatarBadgePlacement = 'start' | 'end'; +export type AvatarBadgePlacement = 'start' | 'end' | 'top' | 'bottom'; diff --git a/registry/components/avatar/avatar-badge.directive.ts b/registry/components/avatar/avatar-badge.directive.ts index 76b3e528..c9a21af7 100644 --- a/registry/components/avatar/avatar-badge.directive.ts +++ b/registry/components/avatar/avatar-badge.directive.ts @@ -1,43 +1,84 @@ import { Directionality } from '@angular/cdk/bidi'; -import { Directive, computed, inject, input } from '@angular/core'; +import { coerceNumberProperty } from '@angular/cdk/coercion'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { cn } from '../shared/utils'; import { AvatarBadgePlacement, AvatarBadgeStatus } from './avatar.types'; -@Directive({ +const STATUS_COLORS: Record = { + online: 'bg-[var(--sanring-badge-online)]', + offline: 'bg-[var(--sanring-badge-offline)]', + away: 'bg-[var(--sanring-badge-away)]', + busy: 'bg-[var(--sanring-badge-busy)]', + default: 'bg-[var(--sanring-badge-default)]', +}; + +@Component({ selector: '[sanringAvatarBadge]', standalone: true, + template: `{{ countLabel() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, host: { '[class]': 'badgeClass()', role: 'status', '[attr.aria-label]': 'resolvedAriaLabel()', + '[attr.aria-hidden]': 'isHidden() ? "true" : null', + '[hidden]': 'isHidden()', }, }) export class AvatarBadgeDirective { readonly class = input(); readonly status = input('default'); - readonly placement = input('end'); + readonly placement = input(); readonly ariaLabel = input(); + readonly count = input(undefined, { + transform: (value: unknown) => + value === undefined || value === null || value === '' + ? undefined + : coerceNumberProperty(value), + }); private readonly directionality = inject(Directionality, { optional: true }); - protected readonly resolvedAriaLabel = computed(() => this.ariaLabel() ?? this.status()); + protected readonly countLabel = computed(() => { + const count = this.count(); + if (typeof count !== 'number' || count <= 0) return ''; + return count > 99 ? '99+' : String(count); + }); + + protected readonly isCount = computed(() => this.countLabel() !== ''); + protected readonly isHidden = computed(() => { + const count = this.count(); + return typeof count === 'number' && count <= 0; + }); + + protected readonly resolvedPlacement = computed( + () => this.placement() ?? (this.isCount() ? 'top' : 'end'), + ); + + protected readonly resolvedAriaLabel = computed( + () => this.ariaLabel() ?? (this.countLabel() || this.status()), + ); protected readonly badgeClass = computed(() => { - const statusColors: Record = { - online: 'bg-[var(--sanring-badge-online)]', - offline: 'bg-[var(--sanring-badge-offline)]', - away: 'bg-[var(--sanring-badge-away)]', - busy: 'bg-[var(--sanring-badge-busy)]', - default: 'bg-[var(--sanring-badge-default)]', - }; + const placement = this.resolvedPlacement(); const isRtl = this.directionality?.value === 'rtl'; - const isVisualEnd = this.placement() === 'end'; + const isVisualEnd = placement !== 'start'; const sideClass = isVisualEnd !== isRtl ? 'right-0' : 'left-0'; + const verticalClass = placement === 'top' ? 'top-0' : 'bottom-0'; + const status = this.status(); + const colorClass = + this.isCount() && status === 'default' + ? 'bg-[var(--sanring-error-50)]' + : (STATUS_COLORS[status] ?? STATUS_COLORS['default']); return cn( - 'absolute bottom-0 z-10 flex size-3 items-center justify-center rounded-full text-white ring-2 ring-[var(--sanring-background)]', + 'absolute z-10 flex items-center justify-center rounded-full text-white ring-2 ring-[var(--sanring-background)]', + this.isCount() + ? 'h-4 min-w-4 px-1 text-[10px] font-semibold leading-none' + : 'size-3', + verticalClass, sideClass, - statusColors[this.status()], + colorClass, this.class(), ); }); diff --git a/registry/components/avatar/avatar.types.ts b/registry/components/avatar/avatar.types.ts index b65d82af..e2adbc16 100644 --- a/registry/components/avatar/avatar.types.ts +++ b/registry/components/avatar/avatar.types.ts @@ -1,4 +1,4 @@ export type AvatarStatus = 'idle' | 'loading' | 'loaded' | 'error'; export type AvatarSize = 'sm' | 'md' | 'lg'; export type AvatarBadgeStatus = 'online' | 'offline' | 'away' | 'busy' | 'default' | (string & {}); -export type AvatarBadgePlacement = 'start' | 'end'; +export type AvatarBadgePlacement = 'start' | 'end' | 'top' | 'bottom';