All Downloads are FREE. Search and download functionalities are using the official Maven repository.

package.src.vaadin-avatar-group-mixin.js Maven / Gradle / Ivy

The newest version!
/**
 * @license
 * Copyright (c) 2020 - 2024 Vaadin Ltd.
 * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
 */
import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js';
import { html, render } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { announce } from '@vaadin/a11y-base/src/announce.js';
import { OverlayClassMixin } from '@vaadin/component-base/src/overlay-class-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;

/**
 * A mixin providing common avatar group functionality.
 *
 * @polymerMixin
 * @mixes ResizeMixin
 * @mixes OverlayClassMixin
 */
export const AvatarGroupMixin = (superClass) =>
  class AvatarGroupMixinClass extends ResizeMixin(OverlayClassMixin(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 | undefined}
         */
        items: {
          type: Array,
          observer: '__itemsChanged',
          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,
        },

        /**
         * The object used to localize this component.
         * To change the default localization, replace the entire
         * _i18n_ object or just the property you want to modify.
         *
         * The object has the following JSON structure and default values:
         * ```
         * {
         *   // 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}
         * @default {English/US}
         */
        i18n: {
          type: Object,
          sync: true,
          value: () => {
            return {
              anonymous: 'anonymous',
              activeUsers: {
                one: 'Currently one active user',
                many: 'Currently {count} active users',
              },
              joined: '{user} joined',
              left: '{user} left',
            };
          },
        },

        /** @private */
        _avatars: {
          type: Array,
          value: () => [],
          sync: true,
        },

        /** @private */
        __itemsInView: {
          type: Number,
          value: null,
          sync: true,
        },

        /** @private */
        _overflow: {
          type: Object,
          sync: true,
        },

        /** @private */
        _overflowItems: {
          type: Array,
          observer: '__overflowItemsChanged',
          computed: '__computeOverflowItems(items, __itemsInView, maxItemsVisible)',
          sync: true,
        },

        /** @private */
        _overflowTooltip: {
          type: Object,
          sync: true,
        },

        /** @private */
        _opened: {
          type: Boolean,
          sync: true,
        },
      };
    }

    static get observers() {
      return [
        '__i18nItemsChanged(i18n, items)',
        '__openedChanged(_opened, _overflow)',
        '__updateAvatarsTheme(_overflow, _avatars, _theme)',
        '__updateAvatars(items, __itemsInView, maxItemsVisible, _overflow, i18n)',
        '__updateOverflowAvatar(_overflow, items, __itemsInView, maxItemsVisible)',
        '__updateOverflowTooltip(_overflowTooltip, items, __itemsInView, maxItemsVisible)',
      ];
    }

    /** @protected */
    ready() {
      super.ready();

      this._overflowController = new SlotController(this, 'overflow', 'vaadin-avatar', {
        initializer: (overflow) => {
          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._overflowController);

      const overlay = this.$.overlay;
      overlay.renderer = this.__overlayRenderer.bind(this);
      this._overlayElement = overlay;

      afterNextRender(this, () => {
        this.__setItemsInView();
      });
    }

    /** @protected */
    disconnectedCallback() {
      super.disconnectedCallback();

      this._opened = false;
    }

    /** @private */
    __getMessage(user, action) {
      return action.replace('{user}', user.name || user.abbr || this.i18n.anonymous);
    }

    /**
     * Renders items when they are provided by the `items` property and clears the content otherwise.
     * @param {!HTMLElement} root
     * @param {!Select} _select
     * @private
     */
    __overlayRenderer(root) {
      let menu = root.firstElementChild;
      if (!menu) {
        menu = document.createElement('vaadin-avatar-group-menu');
        menu.addEventListener('keydown', (event) => this._onListKeyDown(event));
        root.appendChild(menu);
      }

      menu.textContent = '';

      if (!this._overflowItems) {
        return;
      }

      this._overflowItems.forEach((item) => {
        menu.appendChild(this.__createItemElement(item));
      });
    }

    /** @private */
    __createItemElement(item) {
      const itemElement = document.createElement('vaadin-avatar-group-menu-item');

      const avatar = document.createElement('vaadin-avatar');
      itemElement.appendChild(avatar);

      avatar.setAttribute('aria-hidden', 'true');
      avatar.setAttribute('tabindex', '-1');
      avatar.i18n = this.i18n;

      if (this._theme) {
        avatar.setAttribute('theme', this._theme);
      }

      avatar.name = item.name;
      avatar.abbr = item.abbr;
      avatar.img = item.img;
      avatar.colorIndex = item.colorIndex;
      if (item.className) {
        avatar.className = item.className;
      }

      if (item.name) {
        const text = document.createTextNode(item.name);
        itemElement.appendChild(text);
      }

      return itemElement;
    }

    /** @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`
              
            `,
          )}
        `,
        this,
        { renderBefore: this._overflow },
      );
    }

    /** @private */
    __updateAvatars(items, itemsInView, maxItemsVisible, overflow) {
      if (!overflow || !Array.isArray(items)) {
        return;
      }

      const limit = this.__getLimit(items.length, itemsInView, maxItemsVisible);

      this.__renderAvatars(limit ? items.slice(0, limit) : items);

      this._avatars = [...this.querySelectorAll('vaadin-avatar')];
    }

    /** @private */
    __computeOverflowItems(items, itemsInView, maxItemsVisible) {
      const count = Array.isArray(items) ? items.length : 0;
      const limit = this.__getLimit(count, itemsInView, maxItemsVisible);
      return limit ? items.slice(limit) : [];
    }

    /** @private */
    __updateOverflowAvatar(overflow, items, itemsInView, maxItemsVisible) {
      if (overflow) {
        const count = Array.isArray(items) ? items.length : 0;
        const maxReached = maxItemsVisible != null && count > this.__getMax(maxItemsVisible);

        overflow.abbr = `+${count - this.__getLimit(count, itemsInView, maxItemsVisible)}`;
        overflow.toggleAttribute('hidden', !maxReached && !(itemsInView && itemsInView < count));
      }
    }

    /** @private */
    __updateAvatarsTheme(overflow, avatars, theme) {
      if (overflow) {
        [overflow, ...avatars].forEach((avatar) => {
          if (theme) {
            avatar.setAttribute('theme', theme);
          } else {
            avatar.removeAttribute('theme');
          }
        });
      }
    }

    /** @private */
    __updateOverflowTooltip(tooltip, items, itemsInView, maxItemsVisible) {
      if (!tooltip || !Array.isArray(items)) {
        return;
      }

      const limit = this.__getLimit(items.length, itemsInView, maxItemsVisible);
      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');
        }
      }

      tooltip.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.i18n.joined || '{user} joined'));
      }

      if (removed) {
        removedMsg = removed.map((user) => this.__getMessage(user, this.i18n.left || '{user} left'));
      }

      const messages = removedMsg.concat(addedMsg);
      if (messages.length > 0) {
        announce(messages.join(', '));
      }
    }

    /** @private */
    __i18nItemsChanged(i18n, items) {
      if (i18n && i18n.activeUsers) {
        const count = Array.isArray(items) ? items.length : 0;
        const field = count === 1 ? 'one' : 'many';
        if (i18n.activeUsers[field]) {
          this.setAttribute('aria-label', i18n.activeUsers[field].replace('{count}', count || 0));
        }

        this._avatars.forEach((avatar) => {
          avatar.i18n = i18n;
        });
      }
    }

    /** @private */
    __openedChanged(opened, overflow) {
      if (!overflow) {
        return;
      }

      if (opened) {
        if (!this._menuElement) {
          this._menuElement = this.$.overlay.querySelector('vaadin-avatar-group-menu');
        }

        this._openedWithFocusRing = overflow.hasAttribute('focus-ring');
      } else if (this.__oldOpened) {
        overflow.focus();
        if (this._openedWithFocusRing) {
          overflow.setAttribute('focus-ring', '');
        }
      }

      overflow.setAttribute('aria-expanded', opened === true);
      this.__oldOpened = opened;
    }

    /** @private */
    __overflowItemsChanged(items, oldItems) {
      if (items || oldItems) {
        this.$.overlay.requestContentUpdate();
      }
    }

    /** @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));
    }
  };




© 2015 - 2024 Weber Informatics LLC | Privacy Policy