Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions examples/avatar-verification.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spectre UI — Avatar State Verification</title>
<link rel="stylesheet" href="../dist/index.css" />
<style>
body {
background-color: var(--sp-surface-page);
color: var(--sp-text-on-page-default);
padding: var(--sp-space-48);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--sp-space-32);
}
.section {
background: var(--sp-surface-card);
padding: var(--sp-space-24);
border: var(--sp-component-border-width) solid var(--sp-color-neutral-200);
border-radius: var(--sp-radius-lg);
}
.row {
display: flex;
gap: var(--sp-space-16);
align-items: center;
margin-top: var(--sp-space-16);
}
h2 { margin-bottom: var(--sp-space-8); }
</style>
</head>
<body>
<h1>Avatar State Verification</h1>
<div class="grid">
<div class="section">
<h2>Interactive & Hover</h2>
<div class="row">
<div class="sp-avatar sp-avatar--md sp-avatar--circle sp-avatar--interactive">JD</div>
<div class="sp-avatar sp-avatar--md sp-avatar--circle sp-avatar--interactive is-hover">HV</div>
<div class="sp-avatar sp-avatar--md sp-avatar--circle sp-avatar--interactive is-focus">FC</div>
<div class="sp-avatar sp-avatar--md sp-avatar--circle sp-avatar--interactive is-active">AC</div>
</div>
</div>
<div class="section">
<h2>Disabled & Loading</h2>
<div class="row">
<div class="sp-avatar sp-avatar--md sp-avatar--circle sp-avatar--disabled">DB</div>
<div class="sp-avatar sp-avatar--md sp-avatar--circle sp-avatar--loading">LD</div>
</div>
</div>
<div class="section">
<h2>Full Width</h2>
<div class="row">
<div class="sp-avatar sp-avatar--md sp-avatar--square sp-avatar--full">FULL WIDTH AVATAR</div>
</div>
</div>
</div>
</body>
</html>
32 changes: 30 additions & 2 deletions src/recipes/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,27 @@ export type AvatarShape = keyof typeof AVATAR_SHAPES
export interface AvatarRecipeOptions {
size?: AvatarSize
shape?: AvatarShape
disabled?: boolean
loading?: boolean
interactive?: boolean
hovered?: boolean
focused?: boolean
active?: boolean
fullWidth?: boolean
}

export function getAvatarClasses(opts: AvatarRecipeOptions = {}): string {
const { size: sizeInput, shape: shapeInput } = opts
const {
size: sizeInput,
shape: shapeInput,
disabled = false,
loading = false,
interactive = false,
hovered = false,
focused = false,
active = false,
fullWidth = false,
} = opts

const size = resolveOption({
name: 'avatar size',
Expand All @@ -38,5 +55,16 @@ export function getAvatarClasses(opts: AvatarRecipeOptions = {}): string {
fallback: 'circle',
})

return cx('sp-avatar', `sp-avatar--${size}`, `sp-avatar--${shape}`)
return cx(
'sp-avatar',
`sp-avatar--${size}`,
`sp-avatar--${shape}`,
disabled && 'sp-avatar--disabled',
loading && 'sp-avatar--loading',
interactive && 'sp-avatar--interactive',
hovered && 'sp-avatar--hover is-hover',
focused && 'sp-avatar--focus is-focus',
active && 'sp-avatar--active is-active',
fullWidth && 'sp-avatar--full'
)
}
48 changes: 48 additions & 0 deletions src/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -1564,8 +1564,15 @@
flex-shrink: 0;
background-color: var(--sp-component-avatar-bg);
color: var(--sp-component-avatar-text);
border: var(--sp-component-border-width) solid transparent;
font-family: var(--sp-component-avatar-font);
font-weight: var(--sp-component-avatar-weight);
transition:
background-color var(--sp-duration-fast) var(--sp-easing-out),
color var(--sp-duration-fast) var(--sp-easing-out),
box-shadow var(--sp-duration-fast) var(--sp-easing-out),
border-color var(--sp-duration-fast) var(--sp-easing-out),
opacity var(--sp-duration-fast) var(--sp-easing-out);
}

.sp-avatar--sm {
Expand Down Expand Up @@ -1603,4 +1610,45 @@
.sp-avatar--square {
border-radius: var(--sp-component-avatar-radius-square);
}

.sp-avatar--full {
width: 100%;
}

.sp-avatar--interactive {
cursor: pointer;
}

.sp-avatar--interactive:hover,
.sp-avatar--hover,
.sp-avatar.is-hover {
opacity: var(--sp-opacity-hover);
}

.sp-avatar--interactive:active,
.sp-avatar--active,
.sp-avatar.is-active {
opacity: var(--sp-opacity-active);
}

.sp-avatar--interactive:focus-visible,
.sp-avatar--focus,
.sp-avatar.is-focus {
outline: none;
box-shadow: 0 0 0 calc(var(--sp-focus-ring-width) + var(--sp-component-border-width)) var(--sp-color-focus-primary);
}

.sp-avatar:disabled,
.sp-avatar[aria-disabled="true"],
.sp-avatar--disabled {
opacity: var(--sp-opacity-disabled);
pointer-events: none;
cursor: not-allowed;
}

.sp-avatar--loading,
.sp-avatar[aria-busy="true"] {
opacity: var(--sp-opacity-active);
pointer-events: none;
}
}
29 changes: 29 additions & 0 deletions tests/alert-avatar-recipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,33 @@ describe('getAvatarClasses', () => {
const result = getAvatarClasses({ size: 'xl', shape: 'square' })
expectTokenizedClassString(result)
})

