mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-12 00:15:44 +01:00
Introduce new combobox and make it work with chip input
We introduced a new accessible combobox component. This component is based on headless ui and made compatible with our components and forms. Also we replaced the outdated `Autocomplete` component with the new combobox. Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com> Reviewed-by: Florian Scholdei <florian.scholdei@cloudogu.com>
This commit is contained in:
@@ -37,6 +37,9 @@ import AddListEntryForm from "./AddListEntryForm";
|
||||
import { ScmFormListContextProvider } from "./ScmFormListContext";
|
||||
import { HalRepresentation } from "@scm-manager/ui-types";
|
||||
import ControlledChipInputField from "./chip-input/ControlledChipInputField";
|
||||
import Combobox from "./combobox/Combobox";
|
||||
import ControlledComboboxField from "./combobox/ControlledComboboxField";
|
||||
import { defaultOptionFactory } from "./helpers";
|
||||
|
||||
export type SimpleWebHookConfiguration = {
|
||||
urlPattern: string;
|
||||
@@ -241,6 +244,8 @@ storiesOf("Forms", module)
|
||||
disableB: false,
|
||||
disableC: true,
|
||||
labels: ["test"],
|
||||
people: [{ displayName: "Trillian", id: 2 }],
|
||||
author: null,
|
||||
}}
|
||||
>
|
||||
<ControlledInputField name="url" />
|
||||
@@ -250,6 +255,23 @@ storiesOf("Forms", module)
|
||||
<ControlledCheckboxField name="disableB" />
|
||||
<ControlledCheckboxField name="disableC" />
|
||||
<ControlledChipInputField name="labels" />
|
||||
<ControlledChipInputField
|
||||
name="people"
|
||||
optionFactory={(person) => ({ label: person.displayName, value: person })}
|
||||
>
|
||||
<Combobox
|
||||
options={[
|
||||
{ label: "Zenod", value: { id: 1, displayName: "Zenod" } },
|
||||
{ label: "Arthur", value: { id: 3, displayName: "Arthur" } },
|
||||
{ label: "Cookie Monster", value: { id: 4, displayName: "Cookie Monster" } },
|
||||
]}
|
||||
/>
|
||||
</ControlledChipInputField>
|
||||
<ControlledComboboxField
|
||||
options={["Zenod", "Arthur", "Trillian"].map(defaultOptionFactory)}
|
||||
name="author"
|
||||
nullable
|
||||
/>
|
||||
</Form>
|
||||
))
|
||||
.add("ReadOnly", () => (
|
||||
|
||||
@@ -25,16 +25,45 @@
|
||||
import { storiesOf } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import ChipInputField from "./ChipInputField";
|
||||
import Combobox from "../combobox/Combobox";
|
||||
import { Option } from "@scm-manager/ui-types";
|
||||
|
||||
storiesOf("Chip Input Field", module).add("Default", () => {
|
||||
const [value, setValue] = useState(["test"]);
|
||||
return (
|
||||
<ChipInputField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
label="Test Chips"
|
||||
placeholder="This is a placeholder..."
|
||||
aria-label="My personal chip list"
|
||||
/>
|
||||
);
|
||||
});
|
||||
storiesOf("Chip Input Field", module)
|
||||
.add("Default", () => {
|
||||
const [value, setValue] = useState<Option<string>[]>([]);
|
||||
return (
|
||||
<ChipInputField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
label="Test Chips"
|
||||
placeholder="Type a new chip name and press enter to add"
|
||||
aria-label="My personal chip list"
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add("With Autocomplete", () => {
|
||||
const people = ["Durward Reynolds", "Kenton Towne", "Therese Wunsch", "Benedict Kessler", "Katelyn Rohan"];
|
||||
|
||||
const [value, setValue] = useState<Option<string>[]>([]);
|
||||
|
||||
return (
|
||||
<ChipInputField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
label="Persons"
|
||||
placeholder="Enter a new person"
|
||||
aria-label="Enter a new person"
|
||||
>
|
||||
<Combobox
|
||||
options={(query: string) =>
|
||||
Promise.resolve(
|
||||
people
|
||||
.map<Option<string>>((p) => ({ label: p, value: p }))
|
||||
.filter((t) => !value.some((val) => val.label === t.label) && t.label.startsWith(query))
|
||||
.concat({ label: query, value: query, displayValue: `Use '${query}'` })
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ChipInputField>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import React, { ComponentProps, useCallback } from "react";
|
||||
import React, { KeyboardEventHandler, ReactElement, useCallback } from "react";
|
||||
import { createAttributesForTesting, useGeneratedId } from "@scm-manager/ui-components";
|
||||
import Field from "../base/Field";
|
||||
import Label from "../base/label/Label";
|
||||
@@ -34,8 +34,10 @@ import { createVariantClass } from "../variants";
|
||||
import ChipInput, { NewChipInput } from "../headless-chip-input/ChipInput";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withForwardRef } from "../helpers";
|
||||
import { Option } from "@scm-manager/ui-types";
|
||||
|
||||
const StyledChipInput = styled(ChipInput)`
|
||||
const StyledChipInput: typeof ChipInput = styled(ChipInput)`
|
||||
min-height: 40px;
|
||||
height: min-content;
|
||||
gap: 0.5rem;
|
||||
@@ -48,79 +50,102 @@ const StyledChipInput = styled(ChipInput)`
|
||||
const StyledInput = styled(NewChipInput)`
|
||||
color: var(--scm-secondary-more-color);
|
||||
font-size: 1rem;
|
||||
height: initial;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
` as unknown as typeof NewChipInput;
|
||||
|
||||
const StyledDelete = styled(ChipInput.Chip.Delete)`
|
||||
&:focus {
|
||||
outline-offset: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
type InputFieldProps = {
|
||||
type InputFieldProps<T> = {
|
||||
label: string;
|
||||
createDeleteText?: (value: string) => string;
|
||||
helpText?: string;
|
||||
error?: string;
|
||||
testId?: string;
|
||||
id?: string;
|
||||
} & Pick<ComponentProps<typeof NewChipInput>, "placeholder"> &
|
||||
Pick<ComponentProps<typeof ChipInput>, "onChange" | "value" | "readOnly" | "disabled"> &
|
||||
Pick<ComponentProps<typeof Field>, "className">;
|
||||
children?: ReactElement;
|
||||
placeholder?: string;
|
||||
onChange?: (newValue: Option<T>[]) => void;
|
||||
value?: Option<T>[] | null;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
isNewItemDuplicate?: (existingItem: Option<T>, newItem: Option<T>) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @beta
|
||||
* @since 2.44.0
|
||||
*/
|
||||
const ChipInputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
helpText,
|
||||
readOnly,
|
||||
disabled,
|
||||
error,
|
||||
createDeleteText,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
className,
|
||||
testId,
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [t] = useTranslation("commons", { keyPrefix: "form.chipList" });
|
||||
const deleteTextCallback = useCallback(
|
||||
(item) => (createDeleteText ? createDeleteText(item) : t("delete", { item })),
|
||||
[createDeleteText, t]
|
||||
);
|
||||
const inputId = useGeneratedId(id ?? testId);
|
||||
const labelId = useGeneratedId();
|
||||
const inputDescriptionId = useGeneratedId();
|
||||
const variant = error ? "danger" : undefined;
|
||||
return (
|
||||
<Field className={className} aria-owns={inputId}>
|
||||
<Label id={labelId}>
|
||||
{label}
|
||||
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
||||
</Label>
|
||||
const ChipInputField = function ChipInputField<T>(
|
||||
{
|
||||
label,
|
||||
helpText,
|
||||
readOnly,
|
||||
disabled,
|
||||
error,
|
||||
createDeleteText,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
className,
|
||||
testId,
|
||||
id,
|
||||
children,
|
||||
isLoading,
|
||||
isNewItemDuplicate,
|
||||
...props
|
||||
}: InputFieldProps<T>,
|
||||
ref: React.ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
const [t] = useTranslation("commons", { keyPrefix: "form.chipList" });
|
||||
const deleteTextCallback = useCallback(
|
||||
(item) => (createDeleteText ? createDeleteText(item) : t("delete", { item })),
|
||||
[createDeleteText, t]
|
||||
);
|
||||
const inputId = useGeneratedId(id ?? testId);
|
||||
const labelId = useGeneratedId();
|
||||
const inputDescriptionId = useGeneratedId();
|
||||
const variant = error ? "danger" : undefined;
|
||||
return (
|
||||
<Field className={className} aria-owns={inputId}>
|
||||
<Label id={labelId}>
|
||||
{label}
|
||||
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
||||
</Label>
|
||||
<div className={classNames("control", { "is-loading": isLoading })}>
|
||||
<StyledChipInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={(e) => onChange && onChange(e ?? [])}
|
||||
className="is-flex is-flex-wrap-wrap input"
|
||||
aria-labelledby={labelId}
|
||||
disabled={readOnly || disabled}
|
||||
isNewItemDuplicate={isNewItemDuplicate}
|
||||
>
|
||||
{value?.map((val, index) => (
|
||||
<ChipInput.Chip key={`${val}-${index}`} className="tag is-light">
|
||||
{val}
|
||||
<ChipInput.Chip.Delete aria-label={deleteTextCallback(val)} index={index} className="delete is-small" />
|
||||
{value?.map((option, index) => (
|
||||
<ChipInput.Chip key={option.label} className="tag is-light is-overflow-hidden">
|
||||
<span className="is-ellipsis-overflow">{option.label}</span>
|
||||
<StyledDelete aria-label={deleteTextCallback(option.label)} index={index} className="delete is-small" />
|
||||
</ChipInput.Chip>
|
||||
))}
|
||||
<StyledInput
|
||||
{...props}
|
||||
className={classNames(
|
||||
"is-borderless",
|
||||
"is-flex-grow-1",
|
||||
"has-background-transparent",
|
||||
"is-shadowless",
|
||||
"input",
|
||||
"is-ellipsis-overflow",
|
||||
createVariantClass(variant)
|
||||
)}
|
||||
placeholder={!readOnly && !disabled ? placeholder : ""}
|
||||
@@ -128,14 +153,16 @@ const ChipInputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
|
||||
ref={ref}
|
||||
aria-describedby={inputDescriptionId}
|
||||
{...createAttributesForTesting(testId)}
|
||||
/>
|
||||
>
|
||||
{children ? children : null}
|
||||
</StyledInput>
|
||||
</StyledChipInput>
|
||||
<VisuallyHidden aria-hidden id={inputDescriptionId}>
|
||||
{t("input.description")}
|
||||
</VisuallyHidden>
|
||||
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
export default ChipInputField;
|
||||
</div>
|
||||
<VisuallyHidden aria-hidden id={inputDescriptionId}>
|
||||
{t("input.description")}
|
||||
</VisuallyHidden>
|
||||
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
export default withForwardRef(ChipInputField);
|
||||
|
||||
@@ -26,12 +26,13 @@ import React, { ComponentProps } from "react";
|
||||
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
||||
import { useScmFormContext } from "../ScmFormContext";
|
||||
import { useScmFormPathContext } from "../FormPathContext";
|
||||
import { prefixWithoutIndices } from "../helpers";
|
||||
import { defaultOptionFactory, prefixWithoutIndices } from "../helpers";
|
||||
import classNames from "classnames";
|
||||
import ChipInputField from "./ChipInputField";
|
||||
import { Option } from "@scm-manager/ui-types";
|
||||
|
||||
type Props<T extends Record<string, unknown>> = Omit<
|
||||
ComponentProps<typeof ChipInputField>,
|
||||
Parameters<typeof ChipInputField>[0],
|
||||
"error" | "createDeleteText" | "label" | "defaultChecked" | "required" | keyof ControllerRenderProps
|
||||
> & {
|
||||
rules?: ComponentProps<typeof Controller>["rules"];
|
||||
@@ -39,6 +40,7 @@ type Props<T extends Record<string, unknown>> = Omit<
|
||||
label?: string;
|
||||
defaultValue?: string[];
|
||||
createDeleteText?: (value: string) => string;
|
||||
optionFactory?: (val: any) => Option<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,8 @@ function ControlledChipInputField<T extends Record<string, unknown>>({
|
||||
placeholder,
|
||||
className,
|
||||
createDeleteText,
|
||||
children,
|
||||
optionFactory = defaultOptionFactory,
|
||||
...props
|
||||
}: Props<T>) {
|
||||
const { control, t, readOnly: formReadonly } = useScmFormContext();
|
||||
@@ -73,12 +77,14 @@ function ControlledChipInputField<T extends Record<string, unknown>>({
|
||||
name={nameWithPrefix}
|
||||
rules={rules}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field, fieldState }) => (
|
||||
render={({ field: { value, onChange, ...field }, fieldState }) => (
|
||||
<ChipInputField
|
||||
label={labelTranslation}
|
||||
helpText={helpTextTranslation}
|
||||
placeholder={placeholderTranslation}
|
||||
aria-label={ariaLabelTranslation}
|
||||
value={value ? value.map(optionFactory) : []}
|
||||
onChange={(selectedOptions) => onChange(selectedOptions.map((item) => item.value))}
|
||||
{...props}
|
||||
{...field}
|
||||
readOnly={readOnly ?? formReadonly}
|
||||
@@ -89,7 +95,9 @@ function ControlledChipInputField<T extends Record<string, unknown>>({
|
||||
: undefined
|
||||
}
|
||||
testId={testId ?? `input-${nameWithPrefix}`}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</ChipInputField>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
96
scm-ui/ui-forms/src/combobox/Combobox.stories.tsx
Normal file
96
scm-ui/ui-forms/src/combobox/Combobox.stories.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { storiesOf } from "@storybook/react";
|
||||
import React, { Fragment, useState } from "react";
|
||||
import Combobox from "./Combobox";
|
||||
import { Combobox as HeadlessCombobox } from "@headlessui/react";
|
||||
import { Option } from "@scm-manager/ui-types";
|
||||
|
||||
const waitFor = (ms: number) =>
|
||||
function <T>(result: T) {
|
||||
return new Promise<T>((resolve) => setTimeout(() => resolve(result), ms));
|
||||
};
|
||||
|
||||
const data = [
|
||||
{ label: "Trillian", value: "1" },
|
||||
{ label: "Arthur", value: "2" },
|
||||
{ label: "Zaphod", value: "3" },
|
||||
];
|
||||
|
||||
storiesOf("Combobox", module)
|
||||
.add("Options array", () => {
|
||||
const [value, setValue] = useState<Option<string>>();
|
||||
return <Combobox options={data} value={value} onChange={setValue} />;
|
||||
})
|
||||
.add("Options function", () => {
|
||||
const [value, setValue] = useState<Option<string>>();
|
||||
return <Combobox options={() => data} value={value} onChange={setValue} />;
|
||||
})
|
||||
.add("Options function as promise", () => {
|
||||
const [value, setValue] = useState<Option<string>>();
|
||||
return (
|
||||
<Combobox
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
options={(query) => Promise.resolve(data.filter((t) => t.label.startsWith(query))).then(waitFor(1000))}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add("Children as Element", () => {
|
||||
const [value, setValue] = useState<Option<string>>();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
return (
|
||||
<Combobox value={value} onChange={setValue} onQueryChange={setQuery}>
|
||||
{query ? (
|
||||
<HeadlessCombobox.Option value={{ label: query, value: query }} key={query} as={Fragment}>
|
||||
{({ active }) => <Combobox.Option isActive={active}>{`Create ${query}`}</Combobox.Option>}
|
||||
</HeadlessCombobox.Option>
|
||||
) : null}
|
||||
<HeadlessCombobox.Option value={{ label: "All", value: "All" }} key="all" as={Fragment}>
|
||||
{({ active }) => <Combobox.Option isActive={active}>All</Combobox.Option>}
|
||||
</HeadlessCombobox.Option>
|
||||
<>
|
||||
{data.map((o) => (
|
||||
<HeadlessCombobox.Option value={o} key={o.value} as={Fragment}>
|
||||
{({ active }) => <Combobox.Option isActive={active}>{o.label}</Combobox.Option>}
|
||||
</HeadlessCombobox.Option>
|
||||
))}
|
||||
</>
|
||||
</Combobox>
|
||||
);
|
||||
})
|
||||
.add("Children as render props", () => {
|
||||
const [value, setValue] = useState<Option<string>>();
|
||||
return (
|
||||
<Combobox options={data} value={value} onChange={setValue}>
|
||||
{(o) => (
|
||||
<HeadlessCombobox.Option value={o} key={o.value} as={Fragment}>
|
||||
{({ active }) => <Combobox.Option isActive={active}>{o.label}</Combobox.Option>}
|
||||
</HeadlessCombobox.Option>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
});
|
||||
217
scm-ui/ui-forms/src/combobox/Combobox.tsx
Normal file
217
scm-ui/ui-forms/src/combobox/Combobox.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
ForwardedRef,
|
||||
Fragment,
|
||||
KeyboardEvent,
|
||||
KeyboardEventHandler,
|
||||
ReactElement,
|
||||
Ref,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Combobox as HeadlessCombobox } from "@headlessui/react";
|
||||
import classNames from "classnames";
|
||||
import styled from "styled-components";
|
||||
import { withForwardRef } from "../helpers";
|
||||
import { createAttributesForTesting } from "@scm-manager/ui-components";
|
||||
import { Option } from "@scm-manager/ui-types";
|
||||
|
||||
const OptionsWrapper = styled(HeadlessCombobox.Options).attrs({
|
||||
className: "is-flex is-flex-direction-column has-rounded-border has-box-shadow is-overflow-hidden",
|
||||
})`
|
||||
z-index: 400;
|
||||
top: 2.5rem;
|
||||
border: var(--scm-border);
|
||||
background-color: var(--scm-secondary-background);
|
||||
max-width: 35ch;
|
||||
|
||||
&:empty {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledComboboxOption = styled.li.attrs({
|
||||
className: "px-3 py-2 has-text-inherit is-clickable is-size-6 is-borderless has-background-transparent",
|
||||
})<{ isActive: boolean }>`
|
||||
line-height: inherit;
|
||||
background-color: ${({ isActive }) => (isActive ? "var(--scm-column-selection)" : "")};
|
||||
word-break: break-all;
|
||||
&[data-disabled] {
|
||||
color: unset !important;
|
||||
opacity: 40%;
|
||||
cursor: unset !important;
|
||||
}
|
||||
`;
|
||||
|
||||
type BaseProps<T> = {
|
||||
className?: string;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLElement>;
|
||||
value?: Option<T> | null;
|
||||
onChange?: (value?: Option<T>) => void;
|
||||
onBlur?: () => void;
|
||||
disabled?: boolean;
|
||||
defaultValue?: Option<T>;
|
||||
nullable?: boolean;
|
||||
readOnly?: boolean;
|
||||
onQueryChange?: (value: string) => void;
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
"aria-describedby"?: string;
|
||||
"aria-labelledby"?: string;
|
||||
"aria-label"?: string;
|
||||
testId?: string;
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
form?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type ComboboxProps<T> =
|
||||
| (BaseProps<T> & {
|
||||
options: Array<Option<T>> | ((query: string) => Array<Option<T>> | Promise<Array<Option<T>>>);
|
||||
children?: (option: Option<T>, index: number) => ReactElement;
|
||||
})
|
||||
| (BaseProps<T> & { children: Array<ReactElement | null>; options?: never });
|
||||
|
||||
/**
|
||||
* @beta
|
||||
* @since 2.45.0
|
||||
*/
|
||||
function ComboboxComponent<T>(props: ComboboxProps<T>, ref: ForwardedRef<HTMLInputElement>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const { onQueryChange } = props;
|
||||
|
||||
useEffect(() => onQueryChange && onQueryChange(query), [onQueryChange, query]);
|
||||
|
||||
let options;
|
||||
|
||||
if (Array.isArray(props.children)) {
|
||||
options = props.children;
|
||||
} else if (typeof props.options === "function") {
|
||||
options = <ComboboxOptions<T> children={props.children} options={props.options} query={query} />;
|
||||
} else {
|
||||
options = props.options?.map((o, index) =>
|
||||
typeof props.children === "function" ? (
|
||||
props.children(o, index)
|
||||
) : (
|
||||
<HeadlessCombobox.Option value={o} key={o.label} as={Fragment}>
|
||||
{({ active }) => <StyledComboboxOption isActive={active}>{o.displayValue ?? o.label}</StyledComboboxOption>}
|
||||
</HeadlessCombobox.Option>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HeadlessCombobox
|
||||
as="div"
|
||||
value={props.value}
|
||||
onChange={(e?: Option<T>) => props.onChange && props.onChange(e)}
|
||||
disabled={props.disabled || props.readOnly}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLElement>) => props.onKeyDown && props.onKeyDown(e)}
|
||||
name={props.name}
|
||||
form={props.form}
|
||||
defaultValue={props.defaultValue}
|
||||
// @ts-ignore
|
||||
nullable={props.nullable}
|
||||
className="is-relative is-flex-grow-1 is-flex"
|
||||
by="value"
|
||||
>
|
||||
<HeadlessCombobox.Input<Option<T>>
|
||||
displayValue={(o) => o?.label}
|
||||
ref={ref}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className={classNames(props.className, "is-full-width", "input", "is-ellipsis-overflow")}
|
||||
aria-describedby={props["aria-describedby"]}
|
||||
aria-labelledby={props["aria-labelledby"]}
|
||||
aria-label={props["aria-label"]}
|
||||
id={props.id}
|
||||
placeholder={props.placeholder}
|
||||
onBlur={props.onBlur}
|
||||
{...createAttributesForTesting(props.testId)}
|
||||
/>
|
||||
<OptionsWrapper className="is-absolute">{options}</OptionsWrapper>
|
||||
</HeadlessCombobox>
|
||||
);
|
||||
}
|
||||
|
||||
type ComboboxOptionsProps<T> = {
|
||||
options: (query: string) => Array<Option<T>> | Promise<Array<Option<T>>>;
|
||||
children?: (option: Option<T>, index: number) => ReactElement;
|
||||
query: string;
|
||||
};
|
||||
|
||||
function ComboboxOptions<T>({ query, options, children }: ComboboxOptionsProps<T>) {
|
||||
const [optionsData, setOptionsData] = useState<Array<Option<T>>>([]);
|
||||
const activePromise = useRef<Promise<Array<Option<T>>>>();
|
||||
|
||||
useEffect(() => {
|
||||
const optionsExec = options(query);
|
||||
if (optionsExec instanceof Promise) {
|
||||
activePromise.current = optionsExec;
|
||||
optionsExec
|
||||
.then((newOptions) => {
|
||||
if (activePromise.current === optionsExec) {
|
||||
setOptionsData(newOptions);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (activePromise.current === optionsExec) {
|
||||
setOptionsData([]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setOptionsData(optionsExec);
|
||||
}
|
||||
}, [options, query]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{optionsData?.map((o, index) =>
|
||||
typeof children === "function" ? (
|
||||
children(o, index)
|
||||
) : (
|
||||
<HeadlessCombobox.Option value={o} key={o.label} as={Fragment}>
|
||||
{({ active }) => <StyledComboboxOption isActive={active}>{o.displayValue ?? o.label}</StyledComboboxOption>}
|
||||
</HeadlessCombobox.Option>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Combobox = Object.assign(withForwardRef(ComboboxComponent), {
|
||||
Option: StyledComboboxOption,
|
||||
});
|
||||
|
||||
export default Combobox;
|
||||
62
scm-ui/ui-forms/src/combobox/ComboboxField.tsx
Normal file
62
scm-ui/ui-forms/src/combobox/ComboboxField.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import Field from "../base/Field";
|
||||
import Label from "../base/label/Label";
|
||||
import Help from "../base/help/Help";
|
||||
import React from "react";
|
||||
import { useGeneratedId } from "@scm-manager/ui-components";
|
||||
import { withForwardRef } from "../helpers";
|
||||
import Combobox, { ComboboxProps } from "./Combobox";
|
||||
import classNames from "classnames";
|
||||
|
||||
/**
|
||||
* @beta
|
||||
* @since 2.45.0
|
||||
*/
|
||||
const ComboboxField = function ComboboxField<T>(
|
||||
{
|
||||
label,
|
||||
helpText,
|
||||
error,
|
||||
className,
|
||||
isLoading,
|
||||
...props
|
||||
}: ComboboxProps<T> & { label: string; helpText?: string; error?: string; isLoading?: boolean },
|
||||
ref: React.ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
const labelId = useGeneratedId();
|
||||
return (
|
||||
<Field className={className}>
|
||||
<Label id={labelId}>
|
||||
{label}
|
||||
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
||||
</Label>
|
||||
<div className={classNames("control", { "is-loading": isLoading })}>
|
||||
<Combobox {...props} ref={ref} aria-labelledby={labelId} />
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
export default withForwardRef(ComboboxField);
|
||||
96
scm-ui/ui-forms/src/combobox/ControlledComboboxField.tsx
Normal file
96
scm-ui/ui-forms/src/combobox/ControlledComboboxField.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import React, { ComponentProps } from "react";
|
||||
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
||||
import { useScmFormContext } from "../ScmFormContext";
|
||||
import { useScmFormPathContext } from "../FormPathContext";
|
||||
import { defaultOptionFactory, prefixWithoutIndices } from "../helpers";
|
||||
import classNames from "classnames";
|
||||
import ComboboxField from "./ComboboxField";
|
||||
import { Option } from "@scm-manager/ui-types";
|
||||
|
||||
type Props<T extends Record<string, unknown>> = Omit<
|
||||
Parameters<typeof ComboboxField>[0],
|
||||
"error" | "label" | keyof ControllerRenderProps
|
||||
> & {
|
||||
rules?: ComponentProps<typeof Controller>["rules"];
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
optionFactory?: (val: any) => Option<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @beta
|
||||
* @since 2.45.0
|
||||
*/
|
||||
function ControlledComboboxField<T extends Record<string, unknown>>({
|
||||
name,
|
||||
label,
|
||||
helpText,
|
||||
rules,
|
||||
testId,
|
||||
defaultValue,
|
||||
readOnly,
|
||||
className,
|
||||
optionFactory = defaultOptionFactory,
|
||||
...props
|
||||
}: Props<T>) {
|
||||
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||
const formPathPrefix = useScmFormPathContext();
|
||||
const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name;
|
||||
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
|
||||
const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || "";
|
||||
const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`);
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={nameWithPrefix}
|
||||
rules={rules}
|
||||
defaultValue={defaultValue as never}
|
||||
render={({ field: { onChange, value, ...field }, fieldState }) => (
|
||||
// @ts-ignore
|
||||
<ComboboxField
|
||||
form={formId}
|
||||
readOnly={readOnly ?? formReadonly}
|
||||
className={classNames("column", className)}
|
||||
{...props}
|
||||
{...field}
|
||||
label={labelTranslation}
|
||||
helpText={helpTextTranslation}
|
||||
onChange={(e) => onChange(e?.value)}
|
||||
value={optionFactory(value)}
|
||||
error={
|
||||
fieldState.error
|
||||
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
|
||||
: undefined
|
||||
}
|
||||
testId={testId ?? `select-${nameWithPrefix}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ControlledComboboxField;
|
||||
@@ -24,25 +24,71 @@
|
||||
|
||||
import React, {
|
||||
ButtonHTMLAttributes,
|
||||
ComponentType,
|
||||
Context,
|
||||
createContext,
|
||||
HTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
KeyboardEventHandler,
|
||||
LiHTMLAttributes,
|
||||
ReactElement,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { Option } from "@scm-manager/ui-types";
|
||||
import { mergeRefs, withForwardRef } from "../helpers";
|
||||
|
||||
type ChipInputContextType = {
|
||||
add(newValue: string): void;
|
||||
type ChipInputContextType<T> = {
|
||||
add(newValue: Option<T>): void;
|
||||
remove(index: number): void;
|
||||
inputRef: RefObject<HTMLInputElement>;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
value: string[];
|
||||
};
|
||||
const ChipInputContext = createContext<ChipInputContextType>(null as unknown as ChipInputContextType);
|
||||
|
||||
const ChipInputContext = createContext<ChipInputContextType<never>>(null as unknown as ChipInputContextType<never>);
|
||||
|
||||
function getChipInputContext<T>() {
|
||||
return ChipInputContext as unknown as Context<ChipInputContextType<T>>;
|
||||
}
|
||||
|
||||
type CustomNewChipInputProps<T> = {
|
||||
onChange: (newValue: Option<T>) => void;
|
||||
value?: Option<T> | null;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
ref?: React.Ref<HTMLInputElement>;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
"aria-describedby"?: string;
|
||||
};
|
||||
|
||||
const DefaultNewChipInput = React.forwardRef<HTMLInputElement, CustomNewChipInputProps<string>>(
|
||||
({ onChange, value, ...props }, ref) => {
|
||||
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLInputElement>>(
|
||||
(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (e.currentTarget.value) {
|
||||
onChange({ label: e.currentTarget.value, value: e.currentTarget.value });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="is-flex-grow-1">
|
||||
<input onKeyDown={handleKeyDown} {...props} ref={ref} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
type ChipDeleteProps = {
|
||||
asChild?: boolean;
|
||||
@@ -65,31 +111,33 @@ export const ChipDelete = React.forwardRef<HTMLButtonElement, ChipDeleteProps>((
|
||||
});
|
||||
|
||||
type NewChipInputProps = {
|
||||
asChild?: boolean;
|
||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, "onKeyDown" | "disabled" | "readOnly">;
|
||||
children?: ReactElement | null;
|
||||
ref?: React.Ref<HTMLInputElement>;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
"aria-describedby"?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @beta
|
||||
* @since 2.44.0
|
||||
*/
|
||||
export const NewChipInput = React.forwardRef<HTMLInputElement, NewChipInputProps>(({ asChild, ...props }, ref) => {
|
||||
const { add, value, disabled, readOnly } = useContext(ChipInputContext);
|
||||
const handleKeyDown = useCallback<KeyboardEventHandler<HTMLInputElement>>(
|
||||
(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const newValue = e.currentTarget.value.trim();
|
||||
if (newValue && !value?.includes(newValue)) {
|
||||
add(newValue);
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[add, value]
|
||||
);
|
||||
const Comp = asChild ? Slot : "input";
|
||||
return <Comp {...props} onKeyDown={handleKeyDown} readOnly={readOnly} disabled={disabled} ref={ref} />;
|
||||
export const NewChipInput = withForwardRef(function NewChipInput<T>(
|
||||
props: NewChipInputProps,
|
||||
ref: React.ForwardedRef<HTMLInputElement>
|
||||
) {
|
||||
const { add, disabled, readOnly, inputRef } = useContext(getChipInputContext<T>());
|
||||
|
||||
const Comp = props.children ? Slot : DefaultNewChipInput;
|
||||
return React.createElement(Comp as unknown as ComponentType<CustomNewChipInputProps<T>>, {
|
||||
...props,
|
||||
onChange: add,
|
||||
readOnly,
|
||||
disabled,
|
||||
value: null,
|
||||
ref: mergeRefs(ref, inputRef),
|
||||
});
|
||||
});
|
||||
|
||||
type ChipProps = { asChild?: boolean } & LiHTMLAttributes<HTMLLIElement>;
|
||||
@@ -104,48 +152,68 @@ export const Chip = React.forwardRef<HTMLLIElement, ChipProps>(({ asChild, ...pr
|
||||
return <Comp {...props} ref={ref} />;
|
||||
});
|
||||
|
||||
type Props = {
|
||||
value?: string[];
|
||||
onChange?: (newValue: string[]) => void;
|
||||
type Props<T> = {
|
||||
value?: Option<T>[] | null;
|
||||
onChange?: (newValue?: Option<T>[]) => void;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
isNewItemDuplicate?: (existingItem: Option<T>, newItem: Option<T>) => boolean;
|
||||
} & Omit<HTMLAttributes<HTMLUListElement>, "onChange">;
|
||||
|
||||
/**
|
||||
* @beta
|
||||
* @since 2.44.0
|
||||
*/
|
||||
const ChipInput = React.forwardRef<HTMLUListElement, Props>(
|
||||
({ children, value = [], disabled, readOnly, onChange, ...props }, ref) => {
|
||||
const isInactive = useMemo(() => disabled || readOnly, [disabled, readOnly]);
|
||||
const add = useCallback(
|
||||
(newValue: string) => !isInactive && onChange && onChange([...value, newValue]),
|
||||
[isInactive, onChange, value]
|
||||
);
|
||||
const remove = useCallback(
|
||||
(index: number) => !isInactive && onChange && onChange(value?.filter((_, itdx) => itdx !== index)),
|
||||
[isInactive, onChange, value]
|
||||
);
|
||||
return (
|
||||
<ChipInputContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
value,
|
||||
disabled,
|
||||
readOnly,
|
||||
add,
|
||||
remove,
|
||||
}),
|
||||
[add, disabled, readOnly, remove, value]
|
||||
)}
|
||||
>
|
||||
<ul {...props} ref={ref}>
|
||||
{children}
|
||||
</ul>
|
||||
</ChipInputContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
const ChipInput = withForwardRef(function ChipInput<T>(
|
||||
{ children, value = [], disabled, readOnly, onChange, isNewItemDuplicate, ...props }: Props<T>,
|
||||
ref: React.ForwardedRef<HTMLUListElement>
|
||||
) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isInactive = useMemo(() => disabled || readOnly, [disabled, readOnly]);
|
||||
const add = useCallback<(newValue: Option<T>) => void>(
|
||||
(newItem) => {
|
||||
if (
|
||||
!isInactive &&
|
||||
!value?.some((item) =>
|
||||
isNewItemDuplicate
|
||||
? isNewItemDuplicate(item, newItem)
|
||||
: item.label === newItem.label || item.value === newItem.value
|
||||
)
|
||||
) {
|
||||
if (onChange) {
|
||||
onChange([...(value ?? []), newItem]);
|
||||
}
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
},
|
||||
[isInactive, isNewItemDuplicate, onChange, value]
|
||||
);
|
||||
const remove = useCallback(
|
||||
(index: number) => !isInactive && onChange && onChange(value?.filter((_, itdx) => itdx !== index)),
|
||||
[isInactive, onChange, value]
|
||||
);
|
||||
const Context = getChipInputContext<T>();
|
||||
return (
|
||||
<Context.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
disabled,
|
||||
readOnly,
|
||||
add,
|
||||
remove,
|
||||
inputRef,
|
||||
}),
|
||||
[add, disabled, readOnly, remove]
|
||||
)}
|
||||
>
|
||||
<ul {...props} ref={ref}>
|
||||
{children}
|
||||
</ul>
|
||||
</Context.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
export default Object.assign(ChipInput, {
|
||||
Chip: Object.assign(Chip, {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { ForwardedRef, forwardRef, MutableRefObject, Ref, RefCallback } from "react";
|
||||
|
||||
export function prefixWithoutIndices(path: string): string {
|
||||
return path.replace(/(\.\d+)/g, "");
|
||||
@@ -49,3 +50,25 @@ export function setValues<T>(newValues: T, setValue: UseFormReturn<T>["setValue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function withForwardRef<T extends { name: string }>(component: T): T {
|
||||
return forwardRef(component as unknown as any) as any;
|
||||
}
|
||||
|
||||
export const defaultOptionFactory = (item: any) =>
|
||||
typeof item === "object" && item !== null && "value" in item && typeof item["label"] === "string"
|
||||
? item
|
||||
: { label: item as string, value: item };
|
||||
|
||||
export function mergeRefs<T>(...refs: Array<RefCallback<T> | MutableRefObject<T> | ForwardedRef<T>>) {
|
||||
return (el: T) =>
|
||||
refs.forEach((ref) => {
|
||||
if (ref) {
|
||||
if (typeof ref === "function") {
|
||||
ref(el);
|
||||
} else {
|
||||
ref.current = el;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,10 +35,13 @@ import ControlledTable from "./table/ControlledTable";
|
||||
import ControlledColumn from "./table/ControlledColumn";
|
||||
import AddListEntryForm from "./AddListEntryForm";
|
||||
import { ScmNestedFormPathContextProvider } from "./FormPathContext";
|
||||
import ControlledComboboxField from "./combobox/ControlledComboboxField";
|
||||
|
||||
export { default as Combobox } from "./combobox/Combobox";
|
||||
export { default as ConfigurationForm } from "./ConfigurationForm";
|
||||
export { default as SelectField } from "./select/SelectField";
|
||||
export { default as ChipInputField } from "./chip-input/ChipInputField";
|
||||
export { default as ComboboxField } from "./combobox/ComboboxField";
|
||||
export { default as Select } from "./select/Select";
|
||||
export * from "./resourceHooks";
|
||||
|
||||
@@ -56,4 +59,5 @@ export const Form = Object.assign(FormCmp, {
|
||||
Column: ControlledColumn,
|
||||
}),
|
||||
ChipInput: ControlledChipInputField,
|
||||
Combobox: ControlledComboboxField,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user