Skip to content

Commit

Permalink
fix(Avatar): fix edge-cases for initials
Browse files Browse the repository at this point in the history
  • Loading branch information
filiptammergard committed Apr 5, 2023
1 parent 7c11211 commit 472e9ee
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 40 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-teachers-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@einride/ui": patch
---

Avatar: Fix edge-cases for initials.
65 changes: 65 additions & 0 deletions packages/einride-ui/src/components/content/Avatar/Avatar.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
Meta,
Primary,
Description,
Source,
Title,
ArgTypes,
Subtitle,
Controls,
Story,
Canvas,
Stories,
Markdown,
} from "@storybook/blocks"
import * as Avatar from "./Avatar.stories.tsx"

<Meta of={Avatar} />

<Title />

<Description of={Avatar} />

```tsx
import { Avatar } from "@einride/ui"
```

## Basic

<Description of={Avatar.Basic} />

<Canvas of={Avatar.Basic} />

<Controls of={Avatar.Basic} />

## Shape

<Description of={Avatar.Square} />

<Canvas of={Avatar.Square} />

<Controls of={Avatar.Square} include="radius" />

## Size

<Description of={Avatar.Small} />

<Canvas of={Avatar.Small} />

<Controls of={Avatar.Small} include="size" />

## Initials

<Description of={Avatar.Initials} />

<Canvas of={Avatar.Initials} />

<Controls of={Avatar.Initials} include="name" />

## Custom colors

<Description of={Avatar.CustomColors} />

<Canvas of={Avatar.CustomColors} />

<Controls of={Avatar.CustomColors} include={["color", "background"]} />
Original file line number Diff line number Diff line change
Expand Up @@ -2,66 +2,108 @@ import { expect } from "@storybook/jest"
import { Meta, StoryObj } from "@storybook/react"
import { within } from "@storybook/testing-library"
import { SnapshotWrapper } from "../../../lib/storybook/SnapshotWrapper"
import { borderRadii, contentColors } from "../../../lib/theme/types"
import { Avatar } from "./Avatar"
import { getInitials } from "./getInitials"

const meta = {
component: Avatar,
argTypes: {
as: {
control: false,
},
color: {
control: {
type: "select",
},
options: contentColors,
},
background: {
control: {
type: "select",
},
options: contentColors,
},
radius: {
control: {
type: "select",
},
options: borderRadii,
},
},
} satisfies Meta<typeof Avatar>

export default meta
type Story = StoryObj<typeof meta>

