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

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

View File

@@ -0,0 +1,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>
);
}

View 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'> {}

View File

@@ -0,0 +1,5 @@
import {createSvgIcon} from '../../../../icons/create-svg-icon';
export const CancelFilledIcon = createSvgIcon(
<path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z" />
);

View File

@@ -0,0 +1,486 @@
import React, {
HTMLAttributes,
Key,
ReactElement,
ReactNode,
Ref,
RefObject,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import {useFocusManager} from '@react-aria/focus';
import clsx from 'clsx';
import {mergeProps, useLayoutEffect, useObjectRef} from '@react-aria/utils';
import {useControlledState} from '@react-stately/utils';
import {ChipList} from './chip-list';
import {Field, FieldProps} from '../field';
import {Input} from '../input';
import {Chip, ChipProps} from './chip';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {getInputFieldClassNames} from '../get-input-field-class-names';
import {ProgressCircle} from '../../../progress/progress-circle';
import {useField} from '../use-field';
import {Avatar} from '../../../images/avatar';
import {Listbox} from '../../listbox/listbox';
import {useListbox} from '../../listbox/use-listbox';
import {BaseFieldPropsWithDom} from '../base-field-props';
import {useListboxKeyboardNavigation} from '../../listbox/use-listbox-keyboard-navigation';
import {createEventHandler} from '@common/utils/dom/create-event-handler';
import {ListBoxChildren, ListboxProps} from '../../listbox/types';
import {stringToChipValue} from './string-to-chip-value';
import {Popover} from '../../../overlays/popover';
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
export interface ChipValue extends Omit<NormalizedModel, 'model_type'> {
invalid?: boolean;
errorMessage?: string;
}
export type ChipFieldProps<T> = Omit<
ListboxProps,
'selectionMode' | 'displayWith'
> &
Omit<
BaseFieldPropsWithDom<HTMLInputElement>,
'value' | 'onChange' | 'defaultValue'
> & {
value?: (ChipValue | string)[];
defaultValue?: (ChipValue | string)[];
displayWith?: (value: ChipValue) => ReactNode;
validateWith?: (value: ChipValue) => ChipValue;
allowCustomValue?: boolean;
showDropdownArrow?: boolean;
onChange?: (value: ChipValue[]) => void;
suggestions?: T[];
children?: ListBoxChildren<T>['children'];
placeholder?: string;
chipSize?: ChipProps['size'];
openMenuOnFocus?: boolean;
valueKey?: 'id' | 'name';
onChipClick?: (value: ChipValue) => void;
};
function ChipFieldInner<T>(
props: ChipFieldProps<T>,
ref: Ref<HTMLInputElement>,
) {
const fieldRef = useRef<HTMLDivElement>(null);
const inputRef = useObjectRef(ref);
const {
displayWith = v => v.name,
validateWith,
children,
suggestions,
isLoading,
inputValue,
onInputValueChange,
onItemSelected,
placeholder,
onOpenChange,
chipSize = 'sm',
openMenuOnFocus = true,
showEmptyMessage,
value: propsValue,
defaultValue,
onChange: propsOnChange,
valueKey,
isAsync,
allowCustomValue = true,
showDropdownArrow,
onChipClick,
...inputFieldProps
} = props;
const fieldClassNames = getInputFieldClassNames({
...props,
flexibleHeight: true,
});
const [value, onChange] = useChipFieldValueState(props);
const [listboxIsOpen, setListboxIsOpen] = useState(false);
const loadingIndicator = (
<ProgressCircle isIndeterminate size="sm" aria-label="loading..." />
);
const dropdownArrow = showDropdownArrow ? <KeyboardArrowDownIcon /> : null;
const {fieldProps, inputProps} = useField({
...inputFieldProps,
focusRef: inputRef,
endAdornment: isLoading && listboxIsOpen ? loadingIndicator : dropdownArrow,
});
return (
<Field fieldClassNames={fieldClassNames} {...fieldProps}>
<Input
ref={fieldRef}
className={clsx('flex flex-wrap items-center', fieldClassNames.input)}
onClick={() => {
// refocus input when clicking outside it, but while still inside chip field
inputRef.current?.focus();
}}
>
<ListWrapper
displayChipUsing={displayWith}
onChipClick={onChipClick}
items={value}
setItems={onChange}
chipSize={chipSize}
/>
<ChipInput
size={props.size}
showEmptyMessage={showEmptyMessage}
inputProps={inputProps}
inputValue={inputValue}
onInputValueChange={onInputValueChange}
fieldRef={fieldRef}
inputRef={inputRef}
chips={value}
setChips={onChange}
validateWith={validateWith}
isLoading={isLoading}
suggestions={suggestions}
placeholder={placeholder}
openMenuOnFocus={openMenuOnFocus}
listboxIsOpen={listboxIsOpen}
setListboxIsOpen={setListboxIsOpen}
allowCustomValue={allowCustomValue}
>
{children}
</ChipInput>
</Input>
</Field>
);
}
interface ListWrapperProps {
items: ChipValue[];
setItems: (items: ChipValue[]) => void;
displayChipUsing: (value: ChipValue) => ReactNode;
chipSize?: ChipProps['size'];
onChipClick?: (value: ChipValue) => void;
}
function ListWrapper({
items,
setItems,
displayChipUsing,
chipSize,
onChipClick,
}: ListWrapperProps) {
const manager = useFocusManager();
const removeItem = useCallback(
(key: Key) => {
const i = items.findIndex(cr => cr.id === key);
const newItems = [...items];
if (i > -1) {
newItems.splice(i, 1);
setItems(newItems);
}
return newItems;
},
[items, setItems],
);
return (
<ChipList
className={clsx(
'max-w-full flex-shrink-0 flex-wrap',
chipSize === 'xs' ? 'my-6' : 'my-8',
)}
size={chipSize}
selectable
>
{items.map(item => (
<Chip
key={item.id}
errorMessage={item.errorMessage}
adornment={item.image ? <Avatar circle src={item.image} /> : null}
onClick={() => onChipClick?.(item)}
onRemove={() => {
const newItems = removeItem(item.id);
if (newItems.length) {
// focus previous chip
manager?.focusPrevious({tabbable: true});
} else {
// focus input
manager?.focusLast();
}
}}
>
{displayChipUsing(item)}
</Chip>
))}
</ChipList>
);
}
interface ChipInputProps<T> {
showEmptyMessage?: boolean;
inputProps: ReturnType<typeof useField>['inputProps'];
inputValue?: string;
onInputValueChange?: (value: string) => void;
fieldRef: RefObject<HTMLDivElement>;
inputRef: RefObject<HTMLInputElement>;
chips: ChipValue[];
setChips: (items: ChipValue[]) => void;
validateWith?: (value: ChipValue) => ChipValue;
isLoading?: boolean;
suggestions?: T[];
placeholder?: string;
openMenuOnFocus?: boolean;
listboxIsOpen: boolean;
setListboxIsOpen: (value: boolean) => void;
allowCustomValue: boolean;
children: ListBoxChildren<T>['children'];
size: FieldProps['size'];
}
function ChipInput<T>(props: ChipInputProps<T>) {
const {
inputRef,
fieldRef,
validateWith,
setChips,
chips,
suggestions,
inputProps,
placeholder,
openMenuOnFocus,
listboxIsOpen,
setListboxIsOpen,
allowCustomValue,
isLoading,
size,
} = props;
const manager = useFocusManager();
const addItems = useCallback(
(items?: ChipValue[]) => {
items = (items || []).filter(item => {
const invalid = !item || !item.id || !item.name;
const alreadyExists = chips.findIndex(cr => cr.id === item?.id) > -1;
return !alreadyExists && !invalid;
});
if (!items.length) return;
if (validateWith) {
items = items.map(item => validateWith(item));
}
setChips([...chips, ...items]);
},
[chips, setChips, validateWith],
);
const listbox = useListbox<T>({
...props,
clearInputOnItemSelection: true,
isOpen: listboxIsOpen,
onOpenChange: setListboxIsOpen,
items: suggestions,
selectionMode: 'none',
role: 'listbox',
virtualFocus: true,
onItemSelected: value => {
handleItemSelection(value as string);
},
});
const {
state: {
activeIndex,
setActiveIndex,
isOpen,
setIsOpen,
inputValue,
setInputValue,
},
refs,
listboxId,
collection,
onInputChange,
} = listbox;
const handleItemSelection = (textValue: string) => {
const option =
collection.size && activeIndex != null
? [...collection.values()][activeIndex]
: null;
if (option?.item) {
addItems([option.item]);
} else if (allowCustomValue) {
addItems([stringToChipValue(option ? option.value : textValue)]);
}
setInputValue('');
setActiveIndex(null);
setIsOpen(false);
};
// position dropdown relative to whole chip field, not the input
useLayoutEffect(() => {
if (fieldRef.current && refs.reference.current !== fieldRef.current) {
listbox.reference(fieldRef.current);
}
}, [fieldRef, listbox, refs]);
const {handleTriggerKeyDown, handleListboxKeyboardNavigation} =
useListboxKeyboardNavigation(listbox);
const handleFocusAndClick = createEventHandler(() => {
if (openMenuOnFocus && !isOpen) {
setIsOpen(true);
}
});
return (
<Listbox
listbox={listbox}
mobileOverlay={Popover}
isLoading={isLoading}
onPointerDown={e => {
// prevent focus from leaving input when scrolling listbox via mouse
e.preventDefault();
}}
>
<input
type="text"
className={clsx(
'mx-8 my-4 min-w-30 flex-[1_1_60px] bg-transparent text-sm outline-none',
size === 'xs' ? 'h-20' : 'h-30',
)}
placeholder={placeholder}
{...mergeProps(inputProps, {
ref: inputRef,
value: inputValue,
onChange: onInputChange,
onPaste: e => {
const paste = e.clipboardData.getData('text');
const emails = paste.match(
/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/gi,
);
if (emails) {
e.preventDefault();
const selection = window.getSelection();
if (selection?.rangeCount) {
selection.deleteFromDocument();
addItems(emails.map(email => stringToChipValue(email)));
}
}
},
'aria-autocomplete': 'list',
'aria-controls': isOpen ? listboxId : undefined,
autoComplete: 'off',
autoCorrect: 'off',
spellCheck: 'false',
onKeyDown: e => {
const input = e.target as HTMLInputElement;
if (e.key === 'Enter') {
// prevent form submitting
e.preventDefault();
// add chip from selected listbox option or current input text value
handleItemSelection(input.value);
return;
}
// on escape, clear input and close dropdown
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
setInputValue('');
}
// move focus to input when focus is on first item and prevent arrow up from cycling listbox
if (
e.key === 'ArrowUp' &&
isOpen &&
(activeIndex === 0 || activeIndex == null)
) {
setActiveIndex(null);
return;
}
// block left and right arrows from navigating in input, if focus is on listbox
if (
activeIndex != null &&
(e.key === 'ArrowLeft' || e.key === 'ArrowRight')
) {
e.preventDefault();
return;
}
// move focus on the last chip, if focus is at the start of input
if (
(e.key === 'ArrowLeft' ||
e.key === 'Backspace' ||
e.key === 'Delete') &&
input.selectionStart === 0 &&
activeIndex == null &&
chips.length
) {
manager?.focusPrevious({tabbable: true});
return;
}
// fallthrough to listbox navigation handlers for arrow keys
const handled = handleTriggerKeyDown(e);
if (!handled) {
handleListboxKeyboardNavigation(e);
}
},
onFocus: handleFocusAndClick,
onClick: handleFocusAndClick,
} as HTMLAttributes<HTMLInputElement>)}
/>
</Listbox>
);
}
function useChipFieldValueState({
onChange,
value,
defaultValue,
valueKey,
}: ChipFieldProps<any>) {
// convert value from string[] to ChipValue[], if needed
const propsValue = useMemo(() => {
return mixedValueToChipValue(value);
}, [value]);
// convert defaultValue from string[] to ChipValue[], if needed
const propsDefaultValue = useMemo(() => {
return mixedValueToChipValue(defaultValue);
}, [defaultValue]);
// emit string[] or ChipValue[] on change, based on "valueKey" prop
const handleChange = useCallback(
(value: ChipValue[]) => {
const newValue = valueKey ? value.map(v => v[valueKey]) : value;
onChange?.(newValue as any);
},
[onChange, valueKey],
);
return useControlledState<ChipValue[]>(
!propsValue ? undefined : propsValue,
propsDefaultValue || [],
handleChange,
);
}
function mixedValueToChipValue(
value?: (string | number | ChipValue)[] | null,
): ChipValue[] | undefined {
if (value == null) {
return undefined;
}
return value.map(v => {
return typeof v !== 'object' ? stringToChipValue(v as string) : v;
});
}
export const ChipField = React.forwardRef(ChipFieldInner) as <T>(
props: ChipFieldProps<T> & {ref?: Ref<HTMLInputElement>},
) => ReactElement;

