diff --git a/pages/src/localdev.ts b/pages/src/localdev.ts index 3a421a586..ff5513621 100644 --- a/pages/src/localdev.ts +++ b/pages/src/localdev.ts @@ -34,6 +34,7 @@ import { ESLTooltip, ESLAnimate, ESLAnimateMixin, + ESLAvatar, ESLRelatedTarget } from '@exadel/esl/modules/all'; @@ -116,6 +117,8 @@ ESLTooltip.register(); ESLAnimate.register(); ESLAnimateMixin.register(); +ESLAvatar.register(); + // Register ESL Mixins ESLRelatedTarget.register(); diff --git a/pages/static/assets/avatar/animation.webp b/pages/static/assets/avatar/animation.webp new file mode 100644 index 000000000..d629a582f Binary files /dev/null and b/pages/static/assets/avatar/animation.webp differ diff --git a/pages/static/assets/avatar/transparent.png b/pages/static/assets/avatar/transparent.png new file mode 100644 index 000000000..5804d05cd Binary files /dev/null and b/pages/static/assets/avatar/transparent.png differ diff --git a/pages/static/assets/avatar/transparent.webp b/pages/static/assets/avatar/transparent.webp new file mode 100644 index 000000000..2c3deb897 Binary files /dev/null and b/pages/static/assets/avatar/transparent.webp differ diff --git a/pages/static/assets/avatar/user.jpg b/pages/static/assets/avatar/user.jpg new file mode 100644 index 000000000..0453353fa Binary files /dev/null and b/pages/static/assets/avatar/user.jpg differ diff --git a/pages/static/assets/avatar/user.png b/pages/static/assets/avatar/user.png new file mode 100644 index 000000000..0ca6333db Binary files /dev/null and b/pages/static/assets/avatar/user.png differ diff --git a/pages/static/assets/avatar/user.svg b/pages/static/assets/avatar/user.svg new file mode 100644 index 000000000..83170a389 --- /dev/null +++ b/pages/static/assets/avatar/user.svg @@ -0,0 +1 @@ + diff --git a/pages/static/assets/avatar/user.webp b/pages/static/assets/avatar/user.webp new file mode 100644 index 000000000..e3315ddb9 Binary files /dev/null and b/pages/static/assets/avatar/user.webp differ diff --git a/pages/static/assets/examples/avatar.svg b/pages/static/assets/examples/avatar.svg new file mode 100644 index 000000000..67344d102 --- /dev/null +++ b/pages/static/assets/examples/avatar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/pages/views/components/esl-avatar.njk b/pages/views/components/esl-avatar.njk new file mode 100644 index 000000000..02e7b206b --- /dev/null +++ b/pages/views/components/esl-avatar.njk @@ -0,0 +1,13 @@ +--- +layout: content +title: ESL Avatar +seoTitle: ESL Avatar is a custom element used to represent a user's profile picture +name: ESL Avatar +tags: [components, beta] +aside: + source: src/modules/esl-avatar + examples: + - avatar +--- + +{% mdRender 'src/modules/esl-avatar/README.md', 'intro' %} diff --git a/pages/views/examples/avatar.njk b/pages/views/examples/avatar.njk new file mode 100644 index 000000000..7c9fc8e79 --- /dev/null +++ b/pages/views/examples/avatar.njk @@ -0,0 +1,243 @@ +--- +layout: content +title: Avatar +seoTitle: Avatar component examples based on ESL web components +name: Avatar +tags: [examples, beta] +icon: examples/avatar.svg +aside: + components: + - esl-avatar +--- + +{% macro avatarElement(class, src, username, loading) %} + +{% endmacro %} + +{% macro avatarDemo(src, username, loading) %} +
+ {{ avatarElement('avatar-demo-1', src, username, loading) }} + {{ avatarElement('avatar-demo-2', src, username, loading) }} + {{ avatarElement('avatar-demo-3', src, username, loading) }} + {{ avatarElement('avatar-demo-4', src, username, loading) }} +
+{% endmacro %} + + + +
+
+
+
+
+

