[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>
This commit is contained in:
parent
460a846f89
commit
7c5e4dd3d6
|
@ -1,6 +1,7 @@
|
||||||
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||||
|
|
||||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||||
|
import { Utils } from "@bitwarden/common/misc/utils";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-org-badge",
|
selector: "app-org-badge",
|
||||||
|
@ -20,37 +21,12 @@ export class OrganizationNameBadgeComponent implements OnInit {
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.organizationName == null || this.organizationName === "") {
|
if (this.organizationName == null || this.organizationName === "") {
|
||||||
this.organizationName = this.i18nService.t("me");
|
this.organizationName = this.i18nService.t("me");
|
||||||
this.color = this.stringToColor(this.profileName.toUpperCase());
|
this.color = Utils.stringToColor(this.profileName.toUpperCase());
|
||||||
}
|
}
|
||||||
if (this.color == null) {
|
if (this.color == null) {
|
||||||
this.color = this.stringToColor(this.organizationName.toUpperCase());
|
this.color = Utils.stringToColor(this.organizationName.toUpperCase());
|
||||||
}
|
}
|
||||||
this.textColor = this.pickTextColorBasedOnBgColor();
|
this.textColor = Utils.pickTextColorBasedOnBgColor(this.color);
|
||||||
}
|
|
||||||
|
|
||||||
// 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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emitOnOrganizationClicked() {
|
emitOnOrganizationClicked() {
|
||||||
|
|
|
@ -68,7 +68,7 @@ export class AvatarComponent implements OnChanges, OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
const charObj = this.getCharText(chars);
|
const charObj = this.getCharText(chars);
|
||||||
const color = this.stringToColor(upperData);
|
const color = Utils.stringToColor(upperData);
|
||||||
const svg = this.getSvg(this.size, color);
|
const svg = this.getSvg(this.size, color);
|
||||||
svg.appendChild(charObj);
|
svg.appendChild(charObj);
|
||||||
const html = window.document.createElement("div").appendChild(svg).outerHTML;
|
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 {
|
private getFirstLetters(data: string, count: number): string {
|
||||||
const parts = data.split(" ");
|
const parts = data.split(" ");
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
|
|
|
@ -370,6 +370,39 @@ export class Utils {
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
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
|
* @throws Will throw an error if the ContainerService has not been attached to the window object
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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("") : "";
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {}
|
|
@ -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",
|
||||||
|
};
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./avatar.module";
|
||||||
|
export * from "./avatar.component";
|
Loading…
Reference in New Issue