diff --git a/frontend/components/buttons/DropdownButton/_styles.scss b/frontend/components/buttons/DropdownButton/_styles.scss index d22bb4e208..f7e7ecc379 100644 --- a/frontend/components/buttons/DropdownButton/_styles.scss +++ b/frontend/components/buttons/DropdownButton/_styles.scss @@ -19,7 +19,7 @@ padding: 0; margin: 0; display: none; - z-index: 2; + z-index: 99; border-radius: 2px; background-color: $core-white; box-shadow: 0 4px 10px rgba(52, 59, 96, 0.15); diff --git a/frontend/components/forms/fields/Dropdown/_styles.scss b/frontend/components/forms/fields/Dropdown/_styles.scss index e99a2479f9..b9033d7338 100644 --- a/frontend/components/forms/fields/Dropdown/_styles.scss +++ b/frontend/components/forms/fields/Dropdown/_styles.scss @@ -67,7 +67,7 @@ &:hover:not(.is-disabled) { box-shadow: none; - border-color: $core-vibrant-blue; + border-color: $core-vibrant-blue-over; } } @@ -135,16 +135,9 @@ } } - &.is-focused { + &.is-focused:not(.is-disabled) { &.dropdown__select { - border: 1px solid $core-vibrant-blue; - } - - &:not(.is-open) { - .Select-control { - box-shadow: none; - border-color: $core-vibrant-blue; - } + border: 1px solid $core-vibrant-blue-down; } } diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index 1c38744bbc..9ccccdf12a 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -244,12 +244,25 @@ const DropdownWrapper = ({ stroke: COLORS["core-vibrant-blue-over"], }, }, - "&.react-select__control--is-focused": { + ".react-select__control--is-focused": { backgroundColor: "rgba(25, 33, 71, 0.05)", + boxShadow: "none", + ".dropdown-wrapper__placeholder": { + color: COLORS["core-vibrant-blue-down"], + }, + ".dropdown-wrapper__indicator path": { + stroke: COLORS["core-vibrant-blue-down"], + }, }, - "&:active .dropdown-wrapper__indicator path": { - stroke: COLORS["core-vibrant-blue-down"], - }, + ...(state.isFocused && { + backgroundColor: "rgba(25, 33, 71, 0.05)", + ".dropdown-wrapper__placeholder": { + color: COLORS["core-vibrant-blue-down"], + }, + ".dropdown-wrapper__indicator path": { + stroke: COLORS["core-vibrant-blue-down"], + }, + }), // TODO: Figure out a way to apply separate &:focus-visible styling // Currently only relying on &:focus styling for tabbing through app ...(state.menuIsOpen && { @@ -277,7 +290,7 @@ const DropdownWrapper = ({ : COLORS["ui-fleet-black-10"], "&:hover": { boxShadow: "none", - borderColor: COLORS["core-fleet-blue"], + borderColor: COLORS["core-vibrant-blue-over"], ".dropdown-wrapper__single-value": { color: COLORS["core-vibrant-blue-over"], }, @@ -291,13 +304,22 @@ const DropdownWrapper = ({ // When tabbing // Relies on --is-focused for styling as &:focus-visible cannot be applied "&.react-select__control--is-focused": { - ".dropdown-wrapper__single-value": { - color: COLORS["core-vibrant-blue-over"], - }, + borderColor: COLORS["core-vibrant-blue-down"], ".dropdown-wrapper__indicator path": { - stroke: COLORS["core-vibrant-blue-over"], + stroke: COLORS["core-vibrant-blue-down"], + }, + ".filter-icon path": { + fill: COLORS["core-vibrant-blue-down"], }, }, + ...(state.isFocused && { + ".dropdown-wrapper__placeholder": { + color: COLORS["core-vibrant-blue-down"], + }, + ".dropdown-wrapper__indicator path": { + stroke: COLORS["core-vibrant-blue-down"], + }, + }), ...(state.isDisabled && { ".dropdown-wrapper__single-value": { color: COLORS["ui-fleet-black-50"], diff --git a/frontend/components/forms/fields/InputField/InputField.stories.jsx b/frontend/components/forms/fields/InputField/InputField.stories.jsx index 7b27aa435c..2c4c0e387b 100644 --- a/frontend/components/forms/fields/InputField/InputField.stories.jsx +++ b/frontend/components/forms/fields/InputField/InputField.stories.jsx @@ -1,23 +1,126 @@ +import React from "react"; import InputField from "."; -const meta = { +export default { component: InputField, title: "Components/FormFields/InputField", -}; - -export default meta; - -export const Basic = {}; - -export const WithCopyEnabled = { - args: { - enableCopy: true, + argTypes: { + type: { + control: "select", + options: ["text", "password", "email", "number", "textarea"], + }, + value: { + control: "text", + }, + placeholder: { + control: "text", + }, + label: { + control: "text", + }, + error: { + control: "text", + }, + helpText: { + control: "text", + }, + disabled: { + control: "boolean", + }, + readOnly: { + control: "boolean", + }, + autofocus: { + control: "boolean", + }, + enableCopy: { + control: "boolean", + }, + copyButtonPosition: { + control: "radio", + options: ["inside", "outside"], + }, }, }; -export const WithCopyEnabledInsideInput = { - args: { - enableCopy: true, - copyButtonPosition: "inside", - }, +const Template = (args) => ; + +export const Basic = Template.bind({}); +Basic.args = { + name: "basic-input", + label: "Basic Input", + value: "", + placeholder: "Enter text here", +}; + +export const WithValue = Template.bind({}); +WithValue.args = { + ...Basic.args, + value: "Sample text", +}; + +export const WithError = Template.bind({}); +WithError.args = { + ...Basic.args, + error: "This field is required", +}; + +export const Disabled = Template.bind({}); +Disabled.args = { + ...Basic.args, + disabled: true, +}; + +export const ReadOnly = Template.bind({}); +ReadOnly.args = { + ...Basic.args, + readOnly: true, + value: "Read-only content", +}; + +export const WithHelpText = Template.bind({}); +WithHelpText.args = { + ...Basic.args, + helpText: "This is some helpful information about the input field.", +}; + +export const Password = Template.bind({}); +Password.args = { + ...Basic.args, + type: "password", + label: "Password", + placeholder: "Enter your password", +}; + +export const Textarea = Template.bind({}); +Textarea.args = { + ...Basic.args, + type: "textarea", + label: "Text Area", + placeholder: "Enter multiple lines of text", +}; + +export const WithCopyEnabled = Template.bind({}); +WithCopyEnabled.args = { + ...Basic.args, + enableCopy: true, + value: "This text can be copied", +}; + +export const WithCopyEnabledInsideInput = Template.bind({}); +WithCopyEnabledInsideInput.args = { + ...WithCopyEnabled.args, + copyButtonPosition: "inside", +}; + +export const WithTooltip = Template.bind({}); +WithTooltip.args = { + ...Basic.args, + tooltip: "This is a tooltip for the input field", +}; + +export const AutoFocus = Template.bind({}); +AutoFocus.args = { + ...Basic.args, + autofocus: true, }; diff --git a/frontend/components/forms/fields/InputField/InputField.stories.tsx b/frontend/components/forms/fields/InputField/InputField.stories.tsx index 79e6018364..9f9cf81f24 100644 --- a/frontend/components/forms/fields/InputField/InputField.stories.tsx +++ b/frontend/components/forms/fields/InputField/InputField.stories.tsx @@ -1,5 +1,5 @@ import { Meta, StoryObj } from "@storybook/react"; -import { noop } from "lodash"; +import { action } from "@storybook/addon-actions"; // @ts-ignore import InputField from "."; @@ -9,20 +9,24 @@ import "../../../../index.scss"; const meta: Meta = { component: InputField, title: "Components/FormFields/Input", - args: { - autofocus: false, - readOnly: false, - disabled: false, - error: "", - inputClassName: "", - inputWrapperClass: "", - inputOptions: {}, - name: "", - placeholder: "Type here...", - type: "", - value: "", - onFocus: noop, - onChange: noop, + argTypes: { + type: { + control: "select", + options: ["text", "password", "email", "number", "textarea"], + }, + autofocus: { control: "boolean" }, + readOnly: { control: "boolean" }, + disabled: { control: "boolean" }, + blockAutoComplete: { control: "boolean" }, + enableCopy: { control: "boolean" }, + copyButtonPosition: { + control: "radio", + options: ["inside", "outside"], + }, + labelTooltipPosition: { + control: "select", + options: ["top", "right", "bottom", "left"], + }, }, }; @@ -30,4 +34,94 @@ export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + args: { + name: "default-input", + label: "Default Input", + placeholder: "Type here...", + value: "", + onChange: action("onChange"), + onFocus: action("onFocus"), + onBlur: action("onBlur"), + }, +}; + +export const WithError: Story = { + args: { + ...Default.args, + name: "error-input", + label: "Input with Error", + error: "This field is required", + value: "", + }, +}; + +export const WithHelpText: Story = { + args: { + ...Default.args, + name: "help-text-input", + label: "Input with Help Text", + helpText: "This is some helpful information about the input field.", + }, +}; + +export const WithTooltip: Story = { + args: { + ...Default.args, + name: "tooltip-input", + label: "Input with Tooltip", + tooltip: "This is additional information in a tooltip.", + labelTooltipPosition: "right", + }, +}; + +export const Password: Story = { + args: { + ...Default.args, + name: "password-input", + label: "Password Input", + type: "password", + placeholder: "Enter password", + }, +}; + +export const ReadOnly: Story = { + args: { + ...Default.args, + name: "readonly-input", + label: "Read-only Input", + readOnly: true, + value: "This is read-only content", + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + name: "disabled-input", + label: "Disabled Input", + disabled: true, + value: "This input is disabled", + }, +}; + +export const WithCopyButton: Story = { + args: { + ...Default.args, + name: "copy-input", + label: "Input with Copy Button", + value: "Click to copy this text", + enableCopy: true, + copyButtonPosition: "outside", + }, +}; + +export const Textarea: Story = { + args: { + ...Default.args, + name: "textarea-input", + label: "Textarea Input", + type: "textarea", + placeholder: "Enter multiple lines of text...", + }, +}; diff --git a/frontend/components/forms/fields/InputField/InputField.tests.tsx b/frontend/components/forms/fields/InputField/InputField.tests.tsx new file mode 100644 index 0000000000..1aa82607e3 --- /dev/null +++ b/frontend/components/forms/fields/InputField/InputField.tests.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +// @ts-ignore +import InputField from "./InputField"; + +describe("InputField Component", () => { + const mockOnChange = jest.fn(); + const mockOnBlur = jest.fn(); + const mockOnFocus = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders with label and placeholder", () => { + render( + + ); + + expect(screen.getByText(/test input/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/enter text/i)).toBeInTheDocument(); + }); + + test("calls onChange when input value changes", async () => { + render( + + ); + + await userEvent.type( + screen.getByPlaceholderText(/enter text/i), + "New Value" + ); + expect(mockOnChange).toHaveBeenCalledTimes(9); // 'New Value' has 9 characters + }); + + test("renders help text when provided", () => { + render( + + ); + + expect(screen.getByText(/this is a help text/i)).toBeInTheDocument(); + }); + + test("renders error message when provided", () => { + render( + + ); + + expect(screen.getByText(/this is an error message/i)).toBeInTheDocument(); + }); + + test("renders as textarea when type is textarea", () => { + render( + + ); + + expect(screen.getByRole("textbox")).toHaveAttribute( + "name", + "test-textarea" + ); + }); + + test("renders copy button when enableCopy is true", () => { + render( + + ); + + expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument(); + }); + + test("calls onBlur when input loses focus", async () => { + render( + + ); + + const input = screen.getByPlaceholderText(/enter text/i); + await userEvent.click(input); + await userEvent.tab(); + + expect(mockOnBlur).toHaveBeenCalledTimes(1); + }); + + test("calls onFocus when input gains focus", async () => { + render( + + ); + + const input = screen.getByPlaceholderText(/enter text/i); + await userEvent.click(input); + + expect(mockOnFocus).toHaveBeenCalledTimes(1); + }); + + test("renders as disabled when disabled prop is true", () => { + render( + + ); + + expect(screen.getByPlaceholderText(/enter text/i)).toBeDisabled(); + }); +}); diff --git a/frontend/components/forms/fields/InputField/_styles.scss b/frontend/components/forms/fields/InputField/_styles.scss index dc54398668..38a09944fa 100644 --- a/frontend/components/forms/fields/InputField/_styles.scss +++ b/frontend/components/forms/fields/InputField/_styles.scss @@ -4,7 +4,7 @@ border: solid 1px $ui-fleet-black-10; border-radius: $border-radius; font-size: $small; - padding: 7px 12px; + padding: $pad-small $pad-medium; color: $core-fleet-blue; font-family: "Inter", sans-serif; box-sizing: border-box; @@ -16,14 +16,17 @@ color: $ui-fleet-black-50; } - &:focus { - outline: none; - border-color: $core-vibrant-blue; + &:hover:not(.input-field--read-only) { + box-shadow: none; + border: 1px solid $core-vibrant-blue-over; } - &:hover &:not(.input-field--read-only) { + &:active:not(.input-field--read-only), + &:focus:not(.input-field--read-only), + &:focus-visible:not(.input-field--read-only) { box-shadow: none; - border: 1px solid $core-vibrant-blue; + outline: 0; + border: 1px solid $core-vibrant-blue-down; } &--disabled { diff --git a/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx b/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx index 3d25f22399..309afac710 100644 --- a/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx +++ b/frontend/components/forms/fields/InputFieldWithIcon/InputFieldWithIcon.jsx @@ -2,8 +2,8 @@ import React from "react"; import PropTypes from "prop-types"; import classnames from "classnames"; +import { ICON_MAP } from "components/icons"; import Icon from "components/Icon/Icon"; -import FleetIcon from "components/icons/FleetIcon"; import TooltipWrapper from "components/TooltipWrapper"; import Button from "components/buttons/Button"; import InputField from "../InputField"; @@ -15,8 +15,7 @@ class InputFieldWithIcon extends InputField { autofocus: PropTypes.bool, error: PropTypes.string, helpText: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), - iconName: PropTypes.string, - iconSvg: PropTypes.string, + iconSvg: PropTypes.oneOf(Object.keys(ICON_MAP)), label: PropTypes.string, name: PropTypes.string, onChange: PropTypes.func, @@ -27,18 +26,18 @@ class InputFieldWithIcon extends InputField { type: PropTypes.string, className: PropTypes.string, disabled: PropTypes.bool, - iconPosition: PropTypes.oneOf(["start", "end"]), inputOptions: PropTypes.object, // eslint-disable-line react/forbid-prop-types tooltip: PropTypes.string, ignore1Password: PropTypes.bool, }; renderHeading = () => { - const { error, placeholder, name, tooltip } = this.props; + const { error, placeholder, name, tooltip, disabled } = this.props; const label = this.props.label ?? placeholder; const labelClasses = classnames(`${baseClass}__label`, { [`${baseClass}__errors`]: !!error, + [`${baseClass}__label--disabled`]: disabled, }); return ( @@ -76,7 +75,6 @@ class InputFieldWithIcon extends InputField { const { className, error, - iconName, iconSvg, name, placeholder, @@ -84,7 +82,6 @@ class InputFieldWithIcon extends InputField { type, value, disabled, - iconPosition, inputOptions, ignore1Password, onClick, @@ -93,22 +90,20 @@ class InputFieldWithIcon extends InputField { } = this.props; const { onInputChange, renderHelpText } = this; - const wrapperClasses = classnames(baseClass, "form-field", { - [`${baseClass}--icon-start`]: iconPosition && iconPosition === "start", - }); + const wrapperClasses = classnames(baseClass, "form-field"); const inputClasses = classnames( `${baseClass}__input`, - "input-with-icon", className, + { "input-with-icon": !!iconSvg }, { [`${baseClass}__input--error`]: error }, - { [`${baseClass}__input--password`]: type === "password" && value }, - { - [`${baseClass}__input--icon-start`]: - iconPosition && iconPosition === "start", - } + { [`${baseClass}__input--password`]: type === "password" && value } ); + const inputWrapperClasses = classnames(`${baseClass}__input-wrapper`, { + [`${baseClass}__input-wrapper--disabled`]: disabled, + }); + const iconClasses = classnames( `${baseClass}__icon`, { [`${baseClass}__icon--error`]: error }, @@ -122,7 +117,7 @@ class InputFieldWithIcon extends InputField { return (
{this.props.label && this.renderHeading()} -
+
{iconSvg && } - {iconName && } {clearButton && !!value && (