@@ -0,0 +1,5 @@
|
||||
import {createSvgIcon} from '../../../../icons/create-svg-icon';
|
||||
|
||||
export const CancelFilledIcon = createSvgIcon(
|
||||
<path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z" />
|
||||
);
|
||||
486
common/resources/client/ui/forms/input-field/chip-field/chip-field.tsx
Executable file
486
common/resources/client/ui/forms/input-field/chip-field/chip-field.tsx
Executable file
@@ -0,0 +1,486 @@
|
||||
import React, {
|
||||
HTMLAttributes,
|
||||
Key,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
Ref,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {useFocusManager} from '@react-aria/focus';
|
||||
import clsx from 'clsx';
|
||||
import {mergeProps, useLayoutEffect, useObjectRef} from '@react-aria/utils';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {ChipList} from './chip-list';
|
||||
import {Field, FieldProps} from '../field';
|
||||
import {Input} from '../input';
|
||||
import {Chip, ChipProps} from './chip';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {getInputFieldClassNames} from '../get-input-field-class-names';
|
||||
import {ProgressCircle} from '../../../progress/progress-circle';
|
||||
import {useField} from '../use-field';
|
||||
import {Avatar} from '../../../images/avatar';
|
||||
import {Listbox} from '../../listbox/listbox';
|
||||
import {useListbox} from '../../listbox/use-listbox';
|
||||
import {BaseFieldPropsWithDom} from '../base-field-props';
|
||||
import {useListboxKeyboardNavigation} from '../../listbox/use-listbox-keyboard-navigation';
|
||||
import {createEventHandler} from '@common/utils/dom/create-event-handler';
|
||||
import {ListBoxChildren, ListboxProps} from '../../listbox/types';
|
||||
import {stringToChipValue} from './string-to-chip-value';
|
||||
import {Popover} from '../../../overlays/popover';
|
||||
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
|
||||
|
||||
export interface ChipValue extends Omit<NormalizedModel, 'model_type'> {
|
||||
invalid?: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export type ChipFieldProps<T> = Omit<
|
||||
ListboxProps,
|
||||
'selectionMode' | 'displayWith'
|
||||
> &
|
||||
Omit<
|
||||
BaseFieldPropsWithDom<HTMLInputElement>,
|
||||
'value' | 'onChange' | 'defaultValue'
|
||||
> & {
|
||||
value?: (ChipValue | string)[];
|
||||
defaultValue?: (ChipValue | string)[];
|
||||
displayWith?: (value: ChipValue) => ReactNode;
|
||||
validateWith?: (value: ChipValue) => ChipValue;
|
||||
allowCustomValue?: boolean;
|
||||
showDropdownArrow?: boolean;
|
||||
onChange?: (value: ChipValue[]) => void;
|
||||
suggestions?: T[];
|
||||
children?: ListBoxChildren<T>['children'];
|
||||
placeholder?: string;
|
||||
chipSize?: ChipProps['size'];
|
||||
openMenuOnFocus?: boolean;
|
||||
valueKey?: 'id' | 'name';
|
||||
onChipClick?: (value: ChipValue) => void;
|
||||
};
|
||||
|
||||
function ChipFieldInner<T>(
|
||||
props: ChipFieldProps<T>,
|
||||
ref: Ref<HTMLInputElement>,
|
||||
) {
|
||||
const fieldRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useObjectRef(ref);
|
||||
const {
|
||||
displayWith = v => v.name,
|
||||
validateWith,
|
||||
children,
|
||||
suggestions,
|
||||
isLoading,
|
||||
inputValue,
|
||||
onInputValueChange,
|
||||
onItemSelected,
|
||||
placeholder,
|
||||
onOpenChange,
|
||||
chipSize = 'sm',
|
||||
openMenuOnFocus = true,
|
||||
showEmptyMessage,
|
||||
value: propsValue,
|
||||
defaultValue,
|
||||
onChange: propsOnChange,
|
||||
valueKey,
|
||||
isAsync,
|
||||
allowCustomValue = true,
|
||||
showDropdownArrow,
|
||||
onChipClick,
|
||||
...inputFieldProps
|
||||
} = props;
|
||||
const fieldClassNames = getInputFieldClassNames({
|
||||
...props,
|
||||
flexibleHeight: true,
|
||||
});
|
||||
|
||||
const [value, onChange] = useChipFieldValueState(props);
|
||||
|
||||
const [listboxIsOpen, setListboxIsOpen] = useState(false);
|
||||
|
||||
const loadingIndicator = (
|
||||
<ProgressCircle isIndeterminate size="sm" aria-label="loading..." />
|
||||
);
|
||||
|
||||
const dropdownArrow = showDropdownArrow ? <KeyboardArrowDownIcon /> : null;
|
||||
|
||||
const {fieldProps, inputProps} = useField({
|
||||
...inputFieldProps,
|
||||
focusRef: inputRef,
|
||||
endAdornment: isLoading && listboxIsOpen ? loadingIndicator : dropdownArrow,
|
||||
});
|
||||
|
||||
return (
|
||||
<Field fieldClassNames={fieldClassNames} {...fieldProps}>
|
||||
<Input
|
||||
ref={fieldRef}
|
||||
className={clsx('flex flex-wrap items-center', fieldClassNames.input)}
|
||||
onClick={() => {
|
||||
// refocus input when clicking outside it, but while still inside chip field
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<ListWrapper
|
||||
displayChipUsing={displayWith}
|
||||
onChipClick={onChipClick}
|
||||
items={value}
|
||||
setItems={onChange}
|
||||
chipSize={chipSize}
|
||||
/>
|
||||
<ChipInput
|
||||
size={props.size}
|
||||
showEmptyMessage={showEmptyMessage}
|
||||
inputProps={inputProps}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={onInputValueChange}
|
||||
fieldRef={fieldRef}
|
||||
inputRef={inputRef}
|
||||
chips={value}
|
||||
setChips={onChange}
|
||||
validateWith={validateWith}
|
||||
isLoading={isLoading}
|
||||
suggestions={suggestions}
|
||||
placeholder={placeholder}
|
||||
openMenuOnFocus={openMenuOnFocus}
|
||||
listboxIsOpen={listboxIsOpen}
|
||||
setListboxIsOpen={setListboxIsOpen}
|
||||
allowCustomValue={allowCustomValue}
|
||||
>
|
||||
{children}
|
||||
</ChipInput>
|
||||
</Input>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListWrapperProps {
|
||||
items: ChipValue[];
|
||||
setItems: (items: ChipValue[]) => void;
|
||||
displayChipUsing: (value: ChipValue) => ReactNode;
|
||||
chipSize?: ChipProps['size'];
|
||||
onChipClick?: (value: ChipValue) => void;
|
||||
}
|
||||
function ListWrapper({
|
||||
items,
|
||||
setItems,
|
||||
displayChipUsing,
|
||||
chipSize,
|
||||
onChipClick,
|
||||
}: ListWrapperProps) {
|
||||
const manager = useFocusManager();
|
||||
const removeItem = useCallback(
|
||||
(key: Key) => {
|
||||
const i = items.findIndex(cr => cr.id === key);
|
||||
const newItems = [...items];
|
||||
if (i > -1) {
|
||||
newItems.splice(i, 1);
|
||||
setItems(newItems);
|
||||
}
|
||||
return newItems;
|
||||
},
|
||||
[items, setItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChipList
|
||||
className={clsx(
|
||||
'max-w-full flex-shrink-0 flex-wrap',
|
||||
chipSize === 'xs' ? 'my-6' : 'my-8',
|
||||
)}
|
||||
size={chipSize}
|
||||
selectable
|
||||
>
|
||||
{items.map(item => (
|
||||
<Chip
|
||||
key={item.id}
|
||||
errorMessage={item.errorMessage}
|
||||
adornment={item.image ? <Avatar circle src={item.image} /> : null}
|
||||
onClick={() => onChipClick?.(item)}
|
||||
onRemove={() => {
|
||||
const newItems = removeItem(item.id);
|
||||
if (newItems.length) {
|
||||
// focus previous chip
|
||||
manager?.focusPrevious({tabbable: true});
|
||||
} else {
|
||||
// focus input
|
||||
manager?.focusLast();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{displayChipUsing(item)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipList>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChipInputProps<T> {
|
||||
showEmptyMessage?: boolean;
|
||||
inputProps: ReturnType<typeof useField>['inputProps'];
|
||||
inputValue?: string;
|
||||
onInputValueChange?: (value: string) => void;
|
||||
fieldRef: RefObject<HTMLDivElement>;
|
||||
inputRef: RefObject<HTMLInputElement>;
|
||||
chips: ChipValue[];
|
||||
setChips: (items: ChipValue[]) => void;
|
||||
validateWith?: (value: ChipValue) => ChipValue;
|
||||
isLoading?: boolean;
|
||||
suggestions?: T[];
|
||||
placeholder?: string;
|
||||
openMenuOnFocus?: boolean;
|
||||
listboxIsOpen: boolean;
|
||||
setListboxIsOpen: (value: boolean) => void;
|
||||
allowCustomValue: boolean;
|
||||
children: ListBoxChildren<T>['children'];
|
||||
size: FieldProps['size'];
|
||||
}
|
||||
function ChipInput<T>(props: ChipInputProps<T>) {
|
||||
const {
|
||||
inputRef,
|
||||
fieldRef,
|
||||
validateWith,
|
||||
setChips,
|
||||
chips,
|
||||
suggestions,
|
||||
inputProps,
|
||||
placeholder,
|
||||
openMenuOnFocus,
|
||||
listboxIsOpen,
|
||||
setListboxIsOpen,
|
||||
allowCustomValue,
|
||||
isLoading,
|
||||
size,
|
||||
} = props;
|
||||
const manager = useFocusManager();
|
||||
|
||||
const addItems = useCallback(
|
||||
(items?: ChipValue[]) => {
|
||||
items = (items || []).filter(item => {
|
||||
const invalid = !item || !item.id || !item.name;
|
||||
const alreadyExists = chips.findIndex(cr => cr.id === item?.id) > -1;
|
||||
return !alreadyExists && !invalid;
|
||||
});
|
||||
if (!items.length) return;
|
||||
|
||||
if (validateWith) {
|
||||
items = items.map(item => validateWith(item));
|
||||
}
|
||||
setChips([...chips, ...items]);
|
||||
},
|
||||
[chips, setChips, validateWith],
|
||||
);
|
||||
|
||||
const listbox = useListbox<T>({
|
||||
...props,
|
||||
clearInputOnItemSelection: true,
|
||||
isOpen: listboxIsOpen,
|
||||
onOpenChange: setListboxIsOpen,
|
||||
items: suggestions,
|
||||
selectionMode: 'none',
|
||||
role: 'listbox',
|
||||
virtualFocus: true,
|
||||
onItemSelected: value => {
|
||||
handleItemSelection(value as string);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
state: {
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
},
|
||||
refs,
|
||||
listboxId,
|
||||
collection,
|
||||
onInputChange,
|
||||
} = listbox;
|
||||
|
||||
const handleItemSelection = (textValue: string) => {
|
||||
const option =
|
||||
collection.size && activeIndex != null
|
||||
? [...collection.values()][activeIndex]
|
||||
: null;
|
||||
if (option?.item) {
|
||||
addItems([option.item]);
|
||||
} else if (allowCustomValue) {
|
||||
addItems([stringToChipValue(option ? option.value : textValue)]);
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
setActiveIndex(null);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// position dropdown relative to whole chip field, not the input
|
||||
useLayoutEffect(() => {
|
||||
if (fieldRef.current && refs.reference.current !== fieldRef.current) {
|
||||
listbox.reference(fieldRef.current);
|
||||
}
|
||||
}, [fieldRef, listbox, refs]);
|
||||
|
||||
const {handleTriggerKeyDown, handleListboxKeyboardNavigation} =
|
||||
useListboxKeyboardNavigation(listbox);
|
||||
|
||||
const handleFocusAndClick = createEventHandler(() => {
|
||||
if (openMenuOnFocus && !isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Listbox
|
||||
listbox={listbox}
|
||||
mobileOverlay={Popover}
|
||||
isLoading={isLoading}
|
||||
onPointerDown={e => {
|
||||
// prevent focus from leaving input when scrolling listbox via mouse
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className={clsx(
|
||||
'mx-8 my-4 min-w-30 flex-[1_1_60px] bg-transparent text-sm outline-none',
|
||||
size === 'xs' ? 'h-20' : 'h-30',
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
{...mergeProps(inputProps, {
|
||||
ref: inputRef,
|
||||
value: inputValue,
|
||||
onChange: onInputChange,
|
||||
onPaste: e => {
|
||||
const paste = e.clipboardData.getData('text');
|
||||
const emails = paste.match(
|
||||
/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/gi,
|
||||
);
|
||||
if (emails) {
|
||||
e.preventDefault();
|
||||
const selection = window.getSelection();
|
||||
if (selection?.rangeCount) {
|
||||
selection.deleteFromDocument();
|
||||
addItems(emails.map(email => stringToChipValue(email)));
|
||||
}
|
||||
}
|
||||
},
|
||||
'aria-autocomplete': 'list',
|
||||
'aria-controls': isOpen ? listboxId : undefined,
|
||||
autoComplete: 'off',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: 'false',
|
||||
onKeyDown: e => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
// prevent form submitting
|
||||
e.preventDefault();
|
||||
// add chip from selected listbox option or current input text value
|
||||
handleItemSelection(input.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// on escape, clear input and close dropdown
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
setIsOpen(false);
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
// move focus to input when focus is on first item and prevent arrow up from cycling listbox
|
||||
if (
|
||||
e.key === 'ArrowUp' &&
|
||||
isOpen &&
|
||||
(activeIndex === 0 || activeIndex == null)
|
||||
) {
|
||||
setActiveIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// block left and right arrows from navigating in input, if focus is on listbox
|
||||
if (
|
||||
activeIndex != null &&
|
||||
(e.key === 'ArrowLeft' || e.key === 'ArrowRight')
|
||||
) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// move focus on the last chip, if focus is at the start of input
|
||||
if (
|
||||
(e.key === 'ArrowLeft' ||
|
||||
e.key === 'Backspace' ||
|
||||
e.key === 'Delete') &&
|
||||
input.selectionStart === 0 &&
|
||||
activeIndex == null &&
|
||||
chips.length
|
||||
) {
|
||||
manager?.focusPrevious({tabbable: true});
|
||||
return;
|
||||
}
|
||||
|
||||
// fallthrough to listbox navigation handlers for arrow keys
|
||||
const handled = handleTriggerKeyDown(e);
|
||||
if (!handled) {
|
||||
handleListboxKeyboardNavigation(e);
|
||||
}
|
||||
},
|
||||
onFocus: handleFocusAndClick,
|
||||
onClick: handleFocusAndClick,
|
||||
} as HTMLAttributes<HTMLInputElement>)}
|
||||
/>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
|
||||
function useChipFieldValueState({
|
||||
onChange,
|
||||
value,
|
||||
defaultValue,
|
||||
valueKey,
|
||||
}: ChipFieldProps<any>) {
|
||||
// convert value from string[] to ChipValue[], if needed
|
||||
const propsValue = useMemo(() => {
|
||||
return mixedValueToChipValue(value);
|
||||
}, [value]);
|
||||
|
||||
// convert defaultValue from string[] to ChipValue[], if needed
|
||||
const propsDefaultValue = useMemo(() => {
|
||||
return mixedValueToChipValue(defaultValue);
|
||||
}, [defaultValue]);
|
||||
|
||||
// emit string[] or ChipValue[] on change, based on "valueKey" prop
|
||||
const handleChange = useCallback(
|
||||
(value: ChipValue[]) => {
|
||||
const newValue = valueKey ? value.map(v => v[valueKey]) : value;
|
||||
onChange?.(newValue as any);
|
||||
},
|
||||
[onChange, valueKey],
|
||||
);
|
||||
|
||||
return useControlledState<ChipValue[]>(
|
||||
!propsValue ? undefined : propsValue,
|
||||
propsDefaultValue || [],
|
||||
handleChange,
|
||||
);
|
||||
}
|
||||
|
||||
function mixedValueToChipValue(
|
||||
value?: (string | number | ChipValue)[] | null,
|
||||
): ChipValue[] | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.map(v => {
|
||||
return typeof v !== 'object' ? stringToChipValue(v as string) : v;
|
||||
});
|
||||
}
|
||||
|
||||
export const ChipField = React.forwardRef(ChipFieldInner) as <T>(
|
||||
props: ChipFieldProps<T> & {ref?: Ref<HTMLInputElement>},
|
||||
) => ReactElement;
|
||||
48
common/resources/client/ui/forms/input-field/chip-field/chip-list.tsx
Executable file
48
common/resources/client/ui/forms/input-field/chip-field/chip-list.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import React, {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import type {ChipProps} from './chip';
|
||||
|
||||
export interface ChipListProps {
|
||||
className?: string;
|
||||
children?: ReactElement | ReactElement[];
|
||||
size?: ChipProps['size'];
|
||||
radius?: ChipProps['radius'];
|
||||
color?: ChipProps['color'];
|
||||
selectable?: ChipProps['selectable'];
|
||||
wrap?: boolean;
|
||||
}
|
||||
export function ChipList({
|
||||
className,
|
||||
children,
|
||||
size,
|
||||
color,
|
||||
radius,
|
||||
selectable,
|
||||
wrap = true,
|
||||
}: ChipListProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-8',
|
||||
wrap && 'flex-wrap',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Children.map(children, chip => {
|
||||
if (isValidElement<ChipProps>(chip)) {
|
||||
return cloneElement<ChipProps>(chip, {
|
||||
size,
|
||||
color,
|
||||
selectable,
|
||||
radius,
|
||||
});
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
common/resources/client/ui/forms/input-field/chip-field/chip.tsx
Executable file
191
common/resources/client/ui/forms/input-field/chip-field/chip.tsx
Executable file
@@ -0,0 +1,191 @@
|
||||
import React, {
|
||||
cloneElement,
|
||||
JSXElementConstructor,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useFocusManager} from '@react-aria/focus';
|
||||
import {ButtonBase} from '../../../buttons/button-base';
|
||||
import {CancelFilledIcon} from './cancel-filled-icon';
|
||||
import {WarningIcon} from '@common/icons/material/Warning';
|
||||
import {Tooltip} from '../../../tooltip/tooltip';
|
||||
import {To} from 'react-router-dom';
|
||||
|
||||
export interface ChipProps {
|
||||
onRemove?: () => void;
|
||||
disabled?: boolean;
|
||||
selectable?: boolean;
|
||||
invalid?: boolean;
|
||||
errorMessage?: ReactElement | string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
adornment?: null | ReactElement<{
|
||||
size: string;
|
||||
className?: string;
|
||||
circle?: boolean;
|
||||
}>;
|
||||
radius?: string;
|
||||
color?: 'chip' | 'primary' | 'danger' | 'positive';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
elementType?: 'div' | 'a' | JSXElementConstructor<any>;
|
||||
to?: To;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
export function Chip(props: ChipProps) {
|
||||
const {
|
||||
onRemove,
|
||||
disabled,
|
||||
invalid,
|
||||
errorMessage,
|
||||
children,
|
||||
className,
|
||||
selectable = false,
|
||||
radius = 'rounded-full',
|
||||
elementType = 'div',
|
||||
to,
|
||||
onClick,
|
||||
} = props;
|
||||
const chipRef = useRef<HTMLDivElement>(null);
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const focusManager = useFocusManager();
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
focusManager?.focusNext({tabbable: true});
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
focusManager?.focusPrevious({tabbable: true});
|
||||
break;
|
||||
case 'Backspace':
|
||||
case 'Delete':
|
||||
if (chipRef.current === document.activeElement) {
|
||||
onRemove?.();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick: React.MouseEventHandler = e => {
|
||||
e.stopPropagation();
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
} else {
|
||||
chipRef.current!.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const sizeStyle = sizeClassNames(props);
|
||||
|
||||
let adornment =
|
||||
invalid || errorMessage != null ? (
|
||||
<WarningIcon className="text-danger" size="sm" />
|
||||
) : (
|
||||
props.adornment &&
|
||||
cloneElement(props.adornment, {
|
||||
size: sizeStyle.adornment.size,
|
||||
circle: true,
|
||||
className: clsx(props.adornment.props, sizeStyle.adornment.margin),
|
||||
})
|
||||
);
|
||||
|
||||
if (errorMessage && adornment) {
|
||||
adornment = (
|
||||
<Tooltip label={errorMessage} variant="danger">
|
||||
{adornment}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const Element = elementType;
|
||||
|
||||
return (
|
||||
<Element
|
||||
tabIndex={selectable ? 0 : undefined}
|
||||
ref={chipRef}
|
||||
to={to}
|
||||
onKeyDown={selectable ? handleKeyDown : undefined}
|
||||
onClick={selectable ? handleClick : undefined}
|
||||
className={clsx(
|
||||
'relative flex flex-shrink-0 items-center justify-center gap-10 overflow-hidden whitespace-nowrap outline-none',
|
||||
'min-w-0 max-w-full after:pointer-events-none after:absolute after:inset-0',
|
||||
onClick && 'cursor-pointer',
|
||||
radius,
|
||||
colorClassName(props),
|
||||
sizeStyle.chip,
|
||||
!disabled &&
|
||||
selectable &&
|
||||
'hover:after:bg-black/5 focus:after:bg-black/10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{adornment}
|
||||
<div className="flex-auto overflow-hidden overflow-ellipsis">
|
||||
{children}
|
||||
</div>
|
||||
{onRemove && (
|
||||
<ButtonBase
|
||||
ref={deleteButtonRef}
|
||||
className={clsx(
|
||||
'text-black/30 dark:text-white/50',
|
||||
sizeStyle.closeButton,
|
||||
)}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<CancelFilledIcon className="block" width="100%" height="100%" />
|
||||
</ButtonBase>
|
||||
)}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
function sizeClassNames({size, onRemove}: ChipProps) {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return {
|
||||
adornment: {size: 'xs', margin: '-ml-3'},
|
||||
chip: clsx('pl-8 h-20 text-xs font-medium w-max', !onRemove && 'pr-8'),
|
||||
closeButton: 'mr-4 w-14 h-14',
|
||||
};
|
||||
case 'sm':
|
||||
return {
|
||||
adornment: {size: 'xs', margin: '-ml-3'},
|
||||
chip: clsx('pl-8 h-26 text-xs', !onRemove && 'pr-8'),
|
||||
closeButton: 'mr-4 w-18 h-18',
|
||||
};
|
||||
case 'lg':
|
||||
return {
|
||||
adornment: {size: 'md', margin: '-ml-12'},
|
||||
chip: clsx('pl-18 h-38 text-base', !onRemove && 'pr-18'),
|
||||
closeButton: 'mr-6 w-24 h-24',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
adornment: {size: 'sm', margin: '-ml-6'},
|
||||
chip: clsx('pl-12 h-32 text-sm', !onRemove && 'pr-12'),
|
||||
closeButton: 'mr-6 w-22 h-22',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function colorClassName({color}: ChipProps): string {
|
||||
switch (color) {
|
||||
case 'primary':
|
||||
return `bg-primary text-on-primary`;
|
||||
case 'positive':
|
||||
return `bg-positive-lighter text-positive-darker`;
|
||||
case 'danger':
|
||||
return `bg-danger-lighter text-danger-darker`;
|
||||
default:
|
||||
return `bg-chip text-main`;
|
||||
}
|
||||
}
|
||||
31
common/resources/client/ui/forms/input-field/chip-field/form-chip-field.tsx
Executable file
31
common/resources/client/ui/forms/input-field/chip-field/form-chip-field.tsx
Executable file
@@ -0,0 +1,31 @@
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import React from 'react';
|
||||
import {ChipField, ChipFieldProps} from './chip-field';
|
||||
|
||||
export type FormChipFieldProps<T> = ChipFieldProps<T> & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function FormChipField<T>({children, ...props}: FormChipFieldProps<T>) {
|
||||
const {
|
||||
field: {onChange, onBlur, value = [], ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<ChipFieldProps<T>> = {
|
||||
onChange,
|
||||
onBlur,
|
||||
value,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
};
|
||||
|
||||
return (
|
||||
<ChipField ref={ref} {...mergeProps(formProps, props)}>
|
||||
{children}
|
||||
</ChipField>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {ChipValue} from './chip-field';
|
||||
|
||||
export function stringToChipValue(value: string | number): ChipValue {
|
||||
// add both name and description so "validateWith" works properly in chip field, if it depends on description
|
||||
return {id: value, name: `${value}`, description: `${value}`};
|
||||
}
|
||||
Reference in New Issue
Block a user