View File

@@ -0,0 +1,48 @@
import React, {
Children,
cloneElement,
isValidElement,
ReactElement,
} from 'react';
import clsx from 'clsx';
import type {ChipProps} from './chip';
export interface ChipListProps {
className?: string;
children?: ReactElement | ReactElement[];
size?: ChipProps['size'];
radius?: ChipProps['radius'];
color?: ChipProps['color'];
selectable?: ChipProps['selectable'];
wrap?: boolean;
}
export function ChipList({
className,
children,
size,
color,
radius,
selectable,
wrap = true,
}: ChipListProps) {
return (
<div
className={clsx(
'flex items-center gap-8',
wrap && 'flex-wrap',
className,
)}
>
{Children.map(children, chip => {
if (isValidElement<ChipProps>(chip)) {
return cloneElement<ChipProps>(chip, {
size,
color,
selectable,
radius,
});
}
})}
</div>
);
}

View File

@@ -0,0 +1,191 @@
import React, {
cloneElement,
JSXElementConstructor,
ReactElement,
ReactNode,
useRef,
} from 'react';
import clsx from 'clsx';
import {useFocusManager} from '@react-aria/focus';
import {ButtonBase} from '../../../buttons/button-base';
import {CancelFilledIcon} from './cancel-filled-icon';
import {WarningIcon} from '@common/icons/material/Warning';
import {Tooltip} from '../../../tooltip/tooltip';
import {To} from 'react-router-dom';
export interface ChipProps {
onRemove?: () => void;
disabled?: boolean;
selectable?: boolean;
invalid?: boolean;
errorMessage?: ReactElement | string;
children?: ReactNode;
className?: string;
adornment?: null | ReactElement<{
size: string;
className?: string;
circle?: boolean;
}>;
radius?: string;
color?: 'chip' | 'primary' | 'danger' | 'positive';
size?: 'xs' | 'sm' | 'md' | 'lg';
elementType?: 'div' | 'a' | JSXElementConstructor<any>;
to?: To;
onClick?: (e: React.MouseEvent) => void;
}
export function Chip(props: ChipProps) {
const {
onRemove,
disabled,
invalid,
errorMessage,
children,
className,
selectable = false,
radius = 'rounded-full',
elementType = 'div',
to,
onClick,
} = props;
const chipRef = useRef<HTMLDivElement>(null);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const focusManager = useFocusManager();
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
focusManager?.focusNext({tabbable: true});
break;
case 'ArrowLeft':
case 'ArrowUp':
focusManager?.focusPrevious({tabbable: true});
break;
case 'Backspace':
case 'Delete':
if (chipRef.current === document.activeElement) {
onRemove?.();
}
break;
default:
}
};
const handleClick: React.MouseEventHandler = e => {
e.stopPropagation();
if (onClick) {
onClick(e);
} else {
chipRef.current!.focus();
}
};
const sizeStyle = sizeClassNames(props);
let adornment =
invalid || errorMessage != null ? (
<WarningIcon className="text-danger" size="sm" />
) : (
props.adornment &&
cloneElement(props.adornment, {
size: sizeStyle.adornment.size,
circle: true,
className: clsx(props.adornment.props, sizeStyle.adornment.margin),
})
);
if (errorMessage && adornment) {
adornment = (
<Tooltip label={errorMessage} variant="danger">
{adornment}
</Tooltip>
);
}
const Element = elementType;
return (
<Element
tabIndex={selectable ? 0 : undefined}
ref={chipRef}
to={to}
onKeyDown={selectable ? handleKeyDown : undefined}
onClick={selectable ? handleClick : undefined}
className={clsx(
'relative flex flex-shrink-0 items-center justify-center gap-10 overflow-hidden whitespace-nowrap outline-none',
'min-w-0 max-w-full after:pointer-events-none after:absolute after:inset-0',
onClick && 'cursor-pointer',
radius,
colorClassName(props),
sizeStyle.chip,
!disabled &&
selectable &&
'hover:after:bg-black/5 focus:after:bg-black/10',
className,
)}
>
{adornment}
<div className="flex-auto overflow-hidden overflow-ellipsis">
{children}
</div>
{onRemove && (
<ButtonBase
ref={deleteButtonRef}
className={clsx(
'text-black/30 dark:text-white/50',
sizeStyle.closeButton,
)}
onClick={e => {
e.stopPropagation();
onRemove();
}}
tabIndex={-1}
>
<CancelFilledIcon className="block" width="100%" height="100%" />
</ButtonBase>
)}
</Element>
);
}
function sizeClassNames({size, onRemove}: ChipProps) {
switch (size) {
case 'xs':
return {
adornment: {size: 'xs', margin: '-ml-3'},
chip: clsx('pl-8 h-20 text-xs font-medium w-max', !onRemove && 'pr-8'),
closeButton: 'mr-4 w-14 h-14',
};
case 'sm':
return {
adornment: {size: 'xs', margin: '-ml-3'},
chip: clsx('pl-8 h-26 text-xs', !onRemove && 'pr-8'),
closeButton: 'mr-4 w-18 h-18',
};
case 'lg':
return {
adornment: {size: 'md', margin: '-ml-12'},
chip: clsx('pl-18 h-38 text-base', !onRemove && 'pr-18'),
closeButton: 'mr-6 w-24 h-24',
};
default:
return {
adornment: {size: 'sm', margin: '-ml-6'},
chip: clsx('pl-12 h-32 text-sm', !onRemove && 'pr-12'),
closeButton: 'mr-6 w-22 h-22',
};
}
}
function colorClassName({color}: ChipProps): string {
switch (color) {
case 'primary':
return `bg-primary text-on-primary`;
case 'positive':
return `bg-positive-lighter text-positive-darker`;
case 'danger':
return `bg-danger-lighter text-danger-darker`;
default:
return `bg-chip text-main`;
}
}

