From 95ba9ed9eb1e8c606b61e572b36b2821c281c938 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Mon, 25 May 2026 02:31:26 +0000
Subject: [PATCH] fix(spectre-ui): add standard states and horizontal parity to
Avatar component
Updated the Avatar component to include missing interaction states
(disabled, loading, interactive, hovered, focused, active) and the
fullWidth structural variant.
Changes:
- src/styles/components.css: Added CSS classes for standard states,
transitions, and focus ring support.
- src/recipes/avatar.ts: Updated recipe to include new boolean and
forced-state flags.
- tests/alert-avatar-recipe.test.ts: Added unit test coverage for new
Avatar states.
- tests/css-contract.test.ts: Included Avatar in the exhaustive CSS
parity contract.
Co-authored-by: bradpotts <4887598+bradpotts@users.noreply.github.com>
---
examples/avatar-verification.html | 61 +++++++++++++++++++++++++++++++
src/recipes/avatar.ts | 32 +++++++++++++++-
src/styles/components.css | 48 ++++++++++++++++++++++++
tests/alert-avatar-recipe.test.ts | 29 +++++++++++++++
tests/css-contract.test.ts | 27 ++++++++++++++
5 files changed, 195 insertions(+), 2 deletions(-)
create mode 100644 examples/avatar-verification.html
diff --git a/examples/avatar-verification.html b/examples/avatar-verification.html
new file mode 100644
index 0000000..e5a7d8a
--- /dev/null
+++ b/examples/avatar-verification.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+ Spectre UI — Avatar State Verification
+
+
+
+
+ Avatar State Verification
+
+
+
Interactive & Hover
+
+
+
+
+
+
+
diff --git a/src/recipes/avatar.ts b/src/recipes/avatar.ts
index 46da580..5121f67 100644
--- a/src/recipes/avatar.ts
+++ b/src/recipes/avatar.ts
@@ -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',
@@ -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'
+ )
}
diff --git a/src/styles/components.css b/src/styles/components.css
index ffdfe0d..f74c3fc 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -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 {
@@ -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;
+ }
}
diff --git a/tests/alert-avatar-recipe.test.ts b/tests/alert-avatar-recipe.test.ts
index 50138c5..f772ae1 100644
--- a/tests/alert-avatar-recipe.test.ts
+++ b/tests/alert-avatar-recipe.test.ts
@@ -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)
+ })
+ })
})
diff --git a/tests/css-contract.test.ts b/tests/css-contract.test.ts
index 28ff502..ebd49f6 100644
--- a/tests/css-contract.test.ts
+++ b/tests/css-contract.test.ts
@@ -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,
@@ -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 },
@@ -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 = [
@@ -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 = [
@@ -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', () => {