diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx new file mode 100644 index 000000000..f9a447eb7 --- /dev/null +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -0,0 +1,513 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useId, useMemo, createContext } from "react" +import { Combobox } from "@headlessui/react" +import { Float } from "@headlessui-float/react" +import { Label } from "../Label/index" +import { FormHint } from "../FormHint/index" +import { Icon } from "../Icon/index" +import { Spinner } from "../Spinner/index" +import { flip, offset, shift, size } from "@floating-ui/react-dom" +import { usePortalRef } from "../PortalProvider/index" +import { createPortal } from "react-dom" +import { ComboBoxOptionProps } from "../ComboBoxOption/index" + +// STYLES + +const inputWrapperStyles = ` + jn-relative +` + +const labelStyles = ` + jn-pointer-events-none + jn-top-2 + jn-left-[0.9375rem] +` + +const inputStyles = ` + jn-rounded-3px + jn-bg-theme-textinput + jn-text-theme-textinput + jn-border + jn-text-base + jn-leading-4 + jn-w-full + jn-px-4 + jn-h-textinput + jn-text-left + jn-overflow-hidden + jn-text-ellipsis + jn-whitespace-nowrap + focus:jn-outline-none + focus:jn-ring-2 + focus:jn-ring-theme-focus +` + +const withLabelInputStyles = ` + jn-pt-[1.125rem] + jn-pb-1 +` + +const noLabelInputStyles = ` + jn-py-4 +` + +const disabledInputStyles = ` + jn-cursor-not-allowed + jn-pointer-events-none + jn-opacity-50 +` + +const defaultBorderStyles = ` + jn-border-theme-textinput-default +` + +const validStyles = ` + jn-border-theme-success +` + +const invalidStyles = ` + jn-border-theme-error +` + +const buttonStyles = ` + jn-absolute + jn-top-0 + jn-right-0 + jn-h-textinput + jn-w-6 + jn-h-4 + jn-border-l-0 + jn-border-y-[1px] + jn-border-r-[1px] + jn-rounded-tr + jn-rounded-br + jn-appearance-none + jn-bg-theme-textinput + jn-text-theme-textinput +` + +const defaultButtonStyles = ` + jn-border-theme-textinput-default +` + +const invalidButtonStyles = ` + jn-border-theme-error +` + +const validButtonStyles = ` + jn-border-theme-success +` + +const disabledButtonStyles = ` + jn-cursor-not-allowed + jn-pointer-events-none + jn-bg-transparent + jn-opacity-50 +` + +const menuStyles = ` + jn-rounded + jn-bg-theme-background-lvl-1 + jn-w-full + jn-overflow-y-auto +` + +const iconContainerStyles = ` + jn-absolute + jn-top-[.4rem] + jn-right-6 +` + +const centeredIconStyles = ` + jn-absolute + jn-top-1/2 + jn-left-1/2 + jn-translate-y-[-50%] + jn-translate-x-[-0.75rem] +` + +//eslint-disable-next-line no-unused-vars +type AddOptionValueAndLabelFunction = (value: string, label: string | undefined, children: React.ReactNode) => void + +export type ComboBoxContextType = { + selectedValue?: string + truncateOptions: boolean + addOptionValueAndLabel: AddOptionValueAndLabelFunction +} + +// CONTEXT +export const ComboBoxContext = createContext(undefined) + +type OptionValuesAndLabelsKey = string | React.ReactNode +type OptionValuesAndLabelsValue = { + val: string + label?: string + children: React.ReactNode +} + +// COMBOBOX +export const ComboBox = ({ + ariaLabel, + children = null, + className = "", + defaultValue = "", + disabled = false, + error = false, + errortext = "", + helptext = "", + id = "", + invalid = false, + loading = false, + label, + name = "", + nullable = true, + onBlur, + onChange, + onFocus, + onInputChange, + placeholder = "Select…", + required = false, + successtext = "", + truncateOptions = false, + valid = false, + value = "", + valueLabel, + width = "full", + wrapperClassName = "", + ...props +}: ComboBoxProps) => { + const isNotEmptyString = (str: React.ReactNode | string) => { + return !(typeof str === "string" && str.trim().length === 0) + } + + const theId = id || "juno-combobox-" + useId() + const helptextId = "juno-combobox-helptext-" + useId() + + const [optionValuesAndLabels, setOptionValuesAndLabels] = useState( + new Map() + ) + const [query, setQuery] = useState("") + const [selectedValue, setSelectedValue] = useState(value) + const [isLoading, setIsLoading] = useState(false) + const [hasError, setHasError] = useState(false) + const [hasFocus, setFocus] = useState(false) + const [isInvalid, setIsInvalid] = useState(false) + const [isValid, setIsValid] = useState(false) + + // This callback is for all ComboBoxOptions to send us their value, label and children so we can save them as a map in our state. + // We need this because the Select component wants to display the selected value, label or children in the ComboBox input field + // but from the eventHandler we only get the value, not the label or children + const addOptionValueAndLabel = (value: string, label: string | undefined, children: React.ReactNode) => { + // append new entry to optionValuesAndLabels map containing the passed value, label and children + // use callback syntax of setState function here since we want to merge the old state with the new entry + setOptionValuesAndLabels((oldMap) => + new Map(oldMap).set(value || children, { + val: value, + label: label, + children: children, + }) + ) + } + + const invalidated = useMemo( + () => invalid || (errortext && isNotEmptyString(errortext) ? true : false), + [invalid, errortext] + ) + const validated = useMemo( + () => valid || (successtext && isNotEmptyString(successtext) ? true : false), + [valid, successtext] + ) + + useEffect(() => { + setSelectedValue(value) + }, [value]) + + useEffect(() => { + setHasError(error) + }, [error]) + + useEffect(() => { + setIsLoading(loading) + }, [loading]) + + useEffect(() => { + setIsInvalid(invalidated) + }, [invalidated]) + + useEffect(() => { + setIsValid(validated) + }, [validated]) + + const handleChange = (value: string) => { + setSelectedValue(value) + onChange && onChange(value) + } + + const handleInputChange = (event: React.ChangeEvent) => { + setQuery(event?.target?.value) + onInputChange && onInputChange(event) + } + + const handleFocus = (event: React.FocusEvent) => { + setFocus(true) + onFocus && onFocus(event) + } + + const handleBlur = (event: React.FocusEvent) => { + setFocus(false) + onBlur && onBlur(event) + } + + const portalContainerRef = usePortalRef() + + // Headless-UI-Float Middleware + const middleware = [ + offset(4), + shift(), + flip(), + size({ + rootBoundary: "viewport", + apply({ availableWidth, availableHeight, elements }) { + Object.assign(elements.floating.style, { + maxWidth: `${availableWidth}px`, + maxHeight: `${availableHeight}px`, + overflowY: "auto", + }) + }, + }), + ] + + const filteredChildren = + query === "" + ? React.Children.toArray(children) + : React.Children.toArray(children).filter((child) => { + if (React.isValidElement(child)) { + // ensure that we filter on the value that is displayed to the user. Apply the same logic as when rendering + // the options, i.e. match children if present, if not match label, lastly if neither label nor children exist, then check value + const optionDisplayValue = child.props.children?.toString() || child.props.label || child.props.value + return optionDisplayValue?.toLowerCase().includes(query.toLowerCase()) + } else { + return false + } + }) + + return ( + +
+ + + +
+ {label && isNotEmptyString(label) && !isLoading && !hasError ? ( +
+
+ + {createPortal( + + + {filteredChildren} + + , + portalContainerRef ? portalContainerRef : document.body + )} +
+
+ + {errortext && isNotEmptyString(errortext) ? : ""} + {successtext && isNotEmptyString(successtext) ? : ""} + {helptext && isNotEmptyString(helptext) ? : ""} +
+
+ ) +} + +export type ComboBoxWidth = "full" | "auto" + +//eslint-disable-next-line no-unused-vars +type OnChangeHandler = (value: string) => void + +export type ComboBoxProps = { + /** The aria-label of the ComboBox. Defaults to the label if label was passed. */ + ariaLabel?: string + /** The children to Render. Use `ComboBox.Option` elements. */ + children?: React.ReactElement | React.ReactElement[] | null + /** A custom className. Will be passed to the internal text input element of the ComboBox */ + className?: string + /** Pass a defaultValue to use as an uncontrolled Component that will handle its state internally */ + defaultValue?: string + /** Whether the ComboBox is disabled */ + disabled?: boolean + /** Whether the ComboBox has an error. Note this refers to an internal error like failing to load options etc., to indicate failed validation use `invalid` instead. */ + error?: boolean + /** An errortext to display when the ComboBox failed validation or an internal error occurred. */ + errortext?: JSX.Element | string + /** A helptext to render to explain meaning and significance of the ComboBox */ + helptext?: JSX.Element | string + /** The Id of the ComboBox. Will be assigned to the text input part of the ComboBox. If not passed, an id will be auto-generated. */ + id?: string + /** Whether the ComboBox failed validation */ + invalid?: boolean + /** The label of the ComboBox */ + label?: string + /** Whether the ComboBox is busy loading options */ + loading?: boolean + /** The name attribute of the ComboBox when used as part of a form */ + name?: string + /** Whether the ComboBox can be reset to having no value selected by manually clearing the text and clicking outside of the ComboBox. Default is TRUE. When set to FALSE, the selected value can only be changed by selecting another value after the initial selection, but never back to no selected value at all. */ + nullable?: boolean + /** A handler to execute when the ComboBox looses focus */ + onBlur?: React.FocusEventHandler + /** A handler to execute when the ComboBox' selected value changes */ + onChange?: OnChangeHandler + /** A handler to execute when the ComboBox input receives focus */ + onFocus?: React.FocusEventHandler + /** Handler to execute when the ComboBox text input value changes */ + onInputChange?: React.ChangeEventHandler + /** A placeholder to render in the text input */ + placeholder?: string + /** Whether the ComboBox is required */ + required?: boolean + /** A text to display in case the ComboBox was successfully validated. Will set the ComboBox to `valid` when passed. */ + successtext?: JSX.Element | string + /** Whether the option labels should be truncated in case they are longer/wider than the available space in an option or not. Default is FALSE. */ + truncateOptions?: boolean + /** Whether the ComboBox was successfully validated */ + valid?: boolean + /** The selected value of the ComboBox in Controlled Mode. */ + value?: string + /** The label of the passed value or defaultValue. If you want to use controlled mode or pass as defaultValue in uncontrolled mode and additionally use labels for human-readable SelectOptions, you need to also pass the matching label for the passed value/defaultValue so that the Select component can render itself properly */ + valueLabel?: string + /** The width of the text input. Either 'full' (default) or 'auto'. */ + width?: ComboBoxWidth + /** Pass a custom classname to the wrapping
element. This can be useful if you must add styling to the outermost wrapping element of this component, e.g. for positioning. */ + wrapperClassName?: string +} diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx new file mode 100644 index 000000000..f996a7d13 --- /dev/null +++ b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx @@ -0,0 +1,902 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from "react" +import { ComboBox } from "./index" +import { ComboBoxOption } from "../ComboBoxOption/index" +import { PortalProvider } from "../PortalProvider/PortalProvider.component" +import { ComboBoxProps } from "./ComboBox.component" + +type StoryFunction = () => React.ReactNode + +export default { + title: "Forms/ComboBox/ComboBox", + component: ComboBox, + argTypes: { + children: { + control: false, + }, + errortext: { + control: false, + }, + helptext: { + control: false, + }, + successtext: { + control: false, + }, + }, + decorators: [ + (story: StoryFunction) => ( +
+ {story()} +
+ ), + ], +} + +const Template = ({ children, ...args }: TemplateProps) => { + return {children} +} + +// define prop types fro Template +type TemplateProps = { + children: React.ReactNode +} & ComboBoxProps + +const ConstrainedWidthTemplate = ({ children, ...args }: ConstrainedWidthTemplateProps) => { + return ( +
+ {children} +
+ ) +} + +// define prop types for ConstrainedWidthTemplate +type ConstrainedWidthTemplateProps = { + children: React.ReactNode +} & ComboBoxProps + +const ControlledTemplate = ({ value, children }: ControlledTemplateProps) => { + const [v, setV] = useState(value) + + useEffect(() => { + setV(value) + }, [value]) + + return {children} +} + +// define prop types for ControlledTemplate +type ControlledTemplateProps = { + value: string + children: React.ReactNode +} & ComboBoxProps + +export const Default = { + render: Template, + + args: { + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const ControlledComboBox = { + render: ControlledTemplate, + + args: { + value: "Houdini", + label: "A controlled ComboBox", + children: [ + + Caligari + , + + Houdini + , + , + ], + }, +} + +export const UncontrolledComboBox = { + render: Template, + + args: { + defaultValue: "Lencia", + label: "An uncontrolled ComboBox", + children: [ + + Caligari + , + + Houdini + , + , + ], + }, +} + +export const WithLabel = { + render: Template, + + args: { + label: "ComboBox", + placeholder: "", + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const WithLabelAndPlaceholder = { + render: Template, + + args: { + label: "ComboBox", + placeholder: "Type or select an Option…", + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const Required = { + render: Template, + + args: { + label: "Required ComboBox", + required: true, + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const Valid = { + render: Template, + + args: { + label: "Valid ComboBox", + valid: true, + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const Invalid = { + render: Template, + + args: { + label: "invalid ComboBox", + invalid: true, + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const Disabled = { + render: Template, + + args: { + label: "Disabled ComboBox", + disabled: true, + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const DisabledOption = { + render: Template, + + args: { + label: "ComboBox with a Disabled Option", + helptext: "Option Carrots should be disabled", + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + ], + }, +} + +export const WithHelpText = { + render: Template, + + args: { + label: "ComboBox", + helptext: "Helptext to describe meaning and significance of the ComboBox", + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const WithHelpTextAsNode = { + render: Template, + + args: { + label: "ComboBox", + helptext: ( + <> + This is a helptext with a Link + + ), + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const WithErrorText = { + render: Template, + + args: { + label: "ComboBox", + errortext: "Invalidated by passing an errortext", + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const WithSuccessText = { + render: Template, + + args: { + label: "ComboBox", + successtext: "Validated by passing a successtext", + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + + Eggplant + , + + Zucchini + , + + Brussels Sprouts + , + + Horseradish + , + + Green Beans + , + + Mushrooms + , + + Leek + , + + Artichokes + , + + Peas + , + + Potatoes + , + ], + }, +} + +export const NonNullable = { + render: Template, + + args: { + nullable: false, + label: "Non-Nullable ComboBox", + helptext: + "This Select can not be reset to having no value selected. The last selected value will remian selected when emptying the input field.", + children: [ + + Rhubarb + , + + Carrots + , + + Spinach + , + + Tomatoes + , + + Cucumbers + , + + Cauliflower + , + ], + }, +} + +export const NonTruncatedOptions = { + render: ConstrainedWidthTemplate, + + args: { + children: [ + , + , + ], + }, +} + +export const TruncatedOptions = { + render: ConstrainedWidthTemplate, + + args: { + truncateOptions: true, + children: [ + , + , + ], + }, +} + +export const OptionsWithLabels = { + render: Template, + + parameters: { + docs: { + description: { + story: "If an option has both a label and a child, then the child is displayed instead of the label", + }, + }, + }, + + args: { + children: [ + , + + Option 2 child is displayed instead of label + , + ], + }, +} + +export const Loading = { + render: Template, + + args: { + loading: true, + helptext: "ComboBox busy loading options", + }, +} + +export const Error = { + render: Template, + + args: { + error: true, + errortext: "ComboBox having trouble loading options", + }, +} + +export const ValueAndDefaultValue = { + render: Template, + + args: { + value: "Option 1", + defaultValue: "Option 2", + children: [ + , + , + , + ], + }, +} diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx new file mode 100644 index 000000000..fcae8b866 --- /dev/null +++ b/packages/ui-components/src/components/ComboBox/ComboBox.test.tsx @@ -0,0 +1,567 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as React from "react" +import { cleanup, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { ComboBox } from "./index" +import { AppShellProvider } from "../AppShellProvider/AppShellProvider.component" +import { ComboBoxOption } from "../ComboBoxOption/index" + +const mockOnBlur = vi.fn() +const mockOnChange = vi.fn() +const mockOnFocus = vi.fn() +const mockOnInputChange = vi.fn() + +describe("ComboBox", () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + test("renders a ComboBox", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveAttribute("type", "text") + expect(screen.getByRole("combobox")).toHaveClass("juno-combobox-input") + }) + + test("renders a ComboBox with a name as passed", async () => { + await waitFor(() => + render( + + + Option 1 + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + /* Here we need to directly select the input, since headless + a) does not add the name to the visible input element but to another, hidden input element it keeps in sync, and + b) react-testing fails when trying to access hidden elements by role: */ + expect(document.querySelector("input[name='my-wonderful-combobox']")).toBeInTheDocument() + }) + + test("renders a ComboBox with a label as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(document.querySelector(".juno-label")).toBeInTheDocument() + expect(document.querySelector(".juno-label")).toHaveTextContent("My Label") + }) + + test("renders options as passed", async () => { + await waitFor(() => + render( + + + Option 1 + + + ) + ) + const user = userEvent.setup() + const cbox = screen.getByRole("combobox") + const cbutton = screen.getByRole("button") + expect(cbox).toBeInTheDocument() + expect(cbutton).toBeInTheDocument() + await waitFor(async () => { + await user.click(cbutton) + expect(screen.getByRole("listbox")).toBeInTheDocument() + expect(screen.getByRole("option")).toBeInTheDocument() + expect(screen.getByRole("option")).toHaveTextContent("Option 1") + }) + }) + + test("renders an id as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveAttribute("id", "My Id") + }) + + test("renders the id of the ComboBox input as the for attribute of the label", async () => { + await waitFor(() => + render( + + + + ) + ) + const cbox = screen.getByRole("combobox") + const label = document.querySelector(".juno-label") + expect(cbox).toBeInTheDocument() + expect(label).toBeInTheDocument() + expect(label!.getAttribute("for")).toMatch(cbox.getAttribute("id")!) + expect(screen.getByLabelText("the label")).toBeInTheDocument() + }) + + test("renders an aria-label as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveAttribute("aria-label", "my aria-label") + }) + + test("renders the label as an aria-label if no aria-label was passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveAttribute("aria-label", "My Label") + }) + + test("renders a ComboBox with a placeholder as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveAttribute("placeholder", "My Placeholder") + }) + + test("renders a disabled ComboBox as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toBeDisabled() + }) + + test("renders a required ComboBox as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(document.querySelector(".juno-label")).toBeInTheDocument() + expect(document.querySelector(".juno-required")).toBeInTheDocument() + }) + + test("renders a validated ComboBox as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveClass("juno-combobox-valid") + expect(screen.getByTitle("CheckCircle")).toBeInTheDocument() + }) + + test("renders a validated ComboBox when a successtext was passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveClass("juno-combobox-valid") + expect(screen.getByTitle("CheckCircle")).toBeInTheDocument() + }) + + test("renders an invalidated ComboBox as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveClass("juno-combobox-invalid") + expect(screen.getByTitle("Dangerous")).toBeInTheDocument() + }) + + test("renders an invalidated ComboBox when an errortext was passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveClass("juno-combobox-invalid") + expect(screen.getByTitle("Dangerous")).toBeInTheDocument() + }) + + test("renders a helptext as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(document.querySelector(".juno-form-hint")).toBeInTheDocument() + expect(document.querySelector(".juno-form-hint")).toHaveClass("juno-form-hint-help") + expect(document.querySelector(".juno-form-hint")).toHaveTextContent("A helptext goes here") + }) + + test("renders an errortext as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(document.querySelector(".juno-form-hint")).toBeInTheDocument() + expect(document.querySelector(".juno-form-hint")).toHaveClass("juno-form-hint-error") + expect(document.querySelector(".juno-form-hint")).toHaveTextContent("An errortext goes here") + }) + + test("renders a successtext as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(document.querySelector(".juno-form-hint")).toBeInTheDocument() + expect(document.querySelector(".juno-form-hint")).toHaveClass("juno-form-hint-success") + expect(document.querySelector(".juno-form-hint")).toHaveTextContent("A successtext goes here") + }) + + test("renders a loading ComboBox with a Spinner as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toHaveClass("juno-combobox-loading") + expect(document.querySelector(".juno-spinner")).toBeInTheDocument() + }) + + test("renders a ComboBox in error state with an Error icon as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toHaveClass("juno-combobox-error") + expect(screen.getByTitle("Error")).toBeInTheDocument() + }) + + test("fires an onBlur handler as passed when the ComboBox looses focus", async () => { + await waitFor(() => + render( + + + + ) + ) + const user = userEvent.setup() + const cbox = screen.getByRole("combobox") + await waitFor(async () => { + await user.click(cbox) // focus the element + await user.tab() // blur the element + expect(mockOnBlur).toHaveBeenCalled() + }) + }) + + test("fires an onChange handler as passed when the user selects an option", async () => { + await waitFor(() => + render( + + + Option 1 + Option 2 + + + ) + ) + const user = userEvent.setup() + const cbox = screen.getByRole("combobox") + const cbutton = screen.getByRole("button") + expect(cbox).toBeInTheDocument() + expect(cbutton).toBeInTheDocument() + await waitFor(() => user.click(cbutton)) + expect(screen.getByRole("listbox")).toBeInTheDocument() + await waitFor(async () => { + await user.click(screen.getByRole("option", { name: "Option 2" })) + expect(mockOnChange).toHaveBeenCalled() + }) + }) + + test("fires an onFocus handler as passed when the ComboBox receives focus", async () => { + await waitFor(() => + render( + + + + ) + ) + const user = userEvent.setup() + const cbox = screen.getByRole("combobox") + await waitFor(async () => { + await user.click(cbox) + expect(mockOnFocus).toHaveBeenCalled() + }) + }) + + test("fires an onInputChange handler when the user types into the ComboBox", async () => { + await waitFor(() => + render( + + + Something + Something else + + + ) + ) + const user = userEvent.setup() + const cbox = screen.getByRole("combobox") + await waitFor(async () => { + await user.type(cbox, "a") + expect(mockOnInputChange).toHaveBeenCalled() + }) + }) + + test("filters options as the user types", async () => { + await waitFor(() => + render( + + + + aaa + + + aab + + + abc + + + 123 + + + + ) + ) + const user = await waitFor(() => userEvent.setup()) + const cbox = screen.getByRole("combobox") + expect(cbox).toBeInTheDocument() + await waitFor(() => user.type(cbox, "a")) + expect(screen.getByRole("listbox")).toBeInTheDocument() + expect(screen.getByRole("option", { name: "aaa" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "aab" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "abc" })).toBeInTheDocument() + expect(screen.queryByRole("option", { name: "123" })).not.toBeInTheDocument() + + await waitFor(() => user.type(cbox, "b")) + expect(screen.queryByRole("option", { name: "aaa" })).not.toBeInTheDocument() + expect(screen.getByRole("option", { name: "aab" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "abc" })).toBeInTheDocument() + expect(screen.queryByRole("option", { name: "123" })).not.toBeInTheDocument() + await waitFor(() => user.clear(cbox)) + expect(screen.getByRole("option", { name: "aaa" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "aab" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "abc" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "123" })).toBeInTheDocument() + await waitFor(() => user.type(cbox, "1")) + expect(screen.queryByRole("option", { name: "aaa" })).not.toBeInTheDocument() + expect(screen.queryByRole("option", { name: "aab" })).not.toBeInTheDocument() + expect(screen.queryByRole("option", { name: "abc" })).not.toBeInTheDocument() + expect(screen.getByRole("option", { name: "123" })).toBeInTheDocument() + }) + + test("selects an option when the user clicks it and closes the menu", async () => { + await waitFor(() => + render( + + + + aaa + + + aab + + + abc + + + 123 + + + + ) + ) + const user = userEvent.setup() + const cbox = screen.getByRole("combobox") + const cbutton = screen.getByRole("button") + expect(cbox).toBeInTheDocument() + expect(cbutton).toBeInTheDocument() + await waitFor(() => user.click(cbutton)) + expect(screen.getByRole("listbox")).toBeInTheDocument() + + const option = screen.getByRole("option", { name: "abc" }) + await waitFor(() => user.click(option)) + await waitFor(() => { + expect(screen.queryByRole("listbox")).not.toBeInTheDocument() + expect(cbox).toHaveValue("abc") + }) + }) + + test("works as a controlled component with a value as passed", async () => { + await waitFor(() => + render( + + + + aaa + + + aab + + + abc + + + 123 + + + + ) + ) + const user = userEvent.setup() + const cbox = screen.getByRole("combobox") + const toggle = screen.getByRole("button") + expect(cbox).toBeInTheDocument() + expect(toggle).toBeInTheDocument() + expect(cbox).toHaveValue("aab") + await waitFor(() => user.click(toggle)) + + expect(screen.getByRole("listbox")).toBeInTheDocument() + const option123 = screen.getAllByRole("option")[3] + expect(option123).toHaveTextContent("123") + await waitFor(() => user.click(option123)) + expect(cbox).toHaveValue("123") + }) + + test("works as an uncontrolled component with a defaultValue as passed", async () => { + await waitFor(() => + render( + + + aaa + aab + abc + 123 + + + ) + ) + const user = userEvent.setup() + const cbox = screen.getByRole("combobox") + const toggle = screen.getByRole("button") + expect(cbox).toBeInTheDocument() + expect(toggle).toBeInTheDocument() + expect(cbox).toHaveValue("abc") + await waitFor(() => user.click(toggle)) + expect(screen.getByRole("listbox")).toBeInTheDocument() + + const option123 = screen.getAllByRole("option")[3] + expect(option123).toHaveTextContent("123") + + await waitFor(() => user.click(option123)) + expect(cbox).toHaveValue("123") + }) + + // Caution: The below test basically tests headless-ui behaviour, not our logic. This is here only for testing consistency and so that we know should headless ever change their behaviour: + test("works as a controlled component using value when both value and defaultValue have been passed", async () => { + await waitFor(() => + render( + + + + + + + ) + ) + const cbox = screen.getByRole("combobox") + expect(cbox).toBeInTheDocument() + expect(cbox).toHaveValue("option 2") + }) + + test("renders a wrapperClassName to the outer wrapping
element", () => { + render() + expect(document.querySelector(".juno-combobox-wrapper")).toBeInTheDocument() + expect(document.querySelector(".juno-combobox-wrapper")).toHaveClass("my-wrapper-class") + }) + + test("renders a ComboBox with a custom className as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveClass("my-combobox") + }) + + // Skipping because if we pass generic props to the ComboBox component it will be passed to the abstract headless Combobox component, but will not end up in the DOM: + // do not eslint + + test.skip("renders all props as passed", async () => { + await waitFor(() => + render( + + + + ) + ) + expect(screen.getByRole("combobox")).toBeInTheDocument() + expect(screen.getByRole("combobox")).toHaveAttribute("data-lolo", "1234") + }) +}) diff --git a/packages/ui-components/src/components/ComboBox/index.ts b/packages/ui-components/src/components/ComboBox/index.ts new file mode 100644 index 000000000..4874bb22a --- /dev/null +++ b/packages/ui-components/src/components/ComboBox/index.ts @@ -0,0 +1,6 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ComboBox } from "./ComboBox.component" diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx new file mode 100644 index 000000000..1c6fe0b7e --- /dev/null +++ b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Fragment, useEffect, useContext } from "react" +import { Combobox } from "@headlessui/react" +import { ComboBoxContext } from "../ComboBox/ComboBox.component" +import { Icon } from "../Icon/Icon.component" + +const optionStyles = ` + jn-flex + jn-pt-[0.6875rem] + jn-pb-[0.5rem] + jn-pr-[0.875rem] + jn-select-none + data-[headlessui-state=active]:jn-outline-none + data-[headlessui-state=active]:jn-ring-2 + data-[headlessui-state=active]:jn-ring-inset + data-[headlessui-state=active]:jn-ring-theme-focus + data-[headlessui-state=active]:jn-bg-theme-background-lvl-3 +` + +const unselectedOptionStyles = ` + jn-text-theme-default + jn-pl-[2.375rem] +` + +const selectedOptionStyles = ` + jn-text-theme-accent + jn-pl-3.5 +` + +const selectedIconStyles = ` + jn-inline-block + jn-mr-1.5 +` + +const disabledOptionLabelStyles = ` + jn-opacity-50 + jn-cursor-not-allowed +` + +const truncateOptionStyles = ` + jn-block + jn-h-6 + jn-overflow-hidden + jn-text-ellipsis + jn-whitespace-nowrap +` + +export const ComboBoxOption = ({ + children, + disabled = false, + value = "", + label, + className = "", + ...props +}: ComboBoxOptionProps) => { + const comboBoxContext = useContext(ComboBoxContext) + const { + selectedValue: selectedValue, + truncateOptions: truncateOptions, + addOptionValueAndLabel: addOptionValueAndLabel, + } = comboBoxContext || {} + + // send option metadata to the ComboBox parent component via Context + useEffect(() => { + if (addOptionValueAndLabel) { + addOptionValueAndLabel(value, label, children) + } + }, [value, label, children]) + + const theValue = value || children + + return ( + +
  • + {selectedValue === theValue ? : ""} + + {children || label || value} + +
  • +
    + ) +} + +export type ComboBoxOptionProps = { + children?: string + disabled?: boolean + value?: string + label?: string + className?: string +} & React.HTMLProps diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.stories.tsx b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.stories.tsx new file mode 100644 index 000000000..7099768e1 --- /dev/null +++ b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.stories.tsx @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { ComboBox } from "../ComboBox/ComboBox.component" +import { ComboBoxOption, ComboBoxOptionProps } from "./ComboBoxOption.component" + +export default { + title: "Forms/ComboBox/ComboBoxOption", + component: ComboBoxOption, + argTypes: {}, +} + +const Template = (args: ComboBoxOptionProps) => { + return ( + + + + ) +} + +export const Default = { + render: Template, + + args: { + value: "Option 1", + }, +} + +export const Disabled = { + render: Template, + + args: { + disabled: true, + value: "Disabled Option", + }, +} + +export const ChildrenOnly = { + render: Template, + + args: { + children: "Option 1", + }, +} diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.test.tsx b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.test.tsx new file mode 100644 index 000000000..fd3b950f3 --- /dev/null +++ b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.test.tsx @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as React from "react" +import { cleanup, render, screen, waitFor } from "@testing-library/react" +import { AppShellProvider } from "../AppShellProvider/AppShellProvider.component" +import userEvent from "@testing-library/user-event" +import { ComboBox } from "../ComboBox/ComboBox.component" +import { ComboBoxOption } from "./ComboBoxOption.component" + +describe("ComboBoxOption", () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + test("renders a ComboBoxOption", async () => { + await waitFor(() => + render( + + + + + + ) + ) + const toggle = screen.getByRole("button") + expect(toggle).toBeInTheDocument() + await waitFor(() => userEvent.click(toggle)) + expect(screen.getByRole("option")).toBeInTheDocument() + expect(screen.getByRole("option")).toHaveTextContent("Option 1") + }) + + test("renders a ComboBoxOption with label as passed", async () => { + await waitFor(() => + render( + + + + + + ) + ) + const toggle = screen.getByRole("button") + expect(toggle).toBeInTheDocument() + await waitFor(() => userEvent.click(toggle)) + expect(screen.getByRole("option")).toBeInTheDocument() + expect(screen.getByRole("option")).toHaveTextContent("Option 1 Label") + }) + + test("renders a ComboBoxOption with children as passed", async () => { + await waitFor(() => + render( + + + Option 1 child + + + ) + ) + const toggle = screen.getByRole("button") + expect(toggle).toBeInTheDocument() + await waitFor(() => userEvent.click(toggle)) + expect(screen.getByRole("option")).toBeInTheDocument() + expect(screen.getByRole("option")).toHaveTextContent("Option 1 child") + }) + + test("renders a ComboBoxOption with children if both label and children are passed", async () => { + await waitFor(() => + render( + + + + Option 1 child + + + + ) + ) + const toggle = screen.getByRole("button") + expect(toggle).toBeInTheDocument() + await waitFor(() => userEvent.click(toggle)) + expect(screen.getByRole("option")).toBeInTheDocument() + expect(screen.getByRole("option")).toHaveTextContent("Option 1 child") + }) + + test("renders a className as passed", async () => { + await waitFor(() => + render( + + + + + + ) + ) + const toggle = screen.getByRole("button") + expect(toggle).toBeInTheDocument() + await waitFor(() => userEvent.click(toggle)) + expect(screen.getByRole("option")).toBeInTheDocument() + expect(screen.getByRole("option")).toHaveClass("my-fancy-class") + }) + + test("renders all props as passed", async () => { + await waitFor(() => + render( + + + + + + ) + ) + const toggle = screen.getByRole("button") + expect(toggle).toBeInTheDocument() + await waitFor(() => userEvent.click(toggle)) + expect(screen.getByRole("option")).toBeInTheDocument() + expect(screen.getByRole("option")).toHaveAttribute("data-lolol", "123") + }) +}) diff --git a/packages/ui-components/src/components/ComboBoxOption/index.ts b/packages/ui-components/src/components/ComboBoxOption/index.ts new file mode 100644 index 000000000..2cb8f6189 --- /dev/null +++ b/packages/ui-components/src/components/ComboBoxOption/index.ts @@ -0,0 +1,6 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ComboBoxOption, type ComboBoxOptionProps } from "./ComboBoxOption.component"