diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index aaf692544a1..695a28ce839 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -251,11 +251,12 @@ and — on activatable rows only — the hover band. Never hand-roll any of it, `RESOURCE_TILE_FILL` for a glyph, `RESOURCE_TILE_PLAIN` for a brand logo or favicon. -**Member avatars are deliberately two components, not one.** `member-list.tsx` -renders a 14px neutral marker for the dense Teammates/Organization roster, where -the email is the primary content; `components/permissions/member-row.tsx` renders -a 36px `getUserColor`-hashed avatar for member *management* rows that carry a name, -an email, and a role control. Same shape, different job — do not merge them. +**One member avatar.** Every member list, owner cell, and ranking renders emcn +`` — a 14px photo, or the initial +on the neutral disc (`aria-hidden` because the name is always beside it). Never +hand-roll an avatar or give a person a `getUserColor` hash; per-person colors +belong to live collaboration (presence, cursors), where the color matches that +person's cursor. ## Header action order diff --git a/.claude/skills/add-settings-page/SKILL.md b/.claude/skills/add-settings-page/SKILL.md index 02af76f3a8f..9500ac39c73 100644 --- a/.claude/skills/add-settings-page/SKILL.md +++ b/.claude/skills/add-settings-page/SKILL.md @@ -55,8 +55,8 @@ For each page component, confirm the checklist in `.claude/rules/sim-settings-pa `.claude/rules/sim-settings-pages.md` for the token map and the row title/subtitle pairing convention): `git grep -nE "text-\[1[0-8]px\]" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` — should - be 0. Display type above the scale (`text-[40px]` hero headings, the `text-[8px]` - member-avatar initial) is deliberate and out of scope. + be 0. Display type above the scale (`text-[40px]` hero headings) is deliberate + and out of scope. 4. Confirm each page imports `SettingsPanel` and that its `NavigationItem` has an accurate `description` of consistent length with its peers. - Editable pages: confirm Save/Discard go through `SaveDiscardActions` and diff --git a/.cursor/rules/sim-settings-pages.mdc b/.cursor/rules/sim-settings-pages.mdc index 29db1e041e9..7cabb5e583f 100644 --- a/.cursor/rules/sim-settings-pages.mdc +++ b/.cursor/rules/sim-settings-pages.mdc @@ -248,11 +248,12 @@ and — on activatable rows only — the hover band. Never hand-roll any of it, `RESOURCE_TILE_FILL` for a glyph, `RESOURCE_TILE_PLAIN` for a brand logo or favicon. -**Member avatars are deliberately two components, not one.** `member-list.tsx` -renders a 14px neutral marker for the dense Teammates/Organization roster, where -the email is the primary content; `components/permissions/member-row.tsx` renders -a 36px `getUserColor`-hashed avatar for member *management* rows that carry a name, -an email, and a role control. Same shape, different job — do not merge them. +**One member avatar.** Every member list, owner cell, and ranking renders emcn +`` — a 14px photo, or the initial +on the neutral disc (`aria-hidden` because the name is always beside it). Never +hand-roll an avatar or give a person a `getUserColor` hash; per-person colors +belong to live collaboration (presence, cursors), where the color matches that +person's cursor. ## Header action order diff --git a/apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx b/apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx index 7d35b87cef2..eb882c155fe 100644 --- a/apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx +++ b/apps/sim/app/(landing)/changelog/components/changelog-timeline/changelog-timeline.tsx @@ -108,7 +108,7 @@ export function ChangelogTimeline({ initialEntries }: ChangelogTimelineProps) { title={`@${contributor}`} className='block size-6 shrink-0' > - + )} - + {initials} @@ -98,7 +95,7 @@ export function PresenceAvatars({ style={{ zIndex: 0 }} aria-label={`${overflowCount} more ${overflowCount === 1 ? 'user' : 'users'}`} > - + +{overflowCount} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts index 22f3365aa43..fa102e05d3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts @@ -1,2 +1 @@ -export type { OwnerAvatarProps } from './owner-cell' -export { OwnerAvatar, ownerCell } from './owner-cell' +export { ownerCell } from './owner-cell' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx index ebbf3bb9e9b..f3be05b4175 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx @@ -1,36 +1,7 @@ -import { memo } from 'react' +import { Avatar } from '@sim/emcn' import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource' import type { WorkspaceMember } from '@/hooks/queries/workspace' -export interface OwnerAvatarProps { - name: string - image: string | null -} - -/** - * The canonical 14px workspace-member avatar — a photo, or the member's initial on a neutral - * disc. Shared so a member reads identically in a resource row's owner cell and in the - * owner/uploaded-by filter options on every list. - */ -export const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { - if (image) { - return ( - {name} - ) - } - - return ( - - {name.charAt(0).toUpperCase()} - - ) -}) - /** * Resolves a user ID into a ResourceCell with an avatar icon and display name. * Returns null label while members are still loading to avoid flashing raw IDs. @@ -51,7 +22,7 @@ export function ownerCell( if (!member) return { label: null } return { - icon: , + icon: , label: member.name, } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 02cf2f87344..95bf5999af8 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -562,7 +562,7 @@ const Pagination = memo(function Pagination({ }) interface CellContentProps { - /** Pre-rendered icon node (svg/img/span avatar); auto-sized to the chip icon size. */ + /** Pre-rendered icon node (a glyph or a person's `Avatar`); auto-sized to the chip icon size. */ icon?: ReactNode label: string content?: ReactNode diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index a0e2198af8f..9a05428538a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { + Avatar, Button, ChipCombobox, ChipConfirmModal, @@ -79,10 +80,7 @@ import { useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' -import { - OwnerAvatar, - ownerCell, -} from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell' +import { ownerCell } from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell' import { FilesEmptyState, ResourceNoResults, @@ -1938,7 +1936,7 @@ function FilesContent() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: , + iconElement: , })), [members] ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index e88c076a636..051acdec1f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChipDropdownOption } from '@sim/emcn' -import { Button, ChipConfirmModal, ChipDropdown, Tooltip, toast } from '@sim/emcn' +import { Avatar, Button, ChipConfirmModal, ChipDropdown, Tooltip, toast } from '@sim/emcn' import { Database, FolderPlus, Pencil, Plus, Trash } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -39,10 +39,7 @@ import { } from '@/app/workspace/[workspaceId]/components/folders' import { reportBulkOutcome } from '@/app/workspace/[workspaceId]/components/resource/bulk-outcome' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' -import { - OwnerAvatar, - ownerCell, -} from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell' +import { ownerCell } from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell' import { KnowledgeEmptyState, ResourceNoResults, @@ -1331,7 +1328,7 @@ function KnowledgeContent() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: , + iconElement: , })), [members] ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts index 21f94ee9db7..715751e423a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/index.ts @@ -1 +1 @@ -export { MemberAvatar, MemberRow, MemberSection } from './member-list' +export { MemberRow, MemberSection } from './member-list' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx index 72ada4c1ef1..2ea78d2cc95 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/member-list/member-list.tsx @@ -1,41 +1,13 @@ 'use client' import type { ReactNode } from 'react' -import { OverflowText } from '@sim/emcn' +import { Avatar, OverflowText } from '@sim/emcn' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' const ROW_CLASSES = 'flex items-center gap-2.5 p-2' const ROW_STATUS_CLASSES = 'shrink-0 text-[var(--text-muted)] text-caption' -interface MemberAvatarProps { - name: string - image: string | null -} - -/** - * 14px circular avatar used in member rows. Falls back to the first letter of - * the member's name when no image is available. - */ -export function MemberAvatar({ name, image }: MemberAvatarProps) { - if (image) { - return ( - {name} - ) - } - - return ( - - {name.charAt(0).toUpperCase()} - - ) -} - interface MemberRowProps { name: string email: string @@ -56,7 +28,7 @@ interface MemberRowProps { export function MemberRow({ name, email, image, status, roleControl, menu }: MemberRowProps) { return (
- + {status} {roleControl} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx index d57734dbf9e..83f6ed4c0e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/transfer-ownership-dialog/transfer-ownership-dialog.tsx @@ -3,8 +3,6 @@ import { useMemo, useState } from 'react' import { Avatar, - AvatarFallback, - AvatarImage, Badge, Banner, ChipConfirmModal, @@ -15,7 +13,6 @@ import { Skeleton, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' -import { getUserColor } from '@/lib/workspaces/colors' import type { RosterMember } from '@/hooks/queries/organization' interface TransferOwnershipDialogProps { @@ -167,21 +164,13 @@ export function TransferOwnershipDialog({ aria-pressed={isSelected} onClick={() => setSelectedUserId(m.userId)} className={cn( - 'flex w-full items-center gap-3 px-3 py-2 text-left transition-colors', + 'flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors', isSelected ? 'bg-[var(--surface-active)]' : 'hover-hover:bg-[var(--surface-hover)]' )} > - - {m.image && } - - {m.name.charAt(0).toUpperCase()} - - +
({ value: m.userId, label: m.name, - iconElement: , + iconElement: , })), [members] ) diff --git a/apps/sim/components/permissions/member-row.tsx b/apps/sim/components/permissions/member-row.tsx index 75e3f324074..07cb8fa251a 100644 --- a/apps/sim/components/permissions/member-row.tsx +++ b/apps/sim/components/permissions/member-row.tsx @@ -1,7 +1,6 @@ 'use client' -import { Avatar, AvatarFallback, Chip, ChipDropdown, cn, OverflowText } from '@sim/emcn' -import { getUserColor } from '@/lib/workspaces/colors' +import { Avatar, Chip, ChipDropdown, cn, OverflowText } from '@sim/emcn' import type { MemberRole } from './member-role-options' import { RoleLockTooltip } from './role-lock' @@ -9,6 +8,7 @@ export interface MemberRowMember { userId: string userName: string | null userEmail: string | null + userImage: string | null role: TRole } @@ -54,14 +54,12 @@ export function MemberRow({ )} >
- - - {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()} - - +
- +
} + icon={} iconVariant='custom' title={enrollment.email} description={ diff --git a/apps/sim/ee/credential-groups/components/organization-account-people.tsx b/apps/sim/ee/credential-groups/components/organization-account-people.tsx index b641c578950..a141d09ef6d 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-people.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-people.tsx @@ -1,7 +1,7 @@ 'use client' import { type ReactNode, useState } from 'react' -import { Avatar, AvatarFallback, Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn' +import { Avatar, Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' import type { SettingsAction, SettingsBackAction } from '@/components/settings/settings-header' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -111,13 +111,7 @@ export function OrganizationAccountPeople({ {enrollments.map((person) => ( - - {person.email.charAt(0).toUpperCase()} - -
- } + icon={} iconVariant='custom' title={person.email} description={} diff --git a/apps/sim/ee/organization-usage/components/activity-table.tsx b/apps/sim/ee/organization-usage/components/activity-table.tsx index e4acf0dcbb1..2614f9f9a95 100644 --- a/apps/sim/ee/organization-usage/components/activity-table.tsx +++ b/apps/sim/ee/organization-usage/components/activity-table.tsx @@ -1,6 +1,7 @@ 'use client' import { + Avatar, Chip, OverflowText, Table, @@ -13,7 +14,6 @@ import { import { formatDuration } from '@sim/utils/formatting' import type { OrganizationActivityBreakdown } from '@/lib/api/contracts/organization-activity' import type { ActivityDimension } from '@/lib/billing/core/organization-activity' -import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { formatFailureRate } from '@/ee/organization-usage/components/activity-summary' interface ActivityTableProps { @@ -79,7 +79,7 @@ export function ActivityTable({ rows, dimension, onSelectWorkspace }: ActivityTa ) : (
- {isMember && } + {isMember && }
)} diff --git a/apps/sim/ee/organization-usage/components/usage-consumers.tsx b/apps/sim/ee/organization-usage/components/usage-consumers.tsx index 3f6b7ba8670..5a278f134a2 100644 --- a/apps/sim/ee/organization-usage/components/usage-consumers.tsx +++ b/apps/sim/ee/organization-usage/components/usage-consumers.tsx @@ -1,7 +1,7 @@ 'use client' import type { ComponentType } from 'react' -import { cn, disclosureChevronClass, formatChartCompactNumber } from '@sim/emcn' +import { Avatar, cn, disclosureChevronClass, formatChartCompactNumber } from '@sim/emcn' import { ArrowRight, ChevronDown } from '@sim/emcn/icons' import { AnthropicIcon, @@ -34,7 +34,6 @@ import type { OrganizationUsageBreakdownRow, UsageBreakdownDimension, } from '@/lib/api/contracts/organization-usage' -import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { type RowAction, RowActionsMenu, @@ -153,7 +152,7 @@ function UsageConsumerRow({ )} > {isMember ? ( - + ) : ( ProviderIcon && )} diff --git a/apps/sim/lib/api/contracts/skills.ts b/apps/sim/lib/api/contracts/skills.ts index 1a609d95eb3..cb4cc40e35b 100644 --- a/apps/sim/lib/api/contracts/skills.ts +++ b/apps/sim/lib/api/contracts/skills.ts @@ -30,7 +30,7 @@ export const skillEditorSchema = z.object({ userId: z.string(), userName: z.string().nullable(), userEmail: z.string().nullable(), - userImage: z.string().nullable().optional(), + userImage: z.string().nullable(), isWorkspaceAdmin: z.boolean(), }) diff --git a/apps/sim/lib/credentials/members.test.ts b/apps/sim/lib/credentials/members.test.ts index 074b49de7f2..4371c42b324 100644 --- a/apps/sim/lib/credentials/members.test.ts +++ b/apps/sim/lib/credentials/members.test.ts @@ -2,8 +2,19 @@ * @vitest-environment node */ import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' -import { beforeEach, describe, expect, it } from 'vitest' -import { listCredentialMembershipsForUser } from '@/lib/credentials/members' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetUsersWithPermissions } = vi.hoisted(() => ({ + mockGetUsersWithPermissions: vi.fn(), +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: vi.fn(), + getUsersWithPermissions: mockGetUsersWithPermissions, +})) + +import { listCredentialMembers, listCredentialMembershipsForUser } from '@/lib/credentials/members' +import type { CredentialRow } from '@/lib/credentials/queries' describe('listCredentialMembershipsForUser', () => { beforeEach(() => { @@ -21,3 +32,44 @@ describe('listCredentialMembershipsForUser', () => { ]) }) }) + +describe('listCredentialMembers', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('carries each member’s profile image, explicit and derived alike', async () => { + dbChainMockFns.where.mockResolvedValueOnce([ + { + id: 'cm-1', + userId: 'user-1', + role: 'member', + status: 'active', + joinedAt: null, + userName: 'Ada', + userEmail: 'ada@example.com', + userImage: 'ada.png', + }, + ]) + mockGetUsersWithPermissions.mockResolvedValueOnce([ + { + userId: 'user-2', + name: 'Sam', + email: 'sam@example.com', + image: 'sam.png', + permissionType: 'admin', + }, + ]) + + const members = await listCredentialMembers({ + id: 'cred-1', + workspaceId: 'ws-1', + type: 'oauth', + } as CredentialRow) + + expect(members.map((member) => [member.userId, member.userImage])).toEqual([ + ['user-1', 'ada.png'], + ['user-2', 'sam.png'], + ]) + }) +}) diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts index c004c2dc46c..abe6ad0fa9a 100644 --- a/apps/sim/lib/credentials/members.ts +++ b/apps/sim/lib/credentials/members.ts @@ -18,6 +18,7 @@ export interface CredentialMemberView { joinedAt: Date | null userName: string | null userEmail: string | null + userImage: string | null roleSource: 'explicit' | 'workspace-admin' } @@ -34,6 +35,7 @@ export async function listCredentialMembers( joinedAt: credentialMember.joinedAt, userName: user.name, userEmail: user.email, + userImage: user.image, }) .from(credentialMember) .innerJoin(user, eq(credentialMember.userId, user.id)) @@ -61,6 +63,7 @@ export async function listCredentialMembers( joinedAt: null, userName: workspaceMember.name, userEmail: workspaceMember.email, + userImage: workspaceMember.image ?? null, roleSource: 'workspace-admin', }) } diff --git a/packages/emcn/src/components/avatar/avatar.test.tsx b/packages/emcn/src/components/avatar/avatar.test.tsx new file mode 100644 index 00000000000..46073a00743 --- /dev/null +++ b/packages/emcn/src/components/avatar/avatar.test.tsx @@ -0,0 +1,127 @@ +/** @vitest-environment jsdom */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Avatar, AvatarFallback } from './avatar' + +/** + * Stands in for the browser's image loader, which Radix drives through + * `new window.Image()`: a `src` containing `broken` fails, anything else loads. + */ +class FakeImage extends EventTarget { + complete = false + naturalWidth = 0 + referrerPolicy = '' + crossOrigin: string | null = null + private source = '' + + get src() { + return this.source + } + + set src(value: string) { + this.source = value + queueMicrotask(() => { + const loads = !value.includes('broken') + this.complete = true + this.naturalWidth = loads ? 1 : 0 + this.dispatchEvent(new Event(loads ? 'load' : 'error')) + }) + } +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +async function mount(children: ReactNode) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => { + root?.render(children) + }) + await act(async () => {}) + return container +} + +beforeEach(() => { + vi.stubGlobal('Image', FakeImage) +}) + +afterEach(() => { + act(() => root?.unmount()) + container?.remove() + root = null + container = null + vi.unstubAllGlobals() +}) + +describe('Avatar', () => { + it('shows the photo once it loads', async () => { + const view = await mount() + expect(view.querySelector('img')?.getAttribute('src')).toBe('https://example.com/ada.png') + expect(view.textContent).toBe('') + }) + + it('falls back to the initial when the photo fails to load', async () => { + const view = await mount() + expect(view.querySelector('img')).toBeNull() + expect(view.textContent).toBe('A') + }) + + it('labels a person by their name', async () => { + const view = await mount() + expect(view.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe('Ada Lovelace') + }) + + it('announces a status as part of the person, not as a second image', async () => { + const view = await mount() + const images = view.querySelectorAll('[role="img"]') + expect(images).toHaveLength(1) + expect(images[0]?.getAttribute('aria-label')).toBe('Ada, online') + expect(view.querySelector('[data-slot="avatar-status"]')?.getAttribute('aria-hidden')).toBe( + 'true' + ) + }) + + it('announces the status dot itself on a composed avatar', async () => { + const view = await mount( + + AL + + ) + const dot = view.querySelector('[data-slot="avatar-status"]') + expect(dot?.getAttribute('role')).toBe('img') + expect(dot?.getAttribute('aria-label')).toBe('Status: away') + expect(dot?.hasAttribute('aria-hidden')).toBe(false) + }) + + it('never labels an image with an empty name', async () => { + const view = await mount() + expect(view.querySelector('[role="img"]')).toBeNull() + expect(view.textContent).toBe('?') + }) + + it('drops the label when the name is already visible beside it', async () => { + const view = await mount() + expect(view.querySelector('[aria-hidden="true"]')).not.toBeNull() + expect(view.querySelector('[aria-label]')).toBeNull() + expect(view.querySelector('[role="img"]')).toBeNull() + }) + + it('sizes the fallback glyph to the avatar, so a 14px disc needs no override', async () => { + const xs = await mount() + expect(xs.innerHTML).toContain('text-[8px]') + act(() => root?.unmount()) + container?.remove() + + const md = await mount( + + AL + + ) + expect(md.innerHTML).toContain('text-xs') + expect(md.innerHTML).not.toContain('text-[8px]') + }) +}) diff --git a/packages/emcn/src/components/avatar/avatar.tsx b/packages/emcn/src/components/avatar/avatar.tsx index 85ebaa522c8..d3040921d74 100644 --- a/packages/emcn/src/components/avatar/avatar.tsx +++ b/packages/emcn/src/components/avatar/avatar.tsx @@ -12,10 +12,10 @@ import { cn } from '../../lib/cn' const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full', { variants: { size: { - xs: 'h-3.5 w-3.5', - sm: 'h-6 w-6', - md: 'h-8 w-8', - lg: 'h-10 w-10', + xs: 'size-3.5', + sm: 'size-6', + md: 'size-8', + lg: 'size-10', }, }, defaultVariants: { @@ -37,10 +37,10 @@ const avatarStatusVariants = cva( away: 'bg-[var(--caution)]', }, size: { - xs: 'h-1.5 w-1.5 border', - sm: 'h-2 w-2', - md: 'h-2.5 w-2.5', - lg: 'h-3 w-3', + xs: 'size-1.5 border', + sm: 'size-2', + md: 'size-2.5', + lg: 'size-3', }, }, defaultVariants: { @@ -50,66 +50,124 @@ const avatarStatusVariants = cva( } ) +/** + * Variant styles for the fallback, keyed by the enclosing avatar's size. `xs` is a + * 14px disc, where `text-xs` would overflow the border, so its glyph steps down and + * drops the inherited line height that would push it off-center. + */ +const avatarFallbackVariants = cva( + 'flex size-full items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-4)] font-medium text-[var(--text-secondary)]', + { + variants: { + size: { + xs: 'text-[8px] leading-none', + sm: 'text-xs', + md: 'text-xs', + lg: 'text-xs', + }, + }, + defaultVariants: { + size: 'md', + }, + } +) + +type AvatarSize = VariantProps['size'] + +/** Lets `AvatarFallback` size its glyph to the avatar it sits in. */ +const AvatarSizeContext = React.createContext(undefined) + type AvatarStatus = 'online' | 'offline' | 'busy' | 'away' -interface AvatarProps - extends React.ComponentPropsWithoutRef, +interface AvatarBaseProps + extends Omit, 'children'>, VariantProps { /** Shows a status indicator badge on the avatar */ status?: AvatarStatus } +/** + * Either a person — `name`, and optionally `src` — or composed `children`. The two + * never mix: a composed avatar owns its own image and label. + */ +type AvatarProps = AvatarBaseProps & + ( + | { + /** + * A person's name, used as the accessible label (with any `status`) — pass + * `aria-hidden` where the name is already visible beside it. The avatar renders + * `src` with the name's initial as its fallback. + */ + name: string + /** Photo for the person; the initial shows while it loads, or if it fails. */ + src?: string | null + children?: never + } + | { name?: never; src?: never; children?: React.ReactNode } + ) + /** * Avatar component for displaying user profile images with fallback support. * * @example * ```tsx - * import { Avatar, AvatarImage, AvatarFallback } from '../../index' + * import { Avatar, AvatarImage, AvatarFallback } from '@sim/emcn' * - * // Basic usage - * - * - * JD - * + * // A person: their photo, falling back to their initial + * * - * // With size variant + * // Composed, for a custom fallback * * * JD * * * // With status indicator - * - * - * JD - * - * - * // All status types - * // Green - * // Gray - * // Red - * // Yellow/Amber + * * ``` */ const Avatar = React.forwardRef, AvatarProps>( - ({ className, size, status, children, ...props }, ref) => ( -
- - {children} - - {status && ( - - )} -
- ) + ({ className, size, status, name, src, children, ...props }, ref) => { + const hidden = props['aria-hidden'] === true || props['aria-hidden'] === 'true' + const label = name?.trim() + const labelled = Boolean(label) && !hidden + return ( + +
+ + {name === undefined ? ( + children + ) : ( + <> + {src && } + {label?.charAt(0).toUpperCase() || '?'} + + )} + + {/** + * A named avatar carries its status in its own label, and a hidden one + * carries nothing; only a composed avatar announces the dot itself. + */} + {status && ( +
+
+ ) + } ) Avatar.displayName = 'Avatar' @@ -123,7 +181,7 @@ const AvatarImage = React.forwardRef< , React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) +>(({ className, ...props }, ref) => { + const size = React.useContext(AvatarSizeContext) + return ( + + ) +}) AvatarFallback.displayName = 'AvatarFallback' -export { Avatar, AvatarImage, AvatarFallback, avatarVariants, avatarStatusVariants } +export { + Avatar, + AvatarImage, + AvatarFallback, + avatarVariants, + avatarStatusVariants, + avatarFallbackVariants, +}