Avatar without image, text abbreviation only

+ {% code 'html' %} + + + {% endcode %} +

ESL Avatar displays the user's initials. (The first letter from the first word of the username and the first letter from the second word if it exists.).

+
+
+
+ {{ avatarDemo('', 'ESL Developer', '') }} +
+
+
+
+
+
+

The component with JPEG image

+ {% code 'html' %} + + + {% endcode %} +

ESL Avatar displays an avatar image.

+
+
+
+ {{ avatarDemo('/assets/avatar/user.jpg', 'Lead Developer', '')}} +
+
+
+
+
+
+

Avatar with PNG image

+ {% code 'html' %} + + + {% endcode %} +

The component displays an avatar image.

+
+
+
+ {{ avatarDemo('/assets/avatar/user.png', 'Senior Developer', '')}} +
+
+
+
+
+
+

With transparent PNG image (3:2 ratio)

+ {% code 'html' %} + + + {% endcode %} +

It should be noted that the image is not square, but rectangular, the ratio is 3 to 2. As you can see, the component shows the image with the original ratio preserved in such a way that the image covers the entire plane of the component.

+
+
+
+ {{ avatarDemo('/assets/avatar/transparent.png', 'ESL Bot', '')}} +
+
+
+
+
+
+

With SVG image (and eager loading policy)

+ {% code 'html' %} + + + {% endcode %} +

The component displays an avatar image and the image will be loaded as soon as possible.

+
+
+
+ {{ avatarDemo('/assets/avatar/user.svg', 'Anonymous Developer', 'eager')}} +
+
+
+
+
+
+

With WEBP image (and lazy loading policy)

+ {% code 'html' %} + + + {% endcode %} +

It will show the image, but the loading of the image by the browser will be delayed until it appears in the browser's viewport. The behavior is identical to the loading="lazy" mode for image tags. But lazy loading mode is enabled by default.

+
+
+
+ {{ avatarDemo('/assets/avatar/user.webp', 'Junior Developer', '')}} +
+
+
+
+
+
+

With transparent WEBP image (3:2 ratio)

+ {% code 'html' %} + + + {% endcode %} +

It should be noted that the image is not square, but rectangular, the ratio is 3 to 2. As you can see, the component shows the image with the original ratio preserved in such a way that the image covers the entire plane of the component.

+
+
+
+ {{ avatarDemo('/assets/avatar/transparent.webp', 'Intern Developer', '')}} +
+
+
+
+
+
+

With animated WEBP image

+ {% code 'html' %} + + + {% endcode %} +

WOW!!! Live avatar!

+
+
+
+ {{ avatarDemo('/assets/avatar/animation.webp', 'Bored Developer', '')}} +
+
+
+
+
+
+

With image, but the image did not load

+ {% code 'html' %} + + + {% endcode %} +

The component tries to load the image and switches to text mode displaying when it receives the error.

