Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(ui): migrate ComboBox component to typescript #589

Merged
merged 17 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/healthy-needles-complain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudoperators/juno-ui-components": minor
---

Migrate ComboBox and ComboBoxOption to TypeScript
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@
*/

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 { 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 "../../deprecated_js/PortalProvider/index"
import { usePortalRef } from "../PortalProvider/index"
import { createPortal } from "react-dom"
import { ComboBoxOptionProps } from "../ComboBoxOption/ComboBoxOption.component"

// STYLES

Expand Down Expand Up @@ -130,8 +130,24 @@ const centeredIconStyles = `
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
gjaskiewicz marked this conversation as resolved.
Show resolved Hide resolved
export const ComboBoxContext = createContext()
export const ComboBoxContext = createContext<ComboBoxContextType | undefined>(undefined)

type OptionValuesAndLabelsKey = string | React.ReactNode
type OptionValuesAndLabelsValue = {
val: string
label?: string
children: React.ReactNode
}

// COMBOBOX
export const ComboBox = ({
Expand Down Expand Up @@ -163,15 +179,17 @@ export const ComboBox = ({
width = "full",
wrapperClassName = "",
...props
}) => {
const isNotEmptyString = (str) => {
}: ComboBoxProps) => {
gjaskiewicz marked this conversation as resolved.
Show resolved Hide resolved
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 [optionValuesAndLabels, setOptionValuesAndLabels] = useState(
new Map<OptionValuesAndLabelsKey, OptionValuesAndLabelsValue>()
)
const [query, setQuery] = useState("")
const [selectedValue, setSelectedValue] = useState(value)
const [isLoading, setIsLoading] = useState(false)
Expand All @@ -183,7 +201,7 @@ export const ComboBox = ({
// 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) => {
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) =>
Expand Down Expand Up @@ -224,22 +242,22 @@ export const ComboBox = ({
setIsValid(validated)
}, [validated])

const handleChange = (value) => {
const handleChange = (value: string) => {
setSelectedValue(value)
onChange && onChange(value)
}

const handleInputChange = (event) => {
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event?.target?.value)
onInputChange && onInputChange(event)
}

const handleFocus = (event) => {
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
setFocus(true)
onFocus && onFocus(event)
}

const handleBlur = (event) => {
const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
setFocus(false)
onBlur && onBlur(event)
}
Expand All @@ -252,7 +270,7 @@ export const ComboBox = ({
shift(),
flip(),
size({
boundary: "viewport",
rootBoundary: "viewport",
apply({ availableWidth, availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${availableWidth}px`,
Expand All @@ -265,12 +283,16 @@ export const ComboBox = ({

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())
? React.Children.toArray(children)
: React.Children.toArray(children).filter((child) => {
if (React.isValidElement<ComboBoxOptionProps>(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 (
Expand All @@ -294,7 +316,7 @@ export const ComboBox = ({
defaultValue={defaultValue}
disabled={disabled || isLoading || hasError}
name={name}
nullable={nullable}
nullable={nullable as true}
gjaskiewicz marked this conversation as resolved.
Show resolved Hide resolved
onChange={handleChange}
value={selectedValue || defaultValue}
{...props}
Expand Down Expand Up @@ -329,21 +351,21 @@ export const ComboBox = ({
""
)}

<Combobox.Input
<Combobox.Input<OptionValuesAndLabelsKey>
autoComplete="off"
aria-label={ariaLabel || label}
aria-describedby={helptext ? helptextId : ""}
disabled={disabled || isLoading || hasError}
id={theId}
onBlur={handleBlur}
onChange={handleInputChange}
onFocus={handleFocus}
placeholder={!isLoading && !hasError ? placeholder : ""}
displayValue={(val) =>
optionValuesAndLabels.get(val)?.children ||
optionValuesAndLabels.get(val)?.children?.toString() ||
optionValuesAndLabels.get(val)?.label ||
valueLabel ||
val
val?.toString() ||
""
} // Headless-UI expects a callback here
className={`
juno-combobox-input
Expand Down Expand Up @@ -386,7 +408,6 @@ export const ComboBox = ({

{!hasError && !isLoading ? (
<Combobox.Button
disabled={disabled}
className={`
juno-combobox-toggle
${buttonStyles}
gjaskiewicz marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -429,59 +450,64 @@ export const ComboBox = ({
)
}

ComboBox.propTypes = {
export type ComboBoxWidth = "full" | "auto"

//eslint-disable-next-line no-unused-vars
type OnChangeHandler = (value: string) => void

export interface ComboBoxProps {
gjaskiewicz marked this conversation as resolved.
Show resolved Hide resolved
/** The aria-label of the ComboBox. Defaults to the label if label was passed. */
ariaLabel: PropTypes.string,
ariaLabel?: string
/** The children to Render. Use `ComboBox.Option` elements. */
children: PropTypes.node,
children?: React.ReactElement<ComboBoxOptionProps> | React.ReactElement<ComboBoxOptionProps>[] | null
gjaskiewicz marked this conversation as resolved.
Show resolved Hide resolved
gjaskiewicz marked this conversation as resolved.
Show resolved Hide resolved
/** A custom className. Will be passed to the internal text input element of the ComboBox */
className: PropTypes.string,
className?: string
/** Pass a defaultValue to use as an uncontrolled Component that will handle its state internally */
defaultValue: PropTypes.string,
defaultValue?: string
/** Whether the ComboBox is disabled */
disabled: PropTypes.bool,
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: PropTypes.bool,
error?: boolean
/** An errortext to display when the ComboBox failed validation or an internal error occurred. */
errortext: PropTypes.node,
errortext?: JSX.Element | string
/** A helptext to render to explain meaning and significance of the ComboBox */
helptext: PropTypes.node,
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: PropTypes.string,
id?: string
/** Whether the ComboBox failed validation */
invalid: PropTypes.bool,
invalid?: boolean
/** The label of the ComboBox */
label: PropTypes.string,
label?: string
/** Whether the ComboBox is busy loading options */
loading: PropTypes.bool,
loading?: boolean
/** The name attribute of the ComboBox when used as part of a form */
name: PropTypes.string,
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: PropTypes.bool,
nullable?: boolean
/** A handler to execute when the ComboBox looses focus */
onBlur: PropTypes.func,
onBlur?: React.FocusEventHandler<HTMLInputElement>
/** A handler to execute when the ComboBox' selected value changes */
onChange: PropTypes.func,
onChange?: OnChangeHandler
/** A handler to execute when the ComboBox input receives focus */
onFocus: PropTypes.func,
onFocus?: React.FocusEventHandler<HTMLInputElement>
/** Handler to execute when the ComboBox text input value changes */
onInputChange: PropTypes.func,
onInputChange?: React.ChangeEventHandler<HTMLInputElement>
/** A placeholder to render in the text input */
placeholder: PropTypes.string,
placeholder?: string
/** Whether the ComboBox is required */
required: PropTypes.bool,
required?: boolean
/** A text to display in case the ComboBox was successfully validated. Will set the ComboBox to `valid` when passed. */
successtext: PropTypes.node,
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: PropTypes.bool,
truncateOptions?: boolean
/** Whether the ComboBox was successfully validated */
valid: PropTypes.bool,
valid?: boolean
/** The selected value of the ComboBox in Controlled Mode. */
value: PropTypes.string,
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: PropTypes.string,
valueLabel?: string
/** The width of the text input. Either 'full' (default) or 'auto'. */
width: PropTypes.oneOf(["full", "auto"]),
width?: ComboBoxWidth
/** Pass a custom classname to the wrapping <div> 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,
wrapperClassName?: string
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
*/

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"
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",
gjaskiewicz marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -27,39 +29,27 @@ export default {
},
},
decorators: [
(Story) => (
(story: StoryFunction) => (
<div className="jn-pb-12">
<PortalProvider>
<Story />
</PortalProvider>
<PortalProvider>{story()}</PortalProvider>
</div>
),
],
}

const Template = ({ children, ...args }) => {
const Template = ({ children, ...args }: ComboBoxProps) => {
return <ComboBox {...args}>{children}</ComboBox>
}

// define prop types fro Template
Template.propTypes = {
children: PropTypes.any,
}

const ConstrainedWidthTemplate = ({ children, ...args }) => {
const ConstrainedWidthTemplate = ({ children, ...args }: ComboBoxProps) => {
return (
<div style={{ width: "300px" }}>
<ComboBox {...args}>{children}</ComboBox>
</div>
)
}

// define prop types for ConstrainedWidthTemplate
ConstrainedWidthTemplate.propTypes = {
children: PropTypes.any,
}

const ControlledTemplate = ({ value, children }) => {
const ControlledTemplate = ({ value, children }: ControlledTemplateProps) => {
const [v, setV] = useState(value)

useEffect(() => {
Expand All @@ -70,9 +60,8 @@ const ControlledTemplate = ({ value, children }) => {
}

// define prop types for ControlledTemplate
ControlledTemplate.propTypes = {
value: PropTypes.string,
children: PropTypes.any,
interface ControlledTemplateProps extends ComboBoxProps {
value: string
}

export const Default = {
Expand Down
Loading
Loading