it('supports boolean state flags', () => {
const states = [
{ key: 'disabled' as const, className: 'sp-avatar--disabled' },
{ key: 'loading' as const, className: 'sp-avatar--loading' },
{ key: 'interactive' as const, className: 'sp-avatar--interactive' },
{ key: 'fullWidth' as const, className: 'sp-avatar--full' },
]

states.forEach(({ key, className }) => {
const result = getAvatarClasses({ [key]: true })
expect(result).toContain(className)
expectTokenizedClassString(result)
})
})

it('supports forced-state flags', () => {
const states = [
{ key: 'hovered' as const, className: 'sp-avatar--hover is-hover' },
{ key: 'focused' as const, className: 'sp-avatar--focus is-focus' },
{ key: 'active' as const, className: 'sp-avatar--active is-active' },
]

states.forEach(({ key, className }) => {
const result = getAvatarClasses({ [key]: true })
expect(result).toContain(className)
expectTokenizedClassString(result)
Comment on lines +127 to +130
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider splitting className for individual token assertions.

The test currently uses .toContain(className) where className is a multi-token string like 'sp-avatar--hover is-hover'. This works but assumes the classes appear adjacent and in the exact order. A more robust approach would split the expected classes and assert each token individually:

-    states.forEach(({ key, className }) => {
+    states.forEach(({ key, className }) => {
       const result = getAvatarClasses({ [key]: true })
-      expect(result).toContain(className)
+      className.split(/\s+/).forEach(cls => {
+        expect(result).toContain(cls)
+      })
       expectTokenizedClassString(result)
     })

This approach is resilient to class reordering and remains clear about which tokens are expected.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
states.forEach(({ key, className }) => {
const result = getAvatarClasses({ [key]: true })
expect(result).toContain(className)
expectTokenizedClassString(result)
states.forEach(({ key, className }) => {
const result = getAvatarClasses({ [key]: true })
className.split(/\s+/).forEach(cls => {
expect(result).toContain(cls)
})
expectTokenizedClassString(result)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/alert-avatar-recipe.test.ts` around lines 127 - 130, The test currently
asserts multi-token className with expect(result).toContain(className) which is
fragile; update the loop over states in the test to split className on
whitespace (className.split(/\s+/)) and assert each token individually against
the result (for each token call expect(result).toContain(token)), while still
calling expectTokenizedClassString(result); locate references to states,
className, getAvatarClasses and expectTokenizedClassString to make the change.

})
})
})
27 changes: 27 additions & 0 deletions tests/css-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
getAvatarClasses,
getBadgeClasses,
getButtonClasses,
getCardClasses,
Expand Down Expand Up @@ -188,6 +189,17 @@ const ratingSelectors = collectSelectors([
getRatingTextClasses(),
]);

const avatarSelectors = collectSelectors(
buildRecipeOutputs({
axes: {
size: ['sm', 'md', 'lg', 'xl'],
shape: ['circle', 'square'],
},
booleans: ['disabled', 'loading', 'interactive', 'hovered', 'focused', 'active', 'fullWidth'],
getClasses: getAvatarClasses,
}),
);

const recipeSelectorContracts = [
{ name: 'button', selectors: buttonSelectors },
{ name: 'card', selectors: cardSelectors },
Expand All @@ -197,6 +209,7 @@ const recipeSelectorContracts = [
{ name: 'testimonial', selectors: testimonialSelectors },
{ name: 'pricing card', selectors: pricingCardSelectors },
{ name: 'rating', selectors: ratingSelectors },
{ name: 'avatar', selectors: avatarSelectors },
] as const;

const interactionStateContracts = [
Expand Down Expand Up @@ -337,6 +350,16 @@ const interactionStateContracts = [
'.sp-rating--disabled',
],
},
{
name: 'avatar states',
selectors: [
'.sp-avatar--interactive:hover',
'.sp-avatar--interactive:focus-visible',
'.sp-avatar:disabled',
'.sp-avatar[aria-disabled="true"]',
'.sp-avatar--disabled',
],
},
] as const;

const sizeVariantContracts = [
Expand All @@ -360,6 +383,10 @@ const sizeVariantContracts = [
name: 'rating sizes',
selectors: ['.sp-rating--sm', '.sp-rating--md', '.sp-rating--lg'],
},
{
name: 'avatar sizes',
selectors: ['.sp-avatar--sm', '.sp-avatar--md', '.sp-avatar--lg', '.sp-avatar--xl'],
},
] as const;

describe('dist/components.css contract', () => {
Expand Down
Loading