export const Default = {
export const Basic = {
args: {
alt: "Profile picture",
alt: "Astronaut walking on the moon",
src: "https://source.unsplash.com/e5eDHbmHprg/250x250",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const avatar = canvas.getByAltText("Profile picture")
const avatar = canvas.getByRole("img", { name: Basic.args.alt })
expect(avatar).toBeInTheDocument()
},
} satisfies Story

export const Circle = {
/** Control the shape of the avatar with the `radius` prop. */
export const Square = {
args: {
...Default.args,
radius: "full",
...Basic.args,
radius: "sm",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const avatar = canvas.getByAltText("Profile picture")
const avatar = canvas.getByRole("img", { name: Square.args.alt })
expect(avatar).toBeInTheDocument()
},
} satisfies Story

export const Square = {
/** Control the size of the avatar with the `size` prop. */
export const Small = {
args: {
...Default.args,
radius: "sm",
...Basic.args,
size: "sm",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const avatar = canvas.getByAltText("Profile picture")
const avatar = canvas.getByRole("img", { name: Small.args.alt })
expect(avatar).toBeInTheDocument()
},
} satisfies Story

/** Instead of an image you can also show initials by passing a `name`. */
export const Initials = {
args: {
name: "Filip Tammergård",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const initials = canvas.getByText("FT")
const initials = canvas.getByText(getInitials(Initials.args.name) ?? "")
expect(initials).toBeInTheDocument()
},
} satisfies Story

/** Customize the colors with the `color` and `background` props. */
export const CustomColors = {
args: {
name: "Filip Tammergård",
color: "primaryInverted",
background: "primaryInverted",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const initials = canvas.getByText(getInitials(Initials.args.name) ?? "")
expect(initials).toBeInTheDocument()
},
} satisfies Story

export const Snapshot = {
render: () => (
<SnapshotWrapper>
{[Default, Circle, Square, Initials].map((Story, index) => (
{[Basic, Square, Initials].map((Story, index) => (
// eslint-disable-next-line react/no-array-index-key
<Avatar key={index} {...Story.args} />
))}
Expand Down
30 changes: 15 additions & 15 deletions packages/einride-ui/src/components/content/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import isPropValid from "@emotion/is-prop-valid"
import styled from "@emotion/styled"
import { ElementType, forwardRef, HTMLAttributes, ImgHTMLAttributes, useState } from "react"
import { BackgroundColor, BorderRadius, ContentColor, Theme } from "../../../lib/theme/types"
import { ComponentPropsWithoutRef, ElementType, forwardRef, useState } from "react"
import { getBackground, getBorderRadius, getColor } from "../../../lib/theme/prop-system"
import { Background, BorderRadius, Color } from "../../../lib/theme/props"
import { Theme } from "../../../lib/theme/types"
import { getInitials } from "./getInitials"

interface AvatarBaseProps {
/** Effective element used. */
as?: ElementType

/** Color of the avatar. */
color?: ContentColor
color?: Color

/** Background color of the avatar. */
background?: BackgroundColor
background?: Background

/** Radius of the avatar. Default is `full`. */
radius?: BorderRadius
Expand All @@ -21,7 +22,7 @@ interface AvatarBaseProps {
size?: Size
}

interface AvatarImageProps extends ImgHTMLAttributes<HTMLImageElement> {
interface AvatarImageProps extends ComponentPropsWithoutRef<"img"> {
/** Alternate text of the image. */
alt: string

Expand All @@ -32,13 +33,14 @@ interface AvatarImageProps extends ImgHTMLAttributes<HTMLImageElement> {
src: string | undefined
}

interface AvatarInitialsProps extends HTMLAttributes<HTMLDivElement> {
interface AvatarInitialsProps extends ComponentPropsWithoutRef<"div"> {
/** Name of the user, used to compute initials. */
name: string | undefined
}

export type AvatarProps = AvatarBaseProps & (AvatarImageProps | AvatarInitialsProps)

/** An avatar with an image or initials. */
export const Avatar = forwardRef<HTMLImageElement, AvatarProps>(
({ background = "primary", color = "primary", radius = "full", size = "md", ...props }, ref) => {
const [hasError, setHasError] = useState(false)
Expand Down Expand Up @@ -96,22 +98,20 @@ export const Avatar = forwardRef<HTMLImageElement, AvatarProps>(
type Size = "sm" | "md"

interface ImageProps {
background: BackgroundColor
background: Background
radius: BorderRadius
size: Size
textColor: ContentColor
textColor: Color
}

const Image = styled("img", {
shouldForwardProp: (prop) => isPropValid(prop) && prop !== "color",
})<ImageProps>`
background: ${({ background, theme }) => theme.colors.background[background]};
color: ${({ textColor, theme }) => theme.colors.content[textColor]};
const Image = styled.img<ImageProps>`
background: ${({ background, theme }) => getBackground(background, theme)};
color: ${({ textColor, theme }) => getColor(textColor, theme)};
block-size: ${({ radius, theme, size }) => getSize(radius, theme, size)}rem;
inline-size: ${({ radius, theme, size }) => getSize(radius, theme, size)}rem;
border: ${({ theme }) => 0.25 * theme.spacingBase}rem solid
${({ theme }) => theme.colors.border.primary};
border-radius: ${({ radius, theme }) => theme.borderRadii[radius]};
border-radius: ${({ radius, theme }) => getBorderRadius(radius, theme)};
display: flex;
align-items: center;
justify-content: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,37 @@ import { getInitials } from "./getInitials"
describe("getInitials", () => {
it("handles one name", () => {
const name = "Filip"
const initials = getInitials(name)
expect(initials).toEqual("F")
expect(getInitials(name)).toEqual("F")
})

it("handles two names", () => {
const name = "Filip Tammergård"
const initials = getInitials(name)
expect(initials).toEqual("FT")
expect(getInitials(name)).toEqual("FT")
})

it("uses first and last name if many are provided", () => {
const name = "Filip Mats Oskar Tammergård"
const initials = getInitials(name)
expect(initials).toEqual("FT")
expect(getInitials(name)).toEqual("FT")
})

it("returns upper-case initials", () => {
const name = "filip tammergård"
const initials = getInitials(name)
expect(initials).toEqual("FT")
expect(getInitials(name)).toEqual("FT")
})

it("returns empty string when input is empty string", () => {
const name = ""
expect(getInitials(name)).toEqual("")
})

it("trims whitespace from both ends of name", () => {
let name = " Filip Tammergård"
expect(getInitials(name)).toEqual("FT")

name = "Filip Tammergård "
expect(getInitials(name)).toEqual("FT")

name = " Filip Tammergård "
expect(getInitials(name)).toEqual("FT")
})
})
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
export const getInitials = (name: string | undefined): string | null => {
if (!name) return null
export const getInitials = (name: string | undefined): string => {
if (!name) return ""

const parts = name.split(" ")
const parts = name.trim().split(" ")

if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
if (parts.length >= 2)
return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase()

return null
return ""
}

0 comments on commit 472e9ee

Please sign in to comment.