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/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/avatarImg.svg b/elements/pf-v6-avatar/demo/avatarImg.svg new file mode 100644 index 0000000000..73726f9bc5 --- /dev/null +++ b/elements/pf-v6-avatar/demo/avatarImg.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/elements/pf-v6-avatar/demo/basic.html b/elements/pf-v6-avatar/demo/basic.html new file mode 100644 index 0000000000..4ca2a156ec --- /dev/null +++ b/elements/pf-v6-avatar/demo/basic.html @@ -0,0 +1,10 @@ +--- +name: Basic +description: A basic avatar displays a user image. +--- + + + diff --git a/elements/pf-v6-avatar/demo/bordered.html b/elements/pf-v6-avatar/demo/bordered.html new file mode 100644 index 0000000000..f5ab4113cb --- /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/index.html b/elements/pf-v6-avatar/demo/index.html new file mode 100644 index 0000000000..d146cbb04f --- /dev/null +++ b/elements/pf-v6-avatar/demo/index.html @@ -0,0 +1,9 @@ +--- +name: Basic +description: A basic avatar displays a user 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..33467cde0b --- /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..aeb19ce41b --- /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: 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); + 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: block; + 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..a79e28f922 --- /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 { + 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..ecd29d6988 --- /dev/null +++ b/elements/pf-v6-avatar/test/pf-v6-avatar.spec.ts @@ -0,0 +1,148 @@ +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); + }); + + it('hides the placeholder from the accessibility tree', async function() { + const snapshot = await a11ySnapshot(); + expect(snapshot?.children?.find( + (child: { role: string }) => child.role === 'img' + )).to.not.be.ok; + }); + }); + + 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="md"', function() { + let element: PfV6Avatar; + beforeEach(async function() { + element = await createFixture(html``); + await nextFrame(); + }); + + it('renders at the medium size', function() { + expect(element.offsetWidth).to.equal(36); + expect(element.offsetHeight).to.equal(36); + }); + }); + + 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() { + expect(element.offsetWidth).to.be.greaterThan(36); + }); + }); +});