From b4d4765b417d8251bb774538ebbedf84ed2fee6e Mon Sep 17 00:00:00 2001 From: Andrii Vorobiov Date: Wed, 6 May 2020 14:12:46 +0300 Subject: [PATCH 1/3] ui-components: Avatar component - Includes base implementation of 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 component in different states, it is draft version to validate current implementation only. --- .../stories/2-Avatar.stories.tsx | 142 ++++++++++++++++++ .../src/Avatar/Avatar.module.scss | 99 ++++++++++++ .../ui-components/src/Avatar/Avatar.test.tsx | 89 +++++++++++ packages/ui-components/src/Avatar/Avatar.tsx | 52 +++++++ packages/ui-components/src/Avatar/README.md | 35 +++++ packages/ui-components/src/Avatar/index.ts | 1 + packages/ui-components/src/index.js | 1 + 7 files changed, 419 insertions(+) create mode 100644 packages/storybook-ui-components/stories/2-Avatar.stories.tsx create mode 100644 packages/ui-components/src/Avatar/Avatar.module.scss create mode 100644 packages/ui-components/src/Avatar/Avatar.test.tsx create mode 100644 packages/ui-components/src/Avatar/Avatar.tsx create mode 100644 packages/ui-components/src/Avatar/README.md create mode 100644 packages/ui-components/src/Avatar/index.ts diff --git a/packages/storybook-ui-components/stories/2-Avatar.stories.tsx b/packages/storybook-ui-components/stories/2-Avatar.stories.tsx new file mode 100644 index 000000000..92eb406d2 --- /dev/null +++ b/packages/storybook-ui-components/stories/2-Avatar.stories.tsx @@ -0,0 +1,142 @@ +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: "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 withIntentSelectableSizeM = withIntentSelectable.map(c => ({ + ...c, + size: "m", +})); + +const withIntentSelectableSizeS = withIntentSelectable.map(c => ({ + ...c, + size: "s", +})); + +const Container = ({ children }) => ( +
+ {children} +
+); + +const ItemWrapper = ({ children, title }) => ( +
+ + {children} +
+); + +export const example = () => ( +
+

Size L

+

Hover on avatar to see changes (for selectable items only)

+ + + {withIntentNotSelectable.map(({ description, ...props }, idx) => ( + + + + ))} + + + + {withIntentSelectable.map(({ description, ...props }, idx) => ( + + + + ))} + + +

Size M

+ + {withIntentSelectableSizeM.map(({ description, ...props }, idx) => ( + + + + ))} + +

Size S

+ + {withIntentSelectableSizeS.map(({ description, ...props }, idx) => ( + + + + ))} + +
+); + +export const demo = () => ( + + {text("Text", "RL")} + +); diff --git a/packages/ui-components/src/Avatar/Avatar.module.scss b/packages/ui-components/src/Avatar/Avatar.module.scss new file mode 100644 index 000000000..921e1965b --- /dev/null +++ b/packages/ui-components/src/Avatar/Avatar.module.scss @@ -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-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; +} + +.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-active { + @include selectable-border($crl-base-blue); + } + + &.intent-pending { + @include selectable-border($crl-orange-4); + } + + &.intent-invalid { + @include selectable-border($crl-red-4); + } + } +} diff --git a/packages/ui-components/src/Avatar/Avatar.test.tsx b/packages/ui-components/src/Avatar/Avatar.test.tsx new file mode 100644 index 000000000..576953321 --- /dev/null +++ b/packages/ui-components/src/Avatar/Avatar.test.tsx @@ -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(CL); + 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(CL); + wrapper.simulate("click"); + expect(onClickSpy).toHaveBeenCalled(); + }); + + it("does not call onClick function when disabled prop is True", () => { + const onClickSpy = jasmine.createSpy(); + const wrapper = shallow( + + CL + , + ); + wrapper.simulate("click"); + expect(onClickSpy).not.toHaveBeenCalled(); + }); + }); + + describe("Content rendering", () => { + it("renders with string passed as children", () => { + expect(shallow(foo bar).text()).toEqual("foo bar"); + }); + + it("renders with empty content", () => { + expect(shallow().text()).toEqual(""); + }); + }); + + describe("Intent prop", () => { + const intents: AvatarIntent[] = ["invalid", "default", "active", "pending"]; + + intents.forEach(intent => { + it("applies correct classNames depending on intent", () => { + expect( + shallow() + .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() + .find("div.avatar") + .hasClass(`size-${size}`), + ).toBeTruthy(); + }); + }); + }); + + describe("Disabled prop", () => { + it("excludes intent className", () => { + const wrapper = shallow(); + expect(wrapper.hasClass("disabled")).toBeTruthy(); + expect(wrapper.hasClass("intent-invalid")).toBeFalsy(); + }); + + it("excludes selectable className", () => { + const wrapper = shallow(); + expect(wrapper.hasClass("disabled")).toBeTruthy(); + expect(wrapper.hasClass("selectable")).toBeFalsy(); + }); + }); +}); diff --git a/packages/ui-components/src/Avatar/Avatar.tsx b/packages/ui-components/src/Avatar/Avatar.tsx new file mode 100644 index 000000000..647f7f3a3 --- /dev/null +++ b/packages/ui-components/src/Avatar/Avatar.tsx @@ -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" | "active" | "pending" | "invalid"; + +const cx = classNames.bind(styles); + +const Avatar: React.FC = ({ + 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 ( +
+ {children} +
+ ); +}; + +export default Avatar; diff --git a/packages/ui-components/src/Avatar/README.md b/packages/ui-components/src/Avatar/README.md new file mode 100644 index 000000000..c3ca4292f --- /dev/null +++ b/packages/ui-components/src/Avatar/README.md @@ -0,0 +1,35 @@ +[back to components](../README.md) + +# Avatar + +## Properties +#### children?: string +Content of `Avatar`, can be any arbitrary text. +The length of the text is not limited by component itself. +It has to be truncated by user. +### size?: AvatarSize +Available options: `"xs" | "s" | "m" | "l"` + +Default option: `"l"` + +Changes the size of avatar + +### intent?: AvatarIntent +Available options: `"default" | "active" | "pending" | "invalid"` + +Default option: `"default"` + +Changes the color palette of avatar + +### disabled?: boolean +Default option: `false` + +Disable avatar interaction, events handlers on click, and prevents discards `selectable` option + +### selectable?: boolean +Default option: `false` +Applies visual styling when mouse hovering on Avatar + +### onClick?: () => void +Callback function to be called when click on Avatar. +It can be called if `disabled` option isn't set to true. \ No newline at end of file diff --git a/packages/ui-components/src/Avatar/index.ts b/packages/ui-components/src/Avatar/index.ts new file mode 100644 index 000000000..1a54be985 --- /dev/null +++ b/packages/ui-components/src/Avatar/index.ts @@ -0,0 +1 @@ +export { default as Avatar } from "./Avatar"; diff --git a/packages/ui-components/src/index.js b/packages/ui-components/src/index.js index 5c7042709..617553cb1 100644 --- a/packages/ui-components/src/index.js +++ b/packages/ui-components/src/index.js @@ -1 +1,2 @@ export { Badge } from "./Badge"; +export { Avatar } from "./Avatar"; From 407133add03c3b165fb84a852b51ac48f23af908 Mon Sep 17 00:00:00 2001 From: Andrii Vorobiov Date: Fri, 22 May 2020 13:25:57 +0300 Subject: [PATCH 2/3] ui-components: Uppercase text transformation Avatar component has to be forced to display text with upper-case. Added `transformCase` prop with the same styles as in `Badge` component to follow the same styles. --- .../stories/2-Avatar.stories.tsx | 1 + packages/ui-components/src/Avatar/Avatar.module.scss | 12 ++++++++++++ packages/ui-components/src/Avatar/Avatar.test.tsx | 1 + packages/ui-components/src/Avatar/Avatar.tsx | 7 +++++-- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/storybook-ui-components/stories/2-Avatar.stories.tsx b/packages/storybook-ui-components/stories/2-Avatar.stories.tsx index 92eb406d2..fdeaecec3 100644 --- a/packages/storybook-ui-components/stories/2-Avatar.stories.tsx +++ b/packages/storybook-ui-components/stories/2-Avatar.stories.tsx @@ -136,6 +136,7 @@ export const demo = () => ( )} onClick={action("button-click")} selectable={boolean("Selectable", true)} + transformCase={select("transformCase", ["none", "uppercase"], "uppercase")} > {text("Text", "RL")}
diff --git a/packages/ui-components/src/Avatar/Avatar.module.scss b/packages/ui-components/src/Avatar/Avatar.module.scss index 921e1965b..8b0f2f93f 100644 --- a/packages/ui-components/src/Avatar/Avatar.module.scss +++ b/packages/ui-components/src/Avatar/Avatar.module.scss @@ -69,6 +69,18 @@ letter-spacing: 0.1px; } +.noTransform { + text-transform: none; +} + +.transformCase-none { + text-transform: none; +} + +.transformCase-uppercase { + text-transform: uppercase; +} + .disabled { background: $crl-neutral-2; color: $crl-base-text--light; diff --git a/packages/ui-components/src/Avatar/Avatar.test.tsx b/packages/ui-components/src/Avatar/Avatar.test.tsx index 576953321..6054b6342 100644 --- a/packages/ui-components/src/Avatar/Avatar.test.tsx +++ b/packages/ui-components/src/Avatar/Avatar.test.tsx @@ -10,6 +10,7 @@ describe("Avatar", () => { const className = wrapper.prop("className"); expect(className).toContain("intent-default"); expect(className).toContain("size-l"); + expect(className).toContain("transformCase-uppercase"); expect(className).not.toContain("disabled"); expect(className).not.toContain("selectable"); }); diff --git a/packages/ui-components/src/Avatar/Avatar.tsx b/packages/ui-components/src/Avatar/Avatar.tsx index 647f7f3a3..5db76e8ef 100644 --- a/packages/ui-components/src/Avatar/Avatar.tsx +++ b/packages/ui-components/src/Avatar/Avatar.tsx @@ -11,10 +11,12 @@ export interface AvatarProps { disabled?: boolean; selectable?: boolean; onClick?: () => void; + transformCase?: AvatarCase; } export type AvatarSize = "xs" | "s" | "m" | "l"; export type AvatarIntent = "default" | "active" | "pending" | "invalid"; +export type AvatarCase = "none" | "uppercase"; const cx = classNames.bind(styles); @@ -25,15 +27,16 @@ const Avatar: React.FC = ({ disabled = false, selectable = false, onClick, + transformCase = "uppercase", }) => { const classnames = useMemo( () => - cx("avatar", objectToClassnames({ size }), { + cx("avatar", objectToClassnames({ size, transformCase }), { disabled, selectable: !disabled && selectable, [`intent-${intent}`]: !disabled, }), - [intent, size, disabled, selectable], + [intent, size, disabled, selectable, transformCase], ); const onClickHandler = useCallback(() => { From ea2e7fdb4fe27a0d8fd69520ded58886a59ae02b Mon Sep 17 00:00:00 2001 From: Andrii Vorobiov Date: Fri, 22 May 2020 13:45:47 +0300 Subject: [PATCH 3/3] ui-components: Reduce Avatar sizes Avatar component sizing is changed to have only two options: default and small sizes. Very small sizes are removed (xs, and s sizes) Also size naming are changed to reflect that only two options are available for now. --- .../stories/2-Avatar.stories.tsx | 31 +++++-------------- .../src/Avatar/Avatar.module.scss | 18 ++--------- .../ui-components/src/Avatar/Avatar.test.tsx | 4 +-- packages/ui-components/src/Avatar/Avatar.tsx | 4 +-- 4 files changed, 14 insertions(+), 43 deletions(-) diff --git a/packages/storybook-ui-components/stories/2-Avatar.stories.tsx b/packages/storybook-ui-components/stories/2-Avatar.stories.tsx index fdeaecec3..73d7f1991 100644 --- a/packages/storybook-ui-components/stories/2-Avatar.stories.tsx +++ b/packages/storybook-ui-components/stories/2-Avatar.stories.tsx @@ -12,7 +12,7 @@ export default { const baseConfig = { intent: "default", children: "jb", - size: "l", + size: "default", disabled: false, selectable: false, onClick: () => console.log("click"), @@ -53,14 +53,9 @@ const withIntentSelectable = withIntentNotSelectable.map(c => ({ selectable: true, })); -const withIntentSelectableSizeM = withIntentSelectable.map(c => ({ +const withIntentSelectableSmallSize = withIntentSelectable.map(c => ({ ...c, - size: "m", -})); - -const withIntentSelectableSizeS = withIntentSelectable.map(c => ({ - ...c, - size: "s", + size: "small", })); const Container = ({ children }) => ( @@ -87,9 +82,7 @@ const ItemWrapper = ({ children, title }) => ( export const example = () => (
-

Size L

-

Hover on avatar to see changes (for selectable items only)

- +

Default size

{withIntentNotSelectable.map(({ description, ...props }, idx) => ( @@ -97,7 +90,7 @@ export const example = () => ( ))} - +

Hover on avatar to see changes (for selectable items only)

{withIntentSelectable.map(({ description, ...props }, idx) => ( @@ -106,17 +99,9 @@ export const example = () => ( ))} -

Size M

- - {withIntentSelectableSizeM.map(({ description, ...props }, idx) => ( - - - - ))} - -

Size S

+

Small size

- {withIntentSelectableSizeS.map(({ description, ...props }, idx) => ( + {withIntentSelectableSmallSize.map(({ description, ...props }, idx) => ( @@ -127,7 +112,7 @@ export const example = () => ( export const demo = () => ( { const wrapper = shallow(CL); const className = wrapper.prop("className"); expect(className).toContain("intent-default"); - expect(className).toContain("size-l"); + expect(className).toContain("size-default"); expect(className).toContain("transformCase-uppercase"); expect(className).not.toContain("disabled"); expect(className).not.toContain("selectable"); @@ -61,7 +61,7 @@ describe("Avatar", () => { }); describe("Size prop", () => { - const sizes: AvatarSize[] = ["xs", "s", "m", "l"]; + const sizes: AvatarSize[] = ["default", "small"]; sizes.forEach(size => { it("applies correct classNames depending on size", () => { diff --git a/packages/ui-components/src/Avatar/Avatar.tsx b/packages/ui-components/src/Avatar/Avatar.tsx index 5db76e8ef..49e87406c 100644 --- a/packages/ui-components/src/Avatar/Avatar.tsx +++ b/packages/ui-components/src/Avatar/Avatar.tsx @@ -14,7 +14,7 @@ export interface AvatarProps { transformCase?: AvatarCase; } -export type AvatarSize = "xs" | "s" | "m" | "l"; +export type AvatarSize = "default" | "small"; export type AvatarIntent = "default" | "active" | "pending" | "invalid"; export type AvatarCase = "none" | "uppercase"; @@ -23,7 +23,7 @@ const cx = classNames.bind(styles); const Avatar: React.FC = ({ children, intent = "default", - size = "l", + size = "default", disabled = false, selectable = false, onClick,