diff --git a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts index f4a2116eced..b2464fae846 100644 --- a/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts +++ b/apps/web/src/app/auth/settings/account/change-avatar-dialog.component.ts @@ -22,6 +22,7 @@ import { DialogConfig, DialogRef, DialogService, + isAvatarColor, ToastService, } from "@bitwarden/components"; @@ -98,7 +99,7 @@ export class ChangeAvatarDialogComponent implements OnInit, OnDestroy { } submit = async () => { - const defaultColorSelected = AvatarDefaultColors.includes(this.currentSelection); + const defaultColorSelected = isAvatarColor(this.currentSelection); const isValidHex = Utils.validateHexColor(this.currentSelection); const isValidSelection = this.currentSelection == null || defaultColorSelected || isValidHex; diff --git a/apps/web/src/app/components/dynamic-avatar.component.ts b/apps/web/src/app/components/dynamic-avatar.component.ts index 01bc9540daf..6e002a4f97f 100644 --- a/apps/web/src/app/components/dynamic-avatar.component.ts +++ b/apps/web/src/app/components/dynamic-avatar.component.ts @@ -4,7 +4,7 @@ import { Component, Input, OnDestroy } from "@angular/core"; import { Subject } from "rxjs"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; -import { AvatarSizes } from "@bitwarden/components"; +import { AvatarSize } from "@bitwarden/components"; import { SharedModule } from "../shared"; @@ -37,7 +37,7 @@ export class DynamicAvatarComponent implements OnDestroy { @Input() title: string; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() size: AvatarSizes = "base"; + @Input() size: AvatarSize = "base"; private destroy$ = new Subject(); color$ = this.avatarService.avatarColor$; diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index b3512c8b462..ccf19408519 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -13,12 +13,12 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { AriaDisableDirective } from "../a11y"; import { ariaDisableElement } from "../utils"; -export type AvatarSizes = "2xlarge" | "xlarge" | "large" | "base" | "small"; +export type AvatarSize = "2xlarge" | "xlarge" | "large" | "base" | "small"; -export const AvatarDefaultColors = ["teal", "coral", "brand", "green", "purple"]; -export type AvatarColors = (typeof AvatarDefaultColors)[number]; +export const AvatarDefaultColors = ["teal", "coral", "brand", "green", "purple"] as const; +export type AvatarColor = (typeof AvatarDefaultColors)[number]; -const SizeClasses: Record = { +const sizeClasses: Record = { "2xlarge": ["tw-h-16", "tw-w-16", "tw-min-w-16"], xlarge: ["tw-h-14", "tw-w-14", "tw-min-w-14"], large: ["tw-h-11", "tw-w-11", "tw-min-w-11"], @@ -32,7 +32,7 @@ const SizeClasses: Record = { * We reference color variables defined in tw-theme.css to ensure the avatar color handles light and * dark mode. */ -export const DefaultAvatarColors: Record = { +export const defaultAvatarColors: Record = { teal: "tw-bg-bg-avatar-teal", coral: "tw-bg-bg-avatar-coral", brand: "tw-bg-bg-avatar-brand", @@ -45,7 +45,7 @@ export const DefaultAvatarColors: Record = { * color variables defined in tw-theme.css to ensure the avatar color handles light and * dark mode. */ -const DefaultAvatarHoverColors: Record = { +const defaultAvatarHoverColors: Record = { teal: "tw-bg-bg-avatar-teal-hover", coral: "tw-bg-bg-avatar-coral-hover", brand: "tw-bg-bg-avatar-brand-hover", @@ -53,6 +53,14 @@ const DefaultAvatarHoverColors: Record = { purple: "tw-bg-bg-avatar-purple-hover", }; +// Typeguard to check if a given color is an AvatarColor +export function isAvatarColor(color: string | undefined): color is AvatarColor { + if (color === undefined) { + return false; + } + return AvatarDefaultColors.includes(color as AvatarColor); +} + /** * The avatar component is a visual representation of a user profile. Color variations help users * quickly identify the active account and differentiate between multiple accounts in a list. @@ -83,7 +91,7 @@ export class AvatarComponent { * * If no color is provided, a color will be generated based on the id or text. */ - readonly color = input(); + readonly color = input(); /** * Unique identifier used to generate a consistent background color. Takes precedence over text @@ -105,7 +113,7 @@ export class AvatarComponent { /** * Size of the avatar. */ - readonly size = input("base"); + readonly size = input("base"); /** * For button element avatars, whether the button is disabled. No effect for non-button avatars @@ -145,7 +153,7 @@ export class AvatarComponent { protected readonly svgClass = computed(() => { return ["tw-rounded-full"] - .concat(SizeClasses[this.size()] ?? []) + .concat(sizeClasses[this.size()] ?? []) .concat(this.showDisabledStyles() ? ["tw-bg-bg-disabled"] : this.avatarBackgroundColor()); }); @@ -162,12 +170,13 @@ export class AvatarComponent { protected readonly showHoverColor = computed(() => this.isInteractive() && this.isHovering()); protected readonly usingCustomColor = computed(() => { - if (Utils.isNullOrWhitespace(this.color())) { + const color = this.color(); + + if (Utils.isNullOrWhitespace(color)) { return false; } - const defaultColorKeys = Object.keys(DefaultAvatarColors) as AvatarColors[]; - return !defaultColorKeys.includes(this.color() as AvatarColors); + return !isAvatarColor(color); }); /** @@ -182,13 +191,20 @@ export class AvatarComponent { return ""; } + /** + * At this point we're either using a passed-in avatar color or choosing a default based on id + * or text, but Typescript doesn't know that. Use the type guard to confirm that the passed-in + * value is an avatar color, or use a generated default. + */ + const color = this.color(); + const colorIsAvatarColor = isAvatarColor(color); + const chosenAvatarColor = colorIsAvatarColor ? color : this.avatarDefaultColorKey(); + if (this.showHoverColor()) { - return DefaultAvatarHoverColors[ - (this.color() as AvatarColors) ?? this.avatarDefaultColorKey() - ]; + return defaultAvatarHoverColors[chosenAvatarColor]; } - return DefaultAvatarColors[(this.color() as AvatarColors) ?? this.avatarDefaultColorKey()]; + return defaultAvatarColors[chosenAvatarColor]; }); /** @@ -286,14 +302,12 @@ export class AvatarComponent { magicString = this.text()?.toUpperCase() ?? ""; } - const colorKeys = Object.keys(DefaultAvatarColors) as AvatarColors[]; - let hash = 0; - for (let i = 0; i < magicString.length; i++) { - hash = magicString.charCodeAt(i) + ((hash << 5) - hash); + for (const char of magicString) { + hash = char.charCodeAt(0) + ((hash << 5) - hash); } - const index = Math.abs(hash) % colorKeys.length; - return colorKeys[index]; + const index = Math.abs(hash) % AvatarDefaultColors.length; + return AvatarDefaultColors[index]; }); }