-
Notifications
You must be signed in to change notification settings - Fork 324
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(BaseInput): reusable, internal Input component (#2053)
- Loading branch information
1 parent
cd4af2f
commit c694d7d
Showing
5 changed files
with
368 additions
and
0 deletions.
There are no files selected for viewing
169 changes: 169 additions & 0 deletions
169
packages/core/src/components/BaseInput/BaseInput.module.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; |
21 changes: 21 additions & 0 deletions
21
packages/core/src/components/BaseInput/__stories__/BaseInput.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
111
packages/core/src/components/BaseInput/__tests__/BaseInput.jest.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |