28
common/resources/client/ui/forms/input-field/adornment.tsx
Executable file
28
common/resources/client/ui/forms/input-field/adornment.tsx
Executable file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type AdornmentProps = {
|
||||
children: React.ReactNode;
|
||||
direction: 'start' | 'end';
|
||||
position?: string;
|
||||
className?: string;
|
||||
};
|
||||
export function Adornment({
|
||||
children,
|
||||
direction,
|
||||
className,
|
||||
position = direction === 'start' ? 'left-0' : 'right-0',
|
||||
}: AdornmentProps) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute top-0 z-10 flex h-full min-w-42 items-center justify-center text-muted',
|
||||
position,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
common/resources/client/ui/forms/input-field/base-field-props.ts
Executable file
43
common/resources/client/ui/forms/input-field/base-field-props.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
import React, {ElementType, HTMLProps, ReactElement, ReactNode} from 'react';
|
||||
import {InputSize} from './input-size';
|
||||
|
||||
export interface BaseFieldProps {
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
labelSuffix?: ReactNode;
|
||||
labelSuffixPosition?: 'spaced' | 'inline';
|
||||
autoFocus?: boolean;
|
||||
autoSelectText?: boolean;
|
||||
labelElementType?: ElementType;
|
||||
label?: ReactNode;
|
||||
labelPosition?: 'top' | 'side';
|
||||
labelDisplay?: string;
|
||||
size?: InputSize;
|
||||
inputRadius?: 'rounded-full' | 'rounded' | 'rounded-none' | string;
|
||||
inputRing?: string;
|
||||
inputFontSize?: string;
|
||||
inputBorder?: string;
|
||||
inputShadow?: string;
|
||||
invalid?: boolean;
|
||||
errorMessage?: ReactNode;
|
||||
description?: ReactNode;
|
||||
descriptionPosition?: 'top' | 'bottom';
|
||||
flexibleHeight?: boolean;
|
||||
// usually an icon or icon button, displayed inside the input
|
||||
startAdornment?: React.ReactNode;
|
||||
endAdornment?: React.ReactNode;
|
||||
adornmentPosition?: string;
|
||||
// usually a text button, displayed side by side with input
|
||||
startAppend?: ReactElement;
|
||||
endAppend?: ReactElement;
|
||||
className?: string;
|
||||
inputWrapperClassName?: string;
|
||||
inputClassName?: string;
|
||||
unstyled?: boolean;
|
||||
background?: 'bg-transparent' | 'bg-alt' | 'bg' | 'bg-white';
|
||||
inputTestId?: string;
|
||||
}
|
||||
|
||||
export interface BaseFieldPropsWithDom<T>
|
||||
extends BaseFieldProps,
|
||||
Omit<HTMLProps<T>, 'label' | 'size' | 'ref' | 'children'> {}
|
||||
@@ -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}`};
|
||||
}
|
||||
81
common/resources/client/ui/forms/input-field/date/calendar/calendar-cell.tsx
Executable file
81
common/resources/client/ui/forms/input-field/date/calendar/calendar-cell.tsx
Executable file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
CalendarDate,
|
||||
DateValue,
|
||||
getDayOfWeek,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
} from '@internationalized/date';
|
||||
import {useSelectedLocale} from '../../../../../i18n/selected-locale';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {dateIsInvalid} from '../utils';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
|
||||
interface CalendarCellProps {
|
||||
date: CalendarDate;
|
||||
currentMonth: DateValue;
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
}
|
||||
export function CalendarCell({
|
||||
date,
|
||||
currentMonth,
|
||||
state: {
|
||||
dayIsActive,
|
||||
dayIsHighlighted,
|
||||
dayIsRangeStart,
|
||||
dayIsRangeEnd,
|
||||
getCellProps,
|
||||
timezone,
|
||||
min,
|
||||
max,
|
||||
},
|
||||
}: CalendarCellProps) {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const dayOfWeek = getDayOfWeek(date, localeCode);
|
||||
const isActive = dayIsActive(date);
|
||||
const isHighlighted = dayIsHighlighted(date);
|
||||
const isRangeStart = dayIsRangeStart(date);
|
||||
const isRangeEnd = dayIsRangeEnd(date);
|
||||
const dayIsToday = isToday(date, timezone);
|
||||
const sameMonth = isSameMonth(date, currentMonth);
|
||||
const isDisabled = dateIsInvalid(date, min, max);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
aria-disabled={isDisabled}
|
||||
className={clsx(
|
||||
'w-40 h-40 text-sm relative isolate flex-shrink-0',
|
||||
isDisabled && 'text-disabled pointer-events-none',
|
||||
!sameMonth && 'invisible pointer-events-none'
|
||||
)}
|
||||
{...getCellProps(date, sameMonth)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute inset-0 flex items-center justify-center rounded-full w-full h-full select-none z-10 cursor-pointer',
|
||||
!isActive && !dayIsToday && 'hover:bg-hover',
|
||||
isActive && 'bg-primary text-on-primary font-semibold',
|
||||
dayIsToday && !isActive && 'bg-chip'
|
||||
)}
|
||||
>
|
||||
{date.day}
|
||||
</span>
|
||||
{isHighlighted && sameMonth && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute w-full h-full inset-0 bg-primary/focus',
|
||||
(isRangeStart || dayOfWeek === 0 || date.day === 1) &&
|
||||
'rounded-l-full',
|
||||
(isRangeEnd ||
|
||||
dayOfWeek === 6 ||
|
||||
date.day ===
|
||||
currentMonth.calendar.getDaysInMonth(currentMonth)) &&
|
||||
'rounded-r-full'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
common/resources/client/ui/forms/input-field/date/calendar/calendar-month.tsx
Executable file
169
common/resources/client/ui/forms/input-field/date/calendar/calendar-month.tsx
Executable file
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {m} from 'framer-motion';
|
||||
import {
|
||||
CalendarDate,
|
||||
endOfMonth,
|
||||
getWeeksInMonth,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from '@internationalized/date';
|
||||
import {KeyboardArrowLeftIcon} from '../../../../../icons/material/KeyboardArrowLeft';
|
||||
import {IconButton} from '../../../../buttons/icon-button';
|
||||
import {KeyboardArrowRightIcon} from '../../../../../icons/material/KeyboardArrowRight';
|
||||
import {CalendarCell} from './calendar-cell';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {useDateFormatter} from '../../../../../i18n/use-date-formatter';
|
||||
import {useSelectedLocale} from '../../../../../i18n/selected-locale';
|
||||
import {dateIsInvalid} from '../utils';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
|
||||
export interface CalendarMonthProps {
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
startDate: CalendarDate;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
export function CalendarMonth({
|
||||
startDate,
|
||||
state,
|
||||
isFirst,
|
||||
isLast,
|
||||
}: CalendarMonthProps) {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const weeksInMonth = getWeeksInMonth(startDate, localeCode);
|
||||
const monthStart = startOfWeek(startDate, localeCode);
|
||||
|
||||
return (
|
||||
<div className="w-280 flex-shrink-0">
|
||||
<CalendarMonthHeader
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
state={state}
|
||||
currentMonth={startDate}
|
||||
/>
|
||||
<div className="block" role="grid">
|
||||
<WeekdayHeader state={state} startDate={startDate} />
|
||||
{[...new Array(weeksInMonth).keys()].map(weekIndex => (
|
||||
<m.div className="flex mb-6" key={weekIndex}>
|
||||
{[...new Array(7).keys()].map(dayIndex => (
|
||||
<CalendarCell
|
||||
key={dayIndex}
|
||||
date={monthStart.add({weeks: weekIndex, days: dayIndex})}
|
||||
currentMonth={startDate}
|
||||
state={state}
|
||||
/>
|
||||
))}
|
||||
</m.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarMonthHeaderProps {
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
currentMonth: CalendarDate;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
function CalendarMonthHeader({
|
||||
currentMonth,
|
||||
isFirst,
|
||||
isLast,
|
||||
state: {calendarDates, setCalendarDates, timezone, min, max},
|
||||
}: CalendarMonthHeaderProps) {
|
||||
const shiftCalendars = (direction: 'forward' | 'backward') => {
|
||||
const count = calendarDates.length;
|
||||
let newDates: CalendarDate[];
|
||||
if (direction === 'forward') {
|
||||
newDates = calendarDates.map(date =>
|
||||
endOfMonth(date.add({months: count}))
|
||||
);
|
||||
} else {
|
||||
newDates = calendarDates.map(date =>
|
||||
endOfMonth(date.subtract({months: count}))
|
||||
);
|
||||
}
|
||||
setCalendarDates(newDates);
|
||||
};
|
||||
|
||||
const monthFormatter = useDateFormatter({
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
era: currentMonth.calendar.identifier !== 'gregory' ? 'long' : undefined,
|
||||
calendar: currentMonth.calendar.identifier,
|
||||
});
|
||||
|
||||
const isBackwardDisabled = dateIsInvalid(
|
||||
currentMonth.subtract({days: 1}),
|
||||
min,
|
||||
max
|
||||
);
|
||||
const isForwardDisabled = dateIsInvalid(
|
||||
startOfMonth(currentMonth.add({months: 1})),
|
||||
min,
|
||||
max
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-10">
|
||||
<IconButton
|
||||
size="md"
|
||||
className={clsx('text-muted', !isFirst && 'invisible')}
|
||||
disabled={!isFirst || isBackwardDisabled}
|
||||
aria-hidden={!isFirst}
|
||||
onClick={() => {
|
||||
shiftCalendars('backward');
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</IconButton>
|
||||
<div className="text-sm font-semibold select-none">
|
||||
{monthFormatter.format(currentMonth.toDate(timezone))}
|
||||
</div>
|
||||
<IconButton
|
||||
size="md"
|
||||
className={clsx('text-muted', !isLast && 'invisible')}
|
||||
disabled={!isLast || isForwardDisabled}
|
||||
aria-hidden={!isLast}
|
||||
onClick={() => {
|
||||
shiftCalendars('forward');
|
||||
}}
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WeekdayHeaderProps {
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
startDate: CalendarDate;
|
||||
}
|
||||
function WeekdayHeader({state: {timezone}, startDate}: WeekdayHeaderProps) {
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const dayFormatter = useDateFormatter({weekday: 'short'});
|
||||
|
||||
const monthStart = startOfWeek(startDate, localeCode);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{[...new Array(7).keys()].map(index => {
|
||||
const date = monthStart.add({days: index});
|
||||
const dateDay = date.toDate(timezone);
|
||||
const weekday = dayFormatter.format(dateDay);
|
||||
return (
|
||||
<div
|
||||
className="w-40 h-40 text-sm font-semibold relative flex-shrink-0"
|
||||
key={index}
|
||||
>
|
||||
<div className="absolute flex items-center justify-center w-full h-full select-none">
|
||||
{weekday}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
common/resources/client/ui/forms/input-field/date/calendar/calendar.tsx
Executable file
39
common/resources/client/ui/forms/input-field/date/calendar/calendar.tsx
Executable file
@@ -0,0 +1,39 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {startOfMonth, toCalendarDate} from '@internationalized/date';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {CalendarMonth} from './calendar-month';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
|
||||
interface CalendarProps {
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
visibleMonths?: 1 | 2;
|
||||
}
|
||||
export function Calendar({state, visibleMonths = 1}: CalendarProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
|
||||
if (isMobile) {
|
||||
visibleMonths = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{[...new Array(visibleMonths).keys()].map(index => {
|
||||
const startDate = toCalendarDate(
|
||||
startOfMonth(state.calendarDates[index])
|
||||
);
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === visibleMonths - 1;
|
||||
return (
|
||||
<CalendarMonth
|
||||
key={index}
|
||||
state={state}
|
||||
startDate={startDate}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
181
common/resources/client/ui/forms/input-field/date/date-picker/date-picker.tsx
Executable file
181
common/resources/client/ui/forms/input-field/date/date-picker/date-picker.tsx
Executable file
@@ -0,0 +1,181 @@
|
||||
import React, {
|
||||
ComponentPropsWithoutRef,
|
||||
Fragment,
|
||||
MouseEvent,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {parseAbsoluteToLocal, ZonedDateTime} from '@internationalized/date';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {
|
||||
DatePickerValueProps,
|
||||
useDatePickerState,
|
||||
} from './use-date-picker-state';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {DateRangeIcon} from '@common/icons/material/DateRange';
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {Calendar} from '../calendar/calendar';
|
||||
import {
|
||||
DatePickerField,
|
||||
DatePickerFieldProps,
|
||||
} from '../date-range-picker/date-picker-field';
|
||||
import {DateSegmentList} from '../segments/date-segment-list';
|
||||
import {useDateFormatter} from '@common/i18n/use-date-formatter';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import clsx from 'clsx';
|
||||
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
|
||||
|
||||
export interface DatePickerProps
|
||||
extends Omit<DatePickerFieldProps, 'children'>,
|
||||
DatePickerValueProps<ZonedDateTime> {}
|
||||
export function DatePicker({showCalendarFooter, ...props}: DatePickerProps) {
|
||||
const state = useDatePickerState(props);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const now = useCurrentDateTime();
|
||||
|
||||
const footer = showCalendarFooter && (
|
||||
<DialogFooter
|
||||
padding="px-14 pb-14"
|
||||
startAction={
|
||||
<Button
|
||||
disabled={state.isPlaceholder}
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
state.clear();
|
||||
}}
|
||||
>
|
||||
<Trans message="Clear" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
state.setSelectedValue(now);
|
||||
state.setCalendarIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trans message="Today" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
const dialog = (
|
||||
<DialogTrigger
|
||||
offset={8}
|
||||
placement="bottom-start"
|
||||
isOpen={state.calendarIsOpen}
|
||||
onOpenChange={state.setCalendarIsOpen}
|
||||
type="popover"
|
||||
triggerRef={inputRef}
|
||||
returnFocusToTrigger={false}
|
||||
moveFocusToDialog={false}
|
||||
>
|
||||
<Dialog size="auto">
|
||||
<DialogBody
|
||||
className="flex items-start gap-40"
|
||||
padding={showCalendarFooter ? 'px-24 pt-20 pb-10' : null}
|
||||
>
|
||||
<Calendar state={state} visibleMonths={1} />
|
||||
</DialogBody>
|
||||
{footer}
|
||||
</Dialog>
|
||||
</DialogTrigger>
|
||||
);
|
||||
|
||||
const openOnClick: ComponentPropsWithoutRef<'div'> = {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isHourSegment(e)) {
|
||||
state.setCalendarIsOpen(true);
|
||||
} else {
|
||||
state.setCalendarIsOpen(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DatePickerField
|
||||
ref={inputRef}
|
||||
wrapperProps={openOnClick}
|
||||
endAdornment={
|
||||
<DateRangeIcon className={clsx(props.disabled && 'text-disabled')} />
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<DateSegmentList
|
||||
segmentProps={openOnClick}
|
||||
state={state}
|
||||
value={state.selectedValue}
|
||||
onChange={state.setSelectedValue}
|
||||
isPlaceholder={state.isPlaceholder}
|
||||
/>
|
||||
</DatePickerField>
|
||||
{dialog}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormDatePickerProps extends DatePickerProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormDatePicker(props: FormDatePickerProps) {
|
||||
const {min, max} = props;
|
||||
const {trans} = useTrans();
|
||||
const {format} = useDateFormatter();
|
||||
const {
|
||||
field: {onChange, onBlur, value = null, ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
rules: {
|
||||
validate: v => {
|
||||
if (!v) return;
|
||||
const date = parseAbsoluteToLocal(v);
|
||||
if (min && date.compare(min) < 0) {
|
||||
return trans({
|
||||
message: 'Enter a date after :date',
|
||||
values: {date: format(v)},
|
||||
});
|
||||
}
|
||||
if (max && date.compare(max) > 0) {
|
||||
return trans({
|
||||
message: 'Enter a date before :date',
|
||||
values: {date: format(v)},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parsedValue: null | ZonedDateTime = value
|
||||
? parseAbsoluteToLocal(value)
|
||||
: null;
|
||||
|
||||
const formProps: Partial<DatePickerProps> = {
|
||||
onChange: e => {
|
||||
onChange(e ? e.toAbsoluteString() : e);
|
||||
},
|
||||
onBlur,
|
||||
value: parsedValue,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
inputRef: ref,
|
||||
};
|
||||
|
||||
return <DatePicker {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
|
||||
function isHourSegment(e: MouseEvent<HTMLDivElement>): boolean {
|
||||
return ['hour', 'minute', 'dayPeriod'].includes(
|
||||
(e.currentTarget as HTMLElement).ariaLabel || ''
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {HTMLAttributes, useCallback, useState} from 'react';
|
||||
import {
|
||||
CalendarDate,
|
||||
DateValue,
|
||||
isSameDay,
|
||||
toCalendarDate,
|
||||
toZoned,
|
||||
ZonedDateTime,
|
||||
} from '@internationalized/date';
|
||||
import {useBaseDatePickerState} from '../use-base-date-picker-state';
|
||||
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
|
||||
|
||||
export type Granularity = 'day' | 'minute';
|
||||
|
||||
export type DatePickerState = BaseDatePickerState;
|
||||
|
||||
export interface BaseDatePickerState<T = ZonedDateTime, P = boolean> {
|
||||
timezone: string;
|
||||
granularity: Granularity;
|
||||
selectedValue: T;
|
||||
setSelectedValue: (value: T) => void;
|
||||
calendarIsOpen: boolean;
|
||||
setCalendarIsOpen: (isOpen: boolean) => void;
|
||||
calendarDates: CalendarDate[];
|
||||
setCalendarDates: (dates: CalendarDate[]) => void;
|
||||
dayIsActive: (day: CalendarDate) => boolean;
|
||||
dayIsHighlighted: (day: CalendarDate) => boolean;
|
||||
dayIsRangeStart: (day: CalendarDate) => boolean;
|
||||
dayIsRangeEnd: (day: CalendarDate) => boolean;
|
||||
isPlaceholder: P;
|
||||
setIsPlaceholder: (value: P) => void;
|
||||
clear: () => void;
|
||||
min?: ZonedDateTime;
|
||||
max?: ZonedDateTime;
|
||||
closeDialogOnSelection: boolean;
|
||||
getCellProps: (
|
||||
date: CalendarDate,
|
||||
isSameMonth: boolean,
|
||||
) => HTMLAttributes<HTMLElement>;
|
||||
}
|
||||
|
||||
export interface DatePickerValueProps<V, CV = V> {
|
||||
value?: V | null | '';
|
||||
defaultValue?: V | null;
|
||||
onChange?: (value: CV | null) => void;
|
||||
min?: DateValue;
|
||||
max?: DateValue;
|
||||
granularity?: Granularity;
|
||||
closeDialogOnSelection?: boolean;
|
||||
}
|
||||
export function useDatePickerState(
|
||||
props: DatePickerValueProps<ZonedDateTime>,
|
||||
): BaseDatePickerState {
|
||||
const now = useCurrentDateTime();
|
||||
const [isPlaceholder, setIsPlaceholder] = useState(
|
||||
!props.value && !props.defaultValue,
|
||||
);
|
||||
|
||||
// if user clears the date, we will want to still keep an
|
||||
// instance internally, but return null via "onChange" callback
|
||||
const setStateValue = props.onChange;
|
||||
const [internalValue, setInternalValue] = useControlledState(
|
||||
props.value || now,
|
||||
props.defaultValue || now,
|
||||
value => {
|
||||
setIsPlaceholder(false);
|
||||
setStateValue?.(value);
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
granularity,
|
||||
timezone,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
closeDialogOnSelection,
|
||||
} = useBaseDatePickerState(internalValue, props);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setIsPlaceholder(true);
|
||||
setInternalValue(now);
|
||||
setStateValue?.(null);
|
||||
setCalendarIsOpen(false);
|
||||
}, [now, setInternalValue, setStateValue, setCalendarIsOpen]);
|
||||
|
||||
const [calendarDates, setCalendarDates] = useState<CalendarDate[]>(() => {
|
||||
return [toCalendarDate(internalValue)];
|
||||
});
|
||||
|
||||
const setSelectedValue = useCallback(
|
||||
(newValue: DateValue) => {
|
||||
if (min && newValue.compare(min) < 0) {
|
||||
newValue = min;
|
||||
} else if (max && newValue.compare(max) > 0) {
|
||||
newValue = max;
|
||||
}
|
||||
|
||||
// preserve time
|
||||
const value = internalValue
|
||||
? internalValue.set(newValue)
|
||||
: toZoned(newValue, timezone);
|
||||
setInternalValue(value);
|
||||
setCalendarDates([toCalendarDate(value)]);
|
||||
setIsPlaceholder(false);
|
||||
},
|
||||
[setInternalValue, min, max, internalValue, timezone],
|
||||
);
|
||||
|
||||
const dayIsActive = useCallback(
|
||||
(day: DateValue) => !isPlaceholder && isSameDay(internalValue, day),
|
||||
[internalValue, isPlaceholder],
|
||||
);
|
||||
|
||||
const getCellProps = useCallback(
|
||||
(date: DateValue): HTMLAttributes<HTMLElement> => {
|
||||
return {
|
||||
onClick: () => {
|
||||
setSelectedValue?.(date);
|
||||
if (closeDialogOnSelection) {
|
||||
setCalendarIsOpen?.(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
[setSelectedValue, setCalendarIsOpen, closeDialogOnSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedValue: internalValue,
|
||||
setSelectedValue: setInternalValue,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
dayIsActive,
|
||||
dayIsHighlighted: () => false,
|
||||
dayIsRangeStart: () => false,
|
||||
dayIsRangeEnd: () => false,
|
||||
getCellProps,
|
||||
calendarDates,
|
||||
setCalendarDates,
|
||||
isPlaceholder,
|
||||
clear,
|
||||
setIsPlaceholder,
|
||||
min,
|
||||
max,
|
||||
granularity,
|
||||
timezone,
|
||||
closeDialogOnSelection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, {ComponentPropsWithoutRef, FocusEventHandler, Ref} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {createFocusManager} from '@react-aria/focus';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {getInputFieldClassNames} from '../../get-input-field-class-names';
|
||||
import {Field, FieldProps} from '../../field';
|
||||
import {Input} from '../../input';
|
||||
import {useField} from '../../use-field';
|
||||
|
||||
export interface DatePickerFieldProps
|
||||
extends Omit<FieldProps, 'fieldClassNames'> {
|
||||
inputRef?: Ref<HTMLDivElement>;
|
||||
onBlur?: FocusEventHandler;
|
||||
showCalendarFooter?: boolean;
|
||||
}
|
||||
export const DatePickerField = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DatePickerFieldProps
|
||||
>(({inputRef, wrapperProps, children, onBlur, ...other}, ref) => {
|
||||
const fieldClassNames = getInputFieldClassNames(other);
|
||||
const objRef = useObjectRef(ref);
|
||||
|
||||
const {fieldProps, inputProps} = useField({
|
||||
...other,
|
||||
focusRef: objRef,
|
||||
labelElementType: 'span',
|
||||
});
|
||||
|
||||
fieldClassNames.wrapper = clsx(
|
||||
fieldClassNames.wrapper,
|
||||
other.disabled && 'pointer-events-none',
|
||||
);
|
||||
|
||||
return (
|
||||
<Field
|
||||
wrapperProps={mergeProps<ComponentPropsWithoutRef<'div'>[]>(
|
||||
wrapperProps!,
|
||||
{
|
||||
onBlur: e => {
|
||||
if (!objRef.current.contains(e.relatedTarget)) {
|
||||
onBlur?.(e);
|
||||
}
|
||||
},
|
||||
onClick: () => {
|
||||
// focus first segment when clicking on label or somewhere else in the field, but no directly on segment
|
||||
const focusManager = createFocusManager(objRef);
|
||||
focusManager?.focusFirst();
|
||||
},
|
||||
},
|
||||
)}
|
||||
fieldClassNames={fieldClassNames}
|
||||
ref={objRef}
|
||||
{...fieldProps}
|
||||
>
|
||||
<Input
|
||||
inputProps={inputProps}
|
||||
className={clsx(fieldClassNames.input, 'gap-10')}
|
||||
ref={inputRef}
|
||||
>
|
||||
{children}
|
||||
</Input>
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, {
|
||||
ComponentPropsWithoutRef,
|
||||
Fragment,
|
||||
MouseEvent,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {DateRangeIcon} from '../../../../../icons/material/DateRange';
|
||||
import {DialogTrigger} from '../../../../overlays/dialog/dialog-trigger';
|
||||
import {DatePickerField, DatePickerFieldProps} from './date-picker-field';
|
||||
import {useDateRangePickerState} from './use-date-range-picker-state';
|
||||
import {ArrowRightAltIcon} from '../../../../../icons/material/ArrowRightAlt';
|
||||
import {DatePickerValueProps} from '../date-picker/use-date-picker-state';
|
||||
import {DateRangeValue} from './date-range-value';
|
||||
import {DateSegmentList} from '../segments/date-segment-list';
|
||||
import {DateRangeDialog} from './dialog/date-range-dialog';
|
||||
import {useIsMobileMediaQuery} from '../../../../../utils/hooks/is-mobile-media-query';
|
||||
|
||||
export interface DateRangePickerProps
|
||||
extends DatePickerValueProps<Partial<DateRangeValue>>,
|
||||
Omit<DatePickerFieldProps, 'children'> {}
|
||||
export function DateRangePicker(props: DateRangePickerProps) {
|
||||
const {granularity, closeDialogOnSelection, ...fieldProps} = props;
|
||||
const state = useDateRangePickerState(props);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const hideCalendarIcon = isMobile && granularity !== 'day';
|
||||
|
||||
const dialog = (
|
||||
<DialogTrigger
|
||||
offset={8}
|
||||
placement="bottom-start"
|
||||
isOpen={state.calendarIsOpen}
|
||||
onOpenChange={state.setCalendarIsOpen}
|
||||
type="popover"
|
||||
triggerRef={inputRef}
|
||||
returnFocusToTrigger={false}
|
||||
moveFocusToDialog={false}
|
||||
>
|
||||
<DateRangeDialog state={state} />
|
||||
</DialogTrigger>
|
||||
);
|
||||
|
||||
const openOnClick: ComponentPropsWithoutRef<'div'> = {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isHourSegment(e)) {
|
||||
state.setCalendarIsOpen(true);
|
||||
} else {
|
||||
state.setCalendarIsOpen(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const value = state.selectedValue;
|
||||
const onChange = state.setSelectedValue;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<DatePickerField
|
||||
ref={inputRef}
|
||||
wrapperProps={openOnClick}
|
||||
endAdornment={!hideCalendarIcon ? <DateRangeIcon /> : undefined}
|
||||
{...fieldProps}
|
||||
>
|
||||
<DateSegmentList
|
||||
isPlaceholder={state.isPlaceholder?.start}
|
||||
state={state}
|
||||
segmentProps={openOnClick}
|
||||
value={value.start}
|
||||
onChange={newValue => {
|
||||
onChange({start: newValue, end: value.end});
|
||||
}}
|
||||
/>
|
||||
<ArrowRightAltIcon
|
||||
className="block flex-shrink-0 text-muted"
|
||||
size="md"
|
||||
/>
|
||||
<DateSegmentList
|
||||
isPlaceholder={state.isPlaceholder?.end}
|
||||
state={state}
|
||||
segmentProps={openOnClick}
|
||||
value={value.end}
|
||||
onChange={newValue => {
|
||||
onChange({start: value.start, end: newValue});
|
||||
}}
|
||||
/>
|
||||
</DatePickerField>
|
||||
{dialog}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function isHourSegment(e: MouseEvent<HTMLDivElement>): boolean {
|
||||
return ['hour', 'minute', 'dayPeriod'].includes(
|
||||
(e.currentTarget as HTMLElement).ariaLabel || ''
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {ZonedDateTime} from '@internationalized/date';
|
||||
|
||||
export type DateRangeValue = {
|
||||
start: ZonedDateTime;
|
||||
end: ZonedDateTime;
|
||||
preset?: number;
|
||||
compareStart?: ZonedDateTime;
|
||||
compareEnd?: ZonedDateTime;
|
||||
comparePreset?: number;
|
||||
};
|
||||
|
||||
export function dateRangeValueToPayload(value: {
|
||||
dateRange?: DateRangeValue;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const payload = {
|
||||
...value,
|
||||
};
|
||||
if (payload.dateRange) {
|
||||
payload.startDate = payload.dateRange.start.toAbsoluteString();
|
||||
payload.endDate = payload.dateRange.end.toAbsoluteString();
|
||||
payload.compareStartDate =
|
||||
payload.dateRange.compareStart?.toAbsoluteString();
|
||||
payload.compareEndDate = payload.dateRange.compareEnd?.toAbsoluteString();
|
||||
payload.timezone = payload.dateRange.start.timeZone;
|
||||
delete payload.dateRange;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {List, ListItem} from '@common/ui/list/list';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {DateRangeComparePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-presets';
|
||||
|
||||
interface DateRangePresetList {
|
||||
originalRangeValue: DateRangeValue;
|
||||
onPresetSelected: (value: DateRangeValue) => void;
|
||||
selectedValue?: DateRangeValue | null;
|
||||
}
|
||||
export function DateRangeComparePresetList({
|
||||
originalRangeValue,
|
||||
onPresetSelected,
|
||||
selectedValue,
|
||||
}: DateRangePresetList) {
|
||||
return (
|
||||
<List>
|
||||
{DateRangeComparePresets.map(preset => (
|
||||
<ListItem
|
||||
borderRadius="rounded-none"
|
||||
capitalizeFirst
|
||||
key={preset.key}
|
||||
isSelected={selectedValue?.preset === preset.key}
|
||||
onSelected={() => {
|
||||
const newValue = preset.getRangeValue(originalRangeValue);
|
||||
onPresetSelected(newValue);
|
||||
}}
|
||||
>
|
||||
<Trans {...preset.label} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {message} from '@common/i18n/message';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
|
||||
export interface DateRangeComparePreset {
|
||||
key: number;
|
||||
label: MessageDescriptor;
|
||||
getRangeValue: (range: DateRangeValue) => DateRangeValue;
|
||||
}
|
||||
|
||||
export const DateRangeComparePresets: DateRangeComparePreset[] = [
|
||||
{
|
||||
key: 0,
|
||||
label: message('Preceding period'),
|
||||
getRangeValue: (range: DateRangeValue) => {
|
||||
const startDate = range.start;
|
||||
const endDate = range.end;
|
||||
|
||||
const diffInMilliseconds =
|
||||
endDate.toDate().getTime() - startDate.toDate().getTime();
|
||||
const diffInMinutes = diffInMilliseconds / (1000 * 60);
|
||||
const newStart = startDate.subtract({minutes: diffInMinutes});
|
||||
return {
|
||||
preset: 0,
|
||||
start: newStart,
|
||||
end: startDate,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
label: message('Same period last year'),
|
||||
getRangeValue: (range: DateRangeValue) => {
|
||||
return {
|
||||
start: range.start.subtract({years: 1}),
|
||||
end: range.end.subtract({years: 1}),
|
||||
preset: 1,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
label: message('Custom'),
|
||||
getRangeValue: (range: DateRangeValue) => {
|
||||
return {
|
||||
start: range.start.subtract({weeks: 1}),
|
||||
end: range.end.subtract({weeks: 1}),
|
||||
preset: 2,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, {Fragment, ReactNode, useRef, useState} from 'react';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {DatePickerField} from '../date-picker-field';
|
||||
import {DateRangePickerState} from '../use-date-range-picker-state';
|
||||
import {Calendar} from '../../calendar/calendar';
|
||||
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {ArrowRightAltIcon} from '@common/icons/material/ArrowRightAlt';
|
||||
import {DateSegmentList} from '../../segments/date-segment-list';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormattedDateTimeRange} from '@common/i18n/formatted-date-time-range';
|
||||
import {DatePresetList} from './date-range-preset-list';
|
||||
import {useIsTabletMediaQuery} from '@common/utils/hooks/is-tablet-media-query';
|
||||
import {Switch} from '@common/ui/forms/toggle/switch';
|
||||
import {DateRangeComparePresetList} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-preset-list';
|
||||
|
||||
interface DateRangeDialogProps {
|
||||
state: DateRangePickerState;
|
||||
compareState?: DateRangePickerState;
|
||||
compareVisibleDefault?: boolean;
|
||||
showInlineDatePickerField?: boolean;
|
||||
}
|
||||
export function DateRangeDialog({
|
||||
state,
|
||||
compareState,
|
||||
showInlineDatePickerField = false,
|
||||
compareVisibleDefault = false,
|
||||
}: DateRangeDialogProps) {
|
||||
const isTablet = useIsTabletMediaQuery();
|
||||
const {close} = useDialogContext();
|
||||
const initialStateRef = useRef<DateRangePickerState>(state);
|
||||
const hasPlaceholder = state.isPlaceholder.start || state.isPlaceholder.end;
|
||||
const [compareVisible, setCompareVisible] = useState(compareVisibleDefault);
|
||||
|
||||
const footer = (
|
||||
<DialogFooter
|
||||
dividerTop
|
||||
startAction={
|
||||
!hasPlaceholder && !isTablet ? (
|
||||
<div className="text-xs">
|
||||
<FormattedDateTimeRange
|
||||
start={state.selectedValue.start.toDate()}
|
||||
end={state.selectedValue.end.toDate()}
|
||||
options={{dateStyle: 'medium'}}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
state.setSelectedValue(initialStateRef.current.selectedValue);
|
||||
state.setIsPlaceholder(initialStateRef.current.isPlaceholder);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const value = state.selectedValue;
|
||||
if (compareState && compareVisible) {
|
||||
value.compareStart = compareState.selectedValue.start;
|
||||
value.compareEnd = compareState.selectedValue.end;
|
||||
}
|
||||
close(value);
|
||||
}}
|
||||
>
|
||||
<Trans message="Select" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog size="auto">
|
||||
<DialogBody className="flex" padding="p-0">
|
||||
{!isTablet && (
|
||||
<div className="min-w-192 py-14">
|
||||
<DatePresetList
|
||||
selectedValue={state.selectedValue}
|
||||
onPresetSelected={preset => {
|
||||
state.setSelectedValue(preset);
|
||||
if (state.closeDialogOnSelection) {
|
||||
close(preset);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!!compareState && (
|
||||
<Fragment>
|
||||
<Switch
|
||||
className="mx-20 mb-10 mt-14"
|
||||
checked={compareVisible}
|
||||
onChange={e => setCompareVisible(e.target.checked)}
|
||||
>
|
||||
<Trans message="Compare" />
|
||||
</Switch>
|
||||
{compareVisible && (
|
||||
<DateRangeComparePresetList
|
||||
originalRangeValue={state.selectedValue}
|
||||
selectedValue={compareState.selectedValue}
|
||||
onPresetSelected={preset => {
|
||||
compareState.setSelectedValue(preset);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence initial={false}>
|
||||
<Calendars
|
||||
state={state}
|
||||
compareState={compareState}
|
||||
showInlineDatePickerField={showInlineDatePickerField}
|
||||
compareVisible={compareVisible}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</DialogBody>
|
||||
{!state.closeDialogOnSelection && footer}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface CustomRangePanelProps {
|
||||
state: DateRangePickerState;
|
||||
compareState?: DateRangePickerState;
|
||||
showInlineDatePickerField?: boolean;
|
||||
compareVisible: boolean;
|
||||
}
|
||||
function Calendars({
|
||||
state,
|
||||
compareState,
|
||||
showInlineDatePickerField,
|
||||
compareVisible,
|
||||
}: CustomRangePanelProps) {
|
||||
return (
|
||||
<m.div
|
||||
initial={{width: 0, overflow: 'hidden'}}
|
||||
animate={{width: 'auto'}}
|
||||
exit={{width: 0, overflow: 'hidden'}}
|
||||
transition={{type: 'tween', duration: 0.125}}
|
||||
className="border-l px-20 pb-20 pt-10"
|
||||
>
|
||||
{showInlineDatePickerField && (
|
||||
<div>
|
||||
<InlineDatePickerField state={state} />
|
||||
{!!compareState && compareVisible && (
|
||||
<InlineDatePickerField
|
||||
state={compareState}
|
||||
label={<Trans message="Compare" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-36">
|
||||
<Calendar state={state} visibleMonths={2} />
|
||||
</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InlineDatePickerFieldProps {
|
||||
state: DateRangePickerState;
|
||||
label?: ReactNode;
|
||||
}
|
||||
function InlineDatePickerField({state, label}: InlineDatePickerFieldProps) {
|
||||
const {selectedValue, setSelectedValue} = state;
|
||||
return (
|
||||
<DatePickerField className="mb-20 mt-10" label={label}>
|
||||
<DateSegmentList
|
||||
state={state}
|
||||
value={selectedValue.start}
|
||||
onChange={newValue => {
|
||||
setSelectedValue({...selectedValue, start: newValue});
|
||||
}}
|
||||
/>
|
||||
<ArrowRightAltIcon className="block flex-shrink-0 text-muted" size="md" />
|
||||
<DateSegmentList
|
||||
state={state}
|
||||
value={selectedValue.end}
|
||||
onChange={newValue => {
|
||||
setSelectedValue({...selectedValue, end: newValue});
|
||||
}}
|
||||
/>
|
||||
</DatePickerField>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {List, ListItem} from '@common/ui/list/list';
|
||||
import {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
|
||||
interface DateRangePresetList {
|
||||
onPresetSelected: (value: DateRangeValue) => void;
|
||||
selectedValue?: DateRangeValue | null;
|
||||
}
|
||||
export function DatePresetList({
|
||||
onPresetSelected,
|
||||
selectedValue,
|
||||
}: DateRangePresetList) {
|
||||
return (
|
||||
<List>
|
||||
{DateRangePresets.map(preset => (
|
||||
<ListItem
|
||||
borderRadius="rounded-none"
|
||||
capitalizeFirst
|
||||
key={preset.key}
|
||||
isSelected={selectedValue?.preset === preset.key}
|
||||
onSelected={() => {
|
||||
const newValue = preset.getRangeValue();
|
||||
onPresetSelected(newValue);
|
||||
}}
|
||||
>
|
||||
<Trans {...preset.label} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {DateRangeValue} from '../date-range-value';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
import {
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
endOfYear,
|
||||
now,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
} from '@internationalized/date';
|
||||
import {startOfDay} from '@common/utils/date/start-of-day';
|
||||
import {endOfDay} from '@common/utils/date/end-of-day';
|
||||
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
|
||||
import {getUserTimezone} from '@common/i18n/get-user-timezone';
|
||||
|
||||
const Now = startOfDay(now(getUserTimezone()));
|
||||
const locale = getBootstrapData()?.i18n?.language || 'en';
|
||||
|
||||
export interface DateRangePreset {
|
||||
key: number;
|
||||
label: MessageDescriptor;
|
||||
getRangeValue: () => DateRangeValue;
|
||||
}
|
||||
|
||||
export const DateRangePresets: DateRangePreset[] = [
|
||||
{
|
||||
key: 0,
|
||||
label: message('Today'),
|
||||
getRangeValue: () => ({
|
||||
preset: 0,
|
||||
start: Now,
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
label: message('Yesterday'),
|
||||
getRangeValue: () => ({
|
||||
preset: 1,
|
||||
start: Now.subtract({days: 1}),
|
||||
end: endOfDay(Now).subtract({days: 1}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
label: message('This week'),
|
||||
getRangeValue: () => ({
|
||||
preset: 2,
|
||||
start: startOfWeek(Now, locale),
|
||||
end: endOfWeek(endOfDay(Now), locale),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
label: message('Last week'),
|
||||
getRangeValue: () => {
|
||||
const start = startOfWeek(Now, locale).subtract({days: 7});
|
||||
return {
|
||||
preset: 3,
|
||||
start,
|
||||
end: start.add({days: 6}),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 4,
|
||||
label: message('Last 7 days'),
|
||||
getRangeValue: () => ({
|
||||
preset: 4,
|
||||
start: Now.subtract({days: 7}),
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 6,
|
||||
label: message('Last 30 days'),
|
||||
getRangeValue: () => ({
|
||||
preset: 6,
|
||||
start: Now.subtract({days: 30}),
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 7,
|
||||
label: message('Last 3 months'),
|
||||
getRangeValue: () => ({
|
||||
preset: 7,
|
||||
start: Now.subtract({months: 3}),
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 8,
|
||||
label: message('Last 12 months'),
|
||||
getRangeValue: () => ({
|
||||
preset: 8,
|
||||
start: Now.subtract({months: 12}),
|
||||
end: endOfDay(Now),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 9,
|
||||
label: message('This month'),
|
||||
getRangeValue: () => ({
|
||||
preset: 9,
|
||||
start: startOfMonth(Now),
|
||||
end: endOfMonth(endOfDay(Now)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 10,
|
||||
label: message('This year'),
|
||||
getRangeValue: () => ({
|
||||
preset: 10,
|
||||
start: startOfYear(Now),
|
||||
end: endOfYear(endOfDay(Now)),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 11,
|
||||
label: message('Last year'),
|
||||
getRangeValue: () => ({
|
||||
preset: 11,
|
||||
start: startOfYear(Now).subtract({years: 1}),
|
||||
end: endOfYear(endOfDay(Now)).subtract({years: 1}),
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,77 @@
|
||||
import {parseAbsoluteToLocal, ZonedDateTime} from '@internationalized/date';
|
||||
import {DateRangeValue} from './date-range-value';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import React from 'react';
|
||||
import {DateRangePicker, DateRangePickerProps} from './date-range-picker';
|
||||
|
||||
export interface AbsoluteDateRange {
|
||||
start?: string;
|
||||
end?: string;
|
||||
preset?: number;
|
||||
}
|
||||
|
||||
interface FormDateRange {
|
||||
start?: string | ZonedDateTime;
|
||||
end?: string | ZonedDateTime;
|
||||
preset?: number;
|
||||
}
|
||||
|
||||
export interface FormDateRangePickerProps extends DateRangePickerProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormDateRangePicker(props: FormDateRangePickerProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, value, ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<DateRangePickerProps> = {
|
||||
onChange: e => {
|
||||
onChange(e ? dateRangeToAbsoluteRange(e) : null);
|
||||
},
|
||||
onBlur,
|
||||
value: absoluteRangeToDateRange(value),
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
inputRef: ref,
|
||||
};
|
||||
|
||||
return <DateRangePicker {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
|
||||
export function absoluteRangeToDateRange(props: FormDateRange | null) {
|
||||
const {start, end, preset} = props || {};
|
||||
const dateRange: Partial<DateRangeValue> = {preset};
|
||||
try {
|
||||
if (start) {
|
||||
dateRange.start =
|
||||
typeof start === 'string' ? parseAbsoluteToLocal(start) : start;
|
||||
}
|
||||
if (end) {
|
||||
dateRange.end = typeof end === 'string' ? parseAbsoluteToLocal(end) : end;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return dateRange;
|
||||
}
|
||||
|
||||
export function dateRangeToAbsoluteRange({
|
||||
start,
|
||||
end,
|
||||
preset,
|
||||
}: Partial<DateRangeValue> = {}): AbsoluteDateRange {
|
||||
const absoluteRange: AbsoluteDateRange = {
|
||||
preset,
|
||||
};
|
||||
if (start) {
|
||||
absoluteRange.start = start.toAbsoluteString();
|
||||
}
|
||||
if (end) {
|
||||
absoluteRange.end = end.toAbsoluteString();
|
||||
}
|
||||
return absoluteRange;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {HTMLAttributes, useCallback, useState} from 'react';
|
||||
import {
|
||||
CalendarDate,
|
||||
DateValue,
|
||||
endOfMonth,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
maxDate,
|
||||
minDate,
|
||||
startOfMonth,
|
||||
toCalendarDate,
|
||||
toZoned,
|
||||
ZonedDateTime,
|
||||
} from '@internationalized/date';
|
||||
import {
|
||||
BaseDatePickerState,
|
||||
DatePickerValueProps,
|
||||
} from '../date-picker/use-date-picker-state';
|
||||
import {DateRangeValue} from './date-range-value';
|
||||
import {useBaseDatePickerState} from '../use-base-date-picker-state';
|
||||
import {startOfDay} from '@common/utils/date/start-of-day';
|
||||
import {endOfDay} from '@common/utils/date/end-of-day';
|
||||
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
|
||||
|
||||
export interface IsPlaceholderValue {
|
||||
start: boolean;
|
||||
end: boolean;
|
||||
}
|
||||
|
||||
export type DateRangePickerState = BaseDatePickerState<
|
||||
DateRangeValue,
|
||||
IsPlaceholderValue
|
||||
>;
|
||||
|
||||
export function useDateRangePickerState(
|
||||
props: DatePickerValueProps<Partial<DateRangeValue>, DateRangeValue>,
|
||||
): DateRangePickerState {
|
||||
const now = useCurrentDateTime();
|
||||
const [isPlaceholder, setIsPlaceholder] = useState<IsPlaceholderValue>({
|
||||
start: (!props.value || !props.value.start) && !props.defaultValue?.start,
|
||||
end: (!props.value || !props.value.end) && !props.defaultValue?.end,
|
||||
});
|
||||
|
||||
// if user clears the date, we will want to still keep an
|
||||
// instance internally, but return null via "onChange" callback
|
||||
const setStateValue = props.onChange;
|
||||
const [internalValue, setInternalValue] = useControlledState(
|
||||
props.value ? completeRange(props.value, now) : undefined,
|
||||
!props.value ? completeRange(props.defaultValue, now) : undefined,
|
||||
value => {
|
||||
setIsPlaceholder({start: false, end: false});
|
||||
setStateValue?.(value);
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
granularity,
|
||||
timezone,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
closeDialogOnSelection,
|
||||
} = useBaseDatePickerState(internalValue.start, props);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setIsPlaceholder({start: true, end: true});
|
||||
setInternalValue(completeRange(null, now));
|
||||
setStateValue?.(null);
|
||||
setCalendarIsOpen(false);
|
||||
}, [now, setInternalValue, setStateValue, setCalendarIsOpen]);
|
||||
|
||||
const [anchorDate, setAnchorDate] = useState<CalendarDate | null>(null);
|
||||
const [isHighlighting, setIsHighlighting] = useState(false);
|
||||
const [highlightedRange, setHighlightedRange] =
|
||||
useState<DateRangeValue>(internalValue);
|
||||
const [calendarDates, setCalendarDates] = useState<CalendarDate[]>(() => {
|
||||
return rangeToCalendarDates(internalValue, max);
|
||||
});
|
||||
|
||||
const constrainRange = useCallback(
|
||||
(range: DateRangeValue): DateRangeValue => {
|
||||
let start = range.start;
|
||||
let end = range.end;
|
||||
|
||||
// make sure start date is after min date and before max date/range end
|
||||
if (min) {
|
||||
start = maxDate(start, min);
|
||||
}
|
||||
const startMax = max ? minDate(max, end) : end;
|
||||
start = minDate(start, startMax);
|
||||
|
||||
// make sure end date is after min date/range start and before max date
|
||||
const endMin = min ? maxDate(min, start) : start;
|
||||
end = maxDate(end, endMin);
|
||||
|
||||
if (max) {
|
||||
end = minDate(end, max);
|
||||
}
|
||||
|
||||
return {start: toZoned(start, timezone), end: toZoned(end, timezone)};
|
||||
},
|
||||
[min, max, timezone],
|
||||
);
|
||||
|
||||
const setSelectedValue = useCallback(
|
||||
(newRange: DateRangeValue) => {
|
||||
const value = {
|
||||
...constrainRange(newRange),
|
||||
preset: newRange.preset,
|
||||
};
|
||||
setInternalValue(value);
|
||||
setHighlightedRange(value);
|
||||
setCalendarDates(rangeToCalendarDates(value, max));
|
||||
setIsPlaceholder({start: false, end: false});
|
||||
},
|
||||
[setInternalValue, constrainRange, max],
|
||||
);
|
||||
|
||||
const dayIsActive = useCallback(
|
||||
(day: CalendarDate) => {
|
||||
return (
|
||||
(!isPlaceholder.start && isSameDay(day, highlightedRange.start)) ||
|
||||
(!isPlaceholder.end && isSameDay(day, highlightedRange.end))
|
||||
);
|
||||
},
|
||||
[highlightedRange, isPlaceholder],
|
||||
);
|
||||
|
||||
const dayIsHighlighted = useCallback(
|
||||
(day: CalendarDate) => {
|
||||
return (
|
||||
(isHighlighting || (!isPlaceholder.start && !isPlaceholder.end)) &&
|
||||
day.compare(highlightedRange.start) >= 0 &&
|
||||
day.compare(highlightedRange.end) <= 0
|
||||
);
|
||||
},
|
||||
[highlightedRange, isPlaceholder, isHighlighting],
|
||||
);
|
||||
|
||||
const dayIsRangeStart = useCallback(
|
||||
(day: CalendarDate) => isSameDay(day, highlightedRange.start),
|
||||
[highlightedRange],
|
||||
);
|
||||
|
||||
const dayIsRangeEnd = useCallback(
|
||||
(day: CalendarDate) => isSameDay(day, highlightedRange.end),
|
||||
[highlightedRange],
|
||||
);
|
||||
|
||||
const getCellProps = useCallback(
|
||||
(date: CalendarDate, isSameMonth: boolean): HTMLAttributes<HTMLElement> => {
|
||||
return {
|
||||
onPointerEnter: () => {
|
||||
if (isHighlighting && isSameMonth) {
|
||||
setHighlightedRange(
|
||||
makeRange({start: anchorDate!, end: date, timezone}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onClick: () => {
|
||||
if (!isHighlighting) {
|
||||
setIsHighlighting(true);
|
||||
setAnchorDate(date);
|
||||
setHighlightedRange(makeRange({start: date, end: date, timezone}));
|
||||
} else {
|
||||
const finalRange = makeRange({
|
||||
start: anchorDate!,
|
||||
end: date,
|
||||
timezone,
|
||||
});
|
||||
// cast to start and end of day after making range, because "makeRange"
|
||||
// will flip start and end dates, if they are out of order
|
||||
finalRange.start = startOfDay(finalRange.start);
|
||||
finalRange.end = endOfDay(finalRange.end);
|
||||
setIsHighlighting(false);
|
||||
setAnchorDate(null);
|
||||
setSelectedValue?.(finalRange);
|
||||
if (closeDialogOnSelection) {
|
||||
setCalendarIsOpen?.(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
[
|
||||
anchorDate,
|
||||
isHighlighting,
|
||||
setSelectedValue,
|
||||
setCalendarIsOpen,
|
||||
closeDialogOnSelection,
|
||||
timezone,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedValue: internalValue,
|
||||
setSelectedValue,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
dayIsActive,
|
||||
dayIsHighlighted,
|
||||
dayIsRangeStart,
|
||||
dayIsRangeEnd,
|
||||
getCellProps,
|
||||
calendarDates,
|
||||
setIsPlaceholder,
|
||||
isPlaceholder,
|
||||
clear,
|
||||
setCalendarDates,
|
||||
min,
|
||||
max,
|
||||
granularity,
|
||||
timezone,
|
||||
closeDialogOnSelection,
|
||||
};
|
||||
}
|
||||
|
||||
function rangeToCalendarDates(
|
||||
range: DateRangeValue,
|
||||
max?: DateValue,
|
||||
): CalendarDate[] {
|
||||
let start = toCalendarDate(startOfMonth(range.start));
|
||||
let end = toCalendarDate(endOfMonth(range.end));
|
||||
|
||||
// make sure we don't show the same month twice
|
||||
if (isSameMonth(start, end)) {
|
||||
end = endOfMonth(end.add({months: 1}));
|
||||
}
|
||||
|
||||
// if next month is disabled, show previous instead
|
||||
if (max && end.compare(max) > 0) {
|
||||
end = start;
|
||||
start = startOfMonth(start.subtract({months: 1}));
|
||||
}
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
interface MakeRangeProps {
|
||||
start: DateValue;
|
||||
end: DateValue;
|
||||
timezone: string;
|
||||
}
|
||||
function makeRange(props: MakeRangeProps): DateRangeValue {
|
||||
const start = toZoned(props.start, props.timezone);
|
||||
const end = toZoned(props.end, props.timezone);
|
||||
if (start.compare(end) > 0) {
|
||||
return {start: end, end: start};
|
||||
}
|
||||
return {start, end};
|
||||
}
|
||||
|
||||
function completeRange(
|
||||
range: Partial<DateRangeValue> | null | undefined,
|
||||
now: ZonedDateTime,
|
||||
): DateRangeValue {
|
||||
if (range?.start && range?.end) {
|
||||
return range as DateRangeValue;
|
||||
} else if (!range?.start && range?.end) {
|
||||
range.start = range.end.subtract({months: 1});
|
||||
return range as DateRangeValue;
|
||||
} else if (!range?.end && range?.start) {
|
||||
range.end = range.start.add({months: 1});
|
||||
return range as DateRangeValue;
|
||||
}
|
||||
return {start: now, end: now.add({months: 1})};
|
||||
}
|
||||
1
common/resources/client/ui/forms/input-field/date/granularity.ts
Executable file
1
common/resources/client/ui/forms/input-field/date/granularity.ts
Executable file
@@ -0,0 +1 @@
|
||||
export type Granularity = 'day' | 'hour' | 'minute';
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, {ComponentPropsWithoutRef, useMemo} from 'react';
|
||||
import {ZonedDateTime} from '@internationalized/date';
|
||||
import {EditableDateSegment, EditableSegment} from './editable-date-segment';
|
||||
import {LiteralDateSegment, LiteralSegment} from './literal-segment';
|
||||
import {useDateFormatter} from '@common/i18n/use-date-formatter';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {getSegmentLimits} from './utils/get-segment-limits';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
|
||||
interface DateSegmentListProps {
|
||||
segmentProps?: ComponentPropsWithoutRef<'div'>;
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
value: ZonedDateTime;
|
||||
onChange: (newValue: ZonedDateTime) => void;
|
||||
isPlaceholder?: boolean;
|
||||
}
|
||||
export function DateSegmentList({
|
||||
segmentProps,
|
||||
state,
|
||||
value,
|
||||
onChange,
|
||||
isPlaceholder,
|
||||
}: DateSegmentListProps) {
|
||||
const {granularity} = state;
|
||||
const options = useMemo(() => {
|
||||
const memoOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
};
|
||||
if (granularity === 'minute') {
|
||||
memoOptions.hour = 'numeric';
|
||||
memoOptions.minute = 'numeric';
|
||||
}
|
||||
return memoOptions;
|
||||
}, [granularity]);
|
||||
|
||||
const formatter = useDateFormatter(options);
|
||||
|
||||
const dateValue = useMemo(() => value.toDate(), [value]);
|
||||
const segments = useMemo(() => {
|
||||
return formatter.formatToParts(dateValue).map(segment => {
|
||||
const limits = getSegmentLimits(
|
||||
value,
|
||||
segment.type,
|
||||
formatter.resolvedOptions(),
|
||||
);
|
||||
const textValue =
|
||||
isPlaceholder && segment.type !== 'literal'
|
||||
? limits.placeholder
|
||||
: segment.value;
|
||||
return {
|
||||
type: segment.type,
|
||||
text: segment.value === ', ' ? ' ' : textValue,
|
||||
...limits,
|
||||
minLength:
|
||||
segment.type !== 'literal' ? String(limits.maxValue).length : 1,
|
||||
} as LiteralSegment | EditableSegment;
|
||||
});
|
||||
}, [dateValue, formatter, isPlaceholder, value]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.type === 'literal') {
|
||||
return (
|
||||
<LiteralDateSegment
|
||||
domProps={segmentProps}
|
||||
key={index}
|
||||
segment={segment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EditableDateSegment
|
||||
isPlaceholder={isPlaceholder}
|
||||
domProps={segmentProps}
|
||||
state={state}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
segment={segment}
|
||||
key={index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import {useFocusManager} from '@react-aria/focus';
|
||||
import React, {
|
||||
ComponentPropsWithoutRef,
|
||||
HTMLAttributes,
|
||||
KeyboardEventHandler,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {NumberParser} from '@internationalized/number';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {today, ZonedDateTime} from '@internationalized/date';
|
||||
import {useSelectedLocale} from '@common/i18n/selected-locale';
|
||||
import {useDateFormatter} from '@common/i18n/use-date-formatter';
|
||||
import {DatePickerState} from '../date-picker/use-date-picker-state';
|
||||
import {adjustSegment} from './utils/adjust-segment';
|
||||
import {setSegment} from './utils/set-segment';
|
||||
import {PAGE_STEP} from './utils/page-step';
|
||||
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
|
||||
export interface EditableSegment {
|
||||
type: 'day' | 'dayPeriod' | 'hour' | 'minute' | 'month' | 'second' | 'year';
|
||||
text: string;
|
||||
value: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
minLength: number;
|
||||
}
|
||||
|
||||
interface DatePickerSegmentProps {
|
||||
segment: EditableSegment;
|
||||
domProps?: ComponentPropsWithoutRef<'div'>;
|
||||
state: DatePickerState | DateRangePickerState;
|
||||
value: ZonedDateTime;
|
||||
onChange: (newValue: ZonedDateTime) => void;
|
||||
isPlaceholder?: boolean;
|
||||
}
|
||||
export function EditableDateSegment({
|
||||
segment,
|
||||
domProps,
|
||||
value,
|
||||
onChange,
|
||||
isPlaceholder,
|
||||
state: {timezone, calendarIsOpen, setCalendarIsOpen},
|
||||
}: DatePickerSegmentProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const enteredKeys = useRef('');
|
||||
const {localeCode} = useSelectedLocale();
|
||||
const focusManager = useFocusManager();
|
||||
const formatter = useDateFormatter({timeZone: timezone});
|
||||
const parser = useMemo(
|
||||
() => new NumberParser(localeCode, {maximumFractionDigits: 0}),
|
||||
[localeCode],
|
||||
);
|
||||
|
||||
const setSegmentValue = (newValue: number) => {
|
||||
onChange(
|
||||
setSegment(value, segment.type, newValue, formatter.resolvedOptions()),
|
||||
);
|
||||
};
|
||||
|
||||
const adjustSegmentValue = (amount: number) => {
|
||||
onChange(
|
||||
adjustSegment(value, segment.type, amount, formatter.resolvedOptions()),
|
||||
);
|
||||
};
|
||||
|
||||
const backspace = () => {
|
||||
if (parser.isValidPartialNumber(segment.text)) {
|
||||
const newValue = segment.text.slice(0, -1);
|
||||
const parsed = parser.parse(newValue);
|
||||
if (newValue.length === 0 || parsed === 0) {
|
||||
const now = today(timezone);
|
||||
if (segment.type in now) {
|
||||
// @ts-ignore
|
||||
setSegmentValue(now[segment.type]);
|
||||
}
|
||||
} else {
|
||||
setSegmentValue(parsed);
|
||||
}
|
||||
enteredKeys.current = newValue;
|
||||
} else if (segment.type === 'dayPeriod') {
|
||||
adjustSegmentValue(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown: KeyboardEventHandler = e => {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation between date segments and deletion
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusManager?.focusPrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusManager?.focusNext();
|
||||
break;
|
||||
case 'Enter':
|
||||
(e.target as HTMLElement).closest('form')?.requestSubmit();
|
||||
setCalendarIsOpen(!calendarIsOpen);
|
||||
break;
|
||||
case 'Tab':
|
||||
break;
|
||||
case 'Backspace':
|
||||
case 'Delete': {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
backspace();
|
||||
break;
|
||||
}
|
||||
|
||||
// Spinbutton incrementing/decrementing
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
adjustSegmentValue(1);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
adjustSegmentValue(-1);
|
||||
break;
|
||||
case 'PageUp':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
adjustSegmentValue(PAGE_STEP[segment.type] || 1);
|
||||
break;
|
||||
case 'PageDown':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
adjustSegmentValue(-(PAGE_STEP[segment.type] || 1));
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
setSegmentValue(segment.maxValue);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
enteredKeys.current = '';
|
||||
setSegmentValue(segment.minValue);
|
||||
break;
|
||||
}
|
||||
|
||||
onInput(e.key);
|
||||
};
|
||||
|
||||
const amPmFormatter = useDateFormatter({hour: 'numeric', hour12: true});
|
||||
const am = useMemo(() => {
|
||||
const amDate = new Date();
|
||||
amDate.setHours(0);
|
||||
return amPmFormatter
|
||||
.formatToParts(amDate)
|
||||
.find(part => part.type === 'dayPeriod')!.value;
|
||||
}, [amPmFormatter]);
|
||||
const pm = useMemo(() => {
|
||||
const pmDate = new Date();
|
||||
pmDate.setHours(12);
|
||||
return amPmFormatter
|
||||
.formatToParts(pmDate)
|
||||
.find(part => part.type === 'dayPeriod')!.value;
|
||||
}, [amPmFormatter]);
|
||||
|
||||
// Update date values on user keyboard input
|
||||
const onInput = (key: string) => {
|
||||
const newValue = enteredKeys.current + key;
|
||||
|
||||
switch (segment.type) {
|
||||
case 'dayPeriod':
|
||||
if (am.toLowerCase().startsWith(key)) {
|
||||
setSegmentValue(0);
|
||||
} else if (pm.toLowerCase().startsWith(key)) {
|
||||
setSegmentValue(12);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
focusManager?.focusNext();
|
||||
break;
|
||||
case 'day':
|
||||
case 'hour':
|
||||
case 'minute':
|
||||
case 'second':
|
||||
case 'month':
|
||||
case 'year': {
|
||||
if (!parser.isValidPartialNumber(newValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let numberValue = parser.parse(newValue);
|
||||
let segmentValue = numberValue;
|
||||
let allowsZero = segment.minValue === 0;
|
||||
if (segment.type === 'hour' && formatter.resolvedOptions().hour12) {
|
||||
switch (formatter.resolvedOptions().hourCycle) {
|
||||
case 'h11':
|
||||
if (numberValue > 11) {
|
||||
segmentValue = parser.parse(key);
|
||||
}
|
||||
break;
|
||||
case 'h12':
|
||||
allowsZero = false;
|
||||
if (numberValue > 12) {
|
||||
segmentValue = parser.parse(key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (segment.value >= 12 && numberValue > 1) {
|
||||
numberValue += 12;
|
||||
}
|
||||
} else if (numberValue > segment.maxValue) {
|
||||
segmentValue = parser.parse(key);
|
||||
}
|
||||
|
||||
if (Number.isNaN(numberValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSetValue = segmentValue !== 0 || allowsZero;
|
||||
if (shouldSetValue) {
|
||||
setSegmentValue(segmentValue);
|
||||
}
|
||||
|
||||
if (
|
||||
Number(`${numberValue}0`) > segment.maxValue ||
|
||||
newValue.length >= String(segment.maxValue).length
|
||||
) {
|
||||
enteredKeys.current = '';
|
||||
if (shouldSetValue) {
|
||||
focusManager?.focusNext();
|
||||
}
|
||||
} else {
|
||||
enteredKeys.current = newValue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const spinButtonProps: HTMLAttributes<HTMLDivElement> = isMobile
|
||||
? {}
|
||||
: {
|
||||
'aria-label': segment.type,
|
||||
'aria-valuetext': isPlaceholder ? undefined : `${segment.value}`,
|
||||
'aria-valuemin': segment.minValue,
|
||||
'aria-valuemax': segment.maxValue,
|
||||
'aria-valuenow': isPlaceholder ? undefined : segment.value,
|
||||
tabIndex: 0,
|
||||
onKeyDown,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...mergeProps(domProps!, {
|
||||
...spinButtonProps,
|
||||
onFocus: e => {
|
||||
enteredKeys.current = '';
|
||||
e.target.scrollIntoView({block: 'nearest'});
|
||||
},
|
||||
onClick: e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
} as HTMLAttributes<HTMLDivElement>)}
|
||||
className="box-content cursor-default select-none whitespace-nowrap rounded p-2 text-center tabular-nums caret-transparent outline-none focus:bg-primary focus:text-on-primary"
|
||||
>
|
||||
{segment.text.padStart(segment.minLength, '0')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useFocusManager} from '@react-aria/focus';
|
||||
import {ComponentPropsWithoutRef} from 'react';
|
||||
|
||||
export interface LiteralSegment {
|
||||
type: 'literal';
|
||||
minLength: 1;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface LiteralSegmentProps extends ComponentPropsWithoutRef<'div'> {
|
||||
segment: LiteralSegment;
|
||||
domProps?: ComponentPropsWithoutRef<'div'>;
|
||||
}
|
||||
export function LiteralDateSegment({segment, domProps}: LiteralSegmentProps) {
|
||||
const focusManager = useFocusManager();
|
||||
return (
|
||||
<div
|
||||
{...domProps}
|
||||
onPointerDown={e => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
e.preventDefault();
|
||||
const res = focusManager?.focusNext({from: e.target as HTMLElement});
|
||||
if (!res) {
|
||||
focusManager?.focusPrevious({from: e.target as HTMLElement});
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-hidden
|
||||
className="min-w-4 cursor-default select-none"
|
||||
>
|
||||
{segment.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {ZonedDateTime} from '@internationalized/date';
|
||||
|
||||
export function adjustSegment(
|
||||
value: ZonedDateTime,
|
||||
part: string,
|
||||
amount: number,
|
||||
options: Intl.ResolvedDateTimeFormatOptions
|
||||
) {
|
||||
switch (part) {
|
||||
case 'era':
|
||||
case 'year':
|
||||
case 'month':
|
||||
case 'day':
|
||||
return value.cycle(part, amount, {round: part === 'year'});
|
||||
}
|
||||
|
||||
if ('hour' in value) {
|
||||
switch (part) {
|
||||
case 'dayPeriod': {
|
||||
const hours = value.hour;
|
||||
const isPM = hours >= 12;
|
||||
return value.set({hour: isPM ? hours - 12 : hours + 12});
|
||||
}
|
||||
case 'hour':
|
||||
case 'minute':
|
||||
case 'second':
|
||||
return value.cycle(part, amount, {
|
||||
round: part !== 'hour',
|
||||
hourCycle: options.hour12 ? 12 : 24,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DateValue,
|
||||
getMinimumDayInMonth,
|
||||
getMinimumMonthInYear,
|
||||
} from '@internationalized/date';
|
||||
|
||||
export function getSegmentLimits(
|
||||
date: DateValue,
|
||||
type: string,
|
||||
options: Intl.ResolvedDateTimeFormatOptions
|
||||
) {
|
||||
switch (type) {
|
||||
case 'year':
|
||||
return {
|
||||
value: date.year,
|
||||
placeholder: 'yyyy',
|
||||
minValue: 1,
|
||||
maxValue: date.calendar.getYearsInEra(date),
|
||||
};
|
||||
case 'month':
|
||||
return {
|
||||
value: date.month,
|
||||
placeholder: 'mm',
|
||||
minValue: getMinimumMonthInYear(date),
|
||||
maxValue: date.calendar.getMonthsInYear(date),
|
||||
};
|
||||
case 'day':
|
||||
return {
|
||||
value: date.day,
|
||||
minValue: getMinimumDayInMonth(date),
|
||||
maxValue: date.calendar.getDaysInMonth(date),
|
||||
placeholder: 'dd',
|
||||
};
|
||||
}
|
||||
|
||||
if ('hour' in date) {
|
||||
switch (type) {
|
||||
case 'dayPeriod':
|
||||
return {
|
||||
value: date.hour >= 12 ? 12 : 0,
|
||||
minValue: 0,
|
||||
maxValue: 12,
|
||||
placeholder: '--',
|
||||
};
|
||||
case 'hour':
|
||||
if (options.hour12) {
|
||||
const isPM = date.hour >= 12;
|
||||
return {
|
||||
value: date.hour,
|
||||
minValue: isPM ? 12 : 0,
|
||||
maxValue: isPM ? 23 : 11,
|
||||
placeholder: '--',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: date.hour,
|
||||
minValue: 0,
|
||||
maxValue: 23,
|
||||
placeholder: '--',
|
||||
};
|
||||
case 'minute':
|
||||
return {
|
||||
value: date.minute,
|
||||
minValue: 0,
|
||||
maxValue: 59,
|
||||
placeholder: '--',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const PAGE_STEP = {
|
||||
year: 5,
|
||||
month: 2,
|
||||
day: 7,
|
||||
hour: 2,
|
||||
minute: 15,
|
||||
second: 15,
|
||||
dayPeriod: 1,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import {ZonedDateTime} from '@internationalized/date';
|
||||
|
||||
export function setSegment(
|
||||
value: ZonedDateTime,
|
||||
part: string,
|
||||
segmentValue: number,
|
||||
options: Intl.ResolvedDateTimeFormatOptions
|
||||
) {
|
||||
switch (part) {
|
||||
case 'day':
|
||||
case 'month':
|
||||
case 'year':
|
||||
return value.set({[part]: segmentValue});
|
||||
}
|
||||
|
||||
if ('hour' in value) {
|
||||
switch (part) {
|
||||
case 'dayPeriod': {
|
||||
const hours = value.hour;
|
||||
const wasPM = hours >= 12;
|
||||
const isPM = segmentValue >= 12;
|
||||
if (isPM === wasPM) {
|
||||
return value;
|
||||
}
|
||||
return value.set({hour: wasPM ? hours - 12 : hours + 12});
|
||||
}
|
||||
case 'hour':
|
||||
// In 12 hour time, ensure that AM/PM does not change
|
||||
if (options.hour12) {
|
||||
const hours = value.hour;
|
||||
const wasPM = hours >= 12;
|
||||
if (!wasPM && segmentValue === 12) {
|
||||
segmentValue = 0;
|
||||
}
|
||||
if (wasPM && segmentValue < 12) {
|
||||
segmentValue += 12;
|
||||
}
|
||||
}
|
||||
// fallthrough
|
||||
case 'minute':
|
||||
case 'second':
|
||||
return value.set({[part]: segmentValue});
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {useState} from 'react';
|
||||
import {DateValue, toZoned, ZonedDateTime} from '@internationalized/date';
|
||||
import {getDefaultGranularity} from './utils';
|
||||
import type {DatePickerValueProps} from './date-picker/use-date-picker-state';
|
||||
import {DateRangeValue} from './date-range-picker/date-range-value';
|
||||
import {useUserTimezone} from '@common/i18n/use-user-timezone';
|
||||
|
||||
export function useBaseDatePickerState(
|
||||
selectedDate: DateValue,
|
||||
props:
|
||||
| DatePickerValueProps<ZonedDateTime>
|
||||
| DatePickerValueProps<Partial<DateRangeValue>, DateRangeValue>
|
||||
) {
|
||||
const timezone = useUserTimezone();
|
||||
const [calendarIsOpen, setCalendarIsOpen] = useState(false);
|
||||
const closeDialogOnSelection = props.closeDialogOnSelection ?? true;
|
||||
|
||||
const granularity = props.granularity || getDefaultGranularity(selectedDate);
|
||||
const min = props.min ? toZoned(props.min, timezone) : undefined;
|
||||
const max = props.max ? toZoned(props.max, timezone) : undefined;
|
||||
|
||||
return {
|
||||
timezone,
|
||||
granularity,
|
||||
min,
|
||||
max,
|
||||
calendarIsOpen,
|
||||
setCalendarIsOpen,
|
||||
closeDialogOnSelection,
|
||||
};
|
||||
}
|
||||
19
common/resources/client/ui/forms/input-field/date/utils.ts
Executable file
19
common/resources/client/ui/forms/input-field/date/utils.ts
Executable file
@@ -0,0 +1,19 @@
|
||||
import {CalendarDate, DateValue} from '@internationalized/date';
|
||||
|
||||
export function getDefaultGranularity(date: DateValue) {
|
||||
if (date instanceof CalendarDate) {
|
||||
return 'day';
|
||||
}
|
||||
return 'minute';
|
||||
}
|
||||
|
||||
export function dateIsInvalid(
|
||||
date: CalendarDate,
|
||||
min?: DateValue,
|
||||
max?: DateValue
|
||||
) {
|
||||
return (
|
||||
(min != null && date.compare(min) < 0) ||
|
||||
(max != null && date.compare(max) > 0)
|
||||
);
|
||||
}
|
||||
137
common/resources/client/ui/forms/input-field/field.tsx
Executable file
137
common/resources/client/ui/forms/input-field/field.tsx
Executable file
@@ -0,0 +1,137 @@
|
||||
import React, {ComponentPropsWithoutRef, ReactElement, ReactNode} from 'react';
|
||||
import {Adornment} from './adornment';
|
||||
import {InputFieldStyle} from './get-input-field-class-names';
|
||||
import {BaseFieldProps} from './base-field-props';
|
||||
import {removeEmptyValuesFromObject} from '@common/utils/objects/remove-empty-values-from-object';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface FieldProps extends BaseFieldProps {
|
||||
children: ReactNode;
|
||||
wrapperProps?: ComponentPropsWithoutRef<'div'>;
|
||||
labelProps?: ComponentPropsWithoutRef<'label' | 'span'>;
|
||||
descriptionProps?: ComponentPropsWithoutRef<'div'>;
|
||||
errorMessageProps?: ComponentPropsWithoutRef<'div'>;
|
||||
fieldClassNames: InputFieldStyle;
|
||||
}
|
||||
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
children,
|
||||
// Not every component that uses <Field> supports help text.
|
||||
description,
|
||||
errorMessage,
|
||||
descriptionProps = {},
|
||||
errorMessageProps = {},
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
adornmentPosition,
|
||||
startAppend,
|
||||
endAppend,
|
||||
fieldClassNames,
|
||||
disabled,
|
||||
wrapperProps,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={fieldClassNames.wrapper} ref={ref} {...wrapperProps}>
|
||||
<Label {...props} />
|
||||
<div className={fieldClassNames.inputWrapper}>
|
||||
<Adornment
|
||||
direction="start"
|
||||
className={fieldClassNames.adornment}
|
||||
position={adornmentPosition}
|
||||
>
|
||||
{startAdornment}
|
||||
</Adornment>
|
||||
{startAppend && (
|
||||
<Append style={fieldClassNames.append} disabled={disabled}>
|
||||
{startAppend}
|
||||
</Append>
|
||||
)}
|
||||
{children}
|
||||
{endAppend && (
|
||||
<Append style={fieldClassNames.append} disabled={disabled}>
|
||||
{endAppend}
|
||||
</Append>
|
||||
)}
|
||||
<Adornment
|
||||
direction="end"
|
||||
className={fieldClassNames.adornment}
|
||||
position={adornmentPosition}
|
||||
>
|
||||
{endAdornment}
|
||||
</Adornment>
|
||||
</div>
|
||||
{description && !errorMessage && (
|
||||
<div className={fieldClassNames.description} {...descriptionProps}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className={fieldClassNames.error} {...errorMessageProps}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function Label({
|
||||
labelElementType,
|
||||
fieldClassNames,
|
||||
labelProps,
|
||||
label,
|
||||
labelSuffix,
|
||||
labelSuffixPosition = 'spaced',
|
||||
required,
|
||||
}: Omit<FieldProps, 'children'>) {
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ElementType = labelElementType || 'label';
|
||||
const labelNode = (
|
||||
<ElementType className={fieldClassNames.label} {...labelProps}>
|
||||
{label}
|
||||
{required && <span className="text-danger"> *</span>}
|
||||
</ElementType>
|
||||
);
|
||||
|
||||
if (labelSuffix) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'mb-4 flex w-full gap-4',
|
||||
labelSuffixPosition === 'spaced' ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
{labelNode}
|
||||
<div
|
||||
className={clsx(
|
||||
'text-xs text-muted',
|
||||
labelSuffixPosition === 'spaced' ? 'ml-auto' : '',
|
||||
)}
|
||||
>
|
||||
{labelSuffix}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return labelNode;
|
||||
}
|
||||
|
||||
interface AppendProps {
|
||||
children: ReactElement;
|
||||
style: InputFieldStyle['append'];
|
||||
disabled?: boolean;
|
||||
}
|
||||
function Append({children, style, disabled}: AppendProps) {
|
||||
return React.cloneElement(children, {
|
||||
...children.props,
|
||||
disabled: children.props.disabled || disabled,
|
||||
// make sure append styles are not overwritten with empty values
|
||||
...removeEmptyValuesFromObject(style),
|
||||
});
|
||||
}
|
||||
284
common/resources/client/ui/forms/input-field/file-entry-field.tsx
Executable file
284
common/resources/client/ui/forms/input-field/file-entry-field.tsx
Executable file
@@ -0,0 +1,284 @@
|
||||
import React, {
|
||||
cloneElement,
|
||||
ComponentPropsWithRef,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useId,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {Field} from '@common/ui/forms/input-field/field';
|
||||
import {
|
||||
getInputFieldClassNames,
|
||||
InputFieldStyle,
|
||||
} from '@common/ui/forms/input-field/get-input-field-class-names';
|
||||
import {FileEntry} from '@common/uploads/file-entry';
|
||||
import {useAutoFocus} from '@common/ui/focus/use-auto-focus';
|
||||
import {UploadStrategyConfig} from '@common/uploads/uploader/strategy/upload-strategy';
|
||||
import {useActiveUpload} from '@common/uploads/uploader/use-active-upload';
|
||||
import {Disk} from '@common/uploads/types/backend-metadata';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ProgressBar} from '@common/ui/progress/progress-bar';
|
||||
import {Input} from '@common/ui/forms/input-field/input';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {useFileEntryModel} from '@common/uploads/requests/use-file-entry-model';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {validateUpload} from '@common/uploads/uploader/validate-upload';
|
||||
import {UploadedFile} from '@common/uploads/uploaded-file';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
label?: ReactNode;
|
||||
description?: ReactNode;
|
||||
invalid?: boolean;
|
||||
errorMessage?: ReactNode;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
value?: string;
|
||||
onChange?: (newValue: string) => void;
|
||||
allowedFileTypes?: string[];
|
||||
maxFileSize?: number;
|
||||
diskPrefix: string;
|
||||
disk?: Disk;
|
||||
showRemoveButton?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
export function FileEntryField({
|
||||
className,
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
diskPrefix,
|
||||
disk = Disk.uploads,
|
||||
showRemoveButton,
|
||||
invalid,
|
||||
errorMessage,
|
||||
required,
|
||||
autoFocus,
|
||||
disabled,
|
||||
allowedFileTypes,
|
||||
maxFileSize,
|
||||
}: Props) {
|
||||
const {
|
||||
uploadFile,
|
||||
entry,
|
||||
uploadStatus,
|
||||
deleteEntry,
|
||||
isDeletingEntry,
|
||||
percentage,
|
||||
} = useActiveUpload();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useAutoFocus({autoFocus}, inputRef);
|
||||
const {data} = useFileEntryModel(value, {enabled: !entry && !!value});
|
||||
|
||||
const fieldId = useId();
|
||||
const labelId = label ? `${fieldId}-label` : undefined;
|
||||
const descriptionId = description ? `${fieldId}-description` : undefined;
|
||||
|
||||
const currentValue = value || entry?.url;
|
||||
const currentEntry = entry || data?.fileEntry;
|
||||
|
||||
const uploadOptions: UploadStrategyConfig = {
|
||||
showToastOnRestrictionFail: true,
|
||||
restrictions: {
|
||||
allowedFileTypes,
|
||||
maxFileSize,
|
||||
},
|
||||
metadata: {
|
||||
diskPrefix,
|
||||
disk,
|
||||
},
|
||||
onSuccess: (entry: FileEntry) => onChange?.(entry.url),
|
||||
onError: message => {
|
||||
if (message) {
|
||||
toast.danger(message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const inputFieldClassNames = getInputFieldClassNames({
|
||||
description,
|
||||
descriptionPosition: 'top',
|
||||
invalid,
|
||||
disabled: disabled || uploadStatus === 'inProgress',
|
||||
});
|
||||
|
||||
const removeButton = showRemoveButton ? (
|
||||
<Button
|
||||
variant="link"
|
||||
color="danger"
|
||||
size="xs"
|
||||
disabled={isDeletingEntry || !currentValue || disabled}
|
||||
onClick={() => {
|
||||
deleteEntry({
|
||||
onSuccess: () => onChange?.(''),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans message="Remove file" />
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
inputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={clsx('text-sm', className)}>
|
||||
{label && (
|
||||
<div className="flex items-center justify-between gap-24">
|
||||
<div id={labelId} className={inputFieldClassNames.label}>
|
||||
{label}
|
||||
</div>
|
||||
{removeButton}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className={inputFieldClassNames.description}>{description}</div>
|
||||
)}
|
||||
<div aria-labelledby={labelId} aria-describedby={descriptionId}>
|
||||
<Field
|
||||
fieldClassNames={inputFieldClassNames}
|
||||
errorMessage={errorMessage}
|
||||
invalid={invalid}
|
||||
>
|
||||
<FileInputField
|
||||
inputFieldClassNames={inputFieldClassNames}
|
||||
currentValue={currentValue}
|
||||
currentEntry={currentEntry}
|
||||
handleUpload={handleUpload}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descriptionId}
|
||||
// if file is already uploaded (from form or via props) set
|
||||
// required to false, otherwise farm validation will always fail
|
||||
required={currentValue ? false : required}
|
||||
accept={allowedFileTypes?.join(',')}
|
||||
type="file"
|
||||
disabled={uploadStatus === 'inProgress'}
|
||||
className="sr-only"
|
||||
onChange={e => {
|
||||
if (e.target.files?.length) {
|
||||
// "uploadFile" will validate, but need to validate here as well
|
||||
// because there's no easy way to listen for errors using "uploadFile"
|
||||
const errorMessage = validateUpload(
|
||||
new UploadedFile(e.target.files[0]),
|
||||
uploadOptions.restrictions
|
||||
);
|
||||
if (errorMessage && inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
toast.danger(errorMessage);
|
||||
} else {
|
||||
uploadFile(e.target.files[0], uploadOptions);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileInputField>
|
||||
{uploadStatus === 'inProgress' && (
|
||||
<ProgressBar
|
||||
className="absolute left-0 right-0 top-0"
|
||||
size="xs"
|
||||
value={percentage}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileInputFieldProps {
|
||||
children: ReactElement<ComponentPropsWithRef<'input'>>;
|
||||
inputFieldClassNames: InputFieldStyle;
|
||||
currentValue?: string;
|
||||
currentEntry?: FileEntry;
|
||||
handleUpload: () => void;
|
||||
}
|
||||
function FileInputField({
|
||||
children,
|
||||
inputFieldClassNames,
|
||||
currentValue,
|
||||
currentEntry,
|
||||
handleUpload,
|
||||
}: FileInputFieldProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
if (currentValue) {
|
||||
return (
|
||||
<Field
|
||||
wrapperProps={{
|
||||
onClick: () => {
|
||||
buttonRef.current?.focus();
|
||||
buttonRef.current?.click();
|
||||
},
|
||||
}}
|
||||
fieldClassNames={inputFieldClassNames}
|
||||
>
|
||||
<Input className={clsx(inputFieldClassNames.input, 'gap-10')}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded bg-primary px-10 py-2 text-sm font-semibold text-on-primary outline-none"
|
||||
onClick={() => handleUpload()}
|
||||
>
|
||||
<Trans message="Replace file" />
|
||||
</button>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<div className="min-w-0 overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
{currentEntry ? (
|
||||
<m.div key="file-entry-name" {...opacityAnimation}>
|
||||
{currentEntry.name}
|
||||
</m.div>
|
||||
) : (
|
||||
<m.div key="skeleton" {...opacityAnimation}>
|
||||
<Skeleton className="min-w-144" />
|
||||
</m.div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
{children}
|
||||
</Input>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
return cloneElement(children, {
|
||||
className: clsx(
|
||||
inputFieldClassNames.input,
|
||||
'py-8',
|
||||
'file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10'
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
interface FormFileEntryFieldProps extends Props {
|
||||
name: string;
|
||||
}
|
||||
export function FormFileEntryField(props: FormFileEntryFieldProps) {
|
||||
const {
|
||||
field: {onChange, value = null},
|
||||
fieldState: {error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<Props> = {
|
||||
onChange,
|
||||
value,
|
||||
invalid: error != null,
|
||||
errorMessage: error ? <Trans message="Please select a file." /> : null,
|
||||
};
|
||||
|
||||
return <FileEntryField {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
66
common/resources/client/ui/forms/input-field/file-field.tsx
Executable file
66
common/resources/client/ui/forms/input-field/file-field.tsx
Executable file
@@ -0,0 +1,66 @@
|
||||
import React, {ChangeEventHandler} from 'react';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {useController} from 'react-hook-form';
|
||||
import clsx from 'clsx';
|
||||
import {BaseFieldProps} from './base-field-props';
|
||||
import {useField} from './use-field';
|
||||
import {getInputFieldClassNames} from './get-input-field-class-names';
|
||||
import {Field} from './field';
|
||||
import {TextFieldProps} from './text-field/text-field';
|
||||
|
||||
export interface FileFieldProps
|
||||
extends Omit<BaseFieldProps, 'type'> {
|
||||
onChange?: ChangeEventHandler<'input'>;
|
||||
accept?: string;
|
||||
}
|
||||
export const FileField = React.forwardRef<HTMLInputElement, FileFieldProps>(
|
||||
(props, ref) => {
|
||||
const inputRef = useObjectRef(ref);
|
||||
|
||||
const {fieldProps, inputProps} = useField({...props, focusRef: inputRef});
|
||||
|
||||
const inputFieldClassNames = getInputFieldClassNames(props);
|
||||
|
||||
return (
|
||||
<Field ref={ref} fieldClassNames={inputFieldClassNames} {...fieldProps}>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
{...inputProps as any}
|
||||
className={clsx(
|
||||
inputFieldClassNames.input,
|
||||
'py-8',
|
||||
'file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10'
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface FormFileFieldProps extends FileFieldProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormFileField({name, ...props}: FormFileFieldProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name,
|
||||
});
|
||||
|
||||
const [value, setValue] = React.useState('');
|
||||
|
||||
const formProps: TextFieldProps = {
|
||||
onChange: e => {
|
||||
onChange(e.target.files?.[0]);
|
||||
setValue(e.target.value);
|
||||
},
|
||||
onBlur,
|
||||
value,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
};
|
||||
|
||||
return <FileField ref={ref} {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
113
common/resources/client/ui/forms/input-field/file-size-field.tsx
Executable file
113
common/resources/client/ui/forms/input-field/file-size-field.tsx
Executable file
@@ -0,0 +1,113 @@
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import memoize from 'nano-memoize';
|
||||
import {
|
||||
FormTextFieldProps,
|
||||
TextField,
|
||||
TextFieldProps,
|
||||
} from './text-field/text-field';
|
||||
import {prettyBytes} from '../../../uploads/utils/pretty-bytes';
|
||||
import {Option, Select} from '../select/select';
|
||||
import {spaceUnits} from '../../../uploads/utils/space-units';
|
||||
import {
|
||||
convertToBytes,
|
||||
SpaceUnit,
|
||||
} from '../../../uploads/utils/convert-to-bytes';
|
||||
|
||||
// 99TB
|
||||
const MaxValue = 108851651149824;
|
||||
|
||||
export const FormFileSizeField = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
FormTextFieldProps
|
||||
>(({name, ...props}, ref) => {
|
||||
const {
|
||||
field: {
|
||||
onChange: setByteValue,
|
||||
onBlur,
|
||||
value: byteValue = '',
|
||||
ref: inputRef,
|
||||
},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name,
|
||||
});
|
||||
|
||||
const [liveValue, setLiveValue] = useState<number | string>('');
|
||||
const [unit, setUnit] = useState<SpaceUnit | string>('MB');
|
||||
|
||||
useEffect(() => {
|
||||
if (byteValue == null || byteValue === '') {
|
||||
setLiveValue('');
|
||||
return;
|
||||
}
|
||||
const {amount, unit: newUnit} = fromBytes({
|
||||
bytes: Math.min(byteValue, MaxValue),
|
||||
});
|
||||
setUnit(newUnit || 'MB');
|
||||
setLiveValue(Number.isNaN(amount) ? '' : amount);
|
||||
}, [byteValue, unit]);
|
||||
|
||||
const formProps: TextFieldProps = {
|
||||
onChange: e => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (Number.isNaN(value)) {
|
||||
setByteValue(value);
|
||||
} else {
|
||||
const newBytes = convertToBytes(
|
||||
parseInt(e.target.value),
|
||||
unit as SpaceUnit
|
||||
);
|
||||
setByteValue(newBytes);
|
||||
}
|
||||
},
|
||||
onBlur,
|
||||
value: liveValue,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
inputRef,
|
||||
};
|
||||
|
||||
const unitSelect = (
|
||||
<Select
|
||||
minWidth="min-w-80"
|
||||
selectionMode="single"
|
||||
selectedValue={unit}
|
||||
disabled={!byteValue}
|
||||
onSelectionChange={newUnit => {
|
||||
const newBytes = convertToBytes(
|
||||
(liveValue || 0) as number,
|
||||
newUnit as SpaceUnit
|
||||
);
|
||||
setByteValue(newBytes);
|
||||
}}
|
||||
>
|
||||
{spaceUnits.slice(0, 5).map(u => (
|
||||
<Option value={u} key={u}>
|
||||
{u === 'B' ? 'Bytes' : u}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...mergeProps(formProps, props)}
|
||||
type="number"
|
||||
ref={ref}
|
||||
endAppend={unitSelect}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const fromBytes = memoize(
|
||||
({bytes}: {bytes: number}): {amount: number | string; unit: SpaceUnit} => {
|
||||
const pretty = prettyBytes(bytes);
|
||||
if (!pretty) return {amount: '', unit: 'MB'};
|
||||
let amount = parseInt(pretty.split(' ')[0]);
|
||||
// get rid of any punctuation
|
||||
amount = Math.round(amount);
|
||||
return {amount, unit: pretty.split(' ')[1] as SpaceUnit};
|
||||
}
|
||||
);
|
||||
233
common/resources/client/ui/forms/input-field/get-input-field-class-names.ts
Executable file
233
common/resources/client/ui/forms/input-field/get-input-field-class-names.ts
Executable file
@@ -0,0 +1,233 @@
|
||||
import clsx from 'clsx';
|
||||
import {BaseFieldProps} from './base-field-props';
|
||||
import {ButtonSize, getButtonSizeStyle} from '../../buttons/button-size';
|
||||
|
||||
export interface InputFieldStyle {
|
||||
label: string;
|
||||
input: string;
|
||||
wrapper: string;
|
||||
inputWrapper: string;
|
||||
adornment: string;
|
||||
append: {size: string; radius: string};
|
||||
size: {font: string; height: string};
|
||||
description: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
type InputFieldStyleProps = Omit<
|
||||
BaseFieldProps,
|
||||
'value' | 'defaultValue' | 'onChange'
|
||||
>;
|
||||
|
||||
export function getInputFieldClassNames(
|
||||
props: InputFieldStyleProps = {},
|
||||
): InputFieldStyle {
|
||||
const {
|
||||
size = 'md',
|
||||
startAppend,
|
||||
endAppend,
|
||||
className,
|
||||
labelPosition,
|
||||
labelDisplay = 'block',
|
||||
inputClassName,
|
||||
inputWrapperClassName,
|
||||
unstyled,
|
||||
invalid,
|
||||
disabled,
|
||||
background = 'bg-transparent',
|
||||
flexibleHeight,
|
||||
inputShadow = 'shadow-sm',
|
||||
descriptionPosition = 'bottom',
|
||||
inputRing,
|
||||
inputFontSize,
|
||||
labelSuffix,
|
||||
} = {...props};
|
||||
|
||||
if (unstyled) {
|
||||
return {
|
||||
label: '',
|
||||
input: inputClassName || '',
|
||||
wrapper: className || '',
|
||||
inputWrapper: inputWrapperClassName || '',
|
||||
adornment: '',
|
||||
append: {size: '', radius: ''},
|
||||
size: {font: '', height: ''},
|
||||
description: '',
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
const sizeClass = inputSizeClass({
|
||||
size: props.size,
|
||||
flexibleHeight,
|
||||
});
|
||||
if (inputFontSize) {
|
||||
sizeClass.font = inputFontSize;
|
||||
}
|
||||
|
||||
const isInputGroup = startAppend || endAppend;
|
||||
|
||||
const ringColor = invalid
|
||||
? 'focus:ring-danger/focus focus:border-danger/60'
|
||||
: 'focus:ring-primary/focus focus:border-primary/60';
|
||||
const ringClassName = inputRing || `focus:ring ${ringColor}`;
|
||||
|
||||
const radius = getRadius(props);
|
||||
|
||||
return {
|
||||
label: clsx(
|
||||
labelDisplay,
|
||||
'first-letter:capitalize text-left whitespace-nowrap',
|
||||
disabled && 'text-disabled',
|
||||
sizeClass.font,
|
||||
labelSuffix ? '' : labelPosition === 'side' ? 'mr-16' : 'mb-4',
|
||||
),
|
||||
input: clsx(
|
||||
'block text-left relative w-full appearance-none transition-shadow text',
|
||||
background,
|
||||
|
||||
// radius
|
||||
radius.input,
|
||||
|
||||
getInputBorder(props),
|
||||
!disabled && `${ringClassName} focus:outline-none ${inputShadow}`,
|
||||
disabled && 'text-disabled cursor-not-allowed',
|
||||
inputClassName,
|
||||
sizeClass.font,
|
||||
sizeClass.height,
|
||||
getInputPadding(props),
|
||||
),
|
||||
adornment: iconSizeClass(size),
|
||||
append: {
|
||||
size: getButtonSizeStyle(size),
|
||||
radius: radius.append,
|
||||
},
|
||||
wrapper: clsx(className, sizeClass.font, {
|
||||
'flex items-center': labelPosition === 'side',
|
||||
}),
|
||||
inputWrapper: clsx(
|
||||
'isolate relative',
|
||||
inputWrapperClassName,
|
||||
isInputGroup && 'flex items-stretch',
|
||||
),
|
||||
size: sizeClass,
|
||||
description: `text-muted ${
|
||||
descriptionPosition === 'bottom' ? 'pt-10' : 'pb-10'
|
||||
} text-xs`,
|
||||
error: 'text-danger pt-10 text-xs',
|
||||
};
|
||||
}
|
||||
|
||||
function getInputBorder({
|
||||
startAppend,
|
||||
endAppend,
|
||||
inputBorder,
|
||||
invalid,
|
||||
}: InputFieldStyleProps) {
|
||||
if (inputBorder) return inputBorder;
|
||||
|
||||
const isInputGroup = startAppend || endAppend;
|
||||
const borderColor = invalid ? 'border-danger' : 'border-divider';
|
||||
|
||||
if (!isInputGroup) {
|
||||
return `${borderColor} border`;
|
||||
}
|
||||
if (startAppend) {
|
||||
return `${borderColor} border-y border-r`;
|
||||
}
|
||||
return `${borderColor} border-y border-l`;
|
||||
}
|
||||
|
||||
function getInputPadding({
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
inputRadius,
|
||||
}: InputFieldStyleProps) {
|
||||
if (inputRadius === 'rounded-full') {
|
||||
return clsx(
|
||||
startAdornment ? 'pl-54' : 'pl-28',
|
||||
endAdornment ? 'pr-54' : 'pr-28',
|
||||
);
|
||||
}
|
||||
return clsx(
|
||||
startAdornment ? 'pl-46' : 'pl-12',
|
||||
endAdornment ? 'pr-46' : 'pr-12',
|
||||
);
|
||||
}
|
||||
|
||||
function getRadius(props: InputFieldStyleProps): {
|
||||
input: string;
|
||||
append: string;
|
||||
} {
|
||||
const {startAppend, endAppend, inputRadius} = props;
|
||||
const isInputGroup = startAppend || endAppend;
|
||||
|
||||
if (inputRadius === 'rounded-full') {
|
||||
return {
|
||||
input: clsx(
|
||||
!isInputGroup && 'rounded-full',
|
||||
startAppend && 'rounded-r-full rounded-l-none',
|
||||
endAppend && 'rounded-l-full rounded-r-none',
|
||||
),
|
||||
append: startAppend ? 'rounded-l-full' : 'rounded-r-full',
|
||||
};
|
||||
} else if (inputRadius === 'rounded-none') {
|
||||
return {
|
||||
input: '',
|
||||
append: '',
|
||||
};
|
||||
} else if (inputRadius) {
|
||||
return {
|
||||
input: inputRadius,
|
||||
append: inputRadius,
|
||||
};
|
||||
}
|
||||
return {
|
||||
input: clsx(
|
||||
!isInputGroup && 'rounded-input',
|
||||
startAppend && 'rounded-r-input rounded-l-none',
|
||||
endAppend && 'rounded-l-input rounded-r-none',
|
||||
),
|
||||
append: startAppend ? 'rounded-l-input' : 'rounded-r-input',
|
||||
};
|
||||
}
|
||||
|
||||
function inputSizeClass({size, flexibleHeight}: BaseFieldProps) {
|
||||
switch (size) {
|
||||
case '2xs':
|
||||
return {font: 'text-xs', height: flexibleHeight ? 'min-h-24' : 'h-24'};
|
||||
case 'xs':
|
||||
return {font: 'text-xs', height: flexibleHeight ? 'min-h-30' : 'h-30'};
|
||||
case 'sm':
|
||||
return {font: 'text-sm', height: flexibleHeight ? 'min-h-36' : 'h-36'};
|
||||
case 'lg':
|
||||
return {
|
||||
font: 'text-md md:text-lg',
|
||||
height: flexibleHeight ? 'min-h-50' : 'h-50',
|
||||
};
|
||||
case 'xl':
|
||||
return {font: 'text-xl', height: flexibleHeight ? 'min-h-60' : 'h-60'};
|
||||
default:
|
||||
return {font: 'text-sm', height: flexibleHeight ? 'min-h-42' : 'h-42'};
|
||||
}
|
||||
}
|
||||
|
||||
function iconSizeClass(size?: ButtonSize): string {
|
||||
switch (size) {
|
||||
case '2xs':
|
||||
return 'icon-2xs';
|
||||
case 'xs':
|
||||
return 'icon-xs';
|
||||
case 'sm':
|
||||
return 'icon-sm';
|
||||
case 'md':
|
||||
return 'icon-sm';
|
||||
case 'lg':
|
||||
return 'icon-lg';
|
||||
case 'xl':
|
||||
return 'icon-xl';
|
||||
default:
|
||||
// can't return "size" variable here, append in field will not work with it
|
||||
return '';
|
||||
}
|
||||
}
|
||||
1
common/resources/client/ui/forms/input-field/input-size.tsx
Executable file
1
common/resources/client/ui/forms/input-field/input-size.tsx
Executable file
@@ -0,0 +1 @@
|
||||
export type InputSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
45
common/resources/client/ui/forms/input-field/input.tsx
Executable file
45
common/resources/client/ui/forms/input-field/input.tsx
Executable file
@@ -0,0 +1,45 @@
|
||||
import {FocusScope} from '@react-aria/focus';
|
||||
import React, {ComponentPropsWithoutRef, CSSProperties, ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface InputProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: CSSProperties;
|
||||
inputProps?: ComponentPropsWithoutRef<'div'>;
|
||||
wrapperProps?: ComponentPropsWithoutRef<'div'>;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLDivElement, InputProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
children,
|
||||
inputProps,
|
||||
wrapperProps,
|
||||
className,
|
||||
autoFocus,
|
||||
style,
|
||||
onClick,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div {...wrapperProps} onClick={onClick}>
|
||||
<div
|
||||
{...inputProps}
|
||||
role="group"
|
||||
className={clsx(
|
||||
className,
|
||||
'flex items-center focus-within:ring focus-within:ring-primary/focus focus-within:border-primary/60'
|
||||
)}
|
||||
ref={ref}
|
||||
style={style}
|
||||
>
|
||||
<FocusScope autoFocus={autoFocus}>{children}</FocusScope>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
71
common/resources/client/ui/forms/input-field/text-field/text-field-base.tsx
Executable file
71
common/resources/client/ui/forms/input-field/text-field/text-field-base.tsx
Executable file
@@ -0,0 +1,71 @@
|
||||
import React, {ComponentPropsWithoutRef, forwardRef, Ref} from 'react';
|
||||
import type {TextFieldProps} from './text-field';
|
||||
import {Field} from '../field';
|
||||
import {getInputFieldClassNames} from '../get-input-field-class-names';
|
||||
|
||||
interface Props extends TextFieldProps {
|
||||
labelProps?: ComponentPropsWithoutRef<'label'>;
|
||||
inputProps:
|
||||
| ComponentPropsWithoutRef<'input'>
|
||||
| ComponentPropsWithoutRef<'textarea'>;
|
||||
descriptionProps?: ComponentPropsWithoutRef<'div'>;
|
||||
errorMessageProps?: ComponentPropsWithoutRef<'div'>;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
isLoading?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const TextFieldBase = forwardRef<HTMLDivElement, Props>((props, ref) => {
|
||||
const {
|
||||
label,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
startAppend,
|
||||
endAppend,
|
||||
errorMessage,
|
||||
description,
|
||||
labelProps,
|
||||
inputProps,
|
||||
inputRef,
|
||||
descriptionProps,
|
||||
errorMessageProps,
|
||||
inputWrapperClassName,
|
||||
className,
|
||||
inputClassName,
|
||||
disabled,
|
||||
inputElementType,
|
||||
rows,
|
||||
} = props;
|
||||
|
||||
const isTextArea = inputElementType === 'textarea';
|
||||
const ElementType: React.ElementType = isTextArea ? 'textarea' : 'input';
|
||||
const fieldClassNames = getInputFieldClassNames(props);
|
||||
|
||||
return (
|
||||
<Field
|
||||
ref={ref}
|
||||
label={label}
|
||||
labelProps={labelProps}
|
||||
startAdornment={startAdornment}
|
||||
endAdornment={endAdornment}
|
||||
startAppend={startAppend}
|
||||
endAppend={endAppend}
|
||||
errorMessage={errorMessage}
|
||||
description={description}
|
||||
descriptionProps={descriptionProps}
|
||||
errorMessageProps={errorMessageProps}
|
||||
inputWrapperClassName={inputWrapperClassName}
|
||||
className={className}
|
||||
inputClassName={inputClassName}
|
||||
fieldClassNames={fieldClassNames}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ElementType
|
||||
ref={inputRef as any}
|
||||
{...(inputProps as any)}
|
||||
rows={isTextArea ? rows || 4 : undefined}
|
||||
className={fieldClassNames.input}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
});
|
||||
89
common/resources/client/ui/forms/input-field/text-field/text-field.tsx
Executable file
89
common/resources/client/ui/forms/input-field/text-field/text-field.tsx
Executable file
@@ -0,0 +1,89 @@
|
||||
import React, {forwardRef, HTMLProps, Ref} from 'react';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {BaseFieldPropsWithDom} from '../base-field-props';
|
||||
import {getInputFieldClassNames} from '../get-input-field-class-names';
|
||||
import {Field} from '../field';
|
||||
import {useField} from '../use-field';
|
||||
|
||||
export interface TextFieldProps
|
||||
extends BaseFieldPropsWithDom<HTMLInputElement> {
|
||||
rows?: number;
|
||||
inputElementType?: 'input' | 'textarea';
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
value?: string | number;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
|
||||
(
|
||||
{
|
||||
inputElementType = 'input',
|
||||
flexibleHeight,
|
||||
inputRef,
|
||||
inputTestId,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const inputObjRef = useObjectRef(inputRef);
|
||||
|
||||
const {fieldProps, inputProps} = useField<HTMLInputElement>({
|
||||
...props,
|
||||
focusRef: inputObjRef,
|
||||
});
|
||||
|
||||
const isTextArea = inputElementType === 'textarea';
|
||||
const ElementType: React.ElementType = isTextArea ? 'textarea' : 'input';
|
||||
const inputFieldClassNames = getInputFieldClassNames({
|
||||
...props,
|
||||
flexibleHeight: flexibleHeight || inputElementType === 'textarea',
|
||||
});
|
||||
|
||||
if (inputElementType === 'textarea' && !props.unstyled) {
|
||||
inputFieldClassNames.input = `${inputFieldClassNames.input} py-12`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Field ref={ref} fieldClassNames={inputFieldClassNames} {...fieldProps}>
|
||||
<ElementType
|
||||
data-testid={inputTestId}
|
||||
ref={inputObjRef}
|
||||
{...(inputProps as any)}
|
||||
rows={
|
||||
isTextArea
|
||||
? (inputProps as HTMLProps<HTMLTextAreaElement>).rows || 4
|
||||
: undefined
|
||||
}
|
||||
className={inputFieldClassNames.input}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface FormTextFieldProps extends TextFieldProps {
|
||||
name: string;
|
||||
}
|
||||
export const FormTextField = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
FormTextFieldProps
|
||||
>(({name, ...props}, ref) => {
|
||||
const {
|
||||
field: {onChange, onBlur, value = '', ref: inputRef},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name,
|
||||
});
|
||||
|
||||
const formProps: TextFieldProps = {
|
||||
onChange,
|
||||
onBlur,
|
||||
value: value == null ? '' : value, // avoid issues with "null" value when setting form defaults from backend model
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
inputRef,
|
||||
name,
|
||||
};
|
||||
|
||||
return <TextField ref={ref} {...mergeProps(formProps, props)} />;
|
||||
});
|
||||
138
common/resources/client/ui/forms/input-field/use-field.ts
Executable file
138
common/resources/client/ui/forms/input-field/use-field.ts
Executable file
@@ -0,0 +1,138 @@
|
||||
import {HTMLAttributes, HTMLProps, RefObject, useId} from 'react';
|
||||
import {BaseFieldPropsWithDom} from './base-field-props';
|
||||
import {useAutoFocus} from '../../focus/use-auto-focus';
|
||||
import type {FieldProps} from './field';
|
||||
|
||||
interface UseFieldReturn<T> {
|
||||
fieldProps: Omit<FieldProps, 'fieldClassNames' | 'children'>;
|
||||
inputProps: HTMLAttributes<T>;
|
||||
}
|
||||
|
||||
interface Props<T> extends BaseFieldPropsWithDom<T> {
|
||||
focusRef: RefObject<HTMLElement>;
|
||||
}
|
||||
export function useField<T>(props: Props<T>): UseFieldReturn<T> {
|
||||
const {
|
||||
focusRef,
|
||||
labelElementType = 'label',
|
||||
label,
|
||||
labelSuffix,
|
||||
labelSuffixPosition,
|
||||
autoFocus,
|
||||
autoSelectText,
|
||||
labelPosition,
|
||||
descriptionPosition,
|
||||
size,
|
||||
errorMessage,
|
||||
description,
|
||||
flexibleHeight,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
startAppend,
|
||||
adornmentPosition,
|
||||
endAppend,
|
||||
className,
|
||||
inputClassName,
|
||||
inputWrapperClassName,
|
||||
unstyled,
|
||||
background,
|
||||
invalid,
|
||||
disabled,
|
||||
id,
|
||||
inputRadius,
|
||||
inputBorder,
|
||||
inputShadow,
|
||||
inputRing,
|
||||
inputFontSize,
|
||||
...inputDomProps
|
||||
} = props;
|
||||
|
||||
useAutoFocus(props, focusRef);
|
||||
|
||||
const defaultId = useId();
|
||||
const inputId = id || defaultId;
|
||||
const labelId = `${inputId}-label`;
|
||||
const descriptionId = `${inputId}-description`;
|
||||
const errorId = `${inputId}-error`;
|
||||
|
||||
const labelProps = {
|
||||
id: labelId,
|
||||
htmlFor: labelElementType === 'label' ? inputId : undefined,
|
||||
};
|
||||
const descriptionProps = {
|
||||
id: descriptionId,
|
||||
};
|
||||
const errorMessageProps = {
|
||||
id: errorId,
|
||||
};
|
||||
|
||||
const ariaLabel =
|
||||
!props.label && !props['aria-label'] && props.placeholder
|
||||
? props.placeholder
|
||||
: props['aria-label'];
|
||||
|
||||
const inputProps: HTMLProps<T> = {
|
||||
'aria-label': ariaLabel,
|
||||
'aria-invalid': invalid || undefined,
|
||||
id: inputId,
|
||||
disabled,
|
||||
...inputDomProps,
|
||||
};
|
||||
|
||||
const labelledBy = [];
|
||||
if (label) {
|
||||
labelledBy.push(labelProps.id);
|
||||
}
|
||||
if (inputProps['aria-labelledby']) {
|
||||
labelledBy.push(inputProps['aria-labelledby']);
|
||||
}
|
||||
inputProps['aria-labelledby'] = labelledBy.length
|
||||
? labelledBy.join(' ')
|
||||
: undefined;
|
||||
|
||||
const describedBy = [];
|
||||
if (description) {
|
||||
describedBy.push(descriptionProps.id);
|
||||
}
|
||||
if (errorMessage) {
|
||||
describedBy.push(errorMessageProps.id);
|
||||
}
|
||||
if (inputProps['aria-describedby']) {
|
||||
describedBy.push(inputProps['aria-describedby']);
|
||||
}
|
||||
inputProps['aria-describedby'] = describedBy.length
|
||||
? describedBy.join(' ')
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
fieldProps: {
|
||||
errorMessageProps,
|
||||
descriptionProps,
|
||||
labelProps,
|
||||
disabled,
|
||||
label,
|
||||
labelSuffix,
|
||||
labelSuffixPosition,
|
||||
autoFocus,
|
||||
autoSelectText,
|
||||
labelPosition,
|
||||
descriptionPosition,
|
||||
size,
|
||||
errorMessage,
|
||||
description,
|
||||
flexibleHeight,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
startAppend,
|
||||
adornmentPosition,
|
||||
endAppend,
|
||||
className,
|
||||
inputClassName,
|
||||
inputWrapperClassName,
|
||||
unstyled,
|
||||
background,
|
||||
invalid,
|
||||
},
|
||||
inputProps,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user