From 273852ca16f9efc3d20b2dbe03c9c59246161b8d Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Wed, 6 Nov 2024 11:02:33 +0100 Subject: [PATCH 01/12] chore(ui): migrate ComboBox component to typescript --- .changeset/healthy-needles-complain.md | 5 + .../components/ComboBox/ComboBox.component.js | 487 ---------- .../components/ComboBox/ComboBox.stories.js | 902 ------------------ .../src/components/ComboBox/ComboBox.test.js | 568 ----------- .../src/components/ComboBox/index.js | 6 - .../ComboBoxOption.component.js | 100 -- .../ComboBoxOption/ComboBoxOption.stories.js | 47 - .../ComboBoxOption/ComboBoxOption.test.js | 122 --- .../src/components/ComboBoxOption/index.js | 6 - 9 files changed, 5 insertions(+), 2238 deletions(-) create mode 100644 .changeset/healthy-needles-complain.md delete mode 100644 packages/ui-components/src/components/ComboBox/ComboBox.component.js delete mode 100644 packages/ui-components/src/components/ComboBox/ComboBox.stories.js delete mode 100644 packages/ui-components/src/components/ComboBox/ComboBox.test.js delete mode 100644 packages/ui-components/src/components/ComboBox/index.js delete mode 100644 packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.js delete mode 100644 packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.stories.js delete mode 100644 packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.test.js delete mode 100644 packages/ui-components/src/components/ComboBoxOption/index.js diff --git a/.changeset/healthy-needles-complain.md b/.changeset/healthy-needles-complain.md new file mode 100644 index 000000000..efef9ac88 --- /dev/null +++ b/.changeset/healthy-needles-complain.md @@ -0,0 +1,5 @@ +--- +"@cloudoperators/juno-ui-components": minor +--- + +Migrate ComboBox and ComboBoxOption to TypeScript diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.js b/packages/ui-components/src/components/ComboBox/ComboBox.component.js deleted file mode 100644 index 39c69b293..000000000 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.js +++ /dev/null @@ -1,487 +0,0 @@ -/* - * 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 PropTypes from "prop-types" -import { Combobox } from "@headlessui/react" -import { Float } from "@headlessui-float/react" -import { Label } from "../../deprecated_js/Label/index.js" -import { FormHint } from "../../deprecated_js/FormHint/index.js" -import { Icon } from "../../deprecated_js/Icon/index.js" -import { Spinner } from "../../deprecated_js/Spinner/index.js" -import { flip, offset, shift, size } from "@floating-ui/react-dom" -import { usePortalRef } from "../../deprecated_js/PortalProvider/index" -import { createPortal } from "react-dom" - -// 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] -` - -// CONTEXT -export const ComboBoxContext = createContext() - -// 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 -}) => { - const isNotEmptyString = (str) => { - 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, label, children) => { - // 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) => { - setSelectedValue(value) - onChange && onChange(value) - } - - const handleInputChange = (event) => { - setQuery(event?.target?.value) - onInputChange && onInputChange(event) - } - - const handleFocus = (event) => { - setFocus(true) - onFocus && onFocus(event) - } - - const handleBlur = (event) => { - setFocus(false) - onBlur && onBlur(event) - } - - const portalContainerRef = usePortalRef() - - // Headless-UI-Float Middleware - const middleware = [ - offset(4), - shift(), - flip(), - size({ - boundary: "viewport", - apply({ availableWidth, availableHeight, elements }) { - Object.assign(elements.floating.style, { - maxWidth: `${availableWidth}px`, - maxHeight: `${availableHeight}px`, - overflowY: "auto", - }) - }, - }), - ] - - const filteredChildren = - query === "" - ? children - : children.filter((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()) - }) - - return ( - -
- - - -
- {label && isNotEmptyString(label) && !isLoading && !hasError ? ( -
-
- - {createPortal( - - - {filteredChildren} - - , - portalContainerRef ? portalContainerRef : document.body - )} -
-
- - {errortext && isNotEmptyString(errortext) ? : ""} - {successtext && isNotEmptyString(successtext) ? : ""} - {helptext && isNotEmptyString(helptext) ? : ""} -
-
- ) -} - -ComboBox.propTypes = { - /** The aria-label of the ComboBox. Defaults to the label if label was passed. */ - ariaLabel: PropTypes.string, - /** The children to Render. Use `ComboBox.Option` elements. */ - children: PropTypes.node, - /** A custom className. Will be passed to the internal text input element of the ComboBox */ - className: PropTypes.string, - /** Pass a defaultValue to use as an uncontrolled Component that will handle its state internally */ - defaultValue: PropTypes.string, - /** Whether the ComboBox is disabled */ - disabled: PropTypes.bool, - /** 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: PropTypes.bool, - /** An errortext to display when the ComboBox failed validation or an internal error occurred. */ - errortext: PropTypes.node, - /** A helptext to render to explain meaning and significance of the ComboBox */ - helptext: PropTypes.node, - /** 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: PropTypes.string, - /** Whether the ComboBox failed validation */ - invalid: PropTypes.bool, - /** The label of the ComboBox */ - label: PropTypes.string, - /** Whether the ComboBox is busy loading options */ - loading: PropTypes.bool, - /** The name attribute of the ComboBox when used as part of a form */ - name: PropTypes.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: PropTypes.bool, - /** A handler to execute when the ComboBox looses focus */ - onBlur: PropTypes.func, - /** A handler to execute when the ComboBox' selected value changes */ - onChange: PropTypes.func, - /** A handler to execute when the ComboBox input receives focus */ - onFocus: PropTypes.func, - /** Handler to execute when the ComboBox text input value changes */ - onInputChange: PropTypes.func, - /** A placeholder to render in the text input */ - placeholder: PropTypes.string, - /** Whether the ComboBox is required */ - required: PropTypes.bool, - /** A text to display in case the ComboBox was successfully validated. Will set the ComboBox to `valid` when passed. */ - successtext: PropTypes.node, - /** 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: PropTypes.bool, - /** Whether the ComboBox was successfully validated */ - valid: PropTypes.bool, - /** The selected value of the ComboBox in Controlled Mode. */ - value: PropTypes.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: PropTypes.string, - /** The width of the text input. Either 'full' (default) or 'auto'. */ - width: PropTypes.oneOf(["full", "auto"]), - /** 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: PropTypes.string, -} diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.stories.js b/packages/ui-components/src/components/ComboBox/ComboBox.stories.js deleted file mode 100644 index 7e2f1a418..000000000 --- a/packages/ui-components/src/components/ComboBox/ComboBox.stories.js +++ /dev/null @@ -1,902 +0,0 @@ -/* - * 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 PropTypes from "prop-types" -import { ComboBox } from "./index.js" -import { ComboBoxOption } from "../ComboBoxOption/index.js" -import { PortalProvider } from "../../deprecated_js/PortalProvider/PortalProvider.component" - -export default { - title: "Forms/ComboBox/ComboBox", - component: ComboBox, - argTypes: { - children: { - control: false, - }, - errortext: { - control: false, - }, - helptext: { - control: false, - }, - successtext: { - control: false, - }, - }, - decorators: [ - (Story) => ( -
- - - -
- ), - ], -} - -const Template = ({ children, ...args }) => { - return {children} -} - -// define prop types fro Template -Template.propTypes = { - children: PropTypes.any, -} - -const ConstrainedWidthTemplate = ({ children, ...args }) => { - return ( -
- {children} -
- ) -} - -// define prop types for ConstrainedWidthTemplate -ConstrainedWidthTemplate.propTypes = { - children: PropTypes.any, -} - -const ControlledTemplate = ({ value, children }) => { - const [v, setV] = useState(value) - - useEffect(() => { - setV(value) - }, [value]) - - return {children} -} - -// define prop types for ControlledTemplate -ControlledTemplate.propTypes = { - value: PropTypes.string, - children: PropTypes.any, -} - -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.js b/packages/ui-components/src/components/ComboBox/ComboBox.test.js deleted file mode 100644 index 26f85447e..000000000 --- a/packages/ui-components/src/components/ComboBox/ComboBox.test.js +++ /dev/null @@ -1,568 +0,0 @@ -/* - * 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 "../../deprecated_js/AppShellProvider/index" -import { ComboBoxOption } from "../ComboBoxOption/index" - -const mockOnBlur = jest.fn() -const mockOnChange = jest.fn() -const mockOnFocus = jest.fn() -const mockOnInputChange = jest.fn() - -describe("ComboBox", () => { - afterEach(() => { - cleanup() - jest.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(() => { - 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(() => { - user.click(cbox) // focus the element - 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(() => { - 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(() => { - 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(() => { - 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() - - let 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") - let option123 - await waitFor(() => user.click(toggle)) - expect(screen.getByRole("listbox")).toBeInTheDocument() - - 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", async () => { - 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.js b/packages/ui-components/src/components/ComboBox/index.js deleted file mode 100644 index db9a28c06..000000000 --- a/packages/ui-components/src/components/ComboBox/index.js +++ /dev/null @@ -1,6 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { ComboBox } from "./ComboBox.component.js" diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.js b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.js deleted file mode 100644 index ee41e3a9e..000000000 --- a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - * 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 PropTypes from "prop-types" -import { Combobox } from "@headlessui/react" -import { ComboBoxContext } from "../ComboBox/ComboBox.component" -import { Icon } from "../../deprecated_js/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 }) => { - const comboBoxContext = useContext(ComboBoxContext) - const { - selectedValue: selectedValue, - truncateOptions: truncateOptions, - addOptionValueAndLabel: addOptionValueAndLabel, - } = comboBoxContext || {} - - // send option metadata to the ComboBox parent component via Context - useEffect(() => { - addOptionValueAndLabel(value, label, children) - }, [value, label, children]) - - const theValue = value || children - - return ( - -
  • - {selectedValue === theValue ? : ""} - - {children || label || value} - -
  • -
    - ) -} - -ComboBoxOption.propTypes = { - children: PropTypes.string, - disabled: PropTypes.bool, - value: PropTypes.string, - label: PropTypes.string, - className: PropTypes.string, -} diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.stories.js b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.stories.js deleted file mode 100644 index 6a4c0f4cc..000000000 --- a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.stories.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * 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 } from "../ComboBoxOption/ComboBoxOption.component" - -export default { - title: "Forms/ComboBox/ComboBoxOption", - component: ComboBoxOption, - argTypes: {}, -} - -const Template = (args) => { - 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.js b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.test.js deleted file mode 100644 index 06e2cfc65..000000000 --- a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.test.js +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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 "../../deprecated_js/AppShellProvider" -import userEvent from "@testing-library/user-event" -import { ComboBox } from "../ComboBox/ComboBox.component" -import { ComboBoxOption } from "../ComboBoxOption/ComboBoxOption.component" - -describe("ComboBoxOption", () => { - afterEach(() => { - cleanup() - jest.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.js b/packages/ui-components/src/components/ComboBoxOption/index.js deleted file mode 100644 index dbf46f5b1..000000000 --- a/packages/ui-components/src/components/ComboBoxOption/index.js +++ /dev/null @@ -1,6 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { ComboBoxOption } from "./ComboBoxOption.component.js" From bef541d32d59497345e5e1e7e15d750b96ee273c Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Wed, 6 Nov 2024 14:07:54 +0100 Subject: [PATCH 02/12] fix(ui): add missing files --- .../ComboBox/ComboBox.component.tsx | 513 ++++++++++ .../components/ComboBox/ComboBox.stories.tsx | 902 ++++++++++++++++++ .../src/components/ComboBox/ComboBox.test.tsx | 567 +++++++++++ .../src/components/ComboBox/index.ts | 6 + .../ComboBoxOption.component.tsx | 108 +++ .../ComboBoxOption/ComboBoxOption.stories.tsx | 47 + .../ComboBoxOption/ComboBoxOption.test.tsx | 122 +++ .../src/components/ComboBoxOption/index.ts | 6 + 8 files changed, 2271 insertions(+) create mode 100644 packages/ui-components/src/components/ComboBox/ComboBox.component.tsx create mode 100644 packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx create mode 100644 packages/ui-components/src/components/ComboBox/ComboBox.test.tsx create mode 100644 packages/ui-components/src/components/ComboBox/index.ts create mode 100644 packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx create mode 100644 packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.stories.tsx create mode 100644 packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.test.tsx create mode 100644 packages/ui-components/src/components/ComboBoxOption/index.ts 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" From 7ec16b1e90112d4a0dddfc927a2bb4003c172227 Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Fri, 8 Nov 2024 13:08:55 +0100 Subject: [PATCH 03/12] fix(ui): typing improvements --- .../ComboBox/ComboBox.component.tsx | 4 ++-- .../components/ComboBox/ComboBox.stories.tsx | 19 ++++--------------- .../ComboBoxOption.component.tsx | 4 ++-- .../src/components/ComboBoxOption/index.ts | 2 +- 4 files changed, 9 insertions(+), 20 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index f9a447eb7..d73190d3d 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -13,7 +13,7 @@ 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" +import { ComboBoxOptionProps } from "../ComboBoxOption/ComboBoxOption.component" // STYLES @@ -455,7 +455,7 @@ export type ComboBoxWidth = "full" | "auto" //eslint-disable-next-line no-unused-vars type OnChangeHandler = (value: string) => void -export type ComboBoxProps = { +export interface 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. */ diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx index f996a7d13..8efbdae90 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx @@ -37,16 +37,11 @@ export default { ], } -const Template = ({ children, ...args }: TemplateProps) => { +const Template = ({ children, ...args }: ComboBoxProps) => { return {children} } -// define prop types fro Template -type TemplateProps = { - children: React.ReactNode -} & ComboBoxProps - -const ConstrainedWidthTemplate = ({ children, ...args }: ConstrainedWidthTemplateProps) => { +const ConstrainedWidthTemplate = ({ children, ...args }: ComboBoxProps) => { return (
    {children} @@ -54,11 +49,6 @@ const ConstrainedWidthTemplate = ({ children, ...args }: ConstrainedWidthTemplat ) } -// define prop types for ConstrainedWidthTemplate -type ConstrainedWidthTemplateProps = { - children: React.ReactNode -} & ComboBoxProps - const ControlledTemplate = ({ value, children }: ControlledTemplateProps) => { const [v, setV] = useState(value) @@ -70,10 +60,9 @@ const ControlledTemplate = ({ value, children }: ControlledTemplateProps) => { } // define prop types for ControlledTemplate -type ControlledTemplateProps = { +interface ControlledTemplateProps extends ComboBoxProps { value: string - children: React.ReactNode -} & ComboBoxProps +} export const Default = { render: Template, diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx index 1c6fe0b7e..a8830d987 100644 --- a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx +++ b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx @@ -99,10 +99,10 @@ export const ComboBoxOption = ({ ) } -export type ComboBoxOptionProps = { +export interface ComboBoxOptionProps extends React.HTMLProps { children?: string disabled?: boolean value?: string label?: string className?: string -} & React.HTMLProps +} diff --git a/packages/ui-components/src/components/ComboBoxOption/index.ts b/packages/ui-components/src/components/ComboBoxOption/index.ts index 2cb8f6189..51c0a061c 100644 --- a/packages/ui-components/src/components/ComboBoxOption/index.ts +++ b/packages/ui-components/src/components/ComboBoxOption/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { ComboBoxOption, type ComboBoxOptionProps } from "./ComboBoxOption.component" +export { ComboBoxOption } from "./ComboBoxOption.component" From e791bf6961048e281caa23daff4ff54d37b87ed3 Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Tue, 12 Nov 2024 00:37:40 +0100 Subject: [PATCH 04/12] fix(ui): style fix --- .../src/components/ComboBox/ComboBox.component.tsx | 4 ++-- .../components/ComboBoxOption/ComboBoxOption.component.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index d73190d3d..fddfa63f9 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -150,7 +150,7 @@ type OptionValuesAndLabelsValue = { } // COMBOBOX -export const ComboBox = ({ +export const ComboBox: React.FC = ({ ariaLabel, children = null, className = "", @@ -179,7 +179,7 @@ export const ComboBox = ({ width = "full", wrapperClassName = "", ...props -}: ComboBoxProps) => { +}) => { const isNotEmptyString = (str: React.ReactNode | string) => { return !(typeof str === "string" && str.trim().length === 0) } diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx index a8830d987..fcbe045b0 100644 --- a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx +++ b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx @@ -49,14 +49,14 @@ const truncateOptionStyles = ` jn-whitespace-nowrap ` -export const ComboBoxOption = ({ +export const ComboBoxOption: React.FC = ({ children, disabled = false, value = "", label, className = "", ...props -}: ComboBoxOptionProps) => { +}) => { const comboBoxContext = useContext(ComboBoxContext) const { selectedValue: selectedValue, @@ -105,4 +105,4 @@ export interface ComboBoxOptionProps extends React.HTMLProps { value?: string label?: string className?: string -} +} From 1066222a883401c817920d5e81ad0ac47bc5cc21 Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Tue, 12 Nov 2024 16:04:01 +0100 Subject: [PATCH 05/12] fix(ui): review fixes --- .../ComboBox/ComboBox.component.tsx | 14 +- .../ComboBoxOption.component.tsx | 2 +- .../ComboBox/ComboBox.component.js | 487 +++++++++++++++ .../deprecated_js/ComboBox/ComboBox.test.js | 568 ++++++++++++++++++ .../src/deprecated_js/ComboBox/index.js | 6 + .../ComboBoxOption.component.js | 100 +++ .../ComboBoxOption/ComboBoxOption.test.js | 122 ++++ .../src/deprecated_js/ComboBoxOption/index.js | 6 + 8 files changed, 1294 insertions(+), 11 deletions(-) create mode 100644 packages/ui-components/src/deprecated_js/ComboBox/ComboBox.component.js create mode 100644 packages/ui-components/src/deprecated_js/ComboBox/ComboBox.test.js create mode 100644 packages/ui-components/src/deprecated_js/ComboBox/index.js create mode 100644 packages/ui-components/src/deprecated_js/ComboBoxOption/ComboBoxOption.component.js create mode 100644 packages/ui-components/src/deprecated_js/ComboBoxOption/ComboBoxOption.test.js create mode 100644 packages/ui-components/src/deprecated_js/ComboBoxOption/index.js diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index fddfa63f9..77718aa65 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -15,8 +15,6 @@ import { usePortalRef } from "../PortalProvider/index" import { createPortal } from "react-dom" import { ComboBoxOptionProps } from "../ComboBoxOption/ComboBoxOption.component" -// STYLES - const inputWrapperStyles = ` jn-relative ` @@ -139,7 +137,6 @@ export type ComboBoxContextType = { addOptionValueAndLabel: AddOptionValueAndLabelFunction } -// CONTEXT export const ComboBoxContext = createContext(undefined) type OptionValuesAndLabelsKey = string | React.ReactNode @@ -149,10 +146,9 @@ type OptionValuesAndLabelsValue = { children: React.ReactNode } -// COMBOBOX export const ComboBox: React.FC = ({ ariaLabel, - children = null, + children, className = "", defaultValue = "", disabled = false, @@ -419,9 +415,7 @@ export const ComboBox: React.FC = ({ > {({ open }) => } - ) : ( - "" - )} + ) : null}
    @@ -455,11 +449,11 @@ export type ComboBoxWidth = "full" | "auto" //eslint-disable-next-line no-unused-vars type OnChangeHandler = (value: string) => void -export interface ComboBoxProps { +export type ComboBoxProps = typeof Combobox & { /** 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 + children?: React.ReactElement | React.ReactElement[] /** 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 */ diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx index fcbe045b0..e16c1b5de 100644 --- a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx +++ b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx @@ -69,7 +69,7 @@ export const ComboBoxOption: React.FC = ({ if (addOptionValueAndLabel) { addOptionValueAndLabel(value, label, children) } - }, [value, label, children]) + }, [addOptionValueAndLabel, value, label, children]) const theValue = value || children diff --git a/packages/ui-components/src/deprecated_js/ComboBox/ComboBox.component.js b/packages/ui-components/src/deprecated_js/ComboBox/ComboBox.component.js new file mode 100644 index 000000000..39c69b293 --- /dev/null +++ b/packages/ui-components/src/deprecated_js/ComboBox/ComboBox.component.js @@ -0,0 +1,487 @@ +/* + * 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 PropTypes from "prop-types" +import { Combobox } from "@headlessui/react" +import { Float } from "@headlessui-float/react" +import { Label } from "../../deprecated_js/Label/index.js" +import { FormHint } from "../../deprecated_js/FormHint/index.js" +import { Icon } from "../../deprecated_js/Icon/index.js" +import { Spinner } from "../../deprecated_js/Spinner/index.js" +import { flip, offset, shift, size } from "@floating-ui/react-dom" +import { usePortalRef } from "../../deprecated_js/PortalProvider/index" +import { createPortal } from "react-dom" + +// 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] +` + +// CONTEXT +export const ComboBoxContext = createContext() + +// 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 +}) => { + const isNotEmptyString = (str) => { + 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, label, children) => { + // 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) => { + setSelectedValue(value) + onChange && onChange(value) + } + + const handleInputChange = (event) => { + setQuery(event?.target?.value) + onInputChange && onInputChange(event) + } + + const handleFocus = (event) => { + setFocus(true) + onFocus && onFocus(event) + } + + const handleBlur = (event) => { + setFocus(false) + onBlur && onBlur(event) + } + + const portalContainerRef = usePortalRef() + + // Headless-UI-Float Middleware + const middleware = [ + offset(4), + shift(), + flip(), + size({ + boundary: "viewport", + apply({ availableWidth, availableHeight, elements }) { + Object.assign(elements.floating.style, { + maxWidth: `${availableWidth}px`, + maxHeight: `${availableHeight}px`, + overflowY: "auto", + }) + }, + }), + ] + + const filteredChildren = + query === "" + ? children + : children.filter((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()) + }) + + return ( + +
    + + + +
    + {label && isNotEmptyString(label) && !isLoading && !hasError ? ( +
    +
    + + {createPortal( + + + {filteredChildren} + + , + portalContainerRef ? portalContainerRef : document.body + )} +
    +
    + + {errortext && isNotEmptyString(errortext) ? : ""} + {successtext && isNotEmptyString(successtext) ? : ""} + {helptext && isNotEmptyString(helptext) ? : ""} +
    +
    + ) +} + +ComboBox.propTypes = { + /** The aria-label of the ComboBox. Defaults to the label if label was passed. */ + ariaLabel: PropTypes.string, + /** The children to Render. Use `ComboBox.Option` elements. */ + children: PropTypes.node, + /** A custom className. Will be passed to the internal text input element of the ComboBox */ + className: PropTypes.string, + /** Pass a defaultValue to use as an uncontrolled Component that will handle its state internally */ + defaultValue: PropTypes.string, + /** Whether the ComboBox is disabled */ + disabled: PropTypes.bool, + /** 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: PropTypes.bool, + /** An errortext to display when the ComboBox failed validation or an internal error occurred. */ + errortext: PropTypes.node, + /** A helptext to render to explain meaning and significance of the ComboBox */ + helptext: PropTypes.node, + /** 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: PropTypes.string, + /** Whether the ComboBox failed validation */ + invalid: PropTypes.bool, + /** The label of the ComboBox */ + label: PropTypes.string, + /** Whether the ComboBox is busy loading options */ + loading: PropTypes.bool, + /** The name attribute of the ComboBox when used as part of a form */ + name: PropTypes.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: PropTypes.bool, + /** A handler to execute when the ComboBox looses focus */ + onBlur: PropTypes.func, + /** A handler to execute when the ComboBox' selected value changes */ + onChange: PropTypes.func, + /** A handler to execute when the ComboBox input receives focus */ + onFocus: PropTypes.func, + /** Handler to execute when the ComboBox text input value changes */ + onInputChange: PropTypes.func, + /** A placeholder to render in the text input */ + placeholder: PropTypes.string, + /** Whether the ComboBox is required */ + required: PropTypes.bool, + /** A text to display in case the ComboBox was successfully validated. Will set the ComboBox to `valid` when passed. */ + successtext: PropTypes.node, + /** 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: PropTypes.bool, + /** Whether the ComboBox was successfully validated */ + valid: PropTypes.bool, + /** The selected value of the ComboBox in Controlled Mode. */ + value: PropTypes.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: PropTypes.string, + /** The width of the text input. Either 'full' (default) or 'auto'. */ + width: PropTypes.oneOf(["full", "auto"]), + /** 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: PropTypes.string, +} diff --git a/packages/ui-components/src/deprecated_js/ComboBox/ComboBox.test.js b/packages/ui-components/src/deprecated_js/ComboBox/ComboBox.test.js new file mode 100644 index 000000000..26f85447e --- /dev/null +++ b/packages/ui-components/src/deprecated_js/ComboBox/ComboBox.test.js @@ -0,0 +1,568 @@ +/* + * 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 "../../deprecated_js/AppShellProvider/index" +import { ComboBoxOption } from "../ComboBoxOption/index" + +const mockOnBlur = jest.fn() +const mockOnChange = jest.fn() +const mockOnFocus = jest.fn() +const mockOnInputChange = jest.fn() + +describe("ComboBox", () => { + afterEach(() => { + cleanup() + jest.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(() => { + 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(() => { + user.click(cbox) // focus the element + 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(() => { + 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(() => { + 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(() => { + 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() + + let 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") + let option123 + await waitFor(() => user.click(toggle)) + expect(screen.getByRole("listbox")).toBeInTheDocument() + + 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", async () => { + 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/deprecated_js/ComboBox/index.js b/packages/ui-components/src/deprecated_js/ComboBox/index.js new file mode 100644 index 000000000..db9a28c06 --- /dev/null +++ b/packages/ui-components/src/deprecated_js/ComboBox/index.js @@ -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.js" diff --git a/packages/ui-components/src/deprecated_js/ComboBoxOption/ComboBoxOption.component.js b/packages/ui-components/src/deprecated_js/ComboBoxOption/ComboBoxOption.component.js new file mode 100644 index 000000000..ee41e3a9e --- /dev/null +++ b/packages/ui-components/src/deprecated_js/ComboBoxOption/ComboBoxOption.component.js @@ -0,0 +1,100 @@ +/* + * 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 PropTypes from "prop-types" +import { Combobox } from "@headlessui/react" +import { ComboBoxContext } from "../ComboBox/ComboBox.component" +import { Icon } from "../../deprecated_js/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 }) => { + const comboBoxContext = useContext(ComboBoxContext) + const { + selectedValue: selectedValue, + truncateOptions: truncateOptions, + addOptionValueAndLabel: addOptionValueAndLabel, + } = comboBoxContext || {} + + // send option metadata to the ComboBox parent component via Context + useEffect(() => { + addOptionValueAndLabel(value, label, children) + }, [value, label, children]) + + const theValue = value || children + + return ( + +
  • + {selectedValue === theValue ? : ""} + + {children || label || value} + +
  • +
    + ) +} + +ComboBoxOption.propTypes = { + children: PropTypes.string, + disabled: PropTypes.bool, + value: PropTypes.string, + label: PropTypes.string, + className: PropTypes.string, +} diff --git a/packages/ui-components/src/deprecated_js/ComboBoxOption/ComboBoxOption.test.js b/packages/ui-components/src/deprecated_js/ComboBoxOption/ComboBoxOption.test.js new file mode 100644 index 000000000..06e2cfc65 --- /dev/null +++ b/packages/ui-components/src/deprecated_js/ComboBoxOption/ComboBoxOption.test.js @@ -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 "../../deprecated_js/AppShellProvider" +import userEvent from "@testing-library/user-event" +import { ComboBox } from "../ComboBox/ComboBox.component" +import { ComboBoxOption } from "../ComboBoxOption/ComboBoxOption.component" + +describe("ComboBoxOption", () => { + afterEach(() => { + cleanup() + jest.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/deprecated_js/ComboBoxOption/index.js b/packages/ui-components/src/deprecated_js/ComboBoxOption/index.js new file mode 100644 index 000000000..dbf46f5b1 --- /dev/null +++ b/packages/ui-components/src/deprecated_js/ComboBoxOption/index.js @@ -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 } from "./ComboBoxOption.component.js" From 502075a9592449bdfdfa01f10d8820cf1e8377ee Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Tue, 12 Nov 2024 16:30:06 +0100 Subject: [PATCH 06/12] fix(ui): type fix --- .../src/components/ComboBox/ComboBox.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index 77718aa65..c695cbae2 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -449,7 +449,7 @@ export type ComboBoxWidth = "full" | "auto" //eslint-disable-next-line no-unused-vars type OnChangeHandler = (value: string) => void -export type ComboBoxProps = typeof Combobox & { +export interface 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. */ From c2bc215cb87c5d927a76952ae38800512219a96b Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Wed, 13 Nov 2024 12:29:53 +0100 Subject: [PATCH 07/12] fix(ui): storybook children type --- .../ui-components/src/components/ComboBox/ComboBox.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx index 8efbdae90..40f524f25 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx @@ -17,6 +17,9 @@ export default { argTypes: { children: { control: false, + table: { + type: { summary: "ReactNode" }, + }, }, errortext: { control: false, From b26c90be2bfe6cd7fc4749b7dbae6b94d4e9e096 Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Wed, 13 Nov 2024 18:44:20 +0100 Subject: [PATCH 08/12] fix(ui): fix unit tests --- .../src/components/ComboBox/ComboBox.component.tsx | 4 ++-- .../components/ComboBoxOption/ComboBoxOption.component.tsx | 2 +- packages/ui-components/src/index.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index c695cbae2..b38ded723 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -449,11 +449,11 @@ export type ComboBoxWidth = "full" | "auto" //eslint-disable-next-line no-unused-vars type OnChangeHandler = (value: string) => void -export interface ComboBoxProps { +export interface ComboBoxProps extends Omit, 'onChange' | 'onInput' | 'children'> { /** 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[] + children?: React.ReactNode /** 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 */ diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx index e16c1b5de..fcbe045b0 100644 --- a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx +++ b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx @@ -69,7 +69,7 @@ export const ComboBoxOption: React.FC = ({ if (addOptionValueAndLabel) { addOptionValueAndLabel(value, label, children) } - }, [addOptionValueAndLabel, value, label, children]) + }, [value, label, children]) const theValue = value || children diff --git a/packages/ui-components/src/index.js b/packages/ui-components/src/index.js index bc95aa887..dac28f82e 100644 --- a/packages/ui-components/src/index.js +++ b/packages/ui-components/src/index.js @@ -19,8 +19,8 @@ export { CheckboxRow } from "./components/CheckboxRow/index.js" export { CheckboxGroup } from "./components/CheckboxGroup/index.js" export { Code } from "./components/Code/index.js" export { CodeBlock } from "./components/CodeBlock/index" -export { ComboBox } from "./components/ComboBox/index.js" -export { ComboBoxOption } from "./components/ComboBoxOption/index.js" +export { ComboBox } from "./deprecated_js/ComboBox/index.js" +export { ComboBoxOption } from "./deprecated_js/ComboBoxOption/index.js" export { ContentArea } from "./components/ContentArea/index.js" export { ContentHeading } from "./components/ContentHeading/index.js" export { ContentAreaToolbar } from "./components/ContentAreaToolbar/index.js" From 10f6e9f50277853c19ea9b3949798fad1ecf0230 Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Wed, 13 Nov 2024 19:30:00 +0100 Subject: [PATCH 09/12] fix(ui): style fix --- .../src/components/ComboBox/ComboBox.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index b38ded723..389a9df29 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -449,7 +449,7 @@ export type ComboBoxWidth = "full" | "auto" //eslint-disable-next-line no-unused-vars type OnChangeHandler = (value: string) => void -export interface ComboBoxProps extends Omit, 'onChange' | 'onInput' | 'children'> { +export interface ComboBoxProps extends Omit, "onChange" | "onInput" | "children"> { /** The aria-label of the ComboBox. Defaults to the label if label was passed. */ ariaLabel?: string /** The children to Render. Use `ComboBox.Option` elements. */ From 1c962edd85ad6a73d59a3cd296c5b90248492553 Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Sun, 17 Nov 2024 18:33:13 +0100 Subject: [PATCH 10/12] fix(ui): types fix in storybook --- .../ComboBox/ComboBox.component.tsx | 6 +++--- .../components/ComboBox/ComboBox.stories.tsx | 21 +++++++++++++++++++ .../ComboBoxOption.component.tsx | 5 +++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx index 389a9df29..0fd840852 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.component.tsx @@ -463,9 +463,9 @@ export interface ComboBoxProps extends Omit /** 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 + errortext?: React.ReactNode /** A helptext to render to explain meaning and significance of the ComboBox */ - helptext?: JSX.Element | string + helptext?: React.ReactNode /** 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 */ @@ -491,7 +491,7 @@ export interface ComboBoxProps extends Omit /** 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 + successtext?: React.ReactNode /** 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 */ diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx index 40f524f25..098c53e7c 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx @@ -23,12 +23,33 @@ export default { }, errortext: { control: false, + table: { + type: { summary: "ReactNode" }, + }, }, helptext: { control: false, + table: { + type: { summary: "ReactNode" }, + }, }, successtext: { control: false, + table: { + type: { summary: "ReactNode" }, + }, + }, + onBlur: { + control: false + }, + onChange: { + control: false + }, + onFocus: { + control: false + }, + onInputChange: { + control: false }, }, decorators: [ diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx index fcbe045b0..d3aa5416c 100644 --- a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx +++ b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx @@ -100,9 +100,14 @@ export const ComboBoxOption: React.FC = ({ } export interface ComboBoxOptionProps extends React.HTMLProps { + /** Children - will be shown to user */ children?: string + /** If option is disabled */ disabled?: boolean + /** Option value */ value?: string + /** Option label */ label?: string + /** Class for the option */ className?: string } From 006dac52eda72227cd9a153adaff421f4995f1dd Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Sun, 17 Nov 2024 18:49:11 +0100 Subject: [PATCH 11/12] fix(ui): style fix --- .../src/components/ComboBox/ComboBox.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx index 098c53e7c..8c8da8ed9 100644 --- a/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx +++ b/packages/ui-components/src/components/ComboBox/ComboBox.stories.tsx @@ -40,16 +40,16 @@ export default { }, }, onBlur: { - control: false + control: false, }, onChange: { - control: false + control: false, }, onFocus: { - control: false + control: false, }, onInputChange: { - control: false + control: false, }, }, decorators: [ From c76db3c02a087f3b74ffd2de35c5f7ff75f9c9cf Mon Sep 17 00:00:00 2001 From: "gjaskiewicz@objectivity.co.uk" Date: Mon, 18 Nov 2024 12:23:39 +0100 Subject: [PATCH 12/12] fix(ui): fix storybook description --- .../ComboBoxOption/ComboBoxOption.component.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx index d3aa5416c..6951a5766 100644 --- a/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx +++ b/packages/ui-components/src/components/ComboBoxOption/ComboBoxOption.component.tsx @@ -100,14 +100,14 @@ export const ComboBoxOption: React.FC = ({ } export interface ComboBoxOptionProps extends React.HTMLProps { - /** Children - will be shown to user */ + /** Content to render inside the ComboBoxOption. Is specified should be string. */ children?: string - /** If option is disabled */ + /** If option is disabled. */ disabled?: boolean - /** Option value */ + /** Option value. */ value?: string - /** Option label */ + /** Option label. */ label?: string - /** Class for the option */ + /** CSS class for the option. */ className?: string }