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

+
+
JD
+
HV
+
FC
+
AC
+
+
+
+

Disabled & Loading

+
+
DB
+
LD
+
+
+
+

Full Width

+
+
FULL WIDTH AVATAR
+
+
+
+ + 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', () => {