View File

@@ -0,0 +1,31 @@
import {useController} from 'react-hook-form';
import {mergeProps} from '@react-aria/utils';
import React from 'react';
import {ChipField, ChipFieldProps} from './chip-field';
export type FormChipFieldProps<T> = ChipFieldProps<T> & {
name: string;
};
export function FormChipField<T>({children, ...props}: FormChipFieldProps<T>) {
const {
field: {onChange, onBlur, value = [], ref},
fieldState: {invalid, error},
} = useController({
name: props.name,
});
const formProps: Partial<ChipFieldProps<T>> = {
onChange,
onBlur,
value,
invalid,
errorMessage: error?.message,
};
return (
<ChipField ref={ref} {...mergeProps(formProps, props)}>
{children}
</ChipField>
);
}

View File

@@ -0,0 +1,6 @@
import {ChipValue} from './chip-field';
export function stringToChipValue(value: string | number): ChipValue {
// add both name and description so "validateWith" works properly in chip field, if it depends on description
return {id: value, name: `${value}`, description: `${value}`};
}

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
};
},
},
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export type Granularity = 'day' | 'hour' | 'minute';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
export const PAGE_STEP = {
year: 5,
month: 2,
day: 7,
hour: 2,
minute: 15,
second: 15,
dayPeriod: 1,
};

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export type InputSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';

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

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

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

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