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

ui-components: Avatar #48

Merged
merged 3 commits into from
May 26, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions packages/storybook-ui-components/stories/2-Avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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: "default",
disabled: false,
selectable: false,
onClick: () => console.log("click"),
};

const intentConfigs = [
{
description: "Default",
intent: "default",
},
{
description: "Active",
intent: "active",
},
{
description: "Pending",
intent: "pending",
},
{
description: "Invalid",
intent: "invalid",
},
{
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 withIntentSelectableSmallSize = withIntentSelectable.map(c => ({
...c,
size: "small",
}));

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>Default size</h3>
<Container>
{withIntentNotSelectable.map(({ description, ...props }, idx) => (
<ItemWrapper title={description} key={idx}>
<Avatar {...props} />
</ItemWrapper>
))}
</Container>
<h4>Hover on avatar to see changes (for selectable items only)</h4>
<Container>
{withIntentSelectable.map(({ description, ...props }, idx) => (
<ItemWrapper title={description} key={idx}>
<Avatar {...props} />
</ItemWrapper>
))}
</Container>

<h3>Small size</h3>
<Container>
{withIntentSelectableSmallSize.map(({ description, ...props }, idx) => (
<ItemWrapper title={description} key={idx}>
<Avatar {...props} />
</ItemWrapper>
))}
</Container>
</section>
);

export const demo = () => (
<Avatar
size={select("Size", ["default", "small"], "default")}
disabled={boolean("Disabled", false)}
intent={select(
"Intent",
["default", "active", "pending", "invalid"],
"active",
)}
onClick={action("button-click")}
selectable={boolean("Selectable", true)}
transformCase={select("transformCase", ["none", "uppercase"], "uppercase")}
>
{text("Text", "RL")}
</Avatar>
);
97 changes: 97 additions & 0 deletions packages/ui-components/src/Avatar/Avatar.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
@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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flagging to update

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-active {
background: $crl-blue-1;
color: $crl-base-blue;
}

.intent-pending {
background: $crl-orange-1;
color: $crl-orange-4;
}

.intent-invalid {
background: $crl-red-1;
color: $crl-red-4;
koorosh marked this conversation as resolved.
Show resolved Hide resolved
}

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

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

.noTransform {
text-transform: none;
}

.transformCase-none {
text-transform: none;
}

.transformCase-uppercase {
text-transform: uppercase;
}

.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-active {
@include selectable-border($crl-base-blue);
}

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

&.intent-invalid {
@include selectable-border($crl-red-4);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our warning color is $crl-base-red/$crl-red-3; comment re: base colors from above applies here too.

}
}
}
90 changes: 90 additions & 0 deletions packages/ui-components/src/Avatar/Avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from "react";
import { shallow } from "enzyme";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@laurenbarker not an issue for this PR, but given the favorable reception of React Testing Lib, would you be onboard if I create an issue to migrate over from Enzyme?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was thinking the same thing! Created one this morning #49


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-default");
expect(className).toContain("transformCase-uppercase");
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[] = ["invalid", "default", "active", "pending"];

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[] = ["default", "small"];

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="invalid" disabled />);
expect(wrapper.hasClass("disabled")).toBeTruthy();
expect(wrapper.hasClass("intent-invalid")).toBeFalsy();
});

it("excludes selectable className", () => {
const wrapper = shallow(<Avatar disabled selectable />);
expect(wrapper.hasClass("disabled")).toBeTruthy();
expect(wrapper.hasClass("selectable")).toBeFalsy();
});
});
});
55 changes: 55 additions & 0 deletions packages/ui-components/src/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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;
transformCase?: AvatarCase;
}

export type AvatarSize = "default" | "small";
export type AvatarIntent = "default" | "active" | "pending" | "invalid";
export type AvatarCase = "none" | "uppercase";

const cx = classNames.bind(styles);

const Avatar: React.FC<AvatarProps> = ({
children,
intent = "default",
size = "default",
disabled = false,
selectable = false,
onClick,
transformCase = "uppercase",
}) => {
const classnames = useMemo(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the use of useMemo here 👍

() =>
cx("avatar", objectToClassnames({ size, transformCase }), {
disabled,
selectable: !disabled && selectable,
[`intent-${intent}`]: !disabled,
}),
[intent, size, disabled, selectable, transformCase],
);

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

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

export default Avatar;
Loading