From ce761061dc71c1a78811866e75befbe29c260885 Mon Sep 17 00:00:00 2001 From: Steven Spriggs Date: Fri, 8 May 2026 14:07:32 -0400 Subject: [PATCH 1/5] feat(avatar): add pf-v6-avatar element Port pf-v5-avatar to pf-v6-avatar with the following v6 API changes: - Replace `border` string attribute (light/dark) with boolean `bordered` attribute, matching React v6 `isBordered` prop - Drop `dark` boolean attribute (removed in v6 React) - Update all CSS custom property tokens from `--pf-v5-c-avatar--*` to `--pf-v6-c-avatar--*` matching the v6 SCSS source - Use v6 global tokens (`--pf-t--global--border--*`) for bordered variant - Simplify placeholder SVG to use `light-dark()` for automatic light/dark mode, replacing the separate dark boolean - Use private CSS custom properties (`--_*`) with public token fallback defaults at use sites - Remove default value from `alt` property to avoid sprouting attributes - Add `aria-hidden="true"` to placeholder SVG - Add `@cssprop` JSDoc for all public CSS custom properties - Export `AvatarSize` type union Demos match patternfly.org: basic, bordered, size-variations. Tests cover sizes (offsetWidth), load event, alt text (a11ySnapshot), bordered, and placeholder rendering. Closes #2979 Assisted-By: Claude Opus 4.6 --- elements/package.json | 1 + elements/pf-v6-avatar/demo/basic.html | 18 +++ elements/pf-v6-avatar/demo/bordered.html | 19 +++ .../pf-v6-avatar/demo/size-variations.html | 63 +++++++++ elements/pf-v6-avatar/pf-v6-avatar.css | 54 ++++++++ elements/pf-v6-avatar/pf-v6-avatar.ts | 80 +++++++++++ .../pf-v6-avatar/test/pf-v6-avatar.spec.ts | 129 ++++++++++++++++++ 7 files changed, 364 insertions(+) create mode 100644 elements/pf-v6-avatar/demo/basic.html create mode 100644 elements/pf-v6-avatar/demo/bordered.html create mode 100644 elements/pf-v6-avatar/demo/size-variations.html create mode 100644 elements/pf-v6-avatar/pf-v6-avatar.css create mode 100644 elements/pf-v6-avatar/pf-v6-avatar.ts create mode 100644 elements/pf-v6-avatar/test/pf-v6-avatar.spec.ts diff --git a/elements/package.json b/elements/package.json index fe8dda2080..dc67ca9ec7 100644 --- a/elements/package.json +++ b/elements/package.json @@ -15,6 +15,7 @@ "./pf-v5-accordion/pf-v5-accordion.js": "./pf-v5-accordion/pf-v5-accordion.js", "./pf-v5-alert/pf-v5-alert.js": "./pf-v5-alert/pf-v5-alert.js", "./pf-v5-avatar/pf-v5-avatar.js": "./pf-v5-avatar/pf-v5-avatar.js", + "./pf-v6-avatar/pf-v6-avatar.js": "./pf-v6-avatar/pf-v6-avatar.js", "./pf-v5-back-to-top/pf-v5-back-to-top.js": "./pf-v5-back-to-top/pf-v5-back-to-top.js", "./pf-v5-background-image/pf-v5-background-image.js": "./pf-v5-background-image/pf-v5-background-image.js", "./pf-v5-badge/pf-v5-badge.js": "./pf-v5-badge/pf-v5-badge.js", diff --git a/elements/pf-v6-avatar/demo/basic.html b/elements/pf-v6-avatar/demo/basic.html new file mode 100644 index 0000000000..0cbc5325b8 --- /dev/null +++ b/elements/pf-v6-avatar/demo/basic.html @@ -0,0 +1,18 @@ +--- +name: Basic +description: A basic avatar displays a user image or a placeholder graphic when no image is available. +--- +
+ +
+ + + + diff --git a/elements/pf-v6-avatar/demo/bordered.html b/elements/pf-v6-avatar/demo/bordered.html new file mode 100644 index 0000000000..de60d25be3 --- /dev/null +++ b/elements/pf-v6-avatar/demo/bordered.html @@ -0,0 +1,19 @@ +--- +name: Bordered +description: A bordered avatar has a thin border around the image. +--- +
+ +
+ + + + diff --git a/elements/pf-v6-avatar/demo/size-variations.html b/elements/pf-v6-avatar/demo/size-variations.html new file mode 100644 index 0000000000..8983fd4faf --- /dev/null +++ b/elements/pf-v6-avatar/demo/size-variations.html @@ -0,0 +1,63 @@ +--- +name: Size variations +description: Avatars can be displayed in four sizes. +--- +
+
+
Small
+
+ +
+
+ +
+
Medium
+
+ +
+
+ +
+
Large
+
+ +
+
+ +
+
Extra Large
+
+ +
+
+
+ + + + diff --git a/elements/pf-v6-avatar/pf-v6-avatar.css b/elements/pf-v6-avatar/pf-v6-avatar.css new file mode 100644 index 0000000000..ffbe4b2bfc --- /dev/null +++ b/elements/pf-v6-avatar/pf-v6-avatar.css @@ -0,0 +1,54 @@ +:host { + --_width: var(--pf-v6-c-avatar--Width, 2.25rem); + --_height: var(--pf-v6-c-avatar--Height, 2.25rem); + --_border-radius: var(--pf-v6-c-avatar--BorderRadius, var(--pf-t--global--border--radius--pill, 30em)); + --_border-color: var(--pf-v6-c-avatar--BorderColor, transparent); + --_border-width: var(--pf-v6-c-avatar--BorderWidth, 0); + --_placeholder-bg: light-dark(#f0f0f0, #212427); + --_placeholder-fg: light-dark(#d2d2d2, #6a6e73); + + display: inline-block; + width: var(--_width); + height: var(--_height); + border-radius: var(--_border-radius); +} + +:host([hidden]), +[hidden] { + display: none !important; +} + +:host([bordered]) { + --_border-color: var(--pf-v6-c-avatar--m-bordered--BorderColor, var(--pf-t--global--border--color--default, #d2d2d2)); + --_border-width: var(--pf-v6-c-avatar--m-bordered--BorderWidth, var(--pf-t--global--border--width--box--default, 1px)); +} + +:host([size="sm"]) { + --_width: var(--pf-v6-c-avatar--m-sm--Width, 1.5rem); + --_height: var(--pf-v6-c-avatar--m-sm--Height, 1.5rem); +} + +:host([size="md"]) { + --_width: var(--pf-v6-c-avatar--m-md--Width, 2.25rem); + --_height: var(--pf-v6-c-avatar--m-md--Height, 2.25rem); +} + +:host([size="lg"]) { + --_width: var(--pf-v6-c-avatar--m-lg--Width, 4.5rem); + --_height: var(--pf-v6-c-avatar--m-lg--Height, 4.5rem); +} + +:host([size="xl"]) { + --_width: var(--pf-v6-c-avatar--m-xl--Width, 8rem); + --_height: var(--pf-v6-c-avatar--m-xl--Height, 8rem); +} + +svg, +img { + display: inline; + object-fit: cover; + width: var(--_width); + height: var(--_height); + border-radius: var(--_border-radius); + border: var(--_border-width) solid var(--_border-color); +} diff --git a/elements/pf-v6-avatar/pf-v6-avatar.ts b/elements/pf-v6-avatar/pf-v6-avatar.ts new file mode 100644 index 0000000000..b97876c46b --- /dev/null +++ b/elements/pf-v6-avatar/pf-v6-avatar.ts @@ -0,0 +1,80 @@ +import { LitElement, html, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import { customElement } from 'lit/decorators/custom-element.js'; + +import style from './pf-v6-avatar.css'; + +/** Size variants for the avatar. */ +export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'; + +export class PfV6AvatarLoadEvent extends Event { + constructor(public originalEvent: Event) { + super('load', { bubbles: true }); + } +} + +/** + * An **avatar** is a visual used to represent a user. It may contain an image + * or a placeholder graphic. + * @summary Displays a user's avatar image + * @fires {PfV6AvatarLoadEvent} load - when the avatar image loads + * @cssprop {} --pf-v6-c-avatar--Width - Width of the avatar + * @cssprop {} --pf-v6-c-avatar--Height - Height of the avatar + * @cssprop {} --pf-v6-c-avatar--BorderRadius - Border radius of the avatar + * @cssprop {} --pf-v6-c-avatar--BorderColor - Border color of the avatar + * @cssprop {} --pf-v6-c-avatar--BorderWidth - Border width of the avatar + * @cssprop {} --pf-v6-c-avatar--m-sm--Width - Width when size is `sm` + * @cssprop {} --pf-v6-c-avatar--m-sm--Height - Height when size is `sm` + * @cssprop {} --pf-v6-c-avatar--m-md--Width - Width when size is `md` + * @cssprop {} --pf-v6-c-avatar--m-md--Height - Height when size is `md` + * @cssprop {} --pf-v6-c-avatar--m-lg--Width - Width when size is `lg` + * @cssprop {} --pf-v6-c-avatar--m-lg--Height - Height when size is `lg` + * @cssprop {} --pf-v6-c-avatar--m-xl--Width - Width when size is `xl` + * @cssprop {} --pf-v6-c-avatar--m-xl--Height - Height when size is `xl` + * @cssprop {} --pf-v6-c-avatar--m-bordered--BorderColor - Border color when bordered + * @cssprop {} --pf-v6-c-avatar--m-bordered--BorderWidth - Border width when bordered + */ +@customElement('pf-v6-avatar') +export class PfV6Avatar extends LitElement { + static readonly styles: CSSStyleSheet[] = [style]; + + /** The URL to the user's custom avatar image. */ + @property() src?: string; + + /** The alt text for the avatar image. */ + @property({ reflect: true }) alt?: string; + + /** Size of the avatar */ + @property({ reflect: true }) size?: AvatarSize; + + /** Whether to display a border around the avatar */ + @property({ type: Boolean, reflect: true }) bordered = false; + + override render(): TemplateResult<1> { + return this.src != null ? html` + ${this.alt ?? ''} + ` : html` + + `; + } + + #onLoad(event: Event) { + this.dispatchEvent(new PfV6AvatarLoadEvent(event)); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-v6-avatar': PfV6Avatar; + } +} diff --git a/elements/pf-v6-avatar/test/pf-v6-avatar.spec.ts b/elements/pf-v6-avatar/test/pf-v6-avatar.spec.ts new file mode 100644 index 0000000000..cd9a5a34ac --- /dev/null +++ b/elements/pf-v6-avatar/test/pf-v6-avatar.spec.ts @@ -0,0 +1,129 @@ +import { html, expect, oneEvent, nextFrame } from '@open-wc/testing'; +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { a11ySnapshot } from '@patternfly/pfe-tools/test/a11y-snapshot.js'; +import { PfV6Avatar, PfV6AvatarLoadEvent } from '@patternfly/elements/pf-v6-avatar/pf-v6-avatar.js'; + +describe('', function() { + it('imperatively instantiates', function() { + expect(document.createElement('pf-v6-avatar')).to.be.an.instanceof(PfV6Avatar); + }); + + it('should upgrade', async function() { + const el = await createFixture(html``); + expect(el, 'pf-v6-avatar should be an instance of PfV6Avatar') + .to.be.an.instanceOf(customElements.get('pf-v6-avatar')) + .and + .to.be.an.instanceOf(PfV6Avatar); + }); + + describe('without src attr', function() { + let element: PfV6Avatar; + beforeEach(async function() { + element = await createFixture(html``); + await nextFrame(); + }); + + it('displays a placeholder', function() { + const { offsetWidth } = element; + expect(offsetWidth).to.be.greaterThan(0); + }); + + it('has the default size', function() { + expect(element.offsetWidth).to.equal(36); + expect(element.offsetHeight).to.equal(36); + }); + }); + + describe('with a src attr', function() { + let element: PfV6Avatar; + let loaded: string | undefined; + const datauri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAB0UlEQVR4Xu3UAQ0AAAyDsM+/6QspcwAh2zXawGj64K8A8AgKoABwAzh+D1AAuAEcvwcoANwAjt8DFABuAMfvAQoAN4Dj9wAFgBvA8XuAAsAN4Pg9QAHgBnD8HqAAcAM4fg9QALgBHL8HKADcAI7fAxQAbgDH7wEKADeA4/cABYAbwPF7gALADeD4PUAB4AZw/B6gAHADOH4PUAC4ARy/BygA3ACO3wMUAG4Ax+8BCgA3gOP3AAWAG8Dxe4ACwA3g+D1AAeAGcPweoABwAzh+D1AAuAEcvwcoANwAjt8DFABuAMfvAQoAN4Dj9wAFgBvA8XuAAsAN4Pg9QAHgBnD8HqAAcAM4fg9QALgBHL8HKADcAI7fAxQAbgDH7wEKADeA4/cABYAbwPF7gALADeD4PUAB4AZw/B6gAHADOH4PUAC4ARy/BygA3ACO3wMUAG4Ax+8BCgA3gOP3AAWAG8Dxe4ACwA3g+D1AAeAGcPweoABwAzh+D1AAuAEcvwcoANwAjt8DFABuAMfvAQoAN4Dj9wAFgBvA8XuAAsAN4Pg9QAHgBnD8HqAAcAM4fg9QALgBHL8HKADcAI7fAxQAbgDH7wEKADeA4/cABYAbwPF7ADyAB6SPAIFm19U7AAAAAElFTkSuQmCC'; + const onLoad = (e: PfV6AvatarLoadEvent) => { + const paths = e.originalEvent.composedPath() as HTMLImageElement[]; + loaded = paths.find(x => x.localName === 'img')?.src; + }; + beforeEach(async function() { + element = await createFixture(html``); + setTimeout(() => element.src = datauri); + await oneEvent(element, 'load'); + }); + + it('loads the image', function() { + expect(loaded).to.equal(datauri); + }); + + it('fires a PfV6AvatarLoadEvent', function() { + expect(loaded).to.be.ok; + }); + }); + + describe('with alt attr', function() { + let element: PfV6Avatar; + beforeEach(async function() { + element = await createFixture(html` + + `); + await oneEvent(element, 'load'); + }); + + it('passes alt text to the image', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot?.children?.find( + (child: { name: string }) => child.name === 'User avatar' + )).to.be.ok; + }); + }); + + describe('with size="sm"', function() { + let element: PfV6Avatar; + beforeEach(async function() { + element = await createFixture(html``); + await nextFrame(); + }); + + it('renders at the small size', function() { + expect(element.offsetWidth).to.equal(24); + expect(element.offsetHeight).to.equal(24); + }); + }); + + describe('with size="lg"', function() { + let element: PfV6Avatar; + beforeEach(async function() { + element = await createFixture(html``); + await nextFrame(); + }); + + it('renders at the large size', function() { + expect(element.offsetWidth).to.equal(72); + expect(element.offsetHeight).to.equal(72); + }); + }); + + describe('with size="xl"', function() { + let element: PfV6Avatar; + beforeEach(async function() { + element = await createFixture(html``); + await nextFrame(); + }); + + it('renders at the extra large size', function() { + expect(element.offsetWidth).to.equal(128); + expect(element.offsetHeight).to.equal(128); + }); + }); + + describe('with bordered', function() { + let element: PfV6Avatar; + beforeEach(async function() { + element = await createFixture(html``); + await nextFrame(); + }); + + it('renders with a visible border', function() { + // The element should still render with a size larger than 0 + expect(element.offsetWidth).to.be.greaterThan(0); + }); + }); +}); From 4f20b211c5cbc743d671a1c302c7453f5af4aaf2 Mon Sep 17 00:00:00 2001 From: Steven Spriggs Date: Fri, 8 May 2026 15:29:17 -0400 Subject: [PATCH 2/5] fix(avatar): apply review fixes - Use `static styles = [style]` array form convention - Simplify render return type to TemplateResult - Change img/svg display from inline to block (prevents baseline gap) - Add placeholder avatar to basic demo - Add a11ySnapshot test for placeholder hidden from ax tree - Add size=md test case - Strengthen bordered test assertion (check border adds to size) - Add cem generate output path to config Assisted-By: Claude Opus 4.6 --- .config/cem.yaml | 1 + elements/pf-v6-avatar/demo/basic.html | 1 + elements/pf-v6-avatar/pf-v6-avatar.css | 2 +- elements/pf-v6-avatar/pf-v6-avatar.ts | 4 ++-- .../pf-v6-avatar/test/pf-v6-avatar.spec.ts | 23 +++++++++++++++++-- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.config/cem.yaml b/.config/cem.yaml index febc90fcec..86f5f070a6 100644 --- a/.config/cem.yaml +++ b/.config/cem.yaml @@ -1,5 +1,6 @@ sourceControlRootUrl: https://github.com/patternfly/patternfly-elements/tree/main/ generate: + output: ./elements/custom-elements.json files: - ./elements/*/*.ts - ./core/*/*.ts diff --git a/elements/pf-v6-avatar/demo/basic.html b/elements/pf-v6-avatar/demo/basic.html index 0cbc5325b8..67e06153eb 100644 --- a/elements/pf-v6-avatar/demo/basic.html +++ b/elements/pf-v6-avatar/demo/basic.html @@ -5,6 +5,7 @@
+
- - diff --git a/elements/pf-v6-avatar/demo/bordered.html b/elements/pf-v6-avatar/demo/bordered.html index de60d25be3..f5ab4113cb 100644 --- a/elements/pf-v6-avatar/demo/bordered.html +++ b/elements/pf-v6-avatar/demo/bordered.html @@ -5,7 +5,7 @@
+ src="./avatarImg.svg">
diff --git a/elements/pf-v6-avatar/demo/size-variations.html b/elements/pf-v6-avatar/demo/size-variations.html index 8983fd4faf..33467cde0b 100644 --- a/elements/pf-v6-avatar/demo/size-variations.html +++ b/elements/pf-v6-avatar/demo/size-variations.html @@ -8,7 +8,7 @@
+ src="./avatarImg.svg">
@@ -17,7 +17,7 @@
+ src="./avatarImg.svg">
@@ -26,7 +26,7 @@
+ src="./avatarImg.svg">
@@ -35,7 +35,7 @@
+ src="./avatarImg.svg">
From 029d18d30c76f488561340e58c4b95a13e954f11 Mon Sep 17 00:00:00 2001 From: Steven Spriggs Date: Fri, 8 May 2026 17:02:34 -0400 Subject: [PATCH 4/5] fix(avatar): correct css vars --- elements/pf-v6-avatar/pf-v6-avatar.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/elements/pf-v6-avatar/pf-v6-avatar.css b/elements/pf-v6-avatar/pf-v6-avatar.css index 20e338606a..aeb19ce41b 100644 --- a/elements/pf-v6-avatar/pf-v6-avatar.css +++ b/elements/pf-v6-avatar/pf-v6-avatar.css @@ -4,8 +4,8 @@ --_border-radius: var(--pf-v6-c-avatar--BorderRadius, var(--pf-t--global--border--radius--pill, 30em)); --_border-color: var(--pf-v6-c-avatar--BorderColor, transparent); --_border-width: var(--pf-v6-c-avatar--BorderWidth, 0); - --_placeholder-bg: light-dark(#f0f0f0, #212427); - --_placeholder-fg: light-dark(#d2d2d2, #6a6e73); + --_placeholder-bg: var(--pf-t--global--background--color--200, light-dark(var(--pf-t--color--gray--10, #f2f2f2), var(--pf-t--color--gray--80, #292929))); + --_placeholder-fg: var(--pf-t--global--icon--color--subtle, light-dark(var(--pf-t--color--gray--50, #707070), var(--pf-t--color--gray--40, #a3a3a3))); display: inline-block; width: var(--_width); From 47ea912dc2ebd4ec6c2cb066079e9c66266ced8c Mon Sep 17 00:00:00 2001 From: Steven Spriggs Date: Fri, 8 May 2026 17:02:56 -0400 Subject: [PATCH 5/5] fix(avatar): readd typing --- elements/pf-v6-avatar/pf-v6-avatar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elements/pf-v6-avatar/pf-v6-avatar.ts b/elements/pf-v6-avatar/pf-v6-avatar.ts index 39096db4ea..a79e28f922 100644 --- a/elements/pf-v6-avatar/pf-v6-avatar.ts +++ b/elements/pf-v6-avatar/pf-v6-avatar.ts @@ -36,7 +36,7 @@ export class PfV6AvatarLoadEvent extends Event { */ @customElement('pf-v6-avatar') export class PfV6Avatar extends LitElement { - static styles = [style]; + static readonly styles: CSSStyleSheet[] = [style]; /** The URL to the user's custom avatar image. */ @property() src?: string;