Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support partial i18n in avatar group #8646

Merged
merged 6 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions packages/avatar-group/src/vaadin-avatar-group-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@
*/
import type { Constructor } from '@open-wc/dedupe-mixin';
import type { AvatarI18n } from '@vaadin/avatar/src/vaadin-avatar.js';
import type { I18nMixinClass, PartialI18n } from '@vaadin/component-base/src/i18n-mixin.js';
import type { OverlayClassMixinClass } from '@vaadin/component-base/src/overlay-class-mixin.js';
import type { ResizeMixinClass } from '@vaadin/component-base/src/resize-mixin.js';

export interface AvatarGroupI18n extends AvatarI18n {
activeUsers: {
one: string;
many: string;
};
joined: string;
left: string;
}
export type AvatarGroupI18n = PartialI18n<
{
activeUsers: {
one: string;
many: string;
};
joined: string;
left: string;
} & AvatarI18n
>;

export interface AvatarGroupItem {
name?: string;
Expand All @@ -30,7 +33,11 @@ export interface AvatarGroupItem {
*/
export declare function AvatarGroupMixin<T extends Constructor<HTMLElement>>(
base: T,
): Constructor<AvatarGroupMixinClass> & Constructor<OverlayClassMixinClass> & Constructor<ResizeMixinClass> & T;
): Constructor<AvatarGroupMixinClass> &
Constructor<I18nMixinClass<AvatarGroupI18n>> &
Constructor<OverlayClassMixinClass> &
Constructor<ResizeMixinClass> &
T;

export declare class AvatarGroupMixinClass {
/**
Expand Down
119 changes: 61 additions & 58 deletions packages/avatar-group/src/vaadin-avatar-group-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,23 @@ 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 { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.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;

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.
*
Expand All @@ -21,7 +32,7 @@ const MINIMUM_DISPLAYED_AVATARS = 2;
* @mixes OverlayClassMixin
*/
export const AvatarGroupMixin = (superClass) =>
class AvatarGroupMixinClass extends ResizeMixin(OverlayClassMixin(superClass)) {
class AvatarGroupMixinClass extends I18nMixin(ResizeMixin(OverlayClassMixin(superClass)), DEFAULT_I18N) {
static get properties() {
return {
/**
Expand Down Expand Up @@ -68,51 +79,6 @@ export const AvatarGroupMixin = (superClass) =>
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,
Expand Down Expand Up @@ -156,15 +122,52 @@ export const AvatarGroupMixin = (superClass) =>

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

/**
* 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:
* ```
* {
* // 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}
*/
get i18n() {
return super.i18n;
}

set i18n(value) {
super.i18n = value;
}

/** @protected */
ready() {
super.ready();
Expand Down Expand Up @@ -205,7 +208,7 @@ export const AvatarGroupMixin = (superClass) =>

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

/**
Expand Down Expand Up @@ -242,7 +245,7 @@ export const AvatarGroupMixin = (superClass) =>

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

if (this._theme) {
avatar.setAttribute('theme', this._theme);
Expand Down Expand Up @@ -324,7 +327,7 @@ export const AvatarGroupMixin = (superClass) =>
.abbr="${item.abbr}"
.img="${item.img}"
.colorIndex="${item.colorIndex}"
.i18n="${this.i18n}"
.i18n="${this.__effectiveI18n}"
class="${ifDefined(item.className)}"
with-tooltip
></vaadin-avatar>
Expand Down Expand Up @@ -447,11 +450,11 @@ export const AvatarGroupMixin = (superClass) =>
let addedMsg = [];
let removedMsg = [];
if (added) {
addedMsg = added.map((user) => this.__getMessage(user, this.i18n.joined || '{user} joined'));
addedMsg = added.map((user) => this.__getMessage(user, this.__effectiveI18n.joined || '{user} joined'));
}

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

const messages = removedMsg.concat(addedMsg);
Expand All @@ -461,16 +464,16 @@ export const AvatarGroupMixin = (superClass) =>
}

/** @private */
__i18nItemsChanged(i18n, items) {
if (i18n && i18n.activeUsers) {
__i18nItemsChanged(effectiveI18n, items) {
if (effectiveI18n && effectiveI18n.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));
if (effectiveI18n.activeUsers[field]) {
this.setAttribute('aria-label', effectiveI18n.activeUsers[field].replace('{count}', count || 0));
}

this._avatars.forEach((avatar) => {
avatar.i18n = i18n;
avatar.i18n = effectiveI18n;
});
}
}
Expand Down
10 changes: 5 additions & 5 deletions packages/avatar-group/test/avatar-group.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -505,9 +505,9 @@ describe('avatar-group', () => {
it('should pass i18n property to avatars', () => {
group.i18n = customI18n;
const items = group.querySelectorAll('vaadin-avatar');
expect(items[0].i18n).to.deep.equal(customI18n);
expect(items[1].i18n).to.deep.equal(customI18n);
expect(items[2].i18n).to.deep.equal(customI18n);
expect(items[0].i18n).to.deep.equal(group.__effectiveI18n);
expect(items[1].i18n).to.deep.equal(group.__effectiveI18n);
expect(items[2].i18n).to.deep.equal(group.__effectiveI18n);
});

it('should pass i18n property to overlay avatars', async () => {
Expand All @@ -518,8 +518,8 @@ describe('avatar-group', () => {
await oneEvent(overlay, 'vaadin-overlay-open');

const avatars = overlay.querySelectorAll('vaadin-avatar');
expect(avatars[0].i18n).to.deep.equal(customI18n);
expect(avatars[1].i18n).to.deep.equal(customI18n);
expect(avatars[0].i18n).to.deep.equal(group.__effectiveI18n);
expect(avatars[1].i18n).to.deep.equal(group.__effectiveI18n);
});
});

Expand Down
6 changes: 6 additions & 0 deletions packages/avatar-group/test/typings/avatar-group.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import '../../vaadin-avatar-group.js';
import type { ControllerMixinClass } from '@vaadin/component-base/src/controller-mixin.js';
import type { ElementMixinClass } from '@vaadin/component-base/src/element-mixin.js';
import type { I18nMixinClass } from '@vaadin/component-base/src/i18n-mixin.js';
import type { OverlayClassMixinClass } from '@vaadin/component-base/src/overlay-class-mixin.js';
import type { ResizeMixinClass } from '@vaadin/component-base/src/resize-mixin.js';
import type { ThemableMixinClass } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
Expand All @@ -25,10 +26,15 @@ assertType<string | undefined>(item.name);
assertType<number | undefined>(item.colorIndex);
assertType<string | undefined>(item.className);

// I18n
assertType<AvatarGroupI18n>({ joined: 'yesterday' });
assertType<AvatarGroupI18n>({ activeUsers: { one: '1 user' } });

// Mixins
assertType<AvatarGroupMixinClass>(group);
assertType<ControllerMixinClass>(group);
assertType<ElementMixinClass>(group);
assertType<I18nMixinClass<AvatarGroupI18n>>(group);
assertType<OverlayClassMixinClass>(group);
assertType<ResizeMixinClass>(group);
assertType<ThemableMixinClass>(group);
11 changes: 11 additions & 0 deletions packages/component-base/src/i18n-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
*/
import type { Constructor } from '@open-wc/dedupe-mixin';

/**
* Recursively makes all properties of an i18n object optional.
*
* For internal use only.
*/
export type PartialI18n<T> = T extends object
? {
[P in keyof T]?: PartialI18n<T[P]>;
}
: T;

/**
* A mixin that allows to set partial I18N properties.
*/
Expand Down
Loading