@vaadin/avatar-group
Version:
vaadin-avatar-group
512 lines (438 loc) • 15.1 kB
JavaScript
/**
* @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));
}
};