Skip to content
This repository has been archived by the owner on Jan 23, 2025. It is now read-only.

Commit

Permalink
ui-components: Avatar component
Browse files Browse the repository at this point in the history
- Includes base implementation of <Avatar> component which includes
ability to modify intent, size, disable, and set selectable props
to component.
- It is a starting point to extend this implementation for more
custom representations as StepAvatar and Validation which require
customization of styles.
- One more story is added to show <Avatar> component in different
states, it is draft version to validate current implementation only.
  • Loading branch information
koorosh committed May 7, 2020
1 parent ca912ae commit 158a389
Show file tree
Hide file tree
Showing 6 changed files with 380 additions and 0 deletions.
138 changes: 138 additions & 0 deletions packages/storybook-ui-components/stories/2-Avatar.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React from "react";
import { withKnobs, text, boolean, select } from "@storybook/addon-knobs";
import { action } from "@storybook/addon-actions";
import { Avatar } from "@cockroachlabs/ui-components";

export default {
title: "Avatar",
components: Avatar,
decorators: [withKnobs],
};

const baseConfig = {
intent: "default",
children: "jb",
size: "l",
disabled: false,
selectable: false,
onClick: () => console.log("click"),
};

const intentConfigs = [
{
description: "Default",
intent: "default",
},
{
description: "Info",
intent: "info",
},
{
description: "Warning",
intent: "warning",
},
{
description: "Danger",
intent: "danger",
},
{
description: "Disabled",
disabled: true,
},
];

const withIntentNotSelectable = intentConfigs.map(c => ({
...baseConfig,
...c,
}));

const withIntentSelectable = withIntentNotSelectable.map(c => ({
...baseConfig,
...c,
description: `${c.description} Selectable`,
selectable: true,
}));

const withIntentSelectableSizeM = withIntentSelectable.map(c => ({
...c,
size: "m",
}));

const withIntentSelectableSizeS = withIntentSelectable.map(c => ({
...c,
size: "s",
}));

const Container = ({ children }) => (
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-around",
marginBottom: "24px",
}}
>
{children}
</div>
);

const ItemWrapper = ({ children, title }) => (
<div
style={{ display: "flex", flexDirection: "column", alignItems: "center" }}
>
<label style={{ marginBottom: "8px" }}>{title}</label>
{children}
</div>
);

export const example = () => (
<section>
<h3>Size L</h3>
<h4>Hover on avatar to see changes (for selectable items only)</h4>

<Container>
{withIntentNotSelectable.map(({ description, ...props }, idx) => (
<ItemWrapper title={description} key={idx}>
<Avatar {...props} />
</ItemWrapper>
))}
</Container>

<Container>
{withIntentSelectable.map(({ description, ...props }, idx) => (
<ItemWrapper title={description} key={idx}>
<Avatar {...props} />
</ItemWrapper>
))}
</Container>

<h3>Size M</h3>
<Container>
{withIntentSelectableSizeM.map(({ description, ...props }, idx) => (
<ItemWrapper title={description} key={idx}>
<Avatar {...props} />
</ItemWrapper>
))}
</Container>
<h3>Size S</h3>
<Container>
{withIntentSelectableSizeS.map(({ description, ...props }, idx) => (
<ItemWrapper title={description} key={idx}>
<Avatar {...props} />
</ItemWrapper>
))}
</Container>
</section>
);

export const demo = () => (
<Avatar
size={select("Size", ["xs", "s", "m", "l"], "l")}
disabled={boolean("Disabled", false)}
intent={select("Intent", ["default", "info", "warning", "danger"], "info")}
onClick={action("button-click")}
selectable={boolean("Selectable", true)}
>
{text("Text", "RL")}
</Avatar>
);
99 changes: 99 additions & 0 deletions packages/ui-components/src/Avatar/Avatar.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
@import "../styles/tokens.scss";

// TODO (koorosh): Probably has to be extracted to tokens.
@mixin selectable-border($color) {
box-sizing: border-box;
border: 2px solid $color;
}

.avatar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-family: Source Sans Pro, sans-serif; // TODO (koorosh): replace with fonts from tokes
font-style: normal;
font-weight: 600;
line-height: 1;
border-radius: 50%;
overflow: hidden;
user-select: none;
}

.intent-default {
background: $crl-neutral-2;
color: $crl-neutral-7;
}

.intent-info {
background: $crl-blue-1;
color: $crl-base-blue;
}

.intent-warning {
background: $crl-yellow-1;
color: $crl-yellow-4;
}

.intent-danger {
background: $crl-red-1;
color: $crl-red-4;
}

.size-l {
width: 40px;
height: 40px;
font-size: 14px;
letter-spacing: 0.1px;
}

.size-m {
width: 32px;
height: 32px;
font-size: 12px;
letter-spacing: 0.3px;
}

.size-s {
width: 24px;
height: 24px;
font-size: 14px;
letter-spacing: 0.1px;
}

