From c694d7d3cd721c9de2661f62f511d4b90f4e0840 Mon Sep 17 00:00:00 2001 From: Yossi Saadi Date: Wed, 17 Apr 2024 14:14:35 +0300 Subject: [PATCH] chore(BaseInput): reusable, internal Input component (#2053) --- .../BaseInput/BaseInput.module.scss | 169 ++++++++++++++++++ .../src/components/BaseInput/BaseInput.tsx | 46 +++++ .../components/BaseInput/BaseInput.types.ts | 21 +++ .../__stories__/BaseInput.stories.tsx | 21 +++ .../BaseInput/__tests__/BaseInput.jest.tsx | 111 ++++++++++++ 5 files changed, 368 insertions(+) create mode 100644 packages/core/src/components/BaseInput/BaseInput.module.scss create mode 100644 packages/core/src/components/BaseInput/BaseInput.tsx create mode 100644 packages/core/src/components/BaseInput/BaseInput.types.ts create mode 100644 packages/core/src/components/BaseInput/__stories__/BaseInput.stories.tsx create mode 100644 packages/core/src/components/BaseInput/__tests__/BaseInput.jest.tsx diff --git a/packages/core/src/components/BaseInput/BaseInput.module.scss b/packages/core/src/components/BaseInput/BaseInput.module.scss new file mode 100644 index 0000000000..3a2c6e97bd --- /dev/null +++ b/packages/core/src/components/BaseInput/BaseInput.module.scss @@ -0,0 +1,169 @@ +@import "../../styles/typography"; +@import "~monday-ui-style/dist/mixins"; + +.wrapper { + width: 100%; + position: relative; + display: flex; + align-items: center; + flex-shrink: 0; + gap: var(--spacing-small); + padding-block: var(--spacing-xs); + padding-inline: var(--spacing-medium) var(--spacing-xs); + + @include vibe-text(text1, normal); + @include smoothing-text; + + outline: none; + border: 1px solid var(--ui-border-color); + border-radius: var(--border-radius-small); + color: var(--primary-text-color); + background-color: var(--secondary-background-color); + transition: border-color var(--motion-productive-medium) ease-in; + + &.small { + height: 32px; + @include vibe-text(text2, normal); + } + + &.medium { + height: 40px; + } + + &.large { + height: 48px; + padding-block: var(--spacing-small); + } + + &.rightThinnerPadding { + padding-inline-end: var(--spacing-medium); + } + + &:hover { + border-color: var(--primary-text-color); + } + + @supports selector(:has(*)) { + &:has(.input:active, .input:focus) { + border-color: var(--primary-color); + } + + &:has(.input:read-only) { + background-color: var(--allgrey-background-color); + border: none; + + .input { + background-color: var(--allgrey-background-color); + } + } + + &:has(.input:disabled) { + cursor: not-allowed; + user-select: none; + border: none; + pointer-events: none; + background-color: var(--disabled-background-color); + + .input { + background-color: var(--disabled-background-color); + } + } + + &.success { + border-color: var(--positive-color); + + &:hover { + border-color: var(--positive-color); + } + + &:has(.input:active, .input:focus) { + border-color: var(--positive-color); + } + } + + &:has(.input[aria-invalid="true"]) { + border-color: var(--negative-color); + + &:hover { + border-color: var(--negative-color); + } + + &:has(.input:active, .input:focus) { + border-color: var(--negative-color); + } + } + } + + @supports not selector(:has(*)) { + &:focus-within { + border-color: var(--primary-color); + } + + &.readOnly { + background-color: var(--allgrey-background-color); + border: none; + + .input { + background-color: var(--allgrey-background-color); + } + } + + &.disabled { + cursor: not-allowed; + user-select: none; + border: none; + pointer-events: none; + background-color: var(--disabled-background-color); + + .input { + background-color: var(--disabled-background-color); + } + } + + &.success { + border-color: var(--positive-color); + + &:hover { + border-color: var(--positive-color); + } + + &:focus-within { + border-color: var(--positive-color); + } + } + + &.error { + border-color: var(--negative-color); + + &:hover { + border-color: var(--negative-color); + } + + &:focus-within { + border-color: var(--negative-color); + } + } + } + + .input { + all: unset; + + flex: 1; + width: 100%; + height: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: none; + outline: none; + + &::placeholder { + color: var(--secondary-text-color); + font-weight: 400; + } + + &:disabled::placeholder { + color: var(--disabled-text-color); + } + } +} diff --git a/packages/core/src/components/BaseInput/BaseInput.tsx b/packages/core/src/components/BaseInput/BaseInput.tsx new file mode 100644 index 0000000000..186dab93a1 --- /dev/null +++ b/packages/core/src/components/BaseInput/BaseInput.tsx @@ -0,0 +1,46 @@ +import React, { forwardRef } from "react"; +import cx from "classnames"; +import styles from "./BaseInput.module.scss"; +import { BaseInputComponent } from "./BaseInput.types"; +import { getStyle } from "../../helpers/typesciptCssModulesHelper"; + +const BaseInput: BaseInputComponent = forwardRef( + ( + { + size = "medium", + leftRender, + rightRender, + success, + error, + wrapperRole, + inputRole, + className, + wrapperClassName, + ...props + }, + ref + ) => { + const wrapperClassNames = cx( + styles.wrapper, + { + [styles.rightThinnerPadding]: !rightRender, + [styles.error]: error, + [styles.success]: success, + [styles.readOnly]: props.readOnly, + [styles.disabled]: props.disabled + }, + getStyle(styles, size), + wrapperClassName + ); + + return ( +
+ {leftRender} + + {rightRender} +
+ ); + } +); + +export default BaseInput; diff --git a/packages/core/src/components/BaseInput/BaseInput.types.ts b/packages/core/src/components/BaseInput/BaseInput.types.ts new file mode 100644 index 0000000000..bca33d94e7 --- /dev/null +++ b/packages/core/src/components/BaseInput/BaseInput.types.ts @@ -0,0 +1,21 @@ +import { AriaRole, InputHTMLAttributes, ReactNode } from "react"; +import { VibeComponentProps } from "../../types"; +import { BASE_SIZES } from "../../constants"; +import VibeComponent from "../../types/VibeComponent"; + +export type InputSize = (typeof BASE_SIZES)[keyof typeof BASE_SIZES]; +type BaseInputNativeInputProps = Omit, "size" | "role">; +type Renderer = ReactNode | ReactNode[]; + +export interface BaseInputProps extends BaseInputNativeInputProps, VibeComponentProps { + size?: InputSize; + leftRender?: Renderer; + rightRender?: Renderer; + success?: boolean; + error?: boolean; + wrapperRole?: AriaRole; + inputRole?: AriaRole; + wrapperClassName?: string; +} + +export type BaseInputComponent = VibeComponent; diff --git a/packages/core/src/components/BaseInput/__stories__/BaseInput.stories.tsx b/packages/core/src/components/BaseInput/__stories__/BaseInput.stories.tsx new file mode 100644 index 0000000000..b1aa37adcc --- /dev/null +++ b/packages/core/src/components/BaseInput/__stories__/BaseInput.stories.tsx @@ -0,0 +1,21 @@ +import { createStoryMetaSettingsDecorator } from "../../../storybook"; +import { createComponentTemplate } from "vibe-storybook-components"; +import BaseInput from "../BaseInput"; + +const metaSettings = createStoryMetaSettingsDecorator({ + component: BaseInput +}); + +export default { + title: "Internal/BaseInput", + component: BaseInput, + argTypes: metaSettings.argTypes, + decorators: metaSettings.decorators, + tags: ["internal"] +}; + +const baseInputTemplate = createComponentTemplate(BaseInput); + +export const Overview = { + render: baseInputTemplate.bind({}) +}; diff --git a/packages/core/src/components/BaseInput/__tests__/BaseInput.jest.tsx b/packages/core/src/components/BaseInput/__tests__/BaseInput.jest.tsx new file mode 100644 index 0000000000..73747cafcb --- /dev/null +++ b/packages/core/src/components/BaseInput/__tests__/BaseInput.jest.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import userEvent from "@testing-library/user-event"; +import BaseInput from "../BaseInput"; +import { BaseInputProps } from "../BaseInput.types"; + +function renderBaseInput(props?: Partial) { + return render(); +} + +describe("BaseInput", () => { + it("should render correctly", () => { + const { getByLabelText } = renderBaseInput(); + expect(getByLabelText("base-input")).toBeInTheDocument(); + }); + + describe("with declared props", () => { + it("should apply the size class", () => { + const { getByLabelText } = renderBaseInput({ size: "large" }); + expect(getByLabelText("base-input").parentNode).toHaveClass("large"); + }); + + it("should show left and right elements when provided", () => { + const leftRender =
Left
; + const rightRender =
Right
; + const { getByText } = renderBaseInput({ leftRender, rightRender }); + + expect(getByText("Left")).toBeInTheDocument(); + expect(getByText("Right")).toBeInTheDocument(); + }); + + it("should apply the success class", () => { + const { getByLabelText } = renderBaseInput({ success: true }); + expect(getByLabelText("base-input").parentNode).toHaveClass("success"); + }); + + it("should apply wrapper and input role correctly", () => { + const { getByRole } = renderBaseInput({ wrapperRole: "search", inputRole: "combobox" }); + expect(getByRole("search")).toBeInTheDocument(); + expect(getByRole("combobox")).toBeInTheDocument(); + }); + + it("should apply the className for input and wrapperClassName for wrapper", () => { + const { getByLabelText } = renderBaseInput({ className: "inputClass", wrapperClassName: "customWrapper" }); + expect(getByLabelText("base-input")).toHaveClass("inputClass"); + expect(getByLabelText("base-input").parentNode).toHaveClass("customWrapper"); + }); + + it("should forward ref to the input element", () => { + const ref = React.createRef(); + const { getByLabelText } = render(); + expect(ref.current).toBe(getByLabelText("input-base")); + }); + }); + + describe("a11y", () => { + it("should not apply aria-invalid when error prop is not supplied", () => { + const { getByLabelText } = renderBaseInput(); + expect(getByLabelText("base-input")).not.toHaveAttribute("aria-invalid"); + }); + + it("should apply aria-invalid when error prop is supplied", () => { + const { getByLabelText } = renderBaseInput({ error: true }); + expect(getByLabelText("base-input")).toHaveAttribute("aria-invalid", "true"); + }); + }); + + describe("interactions", () => { + it("should capture user input correctly", () => { + const expectedValue = "Hello, World!"; + const { getByLabelText } = renderBaseInput(); + const input = getByLabelText("base-input"); + userEvent.type(input, expectedValue); + expect(input).toHaveValue(expectedValue); + }); + + it("should call onChange on every input", () => { + const expectedValue = "Hello, World!"; + const onChange = jest.fn(); + const { getByLabelText } = renderBaseInput({ onChange }); + const input = getByLabelText("base-input"); + userEvent.type(input, expectedValue); + expect(onChange).toHaveBeenCalledTimes(expectedValue.length); + }); + + it("should handle focus and blur events", () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const { getByLabelText } = renderBaseInput({ onFocus, onBlur }); + const input = getByLabelText("base-input"); + userEvent.click(input); + expect(onFocus).toHaveBeenCalled(); + userEvent.tab(); + expect(onBlur).toHaveBeenCalled(); + }); + + it("should handle key down and up", () => { + const onEnterDown = jest.fn(); + const onKeyDown = (e: React.KeyboardEvent) => e.key === "Enter" && onEnterDown(); + const onEnterUp = jest.fn(); + const onKeyUp = (e: React.KeyboardEvent) => e.key === "Enter" && onEnterUp(); + const { getByLabelText } = renderBaseInput({ onKeyDown, onKeyUp }); + + const input = getByLabelText("base-input"); + userEvent.type(input, "{enter}"); + expect(onEnterDown).toHaveBeenCalled(); + expect(onEnterUp).toHaveBeenCalled(); + }); + }); +});