chore: Add the ability to use icons in WdsButton (#23014)

Adds the ability to pass icon in button component and adds button group
to the storybook

## Description

Fixes #21924
Fixes #21926

Media
> A video or a GIF is preferred. when using Loom, don’t embed because it
looks like it’s a GIF. instead, just link to the video

## Type of change
- Bug fix

## How Has This Been Tested?
- Manual


### Test Plan
> Add Testsmith test cases links that relate to this PR

### Issues raised during DP testing
> Link issues raised during DP testing for better visiblity and tracking
(copy link from comments dropped on this PR)


## Checklist:
### Dev activity
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


### QA activity:
- [ ] Test plan has been approved by relevant developers
- [ ] Test plan has been peer reviewed by QA
- [ ] Cypress test cases have been added and approved by either SDET or
manual QA
- [ ] Organized project review call with relevant stakeholders after
Round 1/2 of QA
- [ ] Added Test Plan Approved label after reveiwing all Cypress test
This commit is contained in:
Pawan Kumar 2023-05-10 17:34:03 +05:30 committed by GitHub
parent 2a14687f35
commit 526a358329
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 477 additions and 44 deletions

View File

@ -0,0 +1,23 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import { Button } from "./";
<Meta
title="Design-system/headless/Button"
component={Button}
args={{
children: "Button",
}}
/>
export const Template = (args) => <Button {...args} />;
# Button
A button is a clickable element that is used to trigger an action.
<Canvas>
<Story name="Button">{Template.bind({})}</Story>
</Canvas>
<ArgsTable story="Button" of={Button} />

View File

@ -6,9 +6,14 @@ import { useFocusRing } from "@react-aria/focus";
import { useHover } from "@react-aria/interactions";
import { useFocusableRef } from "@react-spectrum/utils";
import type { FocusableRef } from "@react-types/shared";
import type { ButtonProps as SpectrumButtonProps } from "@react-types/button";
import type {
AriaButtonProps as SpectrumAriaBaseButtonProps,
ButtonProps as SpectrumButtonProps,
} from "@react-types/button";
export interface ButtonProps extends SpectrumButtonProps {
export interface ButtonProps
extends SpectrumButtonProps,
SpectrumAriaBaseButtonProps {
className?: string;
}

View File

@ -0,0 +1,32 @@
import React from "react";
import type { AriaLabelingProps, DOMProps } from "@react-types/shared";
import type { ReactElement } from "react";
import { filterDOMProps } from "@react-aria/utils";
export interface IconProps extends DOMProps, AriaLabelingProps {
"aria-label"?: string;
children: ReactElement;
"aria-hidden"?: boolean | "false" | "true";
role?: string;
}
export function Icon(props: IconProps) {
const {
"aria-hidden": ariaHiddenProp,
"aria-label": ariaLabel,
children,
role = "img",
...otherProps
} = props;
const ariaHidden = !ariaHiddenProp ? undefined : ariaHiddenProp;
return React.cloneElement(children, {
...filterDOMProps(otherProps),
focusable: "false",
"aria-label": ariaLabel,
"aria-hidden": ariaLabel ? ariaHidden || undefined : true,
role,
"data-icon": "",
});
}

View File

@ -0,0 +1 @@
export { Icon } from "./Icon";

View File

@ -2,3 +2,4 @@
export * from "./components/Button";
export * from "./components/Checkbox";
export * from "./components/Field";
export * from "./components/Icon";

View File

@ -0,0 +1,5 @@
module.exports = {
preset: "ts-jest",
roots: ["<rootDir>/src"],
testEnvironment: "jsdom",
};

View File

@ -6,7 +6,8 @@
"license": "MIT",
"scripts": {
"lint:ci": "eslint --cache .",
"prettier:ci": "prettier --check ."
"prettier:ci": "prettier --check .",
"test:unit": "jest"
},
"dependencies": {
"@capsizecss/core": "^3.1.0",

View File

@ -1,5 +1,8 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import { Button } from "./";
import { Icon } from "@design-system/headless";
import EmotionHappyLineIcon from "remixicon-react/EmotionHappyLineIcon";
<Meta
title="Design-system/widgets/Button"
@ -78,3 +81,53 @@ There are 3 variants of the button component.
{Template.bind({})}
</Story>
</Canvas>
# With Icon
<Canvas>
<Story
name="With Icon"
args={{
children: "With Icon",
icon: (
<Icon>
<EmotionHappyLineIcon />
</Icon>
),
}}
>
{Template.bind({})}
</Story>
</Canvas>
# Icon Position
<Canvas>
<Story
name="Icon Position - Start"
args={{
children: "Icon Start",
icon: (
<Icon>
<EmotionHappyLineIcon />
</Icon>
),
}}
>
{Template.bind({})}
</Story>
<Story
name="Icon Position - End"
args={{
children: "Icon End",
icon: (
<Icon>
<EmotionHappyLineIcon />
</Icon>
),
iconPosition: "end",
}}
>
{Template.bind({})}
</Story>
</Canvas>

View File

@ -0,0 +1,66 @@
import React from "react";
import "@testing-library/jest-dom";
import { Icon } from "@design-system/headless";
import { render, screen } from "@testing-library/react";
import EmotionHappyLineIcon from "remixicon-react/EmotionHappyLineIcon";
import { Button } from "./";
describe("@design-system/widgets/Button", () => {
it("renders children when passed", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button")).toHaveTextContent("Click me");
});
it("passes type to button component", () => {
render(<Button type="submit" />);
expect(screen.getByRole("button")).toHaveAttribute("type", "submit");
});
it("sets variant based on prop", () => {
render(<Button variant="primary" />);
expect(screen.getByRole("button")).toHaveAttribute(
"data-variant",
"primary",
);
});
it("sets disabled attribute based on prop", () => {
render(<Button isDisabled />);
expect(screen.getByRole("button")).toBeDisabled();
expect(screen.getByRole("button")).toHaveAttribute("data-disabled");
});
it("sets data-loading attribute and icon based on loading prop", () => {
render(<Button isLoading />);
expect(screen.getByRole("button")).toHaveAttribute("data-loading");
const icon = screen.getByRole("button").querySelector("[data-icon]");
expect(icon).toBeInTheDocument();
});
it("renders icon when passed", () => {
const { container } = render(
<Button
icon={
<Icon>
<EmotionHappyLineIcon />
</Icon>
}
/>,
);
const icon = container.querySelector("button [data-icon]") as HTMLElement;
expect(icon).toBeInTheDocument();
});
it("sets icon position attribute based on the prop ", () => {
const { container } = render(<Button iconPosition="end" />);
const button = container.querySelector("button") as HTMLElement;
expect(button).toHaveAttribute("data-icon-position", "end");
const styles = window.getComputedStyle(button);
expect(styles.flexDirection).toBe("row-reverse");
});
});

View File

@ -1,4 +1,5 @@
import React, { forwardRef } from "react";
import { Icon as HeadlessIcon } from "@design-system/headless";
import type {
ButtonProps as HeadlessButtonProps,
ButtonRef as HeadlessButtonRef,
@ -20,6 +21,7 @@ export interface ButtonProps extends Omit<HeadlessButtonProps, "className"> {
fontFamily?: fontFamilyTypes;
isFitContainer?: boolean;
isFocused?: boolean;
icon?: React.ReactNode;
iconPosition?: "start" | "end";
}
@ -28,6 +30,8 @@ export const Button = forwardRef(
const {
children,
fontFamily,
icon,
iconPosition = "start",
isFitContainer = false,
isLoading,
// eslint-disable-next-line -- TODO add onKeyUp when the bug is fixedhttps://github.com/adobe/react-spectrum/issues/4350
@ -38,19 +42,28 @@ export const Button = forwardRef(
const renderChildren = () => {
if (isLoading) {
return <Spinner />;
return (
<HeadlessIcon>
<Spinner />
</HeadlessIcon>
);
}
return (
<Text fontFamily={fontFamily} lineClamp={1}>
{children}
</Text>
<>
{icon}
<Text fontFamily={fontFamily} lineClamp={1}>
{children}
</Text>
</>
);
};
return (
<StyledButton
data-button=""
data-fit-container={isFitContainer ? "" : undefined}
data-icon-position={iconPosition === "start" ? undefined : "end"}
data-loading={isLoading ? "" : undefined}
data-variant={variant}
ref={ref}

View File

@ -8,7 +8,7 @@ export const StyledButton = styled(HeadlessButton)<ButtonProps>`
align-items: center;
cursor: pointer;
outline: 0;
gap: var(--spacing-4);
gap: var(--spacing-1);
padding: var(--spacing-2) var(--spacing-4);
height: calc(var(--sizing-root-unit) * 8);
border-radius: var(--border-radius-1);
@ -76,4 +76,13 @@ export const StyledButton = styled(HeadlessButton)<ButtonProps>`
pointer-events: none;
opacity: var(--opacity-disabled);
}
& [data-icon] {
height: calc(var(--sizing-root-unit) * 5);
width: calc(var(--sizing-root-unit) * 5);
}
&[data-icon-position="end"] {
flex-direction: row-reverse;
}
`;

View File

@ -0,0 +1,192 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import { Button } from "../Button";
import { ButtonGroup } from "./";
<Meta
title="Design-system/widgets/Button Group"
component={ButtonGroup}
args={{
children: (
<>
<Button>Option 1</Button>
<Button>Option 2</Button>
<Button>Option 3</Button>
</>
),
}}
/>
export const Template = (args) => <ButtonGroup {...args} />;
## Button Group
A button group is a group of buttons that are visually connected together.
<Canvas>
<Story name="Button Group">{Template.bind({})}</Story>
</Canvas>
<ArgsTable story="Button Group" of={ButtonGroup} />
# Variants
<Canvas>
<Story
name="Button Group - Primary"
args={{
children: (
<>
<Button variant="primary">Option 1</Button>
<Button variant="primary">Option 2</Button>
<Button variant="primary">Option 3</Button>
</>
),
}}
>
{Template.bind({})}
</Story>
<Story
name="Button Group - Secondary"
args={{
children: (
<>
<Button variant="secondary">Option 1</Button>
<Button variant="secondary">Option 2</Button>
<Button variant="secondary">Option 3</Button>
</>
),
}}
>
{Template.bind({})}
</Story>
<Story
name="Button Group - Tertiary"
args={{
children: (
<>
<Button variant="tertiary">Option 1</Button>
<Button variant="tertiary">Option 2</Button>
<Button variant="tertiary">Option 3</Button>
</>
),
}}
>
{Template.bind({})}
</Story>
</Canvas>
# Orientation
<Canvas>
<Story
name="Button Group - Vertical Primary"
args={{
orientation: "vertical",
children: (
<>
<Button variant="primary">Option 2</Button>
<Button variant="primary">Option 2</Button>
<Button variant="primary">Option 3</Button>
</>
),
}}
>
{Template.bind({})}
</Story>
<Story
name="Button Group - Vertical Secondary"
args={{
orientation: "vertical",
children: (
<>
<Button variant="secondary">Option 2</Button>
<Button variant="secondary">Option 2</Button>
<Button variant="secondary">Option 3</Button>
</>
),
}}
>
{Template.bind({})}
</Story>
<Story
name="Button Group - Vertical Tertiary"
args={{
orientation: "vertical",
children: (
<>
<Button variant="tertiary">Option 2</Button>
<Button variant="tertiary">Option 2</Button>
<Button variant="tertiary">Option 3</Button>
</>
),
}}
>
{Template.bind({})}
</Story>
</Canvas>
# Disabled
<Canvas>
<Story
name="Button Group Primary Disabled"
args={{
children: (
<>
<Button variant="primary" isDisabled>
Option 2
</Button>
<Button variant="primary" isDisabled>
Option 2
</Button>
<Button variant="primary" isDisabled>
Option 3
</Button>
</>
),
}}
>
{Template.bind({})}
</Story>
<Story
name="Button Group Secondary Disabled"
args={{
children: (
<>
<Button variant="secondary" isDisabled>
Option 2
</Button>
<Button variant="secondary" isDisabled>
Option 2
</Button>
<Button variant="secondary" isDisabled>
Option 3
</Button>
</>
),
}}
>
{Template.bind({})}
</Story>
<Story
name="Button Group Tertiary Disabled"
args={{
children: (
<>
<Button variant="tertiary" isDisabled>
Option 2
</Button>
<Button variant="tertiary" isDisabled>
Option 2
</Button>
<Button variant="tertiary" isDisabled>
Option 3
</Button>
</>
),
}}
>
{Template.bind({})}
</Story>
</Canvas>

View File

@ -3,11 +3,12 @@ import React, { forwardRef } from "react";
import { StyledContainer } from "./index.styled";
// types
export enum Orientation {
VERTICAL = "vertical",
HORIZONTAL = "horizontal",
}
export const ORIENTATION = {
VERTICAL: "vertical",
HORIZONTAL: "horizontal",
} as const;
type Orientation = (typeof ORIENTATION)[keyof typeof ORIENTATION];
export interface ButtonGroupProps
extends React.ComponentPropsWithoutRef<"div"> {
children?: React.ReactNode;
@ -17,8 +18,17 @@ export interface ButtonGroupProps
// component
export const ButtonGroup = forwardRef<HTMLDivElement, ButtonGroupProps>(
(props, ref) => {
const { orientation = Orientation.HORIZONTAL, ...others } = props;
return <StyledContainer orientation={orientation} ref={ref} {...others} />;
const { orientation = ORIENTATION.HORIZONTAL, ...others } = props;
return (
<StyledContainer
data-orientation={
orientation === ORIENTATION.VERTICAL ? "vertical" : undefined
}
ref={ref}
{...others}
/>
);
},
);

View File

@ -3,13 +3,16 @@ import styled from "styled-components";
import type { ButtonGroupProps } from "./ButtonGroup";
export const StyledContainer = styled.div<ButtonGroupProps>`
--border-width: 1px;
display: flex;
height: 100%;
width: 100%;
flex-direction: ${({ orientation }) =>
orientation === "vertical" ? "column" : "row"};
flex-direction: row;
align-items: center;
justify-content: center;
&[data-orientation="vertical"] {
flex-direction: column;
}
& [data-button] {
// increasing z index to make sure the focused button is on top of the others
@ -17,48 +20,65 @@ export const StyledContainer = styled.div<ButtonGroupProps>`
z-index: 1;
}
&:is([data-variant="filled"]):not([data-disabled]) {
border-color: var(--wds-vs-color-border-onaccent);
}
&:is([data-variant="light"]):not([data-disabled]) {
border-color: var(--wds-vs-color-border-onaccent-light);
}
&:first-child {
border-bottom-right-radius: 0;
${({ orientation }) =>
orientation === "vertical"
? "border-bottom-left-radius: 0; border-bottom-width: calc(var(--border-width) / 2);"
: "border-top-right-radius: 0; border-right-width: calc(var(--border-width) / 2);"}
}
&:last-of-type {
border-top-left-radius: 0;
${({ orientation }) =>
orientation === "vertical"
? "border-top-right-radius: 0; border-top-width: calc(var(--border-width) / 2);"
: "border-bottom-left-radius: 0; border-left-width: calc(var(--border-width) / 2);"}
}
&:not(:first-child):not(:last-of-type) {
border-radius: 0;
}
}
${({ orientation }) =>
orientation === "vertical"
? "border-top-width: calc(var(--border-width) / 2); border-bottom-width: calc(var(--border-width) / 2);"
: "border-left-width: calc(var(--border-width) / 2); border-right-width: calc(var(--border-width) / 2);"}
&:not([data-orientation="vertical"]) [data-button] {
&:first-child {
border-top-right-radius: 0;
border-right-width: calc(var(--border-width-1) / 2);
}
&:last-of-type {
border-bottom-left-radius: 0;
border-left-width: calc(var(--border-width-1) / 2);
}
&:not(:first-child):not(:last-of-type) {
border-left-width: calc(var(--border-width-1) / 2);
border-right-width: calc(var(--border-width-1) / 2);
}
& + [data-button] {
${({ orientation }) =>
orientation === "vertical"
? "margin-top: calc(var(--border-width) * -1);"
: "margin-left: calc(var(--border-width) * -1);"}
margin-left: calc(var(--border-width-1) * -1);
@media (min-resolution: 192dpi) {
${({ orientation }) =>
orientation === "vertical" ? "margin-top: 0px;" : "margin-left: 0px;"}
margin-left: 0px;
}
}
}
&[data-orientation="vertical"] [data-button] {
&:first-child {
border-bottom-left-radius: 0;
border-bottom-width: calc(var(--border-width-1) / 2);
}
&:last-of-type {
border-top-right-radius: 0;
border-top-width: calc(var(--border-width-1) / 2);
}
&:not(:first-child):not(:last-of-type) {
border-top-width: calc(var(--border-width-1) / 2);
border-bottom-width: calc(var(--border-width-1) / 2);
}
& + [data-button] {
margin-top: calc(var(--border-width-1) * -1);
@media (min-resolution: 192dpi) {
margin-top: 0px;
}
}
}

View File

@ -23,7 +23,9 @@ async function webpackConfig(config) {
module.exports = {
stories: [
"../../design-system/widgets/src/**/*.stories.mdx",
"../../design-system/headless/src/**/*.stories.mdx",
"../../design-system/widgets/src/**/*.stories.@(js|jsx|ts|tsx)",
"../../design-system/headless/src/**/*.stories.@(js|jsx|ts|tsx)",
],
addons: [
"@storybook/addon-links",