1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-21 11:54:02 +00:00

update fully to spec

This commit is contained in:
Vicki League
2026-02-12 16:09:35 -05:00
parent d38dc2b20d
commit fddcb1535d
7 changed files with 307 additions and 145 deletions

View File

@@ -17,6 +17,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
AvatarDefaultColors,
DIALOG_DATA,
DialogConfig,
DialogRef,
@@ -48,18 +49,13 @@ export class ChangeAvatarDialogComponent implements OnInit, OnDestroy {
loading = false;
// change palette to new colors
defaultColorPalette: NamedAvatarColor[] = [
{ name: "brightBlue", color: "#16cbfc" },
{ name: "green", color: "#94cc4b" },
{ name: "orange", color: "#ffb520" },
{ name: "lavender", color: "#e5beed" },
{ name: "yellow", color: "#fcff41" },
{ name: "indigo", color: "#acbdf7" },
{ name: "teal", color: "#8ecdc5" },
{ name: "salmon", color: "#ffa3a3" },
{ name: "pink", color: "#ffa2d4" },
];
defaultColorPalette: NamedAvatarColor[] = AvatarDefaultColors.map((color) => {
return {
color,
name: this.i18nService.t(color === "brand" ? "blue" : color),
};
});
customColorSelected = false;
currentSelection: string;
@@ -79,9 +75,6 @@ export class ChangeAvatarDialogComponent implements OnInit, OnDestroy {
}
async ngOnInit() {
//localize the default colors
this.defaultColorPalette.forEach((c) => (c.name = this.i18nService.t(c.name)));
this.customColor$
.pipe(debounceTime(200), takeUntil(this.destroy$))
.subscribe((color: string | null) => {
@@ -104,13 +97,12 @@ export class ChangeAvatarDialogComponent implements OnInit, OnDestroy {
this.setSelection(this.customColor$.value);
}
// does this get used anywhere?
async generateAvatarColor() {
Utils.stringToColor(this.profile.name.toString());
}
submit = async () => {
if (Utils.validateHexColor(this.currentSelection) || this.currentSelection == null) {
const defaultColorSelected = AvatarDefaultColors.includes(this.currentSelection);
const isValidHex = Utils.validateHexColor(this.currentSelection);
const isValidSelection = this.currentSelection == null || defaultColorSelected || isValidHex;
if (isValidSelection) {
await this.avatarService.setAvatarColor(this.currentSelection);
this.dialogRef.close();
this.toastService.showToast({

View File

@@ -8221,6 +8221,15 @@
"pink": {
"message": "Pink"
},
"coral": {
"message": "Coral"
},
"purple": {
"message": "Purple"
},
"blue": {
"message": "Blue"
},
"customColor": {
"message": "Custom Color"
},

View File

@@ -26,7 +26,7 @@
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-avatar [text]="provider.name" [id]="provider.id" size="2xlarge"></bit-avatar> // aaa
<bit-avatar [text]="provider.name" [id]="provider.id" size="2xlarge"></bit-avatar>
</div>
</div>
<button type="submit" bitFormButton bitButton buttonType="primary">

View File

@@ -12,7 +12,7 @@
x="50%"
dy="0.35em"
pointer-events="auto"
[attr.fill]="textColor()"
[class]="textColor()"
[style.fontWeight]="svgFontWeight"
[style.fontSize.px]="svgFontSize"
[style.lineHeight.px]="16"

View File

@@ -1,4 +1,3 @@
import { NgClass } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
@@ -6,13 +5,18 @@ import {
ElementRef,
inject,
input,
signal,
} from "@angular/core";
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 AvatarColors = "teal" | "coral" | "brand" | "green" | "purple";
export const AvatarDefaultColors = ["teal", "coral", "brand", "green", "purple"];
export type AvatarColors = (typeof AvatarDefaultColors)[number];
const SizeClasses: Record<AvatarSizes, string[]> = {
"2xlarge": ["tw-h-16", "tw-w-16", "tw-min-w-16"],
@@ -42,15 +46,16 @@ export const DefaultAvatarColors: Record<AvatarColors, string> = {
* dark mode.
*/
const DefaultAvatarHoverColors: Record<AvatarColors, string> = {
teal: "group-hover/avatar:tw-bg-bg-avatar-teal-hover",
coral: "group-hover/avatar:tw-bg-bg-avatar-coral-hover",
brand: "group-hover/avatar:tw-bg-bg-avatar-brand-hover",
green: "group-hover/avatar:tw-bg-bg-avatar-green-hover",
purple: "group-hover/avatar:tw-bg-bg-avatar-purple-hover",
teal: "tw-bg-bg-avatar-teal-hover",
coral: "tw-bg-bg-avatar-coral-hover",
brand: "tw-bg-bg-avatar-brand-hover",
green: "tw-bg-bg-avatar-green-hover",
purple: "tw-bg-bg-avatar-purple-hover",
};
/**
* Avatars display a background color that helps a user visually recognize their logged in account.
* 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.
*
* Color options include a pre-defined set of palette-approved colors, or users can select a
* custom color. A variance in color across the avatar component is important as it is used in
@@ -60,17 +65,19 @@ const DefaultAvatarHoverColors: Record<AvatarColors, string> = {
* Avatars can be static or interactive.
*/
@Component({
selector: "bit-avatar, button[bit-avatar], a[bit-avatar]",
selector: "bit-avatar, button[bit-avatar]",
templateUrl: "avatar.component.html",
imports: [NgClass],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: "tw-group/avatar",
"(mouseenter)": "isHovering.set(true)",
"(mouseleave)": "isHovering.set(false)",
"[class]": "avatarClass()",
},
// host directive for aria disabled states? check figma for disabled styles
hostDirectives: [AriaDisableDirective],
})
export class AvatarComponent {
private el = inject(ElementRef);
/**
* Background color for the avatar. Provide one of the AvatarColors, or a custom hex code.
*
@@ -100,21 +107,60 @@ export class AvatarComponent {
*/
readonly size = input<AvatarSizes>("base");
/**
* For button element avatars, whether the button is disabled. No effect for non-button avatars
*/
readonly disabled = input<boolean>(false);
constructor() {
ariaDisableElement(this.el.nativeElement, this.disabled);
}
readonly showDisabledStyles = computed(() => {
return this.isInteractive() && this.disabled();
});
protected readonly svgCharCount = 2;
protected readonly svgFontSize = 12;
protected readonly svgFontWeight = 400;
protected readonly svgSize = 32;
protected readonly svgClass = computed(() => {
return ["tw-rounded-full", "tw-border-solid", this.backgroundColorClass()]
.concat(SizeClasses[this.size()] ?? [])
.concat(this.hasHoverEffects() ? this.interactiveSvgClasses() : []);
protected readonly isInteractive = computed(() => {
return this.el.nativeElement.nodeName === "BUTTON";
});
protected readonly hasHoverEffects = computed(() => {
return this.el.nativeElement.nodeName === "BUTTON" || this.el.nativeElement.nodeName === "A";
protected readonly avatarClass = computed(() => {
const classes = [
"tw-leading-[0px]",
"focus-visible:tw-outline-none",
"tw-rounded-full",
"focus-visible:tw-ring-2",
"focus-visible:tw-ring-offset-1",
"focus-visible:tw-ring-border-focus",
"!focus-visible:tw-border-[transparent]",
"focus-visible:tw-z-10",
].concat(this.showDisabledStyles() ? ["tw-cursor-not-allowed"] : []);
return classes;
});
protected readonly svgClass = computed(() => {
return ["tw-rounded-full"]
.concat(SizeClasses[this.size()] ?? [])
.concat(this.showDisabledStyles() ? ["tw-bg-bg-disabled"] : this.avatarBackgroundColor());
});
/**
* Manually track the hover state.
*
* We're doing this instead of using tailwind's hover helper selectors because we need to be able
* to apply a darker color on hover for custom background colors, and we can't use tailwind for
* the dynamic custom background colors due to limitations with how it generates styles at build
* time
*/
protected readonly isHovering = signal(false);
protected readonly showHoverColor = computed(() => this.isInteractive() && this.isHovering());
protected readonly usingCustomColor = computed(() => {
if (Utils.isNullOrWhitespace(this.color())) {
return false;
@@ -124,41 +170,70 @@ export class AvatarComponent {
return !defaultColorKeys.includes(this.color() as AvatarColors);
});
protected readonly customBackgroundColor = computed(() => {
/**
* Background color tailwind class
*
* Returns the appropriate class if using default avatar colors
* Returns an empty string (a "blank" tailwind class) if using custom color
*/
protected readonly avatarBackgroundColor = computed(() => {
// If using custom color instead of default avatar color, early exit
if (this.usingCustomColor()) {
return this.color()!;
return "";
}
return undefined;
});
protected readonly backgroundColorClass = computed(() => {
if (!this.usingCustomColor()) {
return DefaultAvatarColors[(this.color() as AvatarColors) ?? this.avatarDefaultColorKey()];
}
return "";
});
protected readonly interactiveSvgClasses = computed(() => {
if (!this.usingCustomColor()) {
return [
DefaultAvatarHoverColors[(this.color() as AvatarColors) ?? this.avatarDefaultColorKey()],
if (this.showHoverColor()) {
return DefaultAvatarHoverColors[
(this.color() as AvatarColors) ?? this.avatarDefaultColorKey()
];
}
// awaiting design choice for custom color hover state
return "";
return DefaultAvatarColors[(this.color() as AvatarColors) ?? this.avatarDefaultColorKey()];
});
/**
* Background color hex code
*
* Returns the custom color if using a custom background color
* Returns `undefined` if using a default avatar color
*
* Custom hexes need to be applied as a style property, because dynamic values can't be used in
* tailwind arbitrary values due to limitations with how it generates tailwind styles at build
* time
*/
protected readonly customBackgroundColor = computed(() => {
/**
* If using a default avatar color instead of custom color, early exit.
* If button is disabled, we want to use a tailwind class instead, so also early exit
*/
if (!this.usingCustomColor() || this.showDisabledStyles()) {
return undefined;
}
if (this.showHoverColor()) {
// Drop the color's saturation and lightness by 10% when hovering
return `hsl(from ${this.color()} h calc(s - 10) calc(l - 10))`;
}
return this.color();
});
/**
* Text color class that satisfies accessible contrast requirements
*/
protected readonly textColor = computed(() => {
if (this.showDisabledStyles()) {
return "tw-fill-fg-disabled";
}
const customBg = this.customBackgroundColor();
let textColor = "white";
if (customBg) {
return Utils.pickTextColorBasedOnBgColor(customBg, 135, true);
} else {
return "white";
textColor = Utils.pickTextColorBasedOnBgColor(customBg, 135, true);
}
return textColor === "white" ? "tw-fill-fg-white" : "tw-fill-fg-black";
});
protected readonly displayChars = computed(() => {
@@ -195,6 +270,12 @@ export class AvatarComponent {
return characters != null ? characters.slice(0, count).join("") : "";
}
/**
* Deterministically chosen default avatar color
*
* Based on the id first and the text second, choose a color from AvatarColors. This ensures that
* the user sees the same color for the same avatar input every time.
*/
readonly avatarDefaultColorKey = computed(() => {
let magicString = "";
const id = this.id();

View File

@@ -14,52 +14,61 @@ import { AvatarModule } from "@bitwarden/components";
<Primary />
<Controls />
## Size
## Static
### 2XLarge
By default, the avatar component is non-interactive. Use the avatar component as an atom in a larger
molecule like a list item, table, or card to help users differentiate between multiple accounts in a
list.
<Canvas of={stories.XXLarge} />
If the avatar is not a clickable element, there are no hover or focus states.
### XLarge
## Interactive
<Canvas of={stories.XLarge} />
The Avatar can be used as a button. Use the avatar component as a button to open a secondary menu
like an account switcher or dropdown.
### Large
When the avatar is used as a button, there are hover and focus states for accessibility. Although it
is rare for an avatar button to be inactive, this state is supported as well.
<Canvas of={stories.Large} />
<Canvas of={stories.Interactive} />
### Base
### Inactive
<Canvas of={stories.Default} />
<Canvas of={stories.Inactive} />
### Small
## Sizes
<Canvas of={stories.Small} />
There are multiple sizes available for the Avatar:
`"2xlarge" | "xlarge" | "large" | "base" | "small"`
## Background color
<Canvas of={stories.Sizes} />
The Background color can be set 3 ways. The color is generated using the following order of
priority:
## Color
- Color
- ID
- Text, usually set to the user's Name field
The avatar color can be set 3 ways. The color is generated using the following order of priority:
| Method | Input | Accepted inputs |
| -------------- | ------- | ----------------------------------------------------------------------------------- |
| Default colors | `color` | `"teal" \| "coral" \| "brand" \| "green" \| "purple"` |
| Custom color | `color` | Css colors like a hex code |
| ID | `id` | Unique string identifier |
| Text | `text` | String whose first letters are displayed inside the avatar, usually the user's name |
### Default colors
<Canvas of={stories.DefaultColors} />
### Custom color
<Canvas of={stories.CustomColor} />
### Color by ID
<Canvas of={stories.ColorByText} />
Use the user 'ID' field if `Name` is not defined.
<Canvas of={stories.ColorByID} />
## Avatar as a button
### Color by Text
The Avatar can be used as a button.
Typically this is only in the navigation on client apps where account switching is used and in the
web app for the account menu indicator.
When the avatar is used as a button, the following states should be used:
`TODO:` [Jira add stories](https://bitwarden.atlassian.net/browse/CL-101) for button avatars.
[See Figma](https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?type=design&node-id=9730-31746&mode=design&t=IjDIHDb6FZl6bUQW-4)
<Canvas of={stories.ColorByText} />
## Accessibility

View File

@@ -36,56 +36,7 @@ export const Default: Story = {
},
};
export const XXLarge: Story = {
...Default,
args: {
size: "2xlarge",
},
};
export const XLarge: Story = {
...Default,
args: {
size: "xlarge",
},
};
export const Large: Story = {
...Default,
args: {
size: "large",
},
};
export const Small: Story = {
...Default,
args: {
size: "small",
},
};
export const ColorByID: Story = {
...Default,
args: {
id: "236478",
},
};
export const ColorByText: Story = {
...Default,
args: {
text: "Jason Doe",
},
};
export const CustomColor: Story = {
...Default,
args: {
color: "#fbd9ff",
},
};
export const Button: Story = {
export const Interactive: Story = {
render: (args) => {
return {
props: args,
@@ -99,9 +50,129 @@ export const Button: Story = {
},
};
// color by text or id button story?
export const Sizes: Story = {
render: (args) => {
return {
props: args,
template: `
<span class="tw-font-bold">Static</span>
<div class="tw-flex tw-gap-4 tw-mb-10">
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> small </span>
<bit-avatar [color]="'brand'" [text]="'Walt Walterson'" [size]="'small'"></bit-avatar>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> base </span>
<bit-avatar [color]="'brand'" [text]="'Walt Walterson'"></bit-avatar>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> large </span>
<bit-avatar [color]="'brand'" [text]="'Walt Walterson'" [size]="'large'"></bit-avatar>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> xlarge </span>
<bit-avatar [color]="'brand'" [text]="'Walt Walterson'" [size]="'xlarge'"></bit-avatar>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> 2xlarge </span>
<bit-avatar [color]="'brand'" [text]="'Walt Walterson'" [size]="'2xlarge'"></bit-avatar>
</div>
</div>
export const CustomColorButton: Story = {
<span class="tw-font-bold">Interactive</span>
<div class="tw-flex tw-gap-4 tw-mb-10">
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> small </span>
<button bit-avatar [color]="'brand'" [text]="'Walt Walterson'" [size]="'small'"></button>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> base </span>
<button bit-avatar [color]="'brand'" [text]="'Walt Walterson'"></button>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> large </span>
<button bit-avatar [color]="'brand'" [text]="'Walt Walterson'" [size]="'large'"></button>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> xlarge </span>
<button bit-avatar [color]="'brand'" [text]="'Walt Walterson'" [size]="'xlarge'"></button>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span> 2xlarge </span>
<button bit-avatar [color]="'brand'" [text]="'Walt Walterson'" [size]="'2xlarge'"></button>
</div>
</div>
`,
};
},
};
export const DefaultColors: Story = {
render: (args) => {
return {
props: args,
template: `
<span class="tw-font-bold">Static</span>
<div class="tw-flex tw-gap-2 tw-mb-10">
<bit-avatar [color]="'brand'" [text]="'Walt Walterson'"></bit-avatar>
<bit-avatar [color]="'teal'" [text]="'Walt Walterson'"></bit-avatar>
<bit-avatar [color]="'coral'" [text]="'Walt Walterson'"></bit-avatar>
<bit-avatar [color]="'green'" [text]="'Walt Walterson'"></bit-avatar>
<bit-avatar [color]="'purple'" [text]="'Walt Walterson'"></bit-avatar>
</div>
<span class="tw-font-bold">Interactive</span>
<div class="tw-flex tw-gap-2">
<button bit-avatar [color]="'brand'" [text]="'Walt Walterson'"></button>
<button bit-avatar [color]="'teal'" [text]="'Walt Walterson'"></button>
<button bit-avatar [color]="'coral'" [text]="'Walt Walterson'"></button>
<button bit-avatar [color]="'green'" [text]="'Walt Walterson'"></button>
<button bit-avatar [color]="'purple'" [text]="'Walt Walterson'"></button>
</div>
`,
};
},
};
export const ColorByID: Story = {
render: (args) => {
return {
props: args,
template: `
<div class="tw-flex tw-gap-4">
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span class="tw-font-bold"> Static </span>
<bit-avatar ${formatArgsForCodeSnippet<AvatarComponent>(args)}></bit-avatar>
</div>
<div class="tw-flex tw-flex-col tw-gap-2 tw-items-center">
<span class="tw-font-bold"> Interactive </span>
<button bit-avatar ${formatArgsForCodeSnippet<AvatarComponent>(args)}></button>
</div>
</div>
`,
};
},
args: {
id: "236478",
},
};
export const ColorByText: Story = {
...ColorByID,
args: {
text: "Jason Doe",
},
};
export const CustomColor: Story = {
...ColorByID,
args: {
color: "#fbd9fe",
},
};
export const Inactive: Story = {
render: (args) => {
return {
props: args,
@@ -111,6 +182,6 @@ export const CustomColorButton: Story = {
};
},
args: {
color: "#fbd9ff",
disabled: true,
},
};