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:
Eduard Heimbuch
2023-06-19 13:04:26 +02:00
parent e4d846b0d4
commit bc2a599b2c
39 changed files with 1119 additions and 276 deletions

View File

@@ -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>
);
});

View File

@@ -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);

View File

@@ -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>
)}
/>
);