.size-xs {
width: 16px;
height: 16px;
font-size: 10px;
letter-spacing: 0.1px;
}

.disabled {
background: $crl-neutral-2;
color: $crl-base-text--light;

&:focus, &:hover {
border: none;
}
}

.selectable:not(.disabled) {
&:focus, &:hover {
&.intent-default {
@include selectable-border($crl-neutral-7);
}

&.intent-info {
@include selectable-border($crl-base-blue);
}

&.intent-warning {
@include selectable-border($crl-yellow-4);
}

&.intent-danger {
@include selectable-border($crl-red-4);
}
}
}
89 changes: 89 additions & 0 deletions packages/ui-components/src/Avatar/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from "react";
import { shallow } from "enzyme";

import Avatar, { AvatarIntent, AvatarSize } from "./Avatar";

describe("Avatar", () => {
describe("Default props", () => {
it("assigns default style classes based on props", () => {
const wrapper = shallow(<Avatar>CL</Avatar>);
const className = wrapper.prop("className");
expect(className).toContain("intent-default");
expect(className).toContain("size-l");
expect(className).not.toContain("disabled");
expect(className).not.toContain("selectable");
});
});

describe("Handle onClick prop", () => {
it("calls callback function on element click", () => {
const onClickSpy = jasmine.createSpy();
const wrapper = shallow(<Avatar onClick={onClickSpy}>CL</Avatar>);
wrapper.simulate("click");
expect(onClickSpy).toHaveBeenCalled();
});

it("does not call onClick function when disabled prop is True", () => {
const onClickSpy = jasmine.createSpy();
const wrapper = shallow(
<Avatar disabled={true} onClick={onClickSpy}>
CL
</Avatar>,
);
wrapper.simulate("click");
expect(onClickSpy).not.toHaveBeenCalled();
});
});

describe("Content rendering", () => {
it("renders with string passed as children", () => {
expect(shallow(<Avatar>foo bar</Avatar>).text()).toEqual("foo bar");
});

it("renders with empty content", () => {
expect(shallow(<Avatar />).text()).toEqual("");
});
});

describe("Intent prop", () => {
const intents: AvatarIntent[] = ["danger", "default", "info", "warning"];

intents.forEach(intent => {
it("applies correct classNames depending on intent", () => {
expect(
shallow(<Avatar intent={intent} />)
.find("div.avatar")
.hasClass(`intent-${intent}`),
).toBeTruthy();
});
});
});

describe("Size prop", () => {
const sizes: AvatarSize[] = ["xs", "s", "m", "l"];

sizes.forEach(size => {
it("applies correct classNames depending on size", () => {
expect(
shallow(<Avatar size={size} />)
.find("div.avatar")
.hasClass(`size-${size}`),
).toBeTruthy();
});
});
});

describe("Disabled prop", () => {
it("excludes intent className", () => {
const wrapper = shallow(<Avatar intent="danger" disabled />);
expect(wrapper.hasClass("disabled")).toBeTruthy();
expect(wrapper.hasClass("intent-danger")).toBeFalsy();
});

it("excludes selectable className", () => {
const wrapper = shallow(<Avatar disabled selectable />);
expect(wrapper.hasClass("disabled")).toBeTruthy();
expect(wrapper.hasClass("selectable")).toBeFalsy();
});
});
});
52 changes: 52 additions & 0 deletions packages/ui-components/src/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { useCallback, useMemo } from "react";
import classNames from "classnames/bind";

import styles from "./Avatar.module.scss";
import objectToClassnames from "../utils/objectToClassnames";

export interface AvatarProps {
children?: string;
size?: AvatarSize;
intent?: AvatarIntent;
disabled?: boolean;
selectable?: boolean;
onClick?: () => void;
}

export type AvatarSize = "xs" | "s" | "m" | "l";
export type AvatarIntent = "default" | "info" | "warning" | "danger";

const cx = classNames.bind(styles);

const Avatar: React.FC<AvatarProps> = ({
children,
intent = "default",
size = "l",
disabled = false,
selectable = false,
onClick,
}) => {
const classnames = useMemo(
() =>
cx("avatar", objectToClassnames({ size }), {
disabled,
selectable: !disabled && selectable,
[`intent-${intent}`]: !disabled,
}),
[intent, size, disabled, selectable],
);

const onClickHandler = useCallback(() => {
if (!disabled && onClick) {
onClick();
}
}, [onClick, disabled]);

return (
<div className={classnames} onClick={onClickHandler}>
{children}
</div>
);
};

export default Avatar;
1 change: 1 addition & 0 deletions packages/ui-components/src/Avatar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Avatar } from "./Avatar";
1 change: 1 addition & 0 deletions packages/ui-components/src/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { Badge } from "./Badge";
export { Avatar } from "./Avatar";

0 comments on commit 158a389

Please sign in to comment.