diff --git a/frontend/components/TeamsDropdown/TeamsDropdown.tsx b/frontend/components/TeamsDropdown/TeamsDropdown.tsx index e38b1c1b13f3..bc59c6fb0c67 100644 --- a/frontend/components/TeamsDropdown/TeamsDropdown.tsx +++ b/frontend/components/TeamsDropdown/TeamsDropdown.tsx @@ -18,7 +18,7 @@ import { import Icon from "components/Icon"; -interface INumberDropdownOption extends Omit { +export interface INumberDropdownOption extends Omit { value: number; // Redefine the value property to be just number } diff --git a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss index 7fcd24ad33ed..23bf82f07066 100644 --- a/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss +++ b/frontend/components/forms/fields/Dropdown/DropdownOptionTooltipWrapper/_styles.scss @@ -1,7 +1,8 @@ // Used with old react-select dropdown and -// New react-select-5 ActionsDropdown.tsx +// New react-select-5: ActionsDropdown.tsx, DropdownWrapper.tsx .Select > .Select-menu-outer, -.actions-dropdown { +.actions-dropdown, +.react-select__option { .is-disabled * { color: $ui-fleet-black-50; } diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx new file mode 100644 index 000000000000..ee0d223c58ca --- /dev/null +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.stories.tsx @@ -0,0 +1,62 @@ +// stories/DropdownWrapper.stories.tsx + +import React from "react"; +import { Meta, Story } from "@storybook/react"; +import DropdownWrapper, { + IDropdownWrapper, + CustomOptionType, +} from "./DropdownWrapper"; + +// Define metadata for the story +export default { + title: "Components/DropdownWrapper", + component: DropdownWrapper, + argTypes: { + onChange: { action: "changed" }, + }, +} as Meta; + +// Define a template for the stories +const Template: Story = (args) => ( + +); + +// Sample options to be used in the dropdown +const sampleOptions: CustomOptionType[] = [ + { label: "Option 1", value: "option1", helpText: "Help text for option 1" }, + { + label: "Option 2", + value: "option2", + tooltipContent: "Tooltip for option 2", + }, + { label: "Option 3", value: "option3", isDisabled: true }, +]; + +// Default story +export const Default = Template.bind({}); +Default.args = { + options: sampleOptions, + name: "dropdown-example", + label: "Select an option", +}; + +// Disabled story +export const Disabled = Template.bind({}); +Disabled.args = { + ...Default.args, + isDisabled: true, +}; + +// With Help Text story +export const WithHelpText = Template.bind({}); +WithHelpText.args = { + ...Default.args, + helpText: "This is some help text for the dropdown", +}; + +// With Error story +export const WithError = Template.bind({}); +WithError.args = { + ...Default.args, + error: "This is an error message", +}; diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx new file mode 100644 index 000000000000..2715876f8ba4 --- /dev/null +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import DropdownWrapper, { CustomOptionType } from "./DropdownWrapper"; + +const sampleOptions: CustomOptionType[] = [ + { + label: "Option 1", + value: "option1", + tooltipContent: "Tooltip 1", + helpText: "Help text 1", + }, + { + label: "Option 2", + value: "option2", + tooltipContent: "Tooltip 2", + helpText: "Help text 2", + }, +]; + +describe("DropdownWrapper Component", () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders with help text", () => { + render( + + ); + + expect(screen.getByText(/test dropdown/i)).toBeInTheDocument(); + expect(screen.getByText(/this is a help text/i)).toBeInTheDocument(); + }); + + test("calls onChange when an option is selected", async () => { + render( + + ); + + // Open the dropdown + await userEvent.click(screen.getByText(/option 1/i)); + + // Select Option 2 + await userEvent.click(screen.getByText(/option 2/i)); + + expect(mockOnChange).toHaveBeenCalledWith({ + helpText: "Help text 2", + label: "Option 2", + tooltipContent: "Tooltip 2", + value: "option2", + }); + }); + + test("renders error message when provided", () => { + render( + + ); + + expect(screen.getByText(/this is an error message/i)).toBeInTheDocument(); + }); + + test("displays no options message when no options are available", async () => { + render( + + ); + + // Open dropdown + await userEvent.click(screen.getByText(/choose option/i)); + + expect(screen.getByText(/no results found/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx new file mode 100644 index 000000000000..51c5c3cd695f --- /dev/null +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -0,0 +1,340 @@ +/** + * This is a new component built off react-select 5.4 + * meant to replace Dropdown.jsx built off react-select 1.3 + * + * See storybook component for current functionality + * + * Prototyped on UserForm.tsx but added and tested the following: + * Options: text, disabled, option helptext, option tooltip + * Other: label text, dropdown help text, dropdown error + */ + +import classnames from "classnames"; +import React from "react"; +import Select, { + StylesConfig, + DropdownIndicatorProps, + OptionProps, + components, + PropsValue, + SingleValue, +} from "react-select-5"; + +import { COLORS } from "styles/var/colors"; +import { PADDING } from "styles/var/padding"; + +import FormField from "components/forms/FormField"; +import DropdownOptionTooltipWrapper from "components/forms/fields/Dropdown/DropdownOptionTooltipWrapper"; +import Icon from "components/Icon"; + +const getOptionBackgroundColor = (state: any) => { + return state.isSelected || state.isFocused + ? COLORS["ui-vibrant-blue-10"] + : "transparent"; +}; + +export interface CustomOptionType { + label: string; + value: string; + tooltipContent?: string; + helpText?: string; + isDisabled?: boolean; +} + +export interface IDropdownWrapper { + options: CustomOptionType[]; + value?: PropsValue | string; + onChange: (newValue: SingleValue) => void; + name: string; + className?: string; + labelClassname?: string; + error?: string; + label?: JSX.Element | string; + helpText?: JSX.Element | string; + isSearchable?: boolean; + isDisabled?: boolean; + placeholder?: string; + menuPortalTarget?: HTMLElement | null; +} + +const baseClass = "dropdown-wrapper"; + +const DropdownWrapper = ({ + options, + value, + onChange, + name, + className, + labelClassname, + error, + label, + helpText, + isSearchable, + isDisabled = false, + placeholder, + menuPortalTarget, +}: IDropdownWrapper) => { + const wrapperClassNames = classnames(baseClass, className); + + const handleChange = (newValue: SingleValue) => { + onChange(newValue); + }; + + // Ability to handle value of type string or CustomOptionType + const getCurrentValue = () => { + if (typeof value === "string") { + return options.find((option) => option.value === value) || null; + } + return value; + }; + + interface CustomOptionProps + extends Omit, "data"> { + data: CustomOptionType; + } + + const CustomOption = (props: CustomOptionProps) => { + const { data, ...rest } = props; + + const optionContent = ( +
+ {data.label} + {data.helpText && ( + {data.helpText} + )} +
+ ); + + return ( + + {data.tooltipContent ? ( + + {optionContent} + + ) : ( + optionContent + )} + + ); + }; + + const CustomDropdownIndicator = ( + props: DropdownIndicatorProps + ) => { + const { isFocused, selectProps } = props; + const color = + isFocused || selectProps.menuIsOpen + ? "core-fleet-blue" + : "core-fleet-black"; + + return ( + + + + ); + }; + + const customStyles: StylesConfig = { + container: (provided) => ({ + ...provided, + width: "100%", + height: "40px", + }), + control: (provided, state) => ({ + ...provided, + display: "flex", + flexDirection: "row", + width: "100%", + backgroundColor: COLORS["ui-off-white"], + paddingLeft: "8px", // TODO: Update to match styleguide of (16px) when updating rest of UI (8px) + paddingRight: "8px", + cursor: "pointer", + boxShadow: "none", + borderRadius: "4px", + borderColor: state.isFocused + ? COLORS["core-fleet-blue"] + : COLORS["ui-fleet-black-10"], + "&:hover": { + boxShadow: "none", + borderColor: COLORS["core-fleet-blue"], + ".dropdown-wrapper__single-value": { + color: COLORS["core-vibrant-blue-over"], + }, + ".dropdown-wrapper__indicator path": { + stroke: COLORS["core-vibrant-blue-over"], + }, + }, + // When tabbing + // Relies on --is-focused for styling as &:focus-visible cannot be applied + "&.dropdown-wrapper__control--is-focused": { + ".dropdown-wrapper__single-value": { + color: COLORS["core-vibrant-blue-over"], + }, + ".dropdown-wrapper__indicator path": { + stroke: COLORS["core-vibrant-blue-over"], + }, + }, + ...(state.isDisabled && { + ".dropdown-wrapper__single-value": { + color: COLORS["ui-fleet-black-50"], + }, + ".dropdown-wrapper__indicator path": { + stroke: COLORS["ui-fleet-black-50"], + }, + }), + "&:active": { + ".dropdown-wrapper__single-value": { + color: COLORS["core-vibrant-blue-down"], + }, + ".dropdown-wrapper__indicator path": { + stroke: COLORS["core-vibrant-blue-down"], + }, + }, + ...(state.menuIsOpen && { + ".dropdown-wrapper__indicator svg": { + transform: "rotate(180deg)", + transition: "transform 0.25s ease", + }, + }), + }), + singleValue: (provided) => ({ + ...provided, + fontSize: "16px", + margin: 0, + padding: 0, + }), + dropdownIndicator: (provided) => ({ + ...provided, + display: "flex", + padding: "2px", + svg: { + transition: "transform 0.25s ease", + }, + }), + menu: (provided) => ({ + ...provided, + boxShadow: "0 2px 6px rgba(0, 0, 0, 0.1)", + borderRadius: "4px", + zIndex: 6, + overflow: "hidden", + border: 0, + marginTop: 0, + maxHeight: "none", + position: "absolute", + left: "0", + animation: "fade-in 150ms ease-out", + }), + menuList: (provided) => ({ + ...provided, + padding: PADDING["pad-small"], + }), + valueContainer: (provided) => ({ + ...provided, + padding: 0, + }), + option: (provided, state) => ({ + ...provided, + padding: "10px 8px", + fontSize: "14px", + backgroundColor: getOptionBackgroundColor(state), + color: COLORS["core-fleet-black"], + "&:hover": { + backgroundColor: state.isDisabled + ? "transparent" + : COLORS["ui-vibrant-blue-10"], + }, + "&:active": { + backgroundColor: state.isDisabled + ? "transparent" + : COLORS["ui-vibrant-blue-10"], + }, + ...(state.isDisabled && { + color: COLORS["ui-fleet-black-50"], + fontStyle: "italic", + cursor: "not-allowed", + pointerEvents: "none", + }), + // Styles for custom option + ".dropdown-wrapper__option": { + display: "flex", + flexDirection: "column", + gap: "8px", + width: "100%", + }, + ".dropdown-wrapper__help-text": { + fontSize: "12px", + whiteSpace: "normal", + color: COLORS["ui-fleet-black-50"], + fontStyle: "italic", + }, + }), + menuPortal: (base) => ({ ...base, zIndex: 999 }), // Not hidden beneath scrollable sections + noOptionsMessage: (provided) => ({ + ...provided, + textAlign: "left", + fontSize: "14px", + padding: "10px 8px", + }), + }; + + const renderLabel = () => { + const labelWrapperClasses = classnames( + `${baseClass}__label`, + labelClassname, + { + [`${baseClass}__label--error`]: !!error, + [`${baseClass}__label--disabled`]: isDisabled, + } + ); + + if (!label) { + return ""; + } + + return ( + + ); + }; + + return ( + + + classNamePrefix="react-select" + isSearchable={isSearchable} + styles={customStyles} + options={options} + components={{ + Option: CustomOption, + DropdownIndicator: CustomDropdownIndicator, + IndicatorSeparator: () => null, + }} + value={getCurrentValue()} + onChange={handleChange} + isDisabled={isDisabled} + menuPortalTarget={ + menuPortalTarget === undefined ? document.body : menuPortalTarget + } + noOptionsMessage={() => "No results found"} + tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility + placeholder={placeholder} + /> + + ); +}; + +export default DropdownWrapper; diff --git a/frontend/components/forms/fields/DropdownWrapper/_styles.scss b/frontend/components/forms/fields/DropdownWrapper/_styles.scss new file mode 100644 index 000000000000..2a92f5ced65a --- /dev/null +++ b/frontend/components/forms/fields/DropdownWrapper/_styles.scss @@ -0,0 +1,14 @@ +// react-select's