Skip to content

Commit

Permalink
chore(BaseInput): reusable, internal Input component (#2053)
Browse files Browse the repository at this point in the history
  • Loading branch information
YossiSaadi authored Apr 17, 2024
1 parent cd4af2f commit c694d7d
Show file tree
Hide file tree
Showing 5 changed files with 368 additions and 0 deletions.
169 changes: 169 additions & 0 deletions packages/core/src/components/BaseInput/BaseInput.module.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
46 changes: 46 additions & 0 deletions packages/core/src/components/BaseInput/BaseInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={wrapperClassNames} role={wrapperRole}>
{leftRender}
<input {...props} ref={ref} className={cx(styles.input, className)} aria-invalid={error} role={inputRole} />
{rightRender}
</div>
);
}
);

export default BaseInput;
21 changes: 21 additions & 0 deletions packages/core/src/components/BaseInput/BaseInput.types.ts
Original file line number Diff line number Diff line change
@@ -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<InputHTMLAttributes<HTMLInputElement>, "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<BaseInputProps, HTMLInputElement>;
Original file line number Diff line number Diff line change
@@ -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({})
};
111 changes: 111 additions & 0 deletions packages/core/src/components/BaseInput/__tests__/BaseInput.jest.tsx
Original file line number Diff line number Diff line change
@@ -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<BaseInputProps>) {
return render(<BaseInput aria-label="base-input" {...props} />);
}

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 = <div>Left</div>;
const rightRender = <div>Right</div>;
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<HTMLInputElement>();
const { getByLabelText } = render(<BaseInput aria-label="input-base" ref={ref} />);
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();
});
});
});

0 comments on commit c694d7d

Please sign in to comment.