From 7c5e4dd3d6b2ddfc77d8028bf5baa253ab8659b2 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Tue, 6 Sep 2022 11:02:09 -0700 Subject: [PATCH] [CL-7] Avatar (#3153) * CL-7 Begin Implementing Avatar * add figma design to parameters * rework size property * Update Figma file to correct component * remove circle input (avatar will always be a circle) * adjust sizing and limit inputs * Setup color input and functionality * Add border option * fix bug duplicating classes * Update size for large avatar * Remove unnecessary class * Fix typo * Remove 'dynamic' input (Avatar will now regenerate on changes by default) * Use Tailwind class instead of an arbitrary value * Remove gravatars (deprecated, see SG-434) * Rename methods to a more accurate name * Rework classList() getter method * Remove unnecessary logic and services * Make properties private, and rename for better clarity * Move sanitizer logic to the TS code rather than the template * Rework and move function to a common static class in Utils * Rename 'data' to 'text' for clarity * Rework classList implementation * Remove email since we removed gravatars * Remove template * set color based on color, id, or text input * rework generate method * add explicit null/undefined check * remove comment Co-authored-by: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> --- .../organization-name-badge.component.ts | 32 +---- .../src/components/avatar.component.ts | 15 +-- libs/common/src/misc/utils.ts | 33 +++++ .../components/src/avatar/avatar.component.ts | 127 ++++++++++++++++++ libs/components/src/avatar/avatar.module.ts | 11 ++ libs/components/src/avatar/avatar.stories.ts | 60 +++++++++ libs/components/src/avatar/index.ts | 2 + 7 files changed, 238 insertions(+), 42 deletions(-) create mode 100644 libs/components/src/avatar/avatar.component.ts create mode 100644 libs/components/src/avatar/avatar.module.ts create mode 100644 libs/components/src/avatar/avatar.stories.ts create mode 100644 libs/components/src/avatar/index.ts diff --git a/apps/web/src/app/vault/organization-badge/organization-name-badge.component.ts b/apps/web/src/app/vault/organization-badge/organization-name-badge.component.ts index e34c2a8611..27f19c6828 100644 --- a/apps/web/src/app/vault/organization-badge/organization-name-badge.component.ts +++ b/apps/web/src/app/vault/organization-badge/organization-name-badge.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/misc/utils"; @Component({ selector: "app-org-badge", @@ -20,37 +21,12 @@ export class OrganizationNameBadgeComponent implements OnInit { ngOnInit(): void { if (this.organizationName == null || this.organizationName === "") { this.organizationName = this.i18nService.t("me"); - this.color = this.stringToColor(this.profileName.toUpperCase()); + this.color = Utils.stringToColor(this.profileName.toUpperCase()); } if (this.color == null) { - this.color = this.stringToColor(this.organizationName.toUpperCase()); + this.color = Utils.stringToColor(this.organizationName.toUpperCase()); } - this.textColor = this.pickTextColorBasedOnBgColor(); - } - - // This value currently isn't stored anywhere, only calculated in the app-avatar component - // Once we are allowing org colors to be changed and saved, change this out - private stringToColor(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - let color = "#"; - for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 0xff; - color += ("00" + value.toString(16)).substr(-2); - } - return color; - } - - // There are a few ways to calculate text color for contrast, this one seems to fit accessibility guidelines best. - // https://stackoverflow.com/a/3943023/6869691 - private pickTextColorBasedOnBgColor() { - const color = this.color.charAt(0) === "#" ? this.color.substring(1, 7) : this.color; - const r = parseInt(color.substring(0, 2), 16); // hexToR - const g = parseInt(color.substring(2, 4), 16); // hexToG - const b = parseInt(color.substring(4, 6), 16); // hexToB - return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? "black !important" : "white !important"; + this.textColor = Utils.pickTextColorBasedOnBgColor(this.color); } emitOnOrganizationClicked() { diff --git a/libs/angular/src/components/avatar.component.ts b/libs/angular/src/components/avatar.component.ts index 740a66a5e2..14b61c2351 100644 --- a/libs/angular/src/components/avatar.component.ts +++ b/libs/angular/src/components/avatar.component.ts @@ -68,7 +68,7 @@ export class AvatarComponent implements OnChanges, OnInit { } const charObj = this.getCharText(chars); - const color = this.stringToColor(upperData); + const color = Utils.stringToColor(upperData); const svg = this.getSvg(this.size, color); svg.appendChild(charObj); const html = window.document.createElement("div").appendChild(svg).outerHTML; @@ -77,19 +77,6 @@ export class AvatarComponent implements OnChanges, OnInit { } } - private stringToColor(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - let color = "#"; - for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 0xff; - color += ("00" + value.toString(16)).substr(-2); - } - return color; - } - private getFirstLetters(data: string, count: number): string { const parts = data.split(" "); if (parts.length > 1) { diff --git a/libs/common/src/misc/utils.ts b/libs/common/src/misc/utils.ts index 0aebd9d728..f66124bc47 100644 --- a/libs/common/src/misc/utils.ts +++ b/libs/common/src/misc/utils.ts @@ -370,6 +370,39 @@ export class Utils { return s.charAt(0).toUpperCase() + s.slice(1); } + /** + * There are a few ways to calculate text color for contrast, this one seems to fit accessibility guidelines best. + * https://stackoverflow.com/a/3943023/6869691 + * + * @param {string} bgColor + * @param {number} [threshold] see stackoverflow link above + * @param {boolean} [svgTextFill] + * Indicates if this method is performed on an SVG 'fill' attribute (e.g. ). + * This check is necessary because the '!important' tag cannot be used in a 'fill' attribute. + */ + static pickTextColorBasedOnBgColor(bgColor: string, threshold = 186, svgTextFill = false) { + const bgColorHexNums = bgColor.charAt(0) === "#" ? bgColor.substring(1, 7) : bgColor; + const r = parseInt(bgColorHexNums.substring(0, 2), 16); // hexToR + const g = parseInt(bgColorHexNums.substring(2, 4), 16); // hexToG + const b = parseInt(bgColorHexNums.substring(4, 6), 16); // hexToB + const blackColor = svgTextFill ? "black" : "black !important"; + const whiteColor = svgTextFill ? "white" : "white !important"; + return r * 0.299 + g * 0.587 + b * 0.114 > threshold ? blackColor : whiteColor; + } + + static stringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = "#"; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += ("00" + value.toString(16)).substr(-2); + } + return color; + } + /** * @throws Will throw an error if the ContainerService has not been attached to the window object */ diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts new file mode 100644 index 0000000000..2a287ea7fd --- /dev/null +++ b/libs/components/src/avatar/avatar.component.ts @@ -0,0 +1,127 @@ +import { Component, Input, OnChanges } from "@angular/core"; +import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; + +import { Utils } from "@bitwarden/common/misc/utils"; + +type SizeTypes = "large" | "default" | "small"; + +const SizeClasses: Record = { + large: ["tw-h-16", "tw-w-16"], + default: ["tw-h-12", "tw-w-12"], + small: ["tw-h-7", "tw-w-7"], +}; + +@Component({ + selector: "bit-avatar", + template: ``, +}) +export class AvatarComponent implements OnChanges { + @Input() border = false; + @Input() color: string; + @Input() id: number; + @Input() text: string; + @Input() size: SizeTypes = "default"; + + private svgCharCount = 2; + private svgFontSize = 20; + private svgFontWeight = 300; + private svgSize = 48; + src: SafeResourceUrl; + + constructor(public sanitizer: DomSanitizer) {} + + ngOnChanges() { + this.generate(); + } + + get classList() { + return ["tw-rounded-full"] + .concat(SizeClasses[this.size] ?? []) + .concat(this.border ? ["tw-border", "tw-border-solid", "tw-border-secondary-500"] : []); + } + + private generate() { + let chars: string = null; + const upperCaseText = this.text.toUpperCase(); + + chars = this.getFirstLetters(upperCaseText, this.svgCharCount); + + if (chars == null) { + chars = this.unicodeSafeSubstring(upperCaseText, this.svgCharCount); + } + + // If the chars contain an emoji, only show it. + if (chars.match(Utils.regexpEmojiPresentation)) { + chars = chars.match(Utils.regexpEmojiPresentation)[0]; + } + + let svg: HTMLElement; + let hexColor = this.color; + + if (this.color != null) { + svg = this.createSvgElement(this.svgSize, hexColor); + } else if (this.id != null) { + hexColor = Utils.stringToColor(this.id.toString()); + svg = this.createSvgElement(this.svgSize, hexColor); + } else { + hexColor = Utils.stringToColor(upperCaseText); + svg = this.createSvgElement(this.svgSize, hexColor); + } + + const charObj = this.createTextElement(chars, hexColor); + svg.appendChild(charObj); + const html = window.document.createElement("div").appendChild(svg).outerHTML; + const svgHtml = window.btoa(unescape(encodeURIComponent(html))); + this.src = this.sanitizer.bypassSecurityTrustResourceUrl( + "data:image/svg+xml;base64," + svgHtml + ); + } + + private getFirstLetters(data: string, count: number): string { + const parts = data.split(" "); + if (parts.length > 1) { + let text = ""; + for (let i = 0; i < count; i++) { + text += this.unicodeSafeSubstring(parts[i], 1); + } + return text; + } + return null; + } + + private createSvgElement(size: number, color: string): HTMLElement { + const svgTag = window.document.createElement("svg"); + svgTag.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + svgTag.setAttribute("pointer-events", "none"); + svgTag.setAttribute("width", size.toString()); + svgTag.setAttribute("height", size.toString()); + svgTag.style.backgroundColor = color; + svgTag.style.width = size + "px"; + svgTag.style.height = size + "px"; + return svgTag; + } + + private createTextElement(character: string, color: string): HTMLElement { + const textTag = window.document.createElement("text"); + textTag.setAttribute("text-anchor", "middle"); + textTag.setAttribute("y", "50%"); + textTag.setAttribute("x", "50%"); + textTag.setAttribute("dy", "0.35em"); + textTag.setAttribute("pointer-events", "auto"); + textTag.setAttribute("fill", Utils.pickTextColorBasedOnBgColor(color, 135, true)); + textTag.setAttribute( + "font-family", + '"Open Sans","Helvetica Neue",Helvetica,Arial,' + + 'sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"' + ); + textTag.textContent = character; + textTag.style.fontWeight = this.svgFontWeight.toString(); + textTag.style.fontSize = this.svgFontSize + "px"; + return textTag; + } + + private unicodeSafeSubstring(str: string, count: number) { + const characters = str.match(/./gu); + return characters != null ? characters.slice(0, count).join("") : ""; + } +} diff --git a/libs/components/src/avatar/avatar.module.ts b/libs/components/src/avatar/avatar.module.ts new file mode 100644 index 0000000000..ea78ff3a1d --- /dev/null +++ b/libs/components/src/avatar/avatar.module.ts @@ -0,0 +1,11 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; + +import { AvatarComponent } from "./avatar.component"; + +@NgModule({ + imports: [CommonModule], + exports: [AvatarComponent], + declarations: [AvatarComponent], +}) +export class AvatarModule {} diff --git a/libs/components/src/avatar/avatar.stories.ts b/libs/components/src/avatar/avatar.stories.ts new file mode 100644 index 0000000000..7fcb883cfe --- /dev/null +++ b/libs/components/src/avatar/avatar.stories.ts @@ -0,0 +1,60 @@ +import { Meta, Story } from "@storybook/angular"; + +import { AvatarComponent } from "./avatar.component"; + +export default { + title: "Component Library/Avatar", + component: AvatarComponent, + args: { + text: "Walt Walterson", + size: "default", + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=1881%3A16994", + }, + }, +} as Meta; + +const Template: Story = (args: AvatarComponent) => ({ + props: args, +}); + +export const Default = Template.bind({}); +Default.args = { + color: "#175ddc", +}; + +export const Large = Template.bind({}); +Large.args = { + ...Default.args, + size: "large", +}; + +export const Small = Template.bind({}); +Small.args = { + ...Default.args, + size: "small", +}; + +export const LightBackground = Template.bind({}); +LightBackground.args = { + color: "#d2ffcf", +}; + +export const Border = Template.bind({}); +Border.args = { + ...Default.args, + border: true, +}; + +export const ColorByID = Template.bind({}); +ColorByID.args = { + id: 236478, +}; + +export const ColorByText = Template.bind({}); +ColorByText.args = { + text: "Jason Doe", +}; diff --git a/libs/components/src/avatar/index.ts b/libs/components/src/avatar/index.ts new file mode 100644 index 0000000000..d75d62e01a --- /dev/null +++ b/libs/components/src/avatar/index.ts @@ -0,0 +1,2 @@ +export * from "./avatar.module"; +export * from "./avatar.component";