diff --git a/packages/components/src/buttons/docs/ClearButton.stories.tsx b/packages/components/src/buttons/docs/ClearButton.stories.tsx new file mode 100644 index 000000000..0929c830b --- /dev/null +++ b/packages/components/src/buttons/docs/ClearButton.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { ClearButton } from "../src/ClearButton.tsx"; + +/** + * ClearButtons are used to initialize an action. ClearButton labels express what action will occur when the user interacts with it. + * + * [View repository](https://github.com/gsoft-inc/wl-hopper/tree/main/packages/components/src/ClearButtons/src) + * - + * [View ARIA pattern](https://www.w3.org/WAI/ARIA/apg/patterns/clearButton/) + * - + * [View package](https://www.npmjs.com/package/@hopper-ui/components) + * - + * View storybook TODO + */ +const meta = { + title: "Docs/Buttons/ClearButton", + tags: ["autodocs"], + parameters: { + // Disables Chromatic's snapshotting on documentation stories + chromatic: { disableSnapshot: true } + }, + component: ClearButton +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * A default clearButton. + */ +export const Default: Story = { +}; + +/** + * A clearButton can be disabled. + */ +export const Disabled: Story = { + ...Default, + args: { + isDisabled: true + } +}; \ No newline at end of file diff --git a/packages/components/src/buttons/src/ClearButton.module.css b/packages/components/src/buttons/src/ClearButton.module.css new file mode 100644 index 000000000..f7ceaf403 --- /dev/null +++ b/packages/components/src/buttons/src/ClearButton.module.css @@ -0,0 +1,83 @@ +.hop-ClearButton { + --hop-ClearButton-border-radius: var(--hop-shape-rounded-sm); + --hop-ClearButton-focus-ring-color: var(--hop-primary-border-focus); + --hop-ClearButton-color: var(--hop-neutral-icon-weak); + --hop-ClearButton-color-hover: var(--hop-neutral-icon-weak-hover); + --hop-ClearButton-color-pressed: var(--hop-neutral-icon-weak-press); + --hop-ClearButton-color-disabled: var(--hop-neutral-icon-disabled); + --hop-ClearButton-background-color: transparent; + --hop-ClearButton-background-color-hover: var(--hop-neutral-surface-hover); + --hop-ClearButton-background-color-pressed: var(--hop-neutral-surface-press); + --hop-ClearButton-background-color-disabled: transparent; + --hop-ClearButton-spinner-color: var(--hop-neutral-icon); + --hop-ClearButton-height: 1rem; + --hop-ClearButton-width: 1rem; + + /* Internal variable */ + --inline-size: var(--hop-ClearButton-width); + --block-size: var(--hop-ClearButton-height); + --spinner: var(--hop-primary-icon-strong); + --padding-inline: 0; + --padding-block: 0; + --background-color: var(--hop-ClearButton-background-color); + --color: var(--hop-ClearButton-color); + --border: var(--hop-ClearButton-border); + --spinner-color: var(--hop-ClearButton-spinner-color); + --transition: var(--hop-easing-duration-2) var(--hop-easing-productive); + + /** styles */ + cursor: pointer; + + position: relative; + + overflow: hidden; + display: flex; + column-gap: var(--hop-space-inline-xs); + align-items: center; + justify-content: center; + + box-sizing: border-box; + inline-size: var(--inline-size); + block-size: var(--block-size); + padding-block: var(--padding-block); + padding-inline: var(--padding-inline); + + color: var(--color); + text-decoration: none; + white-space: nowrap; + + background-color: var(--background-color); + border: none; + border-radius: var(--hop-ClearButton-border-radius); + outline: none; + + transition: + color var(--transition), + background-color var(--transition); +} + +/** Focus Ring */ +.hop-ClearButton[data-focus-visible] { + outline: 0.125rem solid var(--hop-ClearButton-focus-ring-color); +} + +.hop-ClearButton[data-disabled] { + --background-color: var(--hop-ClearButton-background-color-disabled); + --color: var(--hop-ClearButton-color-disabled); + --border: var(--hop-ClearButton-border-disabled); + + cursor: not-allowed; +} + +.hop-ClearButton[data-hovered]:not([data-disabled]), +.hop-ClearButton[data-focus-visible]:not([data-disabled]) { + --background-color: var(--hop-ClearButton-background-color-hover); + --color: var(--hop-ClearButton-color-hover); + --border: var(--hop-ClearButton-border-hover); +} + +.hop-ClearButton[data-pressed] { + --background-color: var(--hop-ClearButton-background-color-pressed); + --color: var(--hop-ClearButton-color-pressed); + --border: var(--hop-ClearButton-border-pressed); +} \ No newline at end of file diff --git a/packages/components/src/buttons/src/ClearButton.tsx b/packages/components/src/buttons/src/ClearButton.tsx new file mode 100644 index 000000000..079ae64a9 --- /dev/null +++ b/packages/components/src/buttons/src/ClearButton.tsx @@ -0,0 +1,83 @@ +import { DismissIcon } from "@hopper-ui/icons"; +import { + type StyledComponentProps, + useStyledSystem +} from "@hopper-ui/styled-system"; +import { type ForwardedRef, forwardRef } from "react"; +import { + Button as RACButton, + type ButtonProps as RACButtonProps, + composeRenderProps, + useContextProps +} from "react-aria-components"; + +import { useLocalizedString } from "../../i18n/index.ts"; +import { + composeClassnameRenderProps, + cssModule +} from "../../utils/index.ts"; + +import { ClearButtonContext } from "./ClearButtonContext.ts"; + +import styles from "./ClearButton.module.css"; + +export const GlobalClearButtonCssSelector = "hop-ClearButton"; + +export interface ClearButtonProps extends StyledComponentProps> {} + +function ClearButton(props: ClearButtonProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, ClearButtonContext); + + const { stylingProps, ...ownProps } = useStyledSystem(props); + const stringFormatter = useLocalizedString(); + + const { + "aria-label": ariaLabel = stringFormatter.format("ClearButton.clearAriaLabel"), + className, + isDisabled, + style: styleProp, + ...otherProps + } = ownProps; + + const classNames = composeClassnameRenderProps( + className, + GlobalClearButtonCssSelector, + cssModule( + styles, + "hop-ClearButton" + ), + stylingProps.className + ); + + const style = composeRenderProps(styleProp, prev => { + return { + ...stylingProps.style, + ...prev + }; + }); + + + return ( + + + + ); +} + +/** + * ClearButtons are used to initialize an action. ClearButton labels express what action will occur when the user interacts with it. + * + * [View Documentation](TODO) + */ +const _ClearButton = forwardRef(ClearButton); + +_ClearButton.displayName = "ClearButton"; + +export { _ClearButton as ClearButton }; diff --git a/packages/components/src/buttons/src/ClearButtonContext.ts b/packages/components/src/buttons/src/ClearButtonContext.ts new file mode 100644 index 000000000..16397457b --- /dev/null +++ b/packages/components/src/buttons/src/ClearButtonContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import type { ContextValue } from "react-aria-components"; + +import type { ClearButtonProps } from "./ClearButton.tsx"; + +export const ClearButtonContext = createContext>({}); + +ClearButtonContext.displayName = "ClearButtonContext"; diff --git a/packages/components/src/buttons/src/index.ts b/packages/components/src/buttons/src/index.ts index 8caae3732..f56f85417 100644 --- a/packages/components/src/buttons/src/index.ts +++ b/packages/components/src/buttons/src/index.ts @@ -1,2 +1,4 @@ export * from "./Button.tsx"; export * from "./ButtonContext.ts"; +export * from "./ClearButton.tsx"; +export * from "./ClearButtonContext.ts"; diff --git a/packages/components/src/buttons/tests/chromatic/ClearButton.stories.tsx b/packages/components/src/buttons/tests/chromatic/ClearButton.stories.tsx new file mode 100644 index 000000000..8fc52f688 --- /dev/null +++ b/packages/components/src/buttons/tests/chromatic/ClearButton.stories.tsx @@ -0,0 +1,91 @@ +import { Div } from "@hopper-ui/styled-system"; +import type { Meta, StoryObj } from "@storybook/react"; +import { within } from "@storybook/test"; + +import { Inline, Stack } from "../../../layout/index.ts"; +import { ClearButton, type ClearButtonProps } from "../../src/ClearButton.tsx"; + +const meta = { + title: "Components/Buttons/ClearButton", + component: ClearButton +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: args => { + return ( + + +

Default

+ + + +
+ +

Zoom

+ +
+ +
+
+ +
+
+
+
+ ); + } +}; + +const StateTemplate = (args: Partial) => ( + + + +); + +export const DefaultStates: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const buttons = canvas.getAllByRole("button"); + + buttons.forEach(button => { + if (button.getAttribute("disabled") !== "") { // don't try and force states on a disabled input + if (button.getAttribute("data-chromatic-force-focus")) { + button.setAttribute("data-focus-visible", "true"); + button.removeAttribute("data-chromatic-force-focus"); + } + + if (button.getAttribute("data-chromatic-force-hover")) { + button.setAttribute("data-hovered", "true"); + button.removeAttribute("data-chromatic-force-hover"); + } + + if (button.getAttribute("data-chromatic-force-press")) { + button.setAttribute("data-pressed", "true"); + button.removeAttribute("data-chromatic-force-press"); + } + } + }); + }, + render: args => { + return ( + +

Default

+ +

Disabled

+ +

Pressed

+ +

Focus Visible

+ +

Hovered

+ +

Focus Visible and Hovered

+ +
+ ); + } +}; diff --git a/packages/components/src/buttons/tests/jest/ClearButton.ssr.test.tsx b/packages/components/src/buttons/tests/jest/ClearButton.ssr.test.tsx new file mode 100644 index 000000000..64ca62824 --- /dev/null +++ b/packages/components/src/buttons/tests/jest/ClearButton.ssr.test.tsx @@ -0,0 +1,18 @@ +/** + * @jest-environment node + */ +import { renderToString } from "react-dom/server"; + +import { ClearButton } from "../../src/ClearButton.tsx"; + +describe("ClearButton", () => { + it("should render on the server", () => { + const renderOnServer = () => + renderToString( + + ); + + expect(renderOnServer).not.toThrow(); + }); +}); + diff --git a/packages/components/src/buttons/tests/jest/ClearButton.test.tsx b/packages/components/src/buttons/tests/jest/ClearButton.test.tsx new file mode 100644 index 000000000..6f2273019 --- /dev/null +++ b/packages/components/src/buttons/tests/jest/ClearButton.test.tsx @@ -0,0 +1,77 @@ +import { act, screen, waitFor, render } from "@hopper-ui/test-utils"; +import { createRef } from "react"; + +import { ClearButton } from "../../src/ClearButton.tsx"; +import { ClearButtonContext } from "../../src/ClearButtonContext.ts"; + +describe("ClearButton", () => { + it("should render with default class", () => { + render(); + + const element = screen.getByRole("button"); + expect(element).toHaveClass("hop-ClearButton"); + }); + + it("should support custom class", () => { + render(); + + const element = screen.getByRole("button"); + expect(element).toHaveClass("hop-ClearButton"); + expect(element).toHaveClass("test"); + }); + + it("should support custom style", () => { + render(); + + const element = screen.getByRole("button"); + expect(element).toHaveStyle({ marginTop: "var(--hop-space-stack-sm)", marginBottom: "13px" }); + }); + + it("should support DOM props", () => { + render(); + + const element = screen.getByRole("button"); + expect(element).toHaveAttribute("data-foo", "bar"); + }); + + it("should support slots", () => { + render( + + + + ); + + const element = screen.getByRole("button"); + expect(element).toHaveAttribute("slot", "test"); + expect(element).toHaveAttribute("aria-label", "test"); + }); + + it("should support refs", () => { + const ref = createRef(); + render(); + + expect(ref.current).not.toBeNull(); + expect(ref.current instanceof HTMLButtonElement).toBeTruthy(); + }); + + it("should support form props", () => { + render(
); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("form", "foo"); + expect(button).toHaveAttribute("formMethod", "post"); + }); + + // ***** Api ***** + it("should be focused on render when the focus api is called", async () => { + const ref = createRef(); + + render(); + + act(() => { + ref.current?.focus(); + }); + + await waitFor(() => expect(ref.current).toHaveFocus()); + }); +}); diff --git a/packages/components/src/i18n/intl/en-US.json b/packages/components/src/i18n/intl/en-US.json index 883181b03..61280ef8c 100644 --- a/packages/components/src/i18n/intl/en-US.json +++ b/packages/components/src/i18n/intl/en-US.json @@ -1,4 +1,5 @@ { "Button.spinnerAriaLabel": "Loading", + "ClearButton.clearAriaLabel": "Clear", "Input.charactersLeft": "{charLeft, plural, =0 {No characters left} one {# character left} other {# characters left}}." } diff --git a/packages/components/src/i18n/intl/fr-CA.json b/packages/components/src/i18n/intl/fr-CA.json index caaddbc0c..ec160ab00 100644 --- a/packages/components/src/i18n/intl/fr-CA.json +++ b/packages/components/src/i18n/intl/fr-CA.json @@ -1,4 +1,5 @@ { "Button.spinnerAriaLabel": "Chargement en cours", + "ClearButton.clearAriaLabel": "Effacer", "Input.charactersLeft": "{charLeft, plural, =0 {Aucun caractère restant} one {# caractère restant} other {# caractères restants}}." }