+
+
+
+ {{ avatarDemo('/assets/non-existent-image.png', 'Non-existetnt Developer', '')}} +
+
+
+
diff --git a/src/modules/all.less b/src/modules/all.less index f6afe98c3..0ad69b306 100644 --- a/src/modules/all.less +++ b/src/modules/all.less @@ -25,3 +25,5 @@ @import './esl-animate/core.less'; @import './esl-share/core.less'; + +@import './esl-avatar/core.less'; diff --git a/src/modules/all.ts b/src/modules/all.ts index a6dde9383..7040265dc 100644 --- a/src/modules/all.ts +++ b/src/modules/all.ts @@ -40,6 +40,9 @@ export * from './esl-tooltip/core'; // Animate export * from './esl-animate/core'; +// Avatar +export * from './esl-avatar/core'; + // Related Target Mixin export * from './esl-related-target/core'; diff --git a/src/modules/esl-avatar/README.md b/src/modules/esl-avatar/README.md new file mode 100644 index 000000000..f02921d4e --- /dev/null +++ b/src/modules/esl-avatar/README.md @@ -0,0 +1,32 @@ +# [ESL](../../../) Avatar + +Version: *1.0.0-beta*. + +Authors: *Dmytro Shovchko*. + +***Important Notice: the component is under beta version, it is tested and ready to use but be aware of its potential critical API changes.*** + + + +**ESLAvatar** is a versatile UI element representing a user with profile pictures or initials. + +The component works as follows. If the consumer has specified the `src` attribute with the profile picture URL, the component will try to display the image in the inner content. + +If the URL of the picture is not specified or an error occurs when loading the image, the component will switch to text mode. In text mode, it displays the initials from the username or in simple words, just the first letters of each word in the username. The length of this username abbreviation is limited by the `abbr-length` parameter and defaults to 2. + +### Attributes: + +- `abbr-length` - the limit number of letters to be displayed in text-only mode +- `loading` - policy of loading image that is outside of the viewport +- `src` - URL of the avatar picture +- `username` - the name of the user for whom the avatar is displayed. + +### API + +- `abbr` - getter that returns an abbreviation to display in text-only mode and for alt property of image +- `init` - initializes inner content of the component. + +### Events + + - `esl:avatar:changed` - event to dispatch on change of ESLAvatar + \ No newline at end of file diff --git a/src/modules/esl-avatar/core.less b/src/modules/esl-avatar/core.less new file mode 100644 index 000000000..3fb26f7b3 --- /dev/null +++ b/src/modules/esl-avatar/core.less @@ -0,0 +1 @@ +@import './core/esl-avatar.less'; diff --git a/src/modules/esl-avatar/core.ts b/src/modules/esl-avatar/core.ts new file mode 100644 index 000000000..34e3e5ee3 --- /dev/null +++ b/src/modules/esl-avatar/core.ts @@ -0,0 +1 @@ +export * from './core/esl-avatar'; diff --git a/src/modules/esl-avatar/core/esl-avatar.less b/src/modules/esl-avatar/core/esl-avatar.less new file mode 100644 index 000000000..a8c401b72 --- /dev/null +++ b/src/modules/esl-avatar/core/esl-avatar.less @@ -0,0 +1,29 @@ +esl-avatar { + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.3s ease-in-out; + opacity: 0; + border-radius: 50%; + width: var(--esl-avatar-size, 32px); + min-width: var(--esl-avatar-size, 32px); + height: var(--esl-avatar-size, 32px); + min-height: var(--esl-avatar-size, 32px); + overflow: hidden; + text-transform: uppercase; + line-height: 1em; + + &[with-image] { + background: transparent; + } + + &[ready] { + opacity: 1; + } + + .esl-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + } +} diff --git a/src/modules/esl-avatar/core/esl-avatar.shape.ts b/src/modules/esl-avatar/core/esl-avatar.shape.ts new file mode 100644 index 000000000..9162954f0 --- /dev/null +++ b/src/modules/esl-avatar/core/esl-avatar.shape.ts @@ -0,0 +1,29 @@ +import type {ESLBaseElementShape} from '../../esl-base-element/core/esl-base-element.shape'; +import type {ESLAvatar} from './esl-avatar'; + +/** + * Tag declaration interface of ESL Avatar element + * Used for TSX declaration + */ +export interface ESLAvatarTagShape extends ESLBaseElementShape { + /** URL of the avatar image */ + src?: string; + /** The name of the user for whom the avatar is displayed */ + username?: string; + /** Policy of loading image that is outside of the viewport */ + loading?: 'eager' | 'lazy'; + /** The limit number of letters to be displayed in text-only mode */ + 'abbr-length'?: number; + + /** Children are not allowed for ESLAvatar */ + children?: never[]; +} + +declare global { + namespace JSX { + export interface IntrinsicElements { + /** {@link ESLAvatar} custom tag */ + 'esl-avatar': ESLAvatarTagShape; + } + } +} diff --git a/src/modules/esl-avatar/core/esl-avatar.ts b/src/modules/esl-avatar/core/esl-avatar.ts new file mode 100644 index 000000000..8c1db2a6b --- /dev/null +++ b/src/modules/esl-avatar/core/esl-avatar.ts @@ -0,0 +1,104 @@ +import {ExportNs} from '../../esl-utils/environment/export-ns'; +import {ESLBaseElement} from '../../esl-base-element/core'; +import {attr, bind, boolAttr, prop} from '../../esl-utils/decorators'; + +@ExportNs('Avatar') +export class ESLAvatar extends ESLBaseElement { + public static override is = 'esl-avatar'; + public static observedAttributes = ['src', 'username']; + + /** Event to dispatch on change of {@link ESLAvatar} */ + @prop('esl:avatar:changed') public AVATAR_CHANGED_EVENT: string; + + /** Source path of the avatar image */ + @attr() public src: string; + /** The limit number of letters to be displayed in text-only mode */ + @attr({defaultValue: 2}) public abbrLength: number; + /** Policy of loading image that is outside of the viewport */ + @attr({defaultValue: 'lazy'}) public loading: 'eager' | 'lazy'; + /** The name of the user for whom the avatar is displayed */ + @attr({defaultValue: ''}) public username: string; + + /** @readonly Marker of displaying mode with image */ + @boolAttr({readonly: true}) public withImage: boolean; + /** @readonly Ready state marker */ + @boolAttr({readonly: true}) public ready: boolean; + + /** Gets an abbreviation to display in text-only mode and for alt property of image */ + public get abbr(): string { + return this.username.trim() + .split(' ') + .filter(Boolean) + .reduce((acc, el, index) => (index >= this.abbrLength) ? acc : acc + el.slice(0, 1), ''); + } + + protected override connectedCallback(): void { + super.connectedCallback(); + this.init(); + } + + protected override attributeChangedCallback(attrName: string, oldVal: string, newVal: string): void { + if (!this.connected || oldVal === newVal) return; + this.init(); + } + + /** Initializes the avatar */ + public init(): void { + this.buildImageContent(); + this.initA11y(); + this.$$attr('ready', true); + } + + /** Sets initial a11y attributes */ + protected initA11y(): void { + if (this.$$attr('title') === null) this.title = this.username; + } + + /** Builds the image on avatar */ + protected buildImageContent(): void { + const {abbr, loading, src} = this; + if (!src) return this._onImageError(); + + const $img = new Image(); + $img.loading = loading || 'lazy'; + $img.alt = abbr; + $img.src = src; + $img.onerror = this._onImageError; + this.appendContent($img, true); + } + + /** Builds the text on avatar */ + protected buildTextContent(): void { + const $text = document.createElement('abbr'); + $text.textContent = this.abbr; + this.appendContent($text, false); + } + + /** Appends content to the component */ + protected appendContent($content: HTMLElement, isImage: boolean): void { + this.innerHTML = ''; + $content.classList.add(`${ESLAvatar.is}-${isImage ? 'img' : 'text'}`); + this.appendChild($content); + this.$$attr('with-image', isImage); + this.onChange(); + } + + /** Actions on image loading error */ + @bind + protected _onImageError(): void { + this.buildTextContent(); + } + + /** Actions on complete init and ready component */ + private onChange(): void { + this.$$fire(this.AVATAR_CHANGED_EVENT, {bubbles: false}); + } +} +declare global { + export interface ESLLibrary { + Avatar: typeof ESLAvatar; + } + export interface HTMLElementTagNameMap { + 'esl-avatar': ESLAvatar; + } +} diff --git a/src/modules/esl-avatar/test/esl-avatar.test.ts b/src/modules/esl-avatar/test/esl-avatar.test.ts new file mode 100644 index 000000000..df880abe3 --- /dev/null +++ b/src/modules/esl-avatar/test/esl-avatar.test.ts @@ -0,0 +1,129 @@ +import {ESLAvatar} from '../core/esl-avatar'; + +describe('ESLAvatar tests', () => { + beforeAll(() => { + ESLAvatar.register(); + }); + + describe('Static class methods', () => { + test('has create() method that returns new instanse', () => { + const $avatar = ESLAvatar.create(); + expect($avatar).toBeInstanceOf(ESLAvatar); + }); + }); + + describe('instance with image content', () => { + let $avatar: ESLAvatar; + + beforeAll(() => { + $avatar = ESLAvatar.create(); + $avatar.setAttribute('src', '/test/image/url'); + $avatar.setAttribute('username', 'Test user'); + document.body.appendChild($avatar); + }); + + afterAll(() => { + document.body.innerHTML = ''; + }); + + test('has with-image marker', () => { + expect($avatar.$$attr('with-image')).not.toBeNull(); + }); + + test('has image inside', () => { + const $img = $avatar.querySelector('img'); + expect($img).not.toBeNull(); + }); + + test('has image with esl-avatar-img class', () => { + const $img = $avatar.querySelector('img'); + expect($img?.className).toBe('esl-avatar-img'); + }); + + test('has image with proper alt attribute', () => { + const $img = $avatar.querySelector('img'); + expect($img?.alt).toBe($avatar['abbr']); + }); + + test('has image with loading attribute', () => { + const $img = $avatar.querySelector('img'); + expect($img?.loading).toBe('lazy'); + }); + + test('has image with src attribute', () => { + const $img = $avatar.querySelector('img'); + expect($img?.src).toBe('http://localhost/test/image/url'); + }); + + test('has image with error handler', () => { + const $img = $avatar.querySelector('img'); + expect($img?.onerror).toBe($avatar['_onImageError']); + }); + }); + + describe('instance with text only content', () => { + let $avatar: ESLAvatar; + + beforeAll(() => { + $avatar = ESLAvatar.create(); + $avatar.setAttribute('username', 'Test user'); + document.body.appendChild($avatar); + }); + + afterAll(() => { + document.body.innerHTML = ''; + }); + + test('has no with-image marker', () => { + expect($avatar.$$attr('with-image')).toBeNull(); + }); + + test('has abbr inside', () => { + const $abbr = $avatar.querySelector('abbr'); + expect($abbr).not.toBeNull(); + }); + + test('has abbr with esl-avatar-text class', () => { + const $abbr = $avatar.querySelector('abbr'); + expect($abbr?.className).toBe('esl-avatar-text'); + }); + + test('has abbr with proper text content', () => { + const $abbr = $avatar.querySelector('abbr'); + expect($abbr?.textContent).toBe($avatar['abbr']); + }); + }); + + describe('abbreviation text generation', () => { + let $avatar: ESLAvatar; + + beforeAll(() => { + $avatar = ESLAvatar.create(); + $avatar.setAttribute('username', 'Bartholomew jojo Bart Simpson'); + document.body.appendChild($avatar); + }); + + afterAll(() => { + document.body.innerHTML = ''; + }); + + test('should return an abbr with word length not over the default limit', () => { + expect($avatar['abbr']).toBe('Bj'); + }); + + test('should return an abbr with word length not over the limit', () => { + $avatar.abbrLength = 1; + expect($avatar['abbr']).toBe('B'); + }); + + test('should return an abbr for each word of the username when the limit is over the username word count', () => { + $avatar.abbrLength = 5; + expect($avatar['abbr']).toBe('BjBS'); + }); + + test('should return an empty string when the username is empty', () => { + $avatar.username = ''; + expect($avatar['abbr']).toBe(''); + }); + }); +});