first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

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

View 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;

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

View 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`;
}
}

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

View File

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