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

[CL-7] Avatar #3153

Merged
merged 35 commits into from Sep 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
26d01bc
CL-7 Begin Implementing Avatar
rr-bw Jul 20, 2022
9f6a502
add figma design to parameters
rr-bw Jul 21, 2022
88d0936
rework size property
rr-bw Jul 22, 2022
0c5f166
Update Figma file to correct component
rr-bw Jul 22, 2022
2f8279e
remove circle input (avatar will always be a circle)
rr-bw Jul 25, 2022
cb2554c
adjust sizing and limit inputs
rr-bw Jul 25, 2022
2ee4c08
Setup color input and functionality
rr-bw Jul 25, 2022
9c9ceb0
Add border option
rr-bw Jul 25, 2022
62d7cf8
fix bug duplicating classes
rr-bw Jul 25, 2022
5918369
Update size for large avatar
rr-bw Jul 25, 2022
cd40113
Remove unnecessary class
rr-bw Jul 25, 2022
0378c4f
Fix typo
rr-bw Jul 25, 2022
d446cf2
Remove 'dynamic' input (Avatar will now regenerate on changes by defa…
rr-bw Jul 26, 2022
1f4ae26
Use Tailwind class instead of an arbitrary value
rr-bw Jul 26, 2022
ee27aa6
Remove gravatars (deprecated, see SG-434)
rr-bw Jul 26, 2022
92e8093
Rename methods to a more accurate name
rr-bw Jul 26, 2022
7b06039
Rework classList() getter method
rr-bw Jul 26, 2022
ccbd182
Remove unnecessary logic and services
rr-bw Jul 26, 2022
f2d253b
Make properties private, and rename for better clarity
rr-bw Jul 26, 2022
baae8ec
Move sanitizer logic to the TS code rather than the template
rr-bw Jul 26, 2022
ce68975
Rework and move function to a common static class in Utils
rr-bw Jul 26, 2022
f5d4cb9
Rename 'data' to 'text' for clarity
rr-bw Jul 26, 2022
314d725
Rework classList implementation
rr-bw Jul 28, 2022
b3b13ab
Remove email since we removed gravatars
rr-bw Jul 28, 2022
c8c2c8d
Remove template
rr-bw Aug 2, 2022
93b8d16
set color based on color, id, or text input
rr-bw Aug 3, 2022
9952a06
rework generate method
rr-bw Aug 10, 2022
ddc89e6
Merge branch 'master' into CL-7-avatar
rr-bw Aug 19, 2022
7452ebd
Merge branch 'master' into CL-7-avatar
rr-bw Aug 19, 2022
ac43db7
add explicit null/undefined check
rr-bw Aug 19, 2022
07f803d
Merge branch 'master' into CL-7-avatar
rr-bw Aug 22, 2022
f9dbf97
remove comment
rr-bw Aug 23, 2022
36b763a
Merge branch 'master' into CL-7-avatar
rr-bw Aug 23, 2022
4019626
Merge branch 'master' into CL-7-avatar
vincentsalucci Aug 24, 2022
f2e5898
merge master
rr-bw Sep 6, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -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",
Expand All @@ -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() {
Expand Down
15 changes: 1 addition & 14 deletions libs/angular/src/components/avatar.component.ts
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions libs/common/src/misc/utils.ts
Expand Up @@ -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 <text> 'fill' attribute (e.g. <text fill="black"></text>).
* 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
*/
Expand Down
127 changes: 127 additions & 0 deletions 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<SizeTypes, string[]> = {
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: `<img *ngIf="src" [src]="src" title="{{ text }}" [ngClass]="classList" />`,
})
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("") : "";
}
}
11 changes: 11 additions & 0 deletions 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 {}
60 changes: 60 additions & 0 deletions 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<AvatarComponent> = (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",
};
2 changes: 2 additions & 0 deletions libs/components/src/avatar/index.ts
@@ -0,0 +1,2 @@
export * from "./avatar.module";
export * from "./avatar.component";