-
Notifications
You must be signed in to change notification settings - Fork 26
ui-components: Avatar #48
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> | ||
); |
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 | ||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Our warning color is |
||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
import React from "react"; | ||
import { shallow } from "enzyme"; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
}); | ||
}); | ||
}); |
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like the use of |
||
() => | ||
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
flagging to update