UNPKG

@vaadin/avatar-group

Version:
512 lines (438 loc) 15.1 kB
/** * @license * Copyright (c) 2020 - 2026 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { html, render } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; import { announce } from '@vaadin/a11y-base/src/announce.js'; import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js'; import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js'; import { SlotController } from '@vaadin/component-base/src/slot-controller.js'; const MINIMUM_DISPLAYED_AVATARS = 2; const DEFAULT_I18N = { anonymous: 'anonymous', activeUsers: { one: 'Currently one active user', many: 'Currently {count} active users', }, joined: '{user} joined', left: '{user} left', }; /** * A mixin providing common avatar group functionality. */ export const AvatarGroupMixin = (superClass) => class AvatarGroupMixinClass extends I18nMixin(ResizeMixin(superClass)) { static get properties() { return { /** * An array containing the items which will be stamped as avatars. * * The items objects allow to configure [`name`](#/elements/vaadin-avatar#property-name), * [`abbr`](#/elements/vaadin-avatar#property-abbr), [`img`](#/elements/vaadin-avatar#property-img) * and [`colorIndex`](#/elements/vaadin-avatar#property-colorIndex) properties on the * stamped avatars, and set `className` to provide CSS class names. * * #### Example * * ```js * group.items = [ * { * name: 'User name', * img: 'url-to-image.png', * className: 'even' * }, * { * abbr: 'JD', * colorIndex: 1, * className: 'odd' * }, * ]; * ``` * * @type {!Array<!AvatarGroupItem> | undefined} */ items: { type: Array, sync: true, }, /** * The maximum number of avatars to display. By default, all the avatars are displayed. * When _maxItemsVisible_ is set, the overflowing avatars are grouped into one avatar with * a dropdown. Setting 0 or 1 has no effect so there are always at least two avatars visible. * @attr {number} max-items-visible */ maxItemsVisible: { type: Number, sync: true, }, /** @private */ __itemsInView: { type: Number, value: null, sync: true, }, /** @private */ _overflowItems: { type: Array, }, /** @private */ _overflowLimit: { type: Number, }, /** @private */ _opened: { type: Boolean, sync: true, }, }; } static get defaultI18n() { return DEFAULT_I18N; } /** * The object used to localize this component. To change the default * localization, replace this with an object that provides all properties, or * just the individual properties you want to change. * * The object has the following JSON structure and default values: * ```js * { * // Translation of the anonymous user avatar tooltip. * anonymous: 'anonymous', * // Translation of the avatar group accessible label. * // {count} is replaced with the actual count of users. * activeUsers: { * one: 'Currently one active user', * many: 'Currently {count} active users' * }, * // Screen reader announcement when user joins group. * // {user} is replaced with the name or abbreviation. * // When neither is set, "anonymous" is used instead. * joined: '{user} joined', * // Screen reader announcement when user leaves group. * // {user} is replaced with the name or abbreviation. * // When neither is set, "anonymous" is used instead. * left: '{user} left' * } * ``` * @type {!AvatarGroupI18n} */ get i18n() { return super.i18n; } set i18n(value) { super.i18n = value; } /** @protected */ get _avatars() { return [...this.children].filter((node) => node.localName === 'vaadin-avatar'); } /** @protected */ ready() { super.ready(); this._menuController = new SlotController(this, 'overlay', 'vaadin-avatar-group-menu', { initializer: (menu) => { menu.addEventListener('keydown', this._onListKeyDown.bind(this)); this._menuElement = menu; }, }); this._overflowController = new SlotController(this, 'overflow', 'vaadin-avatar', { initializer: (overflow) => { overflow.setAttribute('role', 'button'); overflow.setAttribute('tabindex', '0'); overflow.setAttribute('aria-haspopup', 'menu'); overflow.setAttribute('aria-expanded', 'false'); overflow.addEventListener('click', (e) => this._onOverflowClick(e)); overflow.addEventListener('keydown', (e) => this._onOverflowKeyDown(e)); const tooltip = document.createElement('vaadin-tooltip'); tooltip.setAttribute('slot', 'tooltip'); overflow.appendChild(tooltip); this._overflow = overflow; this._overflowTooltip = tooltip; }, }); this.addController(this._menuController); this.addController(this._overflowController); this._overlayElement = this.$.overlay; } /** @protected */ disconnectedCallback() { super.disconnectedCallback(); this._opened = false; } /** @protected */ willUpdate(props) { super.willUpdate(props); if (props.has('items') || props.has('__itemsInView') || props.has('maxItemsVisible')) { // Calculate overflow limit only once to reuse it in updated() observers const count = Array.isArray(this.items) ? this.items.length : 0; const limit = this.__getLimit(count, this.__itemsInView, this.maxItemsVisible); this._overflowLimit = limit; this._overflowItems = limit ? this.items.slice(limit) : []; } } /** @protected */ updated(props) { super.updated(props); if (props.has('items')) { this.__itemsChanged(this.items, props.get('items')); } if (props.has('items') || props.has('_overflowLimit') || props.has('__effectiveI18n') || props.has('_theme')) { const limit = this._overflowLimit; this.__renderAvatars(limit ? this.items.slice(0, limit) : this.items || []); } if (props.has('items') || props.has('_overflowLimit')) { this.__updateOverflowTooltip(this.items, this._overflowLimit); this.__updateOverflowAvatar(this.items, this._overflowLimit, this.__itemsInView); } if (props.has('__effectiveI18n') || props.has('items')) { this.__i18nItemsChanged(this.__effectiveI18n, this.items); } if (props.has('_opened')) { this.__openedChanged(this._opened, props.get('_opened')); } if (props.has('_theme')) { if (this._theme) { this._overflow.setAttribute('theme', this._theme); } else { this._overflow.removeAttribute('theme'); } } if (props.has('_overflowItems') || props.has('__effectiveI18n') || props.has('_theme')) { this.__renderMenu(); } } /** @private */ __getMessage(user, action) { return action.replace('{user}', user.name || user.abbr || this.__effectiveI18n.anonymous); } /** * Renders items when they are provided by the `items` property and clears the content otherwise. * @private */ __renderMenu() { render( html` ${(this._overflowItems || []).map( (item) => html` <vaadin-avatar-group-menu-item> <vaadin-avatar .name="${item.name}" .abbr="${item.abbr}" .img="${item.img}" .colorIndex="${item.colorIndex}" .i18n="${this.__effectiveI18n}" class="${ifDefined(item.className)}" theme="${ifDefined(this._theme)}" aria-hidden="true" ></vaadin-avatar> ${item.name || ''} </vaadin-avatar-group-menu-item> `, )} `, this._menuElement, { host: this }, ); } /** @private */ _onOverflowClick(e) { e.stopPropagation(); if (this._opened) { this.$.overlay.close(); } else if (!e.defaultPrevented) { this._opened = true; } } /** @private */ _onOverflowKeyDown(e) { if (!this._opened) { if (/^(Enter|SpaceBar|\s)$/u.test(e.key)) { e.preventDefault(); this._opened = true; } } } /** @private */ _onListKeyDown(event) { if (event.key === 'Escape' || event.key === 'Tab') { this._opened = false; } } /** * @protected * @override */ _onResize() { this.__setItemsInView(); } /** @private */ _onVaadinOverlayClose(e) { if (e.detail.sourceEvent && e.detail.sourceEvent.composedPath().includes(this)) { e.preventDefault(); } } /** @private */ _onVaadinOverlayOpen() { if (this._menuElement) { this._menuElement.focus(); } } /** @private */ __renderAvatars(items) { render( html` ${items.map( (item) => html` <vaadin-avatar .name="${item.name}" .abbr="${item.abbr}" .img="${item.img}" .colorIndex="${item.colorIndex}" .i18n="${this.__effectiveI18n}" theme="${ifDefined(this._theme)}" class="${ifDefined(item.className)}" tabindex="0" with-tooltip ></vaadin-avatar> `, )} `, this, { renderBefore: this._overflow }, ); } /** @private */ __updateOverflowAvatar(items, limit, itemsInView) { const overflow = this._overflow; if (overflow) { const count = Array.isArray(items) ? items.length : 0; const maxReached = this.maxItemsVisible != null && count > this.__getMax(this.maxItemsVisible); overflow.abbr = `+${count - limit}`; const hasOverflow = maxReached || (itemsInView && itemsInView < count); overflow.toggleAttribute('hidden', !hasOverflow); this.toggleAttribute('has-overflow', hasOverflow); } } /** @private */ __updateOverflowTooltip(items, limit) { if (!Array.isArray(items)) { return; } if (limit == null) { return; } const result = []; for (let i = limit; i < items.length; i++) { const item = items[i]; if (item) { result.push(item.name || item.abbr || 'anonymous'); } } this._overflowTooltip.text = result.join('\n'); } /** @private */ __getLimit(items, itemsInView, maxItemsVisible) { let limit = null; // Handle max set to 0 or 1 const adjustedMax = this.__getMax(maxItemsVisible); if (maxItemsVisible != null && adjustedMax < items) { limit = adjustedMax - 1; } else if (itemsInView && itemsInView < items) { limit = itemsInView; } return Math.min(limit, this.__calculateAvatarsFitWidth()); } /** @private */ __getMax(maxItemsVisible) { return Math.max(maxItemsVisible, MINIMUM_DISPLAYED_AVATARS); } /** @private */ __itemsChanged(items, oldItems) { this.__setItemsInView(); let added = []; let removed = []; const hasNewItems = Array.isArray(items); const hasOldItems = Array.isArray(oldItems); if (hasOldItems) { removed = oldItems.filter((item) => hasNewItems && !items.includes(item)); } if (hasNewItems) { added = items.filter((item) => hasOldItems && !oldItems.includes(item)); } this.__announceItemsChange(added, removed); } /** @private */ __announceItemsChange(added, removed) { let addedMsg = []; let removedMsg = []; if (added) { addedMsg = added.map((user) => this.__getMessage(user, this.__effectiveI18n.joined || '{user} joined')); } if (removed) { removedMsg = removed.map((user) => this.__getMessage(user, this.__effectiveI18n.left || '{user} left')); } const messages = removedMsg.concat(addedMsg); if (messages.length > 0) { announce(messages.join(', ')); } } /** @private */ __i18nItemsChanged(effectiveI18n, items) { if (effectiveI18n?.activeUsers) { const count = Array.isArray(items) ? items.length : 0; const field = count === 1 ? 'one' : 'many'; if (effectiveI18n.activeUsers[field]) { this.setAttribute('aria-label', effectiveI18n.activeUsers[field].replace('{count}', count || 0)); } } } /** @private */ __openedChanged(opened, oldOpened) { if (opened) { this._openedWithFocusRing = this._overflow.hasAttribute('focus-ring'); } else if (oldOpened) { this._overflow.focus({ focusVisible: this._openedWithFocusRing }); } this._overflow.setAttribute('aria-expanded', opened === true); } /** @private */ __setItemsInView() { const avatars = this._avatars; const items = this.items; // Always show at least two avatars if (!items || !avatars || avatars.length < 3) { return; } let result = this.__calculateAvatarsFitWidth(); // Only show overlay if two or more avatars don't fit if (result === items.length - 1) { result = items.length; } // Close overlay if all avatars become visible if (result >= items.length && this._opened) { this.$.overlay.close(); // FIXME: hack to avoid jump before closing this.$.overlay._flushAnimation('closing'); } // Reserve space for overflow avatar this.__itemsInView = result; } /** @private */ __calculateAvatarsFitWidth() { if (!this.shadowRoot || this._avatars.length < MINIMUM_DISPLAYED_AVATARS) { return MINIMUM_DISPLAYED_AVATARS; } const avatars = this._avatars; // Assume all the avatars have the same width const avatarWidth = avatars[0].clientWidth; // Take negative margin into account const { marginLeft, marginRight } = getComputedStyle(avatars[1]); const offset = this.__isRTL ? parseInt(marginRight, 0) - parseInt(marginLeft, 0) : parseInt(marginLeft, 0) - parseInt(marginRight, 0); return Math.floor((this.$.container.offsetWidth - avatarWidth) / (avatarWidth + offset)); } };