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,61 @@
import React, {ReactElement, useEffect, useRef, useState} from 'react';
import {SvgIconProps} from '@common/icons/svg-icon';
import {useTrans} from '@common/i18n/use-trans';
import {useListboxContext} from '@common/ui/forms/listbox/listbox-context';
import {ProgressCircle} from '@common/ui/progress/progress-circle';
import {KeyboardArrowDownIcon} from '@common/icons/material/KeyboardArrowDown';
interface Props {
isLoading?: boolean;
icon?: ReactElement<SvgIconProps>;
}
export function ComboboxEndAdornment({isLoading, icon}: Props) {
const timeout = useRef<any>(null);
const {trans} = useTrans();
const [showLoading, setShowLoading] = useState(false);
const {
state: {isOpen, inputValue},
} = useListboxContext();
const lastInputValue = useRef(inputValue);
useEffect(() => {
if (isLoading && !showLoading) {
if (timeout.current === null) {
timeout.current = setTimeout(() => {
setShowLoading(true);
}, 500);
}
// If user is typing, clear the timer and restart since it is a new request
if (inputValue !== lastInputValue.current) {
clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
setShowLoading(true);
}, 500);
}
} else if (!isLoading) {
// If loading is no longer happening, clear any timers and hide the loading circle
setShowLoading(false);
clearTimeout(timeout.current);
timeout.current = null;
}
lastInputValue.current = inputValue;
}, [isLoading, showLoading, inputValue]);
// loading circle should only be displayed if menu is open, if menuTrigger is "manual", or first time load (to stop circle from showing up when user selects an option)
const showLoadingIndicator = showLoading && (isOpen || isLoading);
if (showLoadingIndicator) {
return (
<ProgressCircle
aria-label={trans({message: 'Loading'})}
size="sm"
isIndeterminate
/>
);
}
return icon || <KeyboardArrowDownIcon />;
}

View File

@@ -0,0 +1,183 @@
import React, {ReactElement, Ref} from 'react';
import {BaseFieldPropsWithDom} from '../input-field/base-field-props';
import {Item} from '../listbox/item';
import {useListbox} from '../listbox/use-listbox';
import {IconButton} from '../../buttons/icon-button';
import {TextField} from '../input-field/text-field/text-field';
import {Listbox} from '../listbox/listbox';
import {SvgIconProps} from '@common/icons/svg-icon';
import {useListboxKeyboardNavigation} from '@common/ui/forms/listbox/use-listbox-keyboard-navigation';
import {createEventHandler} from '@common/utils/dom/create-event-handler';
import {ListBoxChildren, ListboxProps} from '../listbox/types';
import {Popover} from '../../overlays/popover';
import {ComboboxEndAdornment} from '@common/ui/forms/combobox/combobox-end-adornment';
export {Item as Option};
export type ComboboxProps<T extends object> = Omit<
BaseFieldPropsWithDom<HTMLInputElement>,
'endAdornment'
> &
ListBoxChildren<T> &
ListboxProps & {
selectionMode?: 'single' | 'none';
isAsync?: boolean;
isLoading?: boolean;
openMenuOnFocus?: boolean;
endAdornmentIcon?: ReactElement<SvgIconProps>;
useOptionLabelAsInputValue?: boolean;
hideEndAdornment?: boolean;
onEndAdornmentClick?: () => void;
prependListbox?: boolean;
listboxClassName?: string;
};
function ComboBox<T extends object>(
props: ComboboxProps<T> & {selectionMode: 'single'},
ref: Ref<HTMLInputElement>,
) {
const {
children,
items,
isAsync,
isLoading,
openMenuOnFocus = true,
endAdornmentIcon,
onItemSelected,
maxItems,
clearInputOnItemSelection,
inputValue: userInputValue,
selectedValue,
onSelectionChange,
allowCustomValue = false,
onInputValueChange,
defaultInputValue,
selectionMode = 'single',
useOptionLabelAsInputValue,
showEmptyMessage,
floatingMaxHeight,
hideEndAdornment = false,
blurReferenceOnItemSelection,
isOpen: propsIsOpen,
onOpenChange: propsOnOpenChange,
prependListbox,
listboxClassName,
onEndAdornmentClick,
autoFocusFirstItem = true,
...textFieldProps
} = props;
const listbox = useListbox(
{
...props,
floatingMaxHeight,
blurReferenceOnItemSelection,
selectionMode,
role: 'listbox',
virtualFocus: true,
clearSelectionOnInputClear: true,
},
ref,
);
const {
reference,
listboxId,
onInputChange,
state: {
isOpen,
setIsOpen,
inputValue,
setInputValue,
selectValues,
selectedValues,
setActiveCollection,
},
collection,
} = listbox;
const textLabel = selectedValues[0]
? collection.get(selectedValues[0])?.textLabel
: undefined;
const {handleListboxSearchFieldKeydown} =
useListboxKeyboardNavigation(listbox);
const handleFocusAndClick = createEventHandler(
(e: React.FocusEvent<HTMLInputElement>) => {
if (openMenuOnFocus && !isOpen) {
setIsOpen(true);
}
e.target.select();
},
);
return (
<Listbox
prepend={prependListbox}
className={listboxClassName}
listbox={listbox}
mobileOverlay={Popover}
isLoading={isLoading}
onPointerDown={e => {
// prevent focus from leaving input when scrolling listbox via mouse
e.preventDefault();
}}
>
<TextField
inputRef={reference}
{...textFieldProps}
endAdornment={
!hideEndAdornment ? (
<IconButton
size="md"
tabIndex={-1}
disabled={textFieldProps.disabled}
className="pointer-events-auto"
onPointerDown={e => {
e.preventDefault();
e.stopPropagation();
if (onEndAdornmentClick) {
onEndAdornmentClick();
} else {
setActiveCollection('all');
setIsOpen(!isOpen);
}
}}
>
<ComboboxEndAdornment
isLoading={isLoading}
icon={endAdornmentIcon}
/>
</IconButton>
) : null
}
aria-expanded={isOpen ? 'true' : 'false'}
aria-haspopup="listbox"
aria-controls={isOpen ? listboxId : undefined}
aria-autocomplete="list"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
onChange={onInputChange}
value={useOptionLabelAsInputValue && textLabel ? textLabel : inputValue}
onBlur={e => {
if (allowCustomValue) {
selectValues(e.target.value);
} else if (!clearInputOnItemSelection) {
const val = selectedValues[0];
setInputValue(selectValues.length && val != null ? `${val}` : '');
}
}}
onFocus={handleFocusAndClick}
onClick={handleFocusAndClick}
onKeyDown={e => handleListboxSearchFieldKeydown(e)}
/>
</Listbox>
);
}
const ComboBoxForwardRef = React.forwardRef(ComboBox) as <T extends object>(
props: ComboboxProps<T> & {ref?: Ref<HTMLInputElement>},
) => ReactElement;
export {ComboBoxForwardRef as ComboBox};

View File

@@ -0,0 +1,32 @@
import {useController} from 'react-hook-form';
import {mergeProps} from '@react-aria/utils';
import React from 'react';
import {ComboBox, ComboboxProps} from './combobox';
type Props<T extends object> = ComboboxProps<T> & {
name: string;
selectionMode?: 'single';
};
export function FormComboBox<T extends object>({children, ...props}: Props<T>) {
const {
field: {onChange, onBlur, value = '', ref},
fieldState: {invalid, error},
} = useController({
name: props.name,
});
const formProps: Partial<ComboboxProps<T>> = {
onSelectionChange: onChange,
onBlur,
selectedValue: value,
defaultInputValue: value,
invalid,
errorMessage: error?.message,
};
return (
<ComboBox ref={ref} {...mergeProps(formProps, props)}>
{children}
</ComboBox>
);
}

View File

@@ -0,0 +1,44 @@
import {
FieldValues,
FormProvider,
SubmitHandler,
UseFormReturn,
} from 'react-hook-form';
import {FocusEventHandler, ReactNode} from 'react';
interface Props<T extends FieldValues> {
children: ReactNode;
form: UseFormReturn<T>;
className?: string;
onSubmit: SubmitHandler<T>;
onBeforeSubmit?: () => void;
onBlur?: FocusEventHandler<HTMLFormElement>;
id?: string;
}
export function Form<T extends FieldValues>({
children,
onBeforeSubmit,
onSubmit,
form,
className,
id,
onBlur,
}: Props<T>) {
return (
<FormProvider {...form}>
<form
id={id}
onBlur={onBlur}
className={className}
onSubmit={e => {
// prevent parent forms from submitting, if nested
e.stopPropagation();
onBeforeSubmit?.();
form.handleSubmit(onSubmit)(e);
}}
>
{children}
</form>
</FormProvider>
);
}

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

View File

@@ -0,0 +1,131 @@
import {Children, isValidElement, ReactElement, ReactNode} from 'react';
import memoize from 'nano-memoize';
import {ListboxItemProps} from './item';
import {ListboxSectionProps, Section} from './section';
import {ListBoxChildren} from './types';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
export type ListboxCollection = Map<string | number, CollectionItem<any>>;
export type CollectionItem<T> = {
index: number;
textLabel: string;
element: ReactElement<ListboxItemProps>;
value: string | number;
item?: T;
isDisabled?: boolean;
section?: ReactElement<ListboxSectionProps>;
};
type Props<T> = ListBoxChildren<T> & {
inputValue?: string;
maxItems?: number;
};
export const buildListboxCollection = memoize(
({maxItems, children, items, inputValue}: Props<any>) => {
let collection = childrenToCollection({children, items});
let filteredCollection = filterCollection({collection, inputValue});
if (maxItems) {
collection = new Map([...collection.entries()].slice(0, maxItems));
filteredCollection = new Map(
[...filteredCollection.entries()].slice(0, maxItems)
);
}
return {collection, filteredCollection};
}
);
type filterCollectionProps = {
collection: ListboxCollection;
inputValue?: string;
};
const filterCollection = memoize(
({collection, inputValue}: filterCollectionProps) => {
let filteredCollection: ListboxCollection = new Map();
const query = inputValue ? `${inputValue}`.toLowerCase().trim() : '';
if (!query) {
filteredCollection = collection;
} else {
let filterIndex = 0;
collection.forEach((meta, value) => {
const haystack = meta.item ? JSON.stringify(meta.item) : meta.textLabel;
if (haystack.toLowerCase().trim().includes(query)) {
filteredCollection.set(value, {...meta, index: filterIndex++});
}
});
}
return filteredCollection;
}
);
const childrenToCollection = memoize(
({children, items}: ListBoxChildren<any>) => {
let reactChildren: ReactNode;
if (items && typeof children === 'function') {
reactChildren = items.map(item => children(item));
} else {
reactChildren = children as ReactNode;
}
const collection = new Map<string | number, CollectionItem<any>>();
let optionIndex = 0;
const setOption = (
element: ReactElement<ListboxItemProps>,
section?: any,
sectionIndex?: number,
sectionItemIndex?: number
) => {
const index = optionIndex++;
const item = section
? // get item from nested array
items?.[sectionIndex!].items[sectionItemIndex!]
: // get item from flat array
items?.[index];
collection.set(element.props.value, {
index,
element,
textLabel: getTextLabel(element),
item,
section,
isDisabled: element.props.isDisabled,
value: element.props.value,
});
};
Children.forEach(reactChildren, (child, childIndex) => {
if (!isValidElement(child)) return;
if (child.type === Section) {
Children.forEach(
child.props.children,
(nestedChild, nestedChildIndex) => {
setOption(nestedChild, child, childIndex, nestedChildIndex);
}
);
} else {
setOption(child as ReactElement<ListboxItemProps>);
}
});
return collection;
}
);
function getTextLabel(item: ReactElement<ListboxItemProps>): string {
const content = item.props.children as any;
if (item.props.textLabel) {
return item.props.textLabel;
}
if ((content?.props as MessageDescriptor)?.message) {
return content.props.message;
}
return `${content}` || '';
}

View File

@@ -0,0 +1,99 @@
import React from 'react';
import {useListboxContext} from './listbox-context';
import {ListItemBase, ListItemBaseProps} from '../../list/list-item-base';
export interface ListboxItemProps extends ListItemBaseProps {
value: any;
textLabel?: string;
onSelected?: () => void;
onKeyDown?: any;
tabIndex?: number;
className?: string;
capitalizeFirst?: boolean;
}
export function Item({
children,
value,
startIcon,
endIcon,
endSection,
description,
capitalizeFirst,
textLabel,
isDisabled,
onSelected,
onClick,
...domProps
}: ListboxItemProps) {
const {
collection,
showCheckmark,
virtualFocus,
listboxId,
role,
listItemsRef,
handleItemSelection,
state: {selectedValues, activeIndex, setActiveIndex},
} = useListboxContext();
const isSelected = selectedValues.includes(value);
const index = collection.get(value)?.index;
const isActive = activeIndex === index;
// context value might get out of sync with item due to AnimatePresence
if (index == null) {
return null;
}
const tabIndex = isActive && !isDisabled ? -1 : 0;
return (
<ListItemBase
{...domProps}
onFocus={() => {
if (!virtualFocus) {
setActiveIndex(index);
}
}}
onPointerEnter={e => {
setActiveIndex(index);
if (!virtualFocus) {
e.currentTarget.focus();
}
}}
onPointerDown={e => {
if (virtualFocus) {
e.preventDefault();
}
}}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleItemSelection(value);
onSelected?.();
}
}}
onClick={e => {
handleItemSelection(value);
onSelected?.();
onClick?.(e);
}}
ref={node => (listItemsRef.current[index] = node)}
id={`${listboxId}-${index}`}
role={role === 'menu' ? 'menuitem' : 'option'}
tabIndex={virtualFocus ? undefined : tabIndex}
aria-selected={isActive && isSelected}
showCheckmark={showCheckmark}
isDisabled={isDisabled}
isActive={isActive}
isSelected={isSelected}
startIcon={startIcon}
description={description}
endIcon={endIcon}
endSection={endSection}
capitalizeFirst={capitalizeFirst}
data-value={value}
>
{children}
</ListItemBase>
);
}

View File

@@ -0,0 +1,11 @@
import {createContext, useContext} from 'react';
import {UseListboxReturn} from './types';
type ListBoxReturnType = UseListboxReturn;
export type ListboxContextValue = ListBoxReturnType;
export const ListBoxContext = createContext<ListboxContextValue>(null!);
export function useListboxContext() {
return useContext(ListBoxContext);
}

View File

@@ -0,0 +1,202 @@
import {AnimatePresence} from 'framer-motion';
import React, {
cloneElement,
ComponentPropsWithoutRef,
JSXElementConstructor,
ReactElement,
ReactNode,
RefObject,
useEffect,
useMemo,
useRef,
} from 'react';
import clsx from 'clsx';
import {ListBoxContext, useListboxContext} from './listbox-context';
import {useIsMobileDevice} from '@common/utils/hooks/is-mobile-device';
import {Popover} from '../../overlays/popover';
import {Tray} from '../../overlays/tray';
import {Trans} from '@common/i18n/trans';
import {createPortal} from 'react-dom';
import {UseListboxReturn} from './types';
import {OverlayProps} from '../../overlays/overlay-props';
import {rootEl} from '@common/core/root-el';
interface Props extends ComponentPropsWithoutRef<'div'> {
listbox: UseListboxReturn;
mobileOverlay?: JSXElementConstructor<OverlayProps>;
children?: ReactElement;
searchField?: ReactNode;
isLoading?: boolean;
onClose?: () => void;
prepend?: boolean;
}
export function Listbox({
listbox,
children: trigger,
isLoading,
mobileOverlay = Tray,
searchField,
onClose,
prepend,
className: listboxClassName,
...domProps
}: Props) {
const isMobile = useIsMobileDevice();
const {
floatingWidth,
floatingMinWidth = 'min-w-180',
collection,
showEmptyMessage,
state: {isOpen, setIsOpen},
positionStyle,
floating,
refs,
} = listbox;
const Overlay = !prepend && isMobile ? mobileOverlay : Popover;
const className = clsx(
'text-base sm:text-sm outline-none bg max-h-inherit flex flex-col',
!prepend && 'shadow-xl border py-4',
listboxClassName,
// tray will apply its own rounding and max width
Overlay === Popover && 'rounded-panel',
Overlay === Popover && floatingWidth === 'auto'
? `max-w-288 ${floatingMinWidth}`
: '',
);
const children = useMemo(() => {
let sectionIndex = 0;
const renderedSections: ReactElement[] = [];
return [...collection.values()].reduce<ReactElement[]>((prev, curr) => {
if (!curr.section) {
prev.push(
cloneElement(curr.element, {
key: curr.element.key || curr.element.props.value,
}),
);
} else if (!renderedSections.includes(curr.section)) {
const section = cloneElement(curr.section, {
key: curr.section.key || sectionIndex,
index: sectionIndex,
});
prev.push(section);
// clone element will create new instance of object, need to keep
// track of original instance so sections are not duplicated
renderedSections.push(curr.section);
sectionIndex++;
}
return prev;
}, []);
}, [collection]);
const showContent = children.length > 0 || (showEmptyMessage && !isLoading);
const innerContent = showContent ? (
<div className={className} role="presentation">
{searchField}
<FocusContainer isLoading={isLoading} {...domProps}>
{children}
</FocusContainer>
</div>
) : null;
return (
<ListBoxContext.Provider value={listbox}>
{trigger}
{prepend
? innerContent
: rootEl &&
createPortal(
<AnimatePresence>
{isOpen && showContent && (
<Overlay
triggerRef={refs.reference as RefObject<HTMLElement>}
restoreFocus
isOpen={isOpen}
onClose={() => {
onClose?.();
setIsOpen(false);
}}
isDismissable
style={positionStyle}
ref={floating}
>
{innerContent!}
</Overlay>
)}
</AnimatePresence>,
rootEl,
)}
</ListBoxContext.Provider>
);
}
interface WrapperProps extends ComponentPropsWithoutRef<'div'> {
isLoading?: boolean;
children: ReactElement[];
}
function FocusContainer({
className,
children,
isLoading,
...domProps
}: WrapperProps) {
const {
role,
listboxId,
virtualFocus,
focusItem,
state: {activeIndex, setActiveIndex, selectedIndex},
} = useListboxContext();
const autoFocusRef = useRef(true);
const domRef = useRef<HTMLDivElement>(null);
// reset activeIndex on unmount
useEffect(() => {
return () => setActiveIndex(null);
}, [setActiveIndex]);
// focus active index or menu on mount, because menu will be closed
// on trigger keyDown and focus won't be applied to items
useEffect(() => {
if (autoFocusRef.current) {
const indexToFocus = activeIndex ?? selectedIndex;
// if no activeIndex, focus menu itself
if (indexToFocus == null && !virtualFocus) {
requestAnimationFrame(() => {
domRef.current?.focus({preventScroll: true});
});
} else if (indexToFocus != null) {
// wait until next frame, otherwise auto scroll might not work
requestAnimationFrame(() => {
focusItem('increment', indexToFocus);
});
}
}
autoFocusRef.current = false;
}, [activeIndex, selectedIndex, focusItem, virtualFocus]);
return (
<div
tabIndex={virtualFocus ? undefined : -1}
role={role}
id={listboxId}
className="flex-auto overflow-y-auto overscroll-contain outline-none"
ref={domRef}
{...domProps}
>
{children.length ? children : <EmptyMessage />}
</div>
);
}
function EmptyMessage() {
return (
<div className="px-8 py-4 text-sm italic text-muted">
<Trans message="There are no items matching your query" />
</div>
);
}

View File

@@ -0,0 +1,31 @@
import React, {ReactNode, useId} from 'react';
import clsx from 'clsx';
export interface ListboxSectionProps {
label?: ReactNode;
children: React.ReactNode;
index?: number;
}
export function Section({children, label, index}: ListboxSectionProps) {
const id = useId();
return (
<div
role="group"
className={clsx(index !== 0 && 'border-t my-4')}
aria-labelledby={label ? `be-select-${id}` : undefined}
>
{label && (
<div
className="block uppercase text-muted text-xs px-16 py-10"
role="presentation"
id={`be-select-${id}`}
aria-hidden="true"
>
{label}
</div>
)}
{children}
</div>
);
}

View File

@@ -0,0 +1,126 @@
import React, {MutableRefObject, ReactElement, ReactNode} from 'react';
import {
OffsetOptions,
Placement,
ReferenceType,
UseFloatingReturn,
VirtualElement,
} from '@floating-ui/react-dom';
import {
buildListboxCollection,
ListboxCollection,
} from './build-listbox-collection';
import {ListboxItemProps} from './item';
export type PrimitiveValue = string | number;
type SingleSelectionProps = {
selectionMode?: 'single';
onSelectionChange?: (value: PrimitiveValue) => void;
selectedValue?: PrimitiveValue | null;
defaultSelectedValue?: PrimitiveValue;
};
type MultipleSelectionProps = {
selectionMode?: 'multiple';
onSelectionChange?: (value: PrimitiveValue[]) => void;
selectedValue?: PrimitiveValue[];
defaultSelectedValue?: PrimitiveValue[];
};
type NoneSelectionProps = {
selectionMode?: 'none' | null;
};
type SelectionProps =
| NoneSelectionProps
| SingleSelectionProps
| MultipleSelectionProps;
export interface ListBoxChildren<T> {
items?: T[];
children: ReactNode | ((item: T) => ReactElement<ListboxItemProps>);
}
export type ListboxProps = SelectionProps & {
role?: 'listbox' | 'menu';
virtualFocus?: boolean;
loopFocus?: boolean;
autoFocusFirstItem?: boolean;
autoUpdatePosition?: boolean;
floatingWidth?: 'auto' | 'matchTrigger';
floatingMinWidth?: string;
floatingMaxHeight?: number;
placement?: Placement;
offset?: OffsetOptions;
isAsync?: boolean;
maxItems?: number;
allowEmptySelection?: boolean;
// fired whenever user selects an item (via click or keyboard), regardless of current selection mode
onItemSelected?: (value: PrimitiveValue) => void;
clearSelectionOnInputClear?: boolean;
clearInputOnItemSelection?: boolean;
blurReferenceOnItemSelection?: boolean;
inputValue?: string;
defaultInputValue?: string;
onInputValueChange?: (value: string) => void;
allowCustomValue?: boolean; // for combobox
isLoading?: boolean;
showEmptyMessage?: boolean;
showCheckmark?: boolean;
isOpen?: boolean;
defaultIsOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
};
export interface UseListboxReturn {
handleItemSelection: (value: PrimitiveValue) => void;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
focusItem: (
fallbackOperation: 'increment' | 'decrement',
newIndex: number,
) => void;
allowCustomValue: ListboxProps['allowCustomValue']; // for combobox
loopFocus: ListboxProps['loopFocus'];
floatingWidth: ListboxProps['floatingWidth'];
floatingMinWidth: ListboxProps['floatingMinWidth'];
floatingMaxHeight: ListboxProps['floatingMaxHeight'];
showCheckmark: ListboxProps['showCheckmark'];
// active collection, either filtered or all provided items
collection: ListboxCollection;
collections: ReturnType<typeof buildListboxCollection>;
virtualFocus: ListboxProps['virtualFocus'];
showEmptyMessage: ListboxProps['showEmptyMessage'];
refs: {
reference: React.MutableRefObject<HTMLElement | VirtualElement | null>;
floating: React.MutableRefObject<HTMLElement | null>;
};
reference: (instance: ReferenceType | null) => void;
floating: UseFloatingReturn['refs']['setFloating'];
listboxId: string;
role: ListboxProps['role'];
listContent: (string | null)[];
listItemsRef: MutableRefObject<(HTMLElement | null)[]>;
positionStyle: {
position: 'absolute' | 'fixed';
top: string | number;
left: string | number;
};
state: {
// currently focused or active (if virtual focus) option
activeIndex: number | null;
setActiveIndex: (value: number | null) => void;
selectedIndex?: number | null;
setSelectedIndex: (index: number) => void;
selectionMode: 'single' | 'multiple' | 'none';
selectedValues: PrimitiveValue[];
selectValues: (value: PrimitiveValue[] | PrimitiveValue) => void;
inputValue: string;
setInputValue: (value: string) => void;
isOpen: boolean;
setIsOpen: (value: boolean) => void;
setActiveCollection: (value: 'all' | 'filtered') => void;
};
}

View File

@@ -0,0 +1,120 @@
import React, {KeyboardEvent} from 'react';
import {UseListboxReturn} from './types';
export function useListboxKeyboardNavigation({
state: {isOpen, setIsOpen, selectedIndex, activeIndex, setInputValue},
loopFocus,
collection,
focusItem,
handleItemSelection,
allowCustomValue,
}: UseListboxReturn) {
const handleTriggerKeyDown = (e: React.KeyboardEvent): true | void => {
// ignore if dropdown is open or if event bubbled up from portal
if (isOpen || !e.currentTarget.contains(e.target as HTMLElement)) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setIsOpen(true);
focusItem('increment', selectedIndex != null ? selectedIndex : 0);
return true;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setIsOpen(true);
focusItem(
'decrement',
selectedIndex != null ? selectedIndex : collection.size - 1
);
return true;
} else if (e.key === 'Enter' || e.key === 'Space') {
e.preventDefault();
setIsOpen(true);
focusItem('increment', selectedIndex != null ? selectedIndex : 0);
return true;
}
};
const handleListboxKeyboardNavigation = (
e: React.KeyboardEvent
): true | void => {
const lastIndex = Math.max(0, collection.size - 1);
// ignore if event bubbled up from portal, or dropdown is closed
if (!isOpen || !e.currentTarget.contains(e.target as HTMLElement)) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (activeIndex == null) {
focusItem('increment', 0);
} else if (activeIndex >= lastIndex) {
// if focus is not looping, stay on last index
if (loopFocus) {
focusItem('increment', 0);
}
} else {
focusItem('increment', activeIndex + 1);
}
return true;
case 'ArrowUp':
e.preventDefault();
if (activeIndex == null) {
focusItem('decrement', lastIndex);
} else if (activeIndex <= 0) {
// if focus is not looping, stay on first index
if (loopFocus) {
focusItem('decrement', lastIndex);
}
} else {
focusItem('decrement', activeIndex - 1);
}
return true;
case 'Home':
e.preventDefault();
focusItem('increment', 0);
return true;
case 'End':
e.preventDefault();
focusItem('decrement', lastIndex);
return true;
case 'Tab':
setIsOpen(false);
return true;
}
};
const handleListboxSearchFieldKeydown = (
e: KeyboardEvent<HTMLInputElement>
) => {
if (e.key === 'Enter' && activeIndex != null && collection.size) {
// prevent form submit when selecting item in combobox via "enter"
e.preventDefault();
const [value, obj] = [...collection.entries()][activeIndex];
if (value) {
handleItemSelection(value);
// "onSelected" will not be called for dropdown items, because keydown
// event will never be triggered for them in "virtualFocus" mode
obj.element.props.onSelected?.();
}
return;
}
// on escape, clear input and close dropdown
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
if (!allowCustomValue) {
setInputValue('');
}
}
const handled = handleTriggerKeyDown(e);
if (!handled) {
handleListboxKeyboardNavigation(e);
}
};
return {
handleTriggerKeyDown,
handleListboxKeyboardNavigation,
handleListboxSearchFieldKeydown,
};
}

View File

@@ -0,0 +1,345 @@
import React, {Ref, useCallback, useId, useMemo, useRef, useState} from 'react';
import {useControlledState} from '@react-stately/utils';
import {
buildListboxCollection,
CollectionItem,
} from './build-listbox-collection';
import {useFloatingPosition} from '../../overlays/floating-position';
import {
ListBoxChildren,
ListboxProps,
PrimitiveValue,
UseListboxReturn,
} from './types';
import {VirtualElement} from '@floating-ui/react-dom';
export function useListbox<T>(
props: ListboxProps & ListBoxChildren<T>,
ref?: Ref<HTMLElement>,
): UseListboxReturn {
const {
children,
items,
role = 'listbox',
virtualFocus,
loopFocus = false,
autoFocusFirstItem = true,
onItemSelected,
clearInputOnItemSelection,
blurReferenceOnItemSelection,
floatingWidth = 'matchTrigger',
floatingMinWidth,
floatingMaxHeight,
offset,
placement,
showCheckmark,
showEmptyMessage,
maxItems,
isAsync,
allowCustomValue,
clearSelectionOnInputClear,
} = props;
const selectionMode = props.selectionMode || 'none';
const id = useId();
const listboxId = `${id}-listbox`;
// controlled state for text input (if in combobox mode)
const [inputValue, setInputValue] = useControlledState(
props.inputValue,
props.defaultInputValue || '',
props.onInputValueChange,
);
// mostly for combobox, so can show all collection items on dropdown icon click, even if user has filtered via input
const [activeCollection, setActiveCollection] = useState<'all' | 'filtered'>(
'all',
);
const collections = buildListboxCollection({
children,
items,
// don't filter on client side if async, it will already be filtered on server
inputValue: isAsync ? undefined : inputValue,
maxItems,
});
const collection =
activeCollection === 'all'
? collections.collection
: collections.filteredCollection;
// items for keyboard navigation
const listItemsRef = useRef<Array<HTMLElement | null>>([]);
// plain text labels for typeahead
const listContent: (string | null)[] = useMemo(() => {
return [...collection.values()].map(o =>
o.isDisabled ? null : o.textLabel,
);
}, [collection]);
// state for currently selected values (always array, even in single selection mode)
const {selectedValues, selectValues} = useControlledSelection(props);
const [isOpen, setIsOpen] = useControlledState(
props.isOpen,
props.defaultIsOpen,
props.onOpenChange,
);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
// handle listbox positioning relative to trigger
const floatingProps = useFloatingPosition({
floatingWidth,
ref,
placement,
offset,
maxHeight: floatingMaxHeight ?? 420,
// don't shift floating menu on the sides of combobox, otherwise input might get obscured
shiftCrossAxis: !virtualFocus,
});
const {refs, strategy, x, y} = floatingProps;
// handle selection state for syncing with active index in keyboard navigation
const selectedOption =
selectionMode === 'none' ? undefined : collection.get(selectedValues[0]);
const selectedIndex =
selectionMode === 'none' ? undefined : selectedOption?.index;
const setSelectedIndex = (index: number) => {
if (selectionMode !== 'none') {
const item = [...collection.values()][index];
if (item) {
selectValues(item.value);
}
}
};
// focus and scroll to specified index, in both virtual and regular mode.
// will also skip disabled indices and focus next or previous non-disabled index instead
const focusItem = useCallback(
(fallbackOperation: 'increment' | 'decrement', newIndex: number) => {
const items = [...collection.values()];
const allItemsDisabled = !items.find(i => !i.isDisabled);
const lastIndex = collection.size - 1;
// invalid index
if (
newIndex == null ||
!collection.size ||
newIndex > lastIndex ||
newIndex < 0 ||
allItemsDisabled
) {
setActiveIndex(null);
return;
}
// get next or previous non-disabled item
newIndex = getNonDisabledIndex(
items,
newIndex,
loopFocus,
fallbackOperation,
);
setActiveIndex(newIndex);
if (virtualFocus) {
listItemsRef.current[newIndex]?.scrollIntoView({
block: 'nearest',
});
} else {
listItemsRef.current[newIndex]?.focus();
}
},
[collection, virtualFocus, loopFocus],
);
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
setActiveCollection(e.target.value.trim() ? 'filtered' : 'all');
if (e.target.value) {
setIsOpen(true);
} else if (clearSelectionOnInputClear) {
// deselect currently selected option if user fully clears the input
selectValues('');
}
if (autoFocusFirstItem && activeIndex == null) {
focusItem('increment', 0);
} else {
setActiveIndex(null);
}
},
[
setInputValue,
setIsOpen,
setActiveCollection,
selectValues,
clearSelectionOnInputClear,
focusItem,
autoFocusFirstItem,
activeIndex,
],
);
const handleItemSelection = (value: PrimitiveValue) => {
const reference = refs.reference.current as
| HTMLElement
| VirtualElement
| null;
if (selectionMode !== 'none') {
selectValues(value);
} else {
if (reference && 'focus' in reference) {
reference.focus();
}
}
// is combobox
if (virtualFocus) {
setInputValue(clearInputOnItemSelection ? '' : `${value}`);
if (blurReferenceOnItemSelection && reference && 'blur' in reference) {
reference.blur();
}
}
setActiveCollection('all');
setIsOpen(false);
onItemSelected?.(value);
// make sure "onItemSelected" callback has a chance to use activeIndex value, before clearing it
setActiveIndex(null);
};
return {
// even handlers
handleItemSelection,
onInputChange,
loopFocus,
// config
floatingWidth,
floatingMinWidth,
floatingMaxHeight,
showCheckmark,
collection,
collections,
virtualFocus,
focusItem,
showEmptyMessage: showEmptyMessage && !!inputValue,
allowCustomValue,
// floating ui
refs,
reference: floatingProps.reference,
floating: refs.setFloating,
positionStyle: {
position: strategy,
top: y ?? '',
left: x ?? '',
},
listContent,
listItemsRef,
listboxId,
role,
state: {
// currently focused or active (if virtual focus) option
activeIndex,
setActiveIndex,
selectedIndex,
setSelectedIndex,
selectionMode,
selectedValues,
selectValues,
inputValue,
setInputValue,
isOpen,
setIsOpen,
setActiveCollection,
},
};
}
function getNonDisabledIndex(
items: CollectionItem<unknown>[],
newIndex: number,
loopFocus: boolean,
operation: 'increment' | 'decrement',
) {
const lastIndex = items.length - 1;
while (items[newIndex]?.isDisabled) {
if (operation === 'increment') {
newIndex++;
if (newIndex >= lastIndex) {
// loop from the start, if end reached
if (loopFocus) {
newIndex = 0;
// if focus is not looping, stay on the previous index
} else {
return newIndex - 1;
}
}
} else {
newIndex--;
// loop from the end, if start reached
if (newIndex < 0) {
if (loopFocus) {
newIndex = lastIndex;
// if focus is not looping, stay on the previous index
} else {
return newIndex + 1;
}
}
}
}
return newIndex;
}
function useControlledSelection(props: ListboxProps) {
const {selectionMode, allowEmptySelection} = props;
const selectionEnabled =
selectionMode === 'single' || selectionMode === 'multiple';
const [stateValues, setStateValues] = useControlledState<any>(
!selectionEnabled ? undefined : props.selectedValue,
!selectionEnabled ? undefined : props.defaultSelectedValue,
!selectionEnabled ? undefined : props.onSelectionChange,
);
const selectedValues = useMemo(() => {
// allow specifying null as selected value, but not undefined
if (typeof stateValues === 'undefined') {
return [];
}
return Array.isArray(stateValues) ? stateValues : [stateValues];
}, [stateValues]);
const selectValues = useCallback(
(mixedValue: PrimitiveValue | PrimitiveValue[] | null) => {
const newValues = Array.isArray(mixedValue) ? mixedValue : [mixedValue];
if (selectionMode === 'single') {
setStateValues(newValues[0]);
} else {
newValues.forEach(newValue => {
const index = selectedValues.indexOf(newValue);
if (index === -1) {
selectedValues.push(newValue);
setStateValues([...selectedValues]);
} else if (selectedValues.length > 1 || allowEmptySelection) {
selectedValues.splice(index, 1);
setStateValues([...selectedValues]);
}
});
}
},
[allowEmptySelection, selectedValues, selectionMode, setStateValues],
);
return {
selectedValues,
selectValues,
};
}

View File

@@ -0,0 +1,99 @@
import React, {useRef} from 'react';
import {useCollator} from '../../../i18n/use-collator';
interface UseTypeSelectReturn {
findMatchingItem: (
e: React.KeyboardEvent,
listContent: (string | null)[],
fromIndex?: number | null
) => number | null;
}
interface SearchState {
search: string;
timeout: ReturnType<typeof setTimeout> | undefined;
}
export function useTypeSelect(): UseTypeSelectReturn {
const collator = useCollator({usage: 'search', sensitivity: 'base'});
const state = useRef<SearchState>({
search: '',
timeout: undefined,
}).current;
const getMatchingIndex = (
listContent: (string | null)[],
fromIndex?: number | null
) => {
let index = fromIndex ?? 0;
while (index != null) {
const item = listContent[index];
const substring = item?.slice(0, state.search.length);
if (substring && collator.compare(substring, state.search) === 0) {
return index;
}
if (index < listContent.length - 1) {
index++;
// reached the end of list
} else {
return null;
}
}
return null;
};
const findMatchingItem: UseTypeSelectReturn['findMatchingItem'] = (
e,
listContent,
fromIndex = 0
) => {
const character = getStringForKey(e.key);
if (!character || e.ctrlKey || e.metaKey) {
return null;
}
// Do not propagate the Spacebar event if it's meant to be part of the search.
// When we time out, the search term becomes empty, hence the check on length.
// Trimming is to account for the case of pressing the Spacebar more than once,
// which should cycle through the selection/deselection of the focused item.
if (character === ' ' && state.search.trim().length > 0) {
e.preventDefault();
e.stopPropagation();
}
state.search += character;
// Use the delegate to find a key to focus.
// Prioritize items after the currently focused item, falling back to searching the whole list.
let index = getMatchingIndex(listContent, fromIndex);
// If no key found, search from the top.
if (index == null) {
index = getMatchingIndex(listContent, 0);
}
clearTimeout(state.timeout);
state.timeout = setTimeout(() => {
state.search = '';
}, 500);
return index ?? null;
};
return {findMatchingItem};
}
function getStringForKey(key: string) {
// If the key is of length 1, it is an ASCII value.
// Otherwise, if there are no ASCII characters in the key name,
// it is a Unicode character.
// See https://www.w3.org/TR/uievents-key/
if (key.length === 1 || !/^[A-Z]/i.test(key)) {
return key;
}
return '';
}

View File

@@ -0,0 +1,243 @@
import React, {ReactNode, useRef, useState} from 'react';
import {useTrans} from '../../i18n/use-trans';
import {Trans} from '../../i18n/trans';
import {Avatar} from '../images/avatar';
import {Tooltip} from '../tooltip/tooltip';
import {IconButton} from '../buttons/icon-button';
import {EditIcon} from '../../icons/material/Edit';
import {message} from '../../i18n/message';
import {Item} from './listbox/item';
import {useController, useFormContext} from 'react-hook-form';
import {useControlledState} from '@react-stately/utils';
import {getInputFieldClassNames} from './input-field/get-input-field-class-names';
import clsx from 'clsx';
import {Skeleton} from '../skeleton/skeleton';
import {useNormalizedModels} from '../../users/queries/use-normalized-models';
import {useNormalizedModel} from '../../users/queries/use-normalized-model';
import {AnimatePresence, m} from 'framer-motion';
import {opacityAnimation} from '../animation/opacity-animation';
import {Select} from '@common/ui/forms/select/select';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
import {BaseFieldProps} from '@common/ui/forms/input-field/base-field-props';
interface NormalizedModelFieldProps {
label?: ReactNode;
className?: string;
background?: BaseFieldProps['background'];
value?: string | number;
placeholder?: MessageDescriptor;
searchPlaceholder?: MessageDescriptor;
defaultValue?: string | number;
onChange?: (value: string | number) => void;
invalid?: boolean;
errorMessage?: string;
description?: ReactNode;
autoFocus?: boolean;
queryParams?: Record<string, string>;
endpoint: string;
disabled?: boolean;
required?: boolean;
}
export function NormalizedModelField({
label,
className,
background,
value,
defaultValue = '',
placeholder = message('Select item...'),
searchPlaceholder = message('Find an item...'),
onChange,
description,
errorMessage,
invalid,
autoFocus,
queryParams,
endpoint,
disabled,
required,
}: NormalizedModelFieldProps) {
const inputRef = useRef<HTMLButtonElement>(null);
const [inputValue, setInputValue] = useState('');
const [selectedValue, setSelectedValue] = useControlledState(
value,
defaultValue,
onChange,
);
const query = useNormalizedModels(endpoint, {
query: inputValue,
...queryParams,
});
const {trans} = useTrans();
const fieldClassNames = getInputFieldClassNames({size: 'md'});
if (selectedValue) {
return (
<div className={className}>
<div className={fieldClassNames.label}>{label}</div>
<div
className={clsx(
'rounded-input border p-8',
background,
invalid && 'border-danger',
)}
>
<AnimatePresence initial={false} mode="wait">
<SelectedModelPreview
disabled={disabled}
endpoint={endpoint}
modelId={selectedValue}
queryParams={queryParams}
onEditClick={() => {
setSelectedValue('');
setInputValue('');
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.click();
});
}}
/>
</AnimatePresence>
</div>
{description && !errorMessage && (
<div className={fieldClassNames.description}>{description}</div>
)}
{errorMessage && (
<div className={fieldClassNames.error}>{errorMessage}</div>
)}
</div>
);
}
return (
<Select
className={className}
showSearchField
invalid={invalid}
errorMessage={errorMessage}
description={description}
color="white"
isAsync
background={background}
placeholder={trans(placeholder)}
searchPlaceholder={trans(searchPlaceholder)}
label={label}
isLoading={query.isFetching}
items={query.data?.results}
inputValue={inputValue}
onInputValueChange={setInputValue}
selectionMode="single"
selectedValue={selectedValue}
onSelectionChange={setSelectedValue}
ref={inputRef}
autoFocus={autoFocus}
disabled={disabled}
required={required}
>
{model => (
<Item
value={model.id}
key={model.id}
description={model.description}
startIcon={<Avatar src={model.image} />}
>
{model.name}
</Item>
)}
</Select>
);
}
interface SelectedModelPreviewProps {
modelId: string | number;
selectedValue?: number | string;
onEditClick?: () => void;
endpoint?: string;
disabled?: boolean;
queryParams?: NormalizedModelFieldProps['queryParams'];
}
function SelectedModelPreview({
modelId,
onEditClick,
endpoint,
disabled,
queryParams,
}: SelectedModelPreviewProps) {
const {data, isLoading} = useNormalizedModel(
`${endpoint}/${modelId}`,
queryParams,
);
if (isLoading || !data?.model) {
return <LoadingSkeleton key="skeleton" />;
}
return (
<m.div
className={clsx(
'flex items-center gap-10',
disabled && 'pointer-events-none cursor-not-allowed text-disabled',
)}
key="preview"
{...opacityAnimation}
>
{data.model.image && <Avatar src={data.model.image} />}
<div>
<div className="text-sm leading-4">{data.model.name}</div>
<div className="text-xs text-muted">{data.model.description}</div>
</div>
<Tooltip label={<Trans message="Change item" />}>
<IconButton
className="ml-auto text-muted"
size="sm"
onClick={onEditClick}
disabled={disabled}
>
<EditIcon />
</IconButton>
</Tooltip>
</m.div>
);
}
function LoadingSkeleton() {
return (
<m.div className="flex items-center gap-10" {...opacityAnimation}>
<Skeleton variant="rect" size="w-32 h-32" />
<div className="max-h-[36px] flex-auto">
<Skeleton className="text-xs" />
<Skeleton className="max-h-8 text-xs" />
</div>
<Skeleton variant="icon" size="w-24 h-24" />
</m.div>
);
}
interface FormNormalizedModelFieldProps extends NormalizedModelFieldProps {
name: string;
}
export function FormNormalizedModelField({
name,
...fieldProps
}: FormNormalizedModelFieldProps) {
const {clearErrors} = useFormContext();
const {
field: {onChange, value = ''},
fieldState: {invalid, error},
} = useController({
name,
});
return (
<NormalizedModelField
value={value}
onChange={value => {
onChange(value);
clearErrors(name);
}}
invalid={invalid}
errorMessage={error?.message}
{...fieldProps}
/>
);
}

View File

@@ -0,0 +1 @@
export type Orientation = 'horizontal' | 'vertical';

View File

@@ -0,0 +1,103 @@
import {
Children,
cloneElement,
forwardRef,
isValidElement,
ReactNode,
useId,
} from 'react';
import clsx from 'clsx';
import {useController} from 'react-hook-form';
import {Orientation} from '../orientation';
import {RadioProps} from './radio';
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
export interface RadioGroupProps {
children: ReactNode;
orientation?: Orientation;
size?: 'xs' | 'sm' | 'md' | 'lg';
className?: string;
label?: ReactNode;
disabled?: boolean;
name?: string;
errorMessage?: ReactNode;
description?: ReactNode;
invalid?: boolean;
required?: boolean;
}
export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
(props, ref) => {
const style = getInputFieldClassNames(props);
const {
label,
children,
size,
className,
orientation = 'horizontal',
disabled,
required,
invalid,
errorMessage,
description,
} = props;
const labelProps = {};
const id = useId();
const name = props.name || id;
return (
<fieldset
aria-describedby={description ? `${id}-description` : undefined}
ref={ref}
className={clsx('text-left', className)}
>
{label && (
<legend className={style.label} {...labelProps}>
{label}
</legend>
)}
<div
className={clsx(
'flex',
label ? 'mt-6' : 'mt-0',
orientation === 'vertical' ? 'flex-col gap-10' : 'flex-row gap-16'
)}
>
{Children.map(children, child => {
if (isValidElement<RadioProps>(child)) {
return cloneElement<RadioProps>(child, {
name,
size,
invalid: child.props.invalid || invalid || undefined,
disabled: child.props.disabled || disabled,
required: child.props.required || required,
});
}
})}
</div>
{description && !errorMessage && (
<div className={style.description} id={`${id}-description`}>
{description}
</div>
)}
{errorMessage && <div className={style.error}>{errorMessage}</div>}
</fieldset>
);
}
);
interface FormRadioGroupProps extends RadioGroupProps {
name: string;
}
export function FormRadioGroup({children, ...props}: FormRadioGroupProps) {
const {
fieldState: {error},
} = useController({
name: props.name!,
});
return (
<RadioGroup errorMessage={error?.message} {...props}>
{children}
</RadioGroup>
);
}

View File

@@ -0,0 +1,85 @@
import React, {ComponentPropsWithoutRef, forwardRef} from 'react';
import clsx from 'clsx';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import {useController} from 'react-hook-form';
import {AutoFocusProps, useAutoFocus} from '../../focus/use-auto-focus';
type RadioSize = 'xs' | 'sm' | 'md' | 'lg' | undefined;
export interface RadioProps
extends AutoFocusProps,
Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
size?: RadioSize;
value: string;
invalid?: boolean;
isFirst?: boolean;
}
export const Radio = forwardRef<HTMLInputElement, RadioProps>((props, ref) => {
const {children, autoFocus, size, invalid, isFirst, ...domProps} = props;
const inputRef = useObjectRef(ref);
useAutoFocus({autoFocus}, inputRef);
const sizeClassNames = getSizeClassNames(size);
return (
<label
className={clsx(
'inline-flex gap-8 select-none items-center whitespace-nowrap align-middle',
sizeClassNames.label,
props.disabled && 'text-disabled pointer-events-none',
props.invalid && 'text-danger'
)}
>
<input
type="radio"
className={clsx(
'focus-visible:ring outline-none',
'rounded-full transition-button border-2 appearance-none',
'border-text-muted disabled:border-disabled-fg checked:border-primary checked:hover:border-primary-dark',
'before:bg-primary disabled:before:bg-disabled-fg before:hover:bg-primary-dark',
'before:h-full before:w-full before:block before:rounded-full before:scale-10 before:opacity-0 before:transition before:duration-200',
'checked:before:scale-[.65] checked:before:opacity-100',
sizeClassNames.circle
)}
ref={inputRef}
{...domProps}
/>
{children && <span>{children}</span>}
</label>
);
});
export function FormRadio(props: RadioProps) {
const {
field: {onChange, onBlur, value, ref},
fieldState: {invalid},
} = useController({
name: props.name!,
});
const formProps: Partial<RadioProps> = {
onChange,
onBlur,
checked: props.value === value,
invalid: props.invalid || invalid,
};
return <Radio ref={ref} {...mergeProps(formProps, props)} />;
}
function getSizeClassNames(size?: RadioSize): {
circle: string;
label: string;
} {
switch (size) {
case 'xs':
return {circle: 'h-12 w-12', label: 'text-xs'};
case 'sm':
return {circle: 'h-16 w-16', label: 'text-sm'};
case 'lg':
return {circle: 'h-24 w-24', label: 'text-lg'};
default:
return {circle: 'h-20 w-20', label: 'text-base'};
}
}

View File

@@ -0,0 +1,41 @@
import {RefObject, useEffect, useState} from 'react';
import {m} from 'framer-motion';
interface ActiveIndicatorProps {
selectedValue?: string;
labelsRef: RefObject<Record<string, HTMLLabelElement>>;
}
export function ActiveIndicator({
selectedValue,
labelsRef,
}: ActiveIndicatorProps) {
const [style, setStyle] = useState<{
width: number;
height: number;
left: number;
} | null>(null);
useEffect(() => {
if (selectedValue != null && labelsRef.current) {
const el = labelsRef.current[selectedValue];
if (!el) return;
setStyle({
width: el.offsetWidth,
height: el.offsetHeight,
left: el.offsetLeft,
});
}
}, [setStyle, selectedValue, labelsRef]);
if (!style) {
return null;
}
return (
<m.div
animate={style}
initial={false}
className="bg-paper shadow rounded absolute z-10 pointer-events-none"
/>
);
}

View File

@@ -0,0 +1,64 @@
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useId,
useRef,
} from 'react';
import {RadioGroupProps} from '../radio-group/radio-group';
import {SegmentedRadioProps} from './segmented-radio';
import {ActiveIndicator} from './active-indicator';
import {useControlledState} from '@react-stately/utils';
import clsx from 'clsx';
export interface SegmentedRadioGroupProps
extends Omit<RadioGroupProps, 'orientation'> {
value?: string;
onChange?: (value: string) => void;
defaultValue?: string;
width?: string;
}
export const SegmentedRadioGroup = forwardRef<
HTMLFieldSetElement,
SegmentedRadioGroupProps
>((props, ref) => {
const {children, size, className} = props;
const id = useId();
const name = props.name || id;
const labelsRef = useRef<Record<string, HTMLLabelElement>>({});
const [selectedValue, setSelectedValue] = useControlledState(
props.value,
props.defaultValue,
props.onChange,
);
return (
<fieldset ref={ref} className={clsx(className, props.width ?? 'w-min')}>
<div className="relative isolate flex rounded bg-chip p-4">
<ActiveIndicator selectedValue={selectedValue} labelsRef={labelsRef} />
{Children.map(children, (child, index) => {
if (isValidElement<SegmentedRadioProps>(child)) {
return cloneElement<SegmentedRadioProps>(child, {
isFirst: index === 0,
name,
size,
onChange: e => {
setSelectedValue(e.target.value);
child.props.onChange?.(e);
},
labelRef: el => {
if (el) {
labelsRef.current[child.props.value] = el;
}
},
isSelected: selectedValue === child.props.value,
});
}
})}
</div>
</fieldset>
);
});

View File

@@ -0,0 +1,65 @@
import React, {forwardRef, Ref} from 'react';
import clsx from 'clsx';
import {useObjectRef} from '@react-aria/utils';
import {InputSize} from '../input-field/input-size';
import {useAutoFocus} from '../../focus/use-auto-focus';
import {RadioProps} from '../radio-group/radio';
export interface SegmentedRadioProps extends RadioProps {
labelRef?: Ref<HTMLLabelElement>;
isSelected?: boolean;
}
export const SegmentedRadio = forwardRef<HTMLInputElement, SegmentedRadioProps>(
(props, ref) => {
const {
children,
autoFocus,
size,
invalid,
isFirst,
labelRef,
isSelected,
...domProps
} = props;
const inputRef = useObjectRef(ref);
useAutoFocus({autoFocus}, inputRef);
const sizeClassNames = getSizeClassNames(size);
return (
<label
ref={labelRef}
className={clsx(
'relative z-20 inline-flex flex-auto cursor-pointer select-none items-center justify-center gap-8 whitespace-nowrap align-middle font-medium transition-colors hover:text-main',
isSelected ? 'text-main' : 'text-muted',
!isFirst && '',
sizeClassNames,
props.disabled && 'pointer-events-none text-disabled',
props.invalid && 'text-danger',
)}
>
<input
type="radio"
className="pointer-events-none absolute left-0 top-0 h-full w-full appearance-none rounded focus-visible:outline"
ref={inputRef}
{...domProps}
/>
{children && <span>{children}</span>}
</label>
);
},
);
function getSizeClassNames(size?: InputSize): string {
switch (size) {
case 'xs':
return 'px-6 py-3 text-xs';
case 'sm':
return 'px-10 py-5 text-sm';
case 'lg':
return 'px-16 py-6 text-lg';
default:
return 'px-16 py-8 text-sm';
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
import {BaseFieldProps} from '../input-field/base-field-props';
interface Props
extends BaseFieldProps,
Omit<React.ComponentPropsWithoutRef<'select'>, 'size'> {}
export function NativeSelect(props: Props) {
const style = getInputFieldClassNames(props);
const {label, id, children, size, ...other} = {...props};
return (
<div className={style.wrapper}>
{label && (
<label className={style.label} htmlFor={id}>
{label}
</label>
)}
<select id={id} className={style.input} {...other}>
{children}
</select>
</div>
);
}

View File

@@ -0,0 +1,254 @@
import React, {ReactElement, Ref, RefObject} from 'react';
import clsx from 'clsx';
import {useController} from 'react-hook-form';
import {mergeProps} from '@react-aria/utils';
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
import {Field} from '../input-field/field';
import {BaseFieldPropsWithDom} from '../input-field/base-field-props';
import {useListbox} from '../listbox/use-listbox';
import {useField} from '../input-field/use-field';
import {Item} from '../listbox/item';
import {Section} from '../listbox/section';
import {Listbox} from '../listbox/listbox';
import {Trans} from '@common/i18n/trans';
import {useListboxKeyboardNavigation} from '../listbox/use-listbox-keyboard-navigation';
import {useTypeSelect} from '../listbox/use-type-select';
import {ListBoxChildren, ListboxProps} from '../listbox/types';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
import {SearchIcon} from '@common/icons/material/Search';
import {ComboboxEndAdornment} from '@common/ui/forms/combobox/combobox-end-adornment';
export type SelectProps<T extends object> = Omit<
BaseFieldPropsWithDom<HTMLButtonElement>,
'value'
> &
ListboxProps &
ListBoxChildren<T> & {
hideCaret?: boolean;
selectionMode: 'single';
minWidth?: string;
searchPlaceholder?: string;
showSearchField?: boolean;
valueClassName?: string;
};
function Select<T extends object>(
props: SelectProps<T>,
ref: Ref<HTMLButtonElement>,
) {
const isMobile = useIsMobileMediaQuery();
const {
hideCaret,
placeholder = <Trans message="Select an option..." />,
selectedValue,
onItemSelected,
onOpenChange,
onInputValueChange,
onSelectionChange,
selectionMode,
minWidth = 'min-w-128',
children,
searchPlaceholder,
showEmptyMessage,
showSearchField,
defaultInputValue,
inputValue: userInputValue,
isLoading,
isAsync,
valueClassName,
floatingWidth = isMobile ? 'auto' : 'matchTrigger',
...inputFieldProps
} = props;
const listbox = useListbox(
{
...props,
clearInputOnItemSelection: true,
showEmptyMessage: showEmptyMessage || showSearchField,
floatingWidth,
selectionMode: 'single',
role: 'listbox',
virtualFocus: showSearchField,
},
ref,
);
const {
state: {
selectedValues,
isOpen,
setIsOpen,
activeIndex,
setSelectedIndex,
inputValue,
setInputValue,
},
collections,
focusItem,
listboxId,
reference,
refs,
listContent,
onInputChange,
} = listbox;
const {fieldProps, inputProps} = useField({
...inputFieldProps,
focusRef: refs.reference as RefObject<HTMLButtonElement>,
});
const selectedOption = collections.collection.get(selectedValues[0]);
const content = selectedOption ? (
<span className="flex items-center gap-10">
{selectedOption.element.props.startIcon}
<span
className={clsx(
'overflow-hidden overflow-ellipsis whitespace-nowrap',
valueClassName,
)}
>
{selectedOption.element.props.children}
</span>
</span>
) : (
<span className="italic">{placeholder}</span>
);
const fieldClassNames = getInputFieldClassNames({
...props,
endAdornment: true,
});
const {
handleTriggerKeyDown,
handleListboxKeyboardNavigation,
handleListboxSearchFieldKeydown,
} = useListboxKeyboardNavigation(listbox);
const {findMatchingItem} = useTypeSelect();
// focus matching item when user types, if dropdown is open
const handleListboxTypeSelect = (e: React.KeyboardEvent) => {
if (!isOpen) return;
const i = findMatchingItem(e, listContent, activeIndex);
if (i != null) {
focusItem('increment', i);
}
};
// select matching item when user types, if dropdown is closed
const handleTriggerTypeSelect = (e: React.KeyboardEvent) => {
if (isOpen) return undefined;
const i = findMatchingItem(e, listContent, activeIndex);
if (i != null) {
setSelectedIndex(i);
}
};
return (
<Listbox
listbox={listbox}
onKeyDownCapture={!showSearchField ? handleListboxTypeSelect : undefined}
onKeyDown={handleListboxKeyboardNavigation}
onClose={showSearchField ? () => setInputValue('') : undefined}
isLoading={isLoading}
searchField={
showSearchField && (
<TextField
size={props.size === 'xs' || props.size === 'sm' ? 'xs' : 'sm'}
placeholder={searchPlaceholder}
startAdornment={<SearchIcon />}
className="flex-shrink-0 px-8 pb-8 pt-4"
autoFocus
aria-expanded={isOpen ? 'true' : 'false'}
aria-haspopup="listbox"
aria-controls={isOpen ? listboxId : undefined}
aria-autocomplete="list"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
value={inputValue}
onChange={onInputChange}
onKeyDown={e => {
handleListboxSearchFieldKeydown(e);
}}
/>
)
}
>
<Field
fieldClassNames={fieldClassNames}
{...fieldProps}
endAdornment={
!hideCaret && <ComboboxEndAdornment isLoading={isLoading} />
}
>
<button
{...inputProps}
type="button"
data-selected-value={selectedOption?.value}
aria-expanded={isOpen ? 'true' : 'false'}
aria-haspopup="listbox"
aria-controls={isOpen ? listboxId : undefined}
ref={reference}
onKeyDown={handleTriggerKeyDown}
onKeyDownCapture={
!showSearchField ? handleTriggerTypeSelect : undefined
}
disabled={inputFieldProps.disabled}
onClick={() => {
setIsOpen(!isOpen);
}}
className={clsx(
fieldClassNames.input,
!fieldProps.unstyled && minWidth,
)}
>
{content}
</button>
</Field>
</Listbox>
);
}
const SelectForwardRef = React.forwardRef(Select) as <T extends object>(
props: SelectProps<T> & {ref?: Ref<HTMLButtonElement>},
) => ReactElement;
export {SelectForwardRef as Select};
export type FormSelectProps<T extends object> = SelectProps<T> & {
name: string;
};
export function FormSelect<T extends object>({
children,
...props
}: FormSelectProps<T>) {
const {
field: {onChange, onBlur, value = null, ref},
fieldState: {invalid, error},
} = useController({
name: props.name,
});
const formProps: Partial<SelectProps<T>> = {
onSelectionChange: onChange,
onBlur,
selectedValue: value,
invalid,
errorMessage: error?.message,
name: props.name,
};
// make sure error message is not overridden by undefined or null
const errorMessage = props.errorMessage || error?.message;
return (
<SelectForwardRef
ref={ref}
{...mergeProps(formProps, props, {errorMessage})}
>
{children}
</SelectForwardRef>
);
}
export {Item as Option};
export {Section as OptionGroup};

View File

@@ -0,0 +1,191 @@
import React, {ReactNode} from 'react';
import clsx from 'clsx';
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
import {UseSliderProps, UseSliderReturn} from './use-slider';
export interface BaseSliderProps extends UseSliderProps {
slider: UseSliderReturn;
children: ReactNode;
}
export function BaseSlider(props: BaseSliderProps) {
const {
size = 'md',
inline,
label,
showValueLabel = !!label,
className,
width = 'w-full',
slider,
children,
trackColor = 'primary',
fillColor = 'primary',
} = props;
const {
domProps,
trackRef,
getThumbPercent,
getThumbValueLabel,
labelId,
groupId,
thumbIds,
isDisabled,
numberFormatter,
minValue,
maxValue,
step,
values,
getValueLabel,
} = slider;
let outputValue = '';
let maxLabelLength = Math.max(
[...numberFormatter.format(minValue)].length,
[...numberFormatter.format(maxValue)].length,
[...numberFormatter.format(step)].length,
);
if (getValueLabel) {
outputValue = getValueLabel(values[0]);
} else if (values.length === 1) {
outputValue = getThumbValueLabel(0);
} else if (values.length === 2) {
// This should really use the NumberFormat#formatRange proposal...
// https://github.com/tc39/ecma402/issues/393
// https://github.com/tc39/proposal-intl-numberformat-v3#formatrange-ecma-402-393
outputValue = `${getThumbValueLabel(0)} ${getThumbValueLabel(1)}`;
maxLabelLength =
3 +
2 *
Math.max(
maxLabelLength,
[...numberFormatter.format(minValue)].length,
[...numberFormatter.format(maxValue)].length,
);
}
const style = getInputFieldClassNames({
size,
disabled: isDisabled,
labelDisplay: 'flex',
});
const wrapperClassname = clsx('touch-none', className, width, {
'flex items-center': inline,
});
return (
<div className={wrapperClassname} role="group" id={groupId}>
{(label || showValueLabel) && (
<div className={clsx(style.label, 'select-none')}>
{label && (
<label
onClick={() => {
// Safari does not focus <input type="range"> elements when clicking on an associated <label>,
// so do it manually. In addition, make sure we show the focus ring.
document.getElementById(thumbIds[0])?.focus();
}}
id={labelId}
htmlFor={groupId}
>
{label}
</label>
)}
{showValueLabel && (
<output
htmlFor={thumbIds[0]}
className="ml-auto text-right"
aria-live="off"
style={
!maxLabelLength
? undefined
: {
width: `${maxLabelLength}ch`,
minWidth: `${maxLabelLength}ch`,
}
}
>
{outputValue}
</output>
)}
</div>
)}
<div
ref={trackRef}
className={clsx('relative', getWrapperHeight(props))}
{...domProps}
role="presentation"
>
<div
className={clsx(
'absolute inset-0 m-auto rounded',
getTrackColor(trackColor, isDisabled),
getTrackHeight(size),
)}
/>
<div
className={clsx(
'absolute inset-0 my-auto rounded',
getFillColor(fillColor, isDisabled),
getTrackHeight(size),
)}
style={{width: `${Math.max(getThumbPercent(0) * 100, 0)}%`}}
/>
{children}
</div>
</div>
);
}
function getWrapperHeight({size, wrapperHeight}: UseSliderProps): string {
if (wrapperHeight) return wrapperHeight;
switch (size) {
case 'xs':
return 'h-14';
case 'sm':
return 'h-20';
default:
return 'h-30';
}
}
function getTrackHeight(size: UseSliderProps['size']): string {
switch (size) {
case 'xs':
return 'h-2';
case 'sm':
return 'h-3';
default:
return 'h-4';
}
}
function getTrackColor(color: string, isDisabled: boolean): string {
if (isDisabled) {
color = 'disabled';
}
switch (color) {
case 'disabled':
return 'bg-slider-disabled/60';
case 'primary':
return 'bg-primary-light';
case 'neutral':
return 'bg-divider';
default:
return color;
}
}
function getFillColor(color: string, isDisabled: boolean): string {
if (isDisabled) {
color = 'disabled';
}
switch (color) {
case 'disabled':
return 'bg-slider-disabled';
case 'primary':
return 'bg-primary';
default:
return color;
}
}

View File

@@ -0,0 +1,45 @@
import {BaseSlider} from './base-slider';
import {useSlider, UseSliderProps} from './use-slider';
import {SliderThumb} from './slider-thumb';
import {useTrans} from '../../../i18n/use-trans';
import {message} from '../../../i18n/message';
interface RangeSliderProps
extends UseSliderProps<{start: number; end: number}> {}
export function RangeSlider(props: RangeSliderProps) {
const {onChange, onChangeEnd, value, defaultValue, ...otherProps} = props;
const {trans} = useTrans();
const baseProps: UseSliderProps = {
...otherProps,
value: value != null ? [value.start, value.end] : undefined,
defaultValue:
defaultValue != null
? [defaultValue.start, defaultValue.end]
: // make sure that useSliderState knows we have two handles
[props.minValue ?? 0, props.maxValue ?? 100],
onChange(v) {
onChange?.({start: v[0], end: v[1]});
},
onChangeEnd(v) {
onChangeEnd?.({start: v[0], end: v[1]});
},
};
const slider = useSlider(baseProps);
return (
<BaseSlider {...baseProps} slider={slider}>
<SliderThumb
ariaLabel={trans(message('minimum'))}
index={0}
slider={slider}
/>
<SliderThumb
ariaLabel={trans(message('maximum'))}
index={1}
slider={slider}
/>
</BaseSlider>
);
}

View File

@@ -0,0 +1,174 @@
import React, {Ref, useCallback, useEffect, useRef} from 'react';
import clsx from 'clsx';
import {UseSliderReturn} from './use-slider';
import {useGlobalListeners, useObjectRef} from '@react-aria/utils';
import {createEventHandler} from '@common/utils/dom/create-event-handler';
import {BaseSliderProps} from '@common/ui/forms/slider/base-slider';
interface SliderThumb {
index: number;
slider: UseSliderReturn;
isDisabled?: boolean;
ariaLabel?: string;
inputRef?: Ref<HTMLInputElement>;
onBlur?: React.FocusEventHandler;
fillColor?: BaseSliderProps['fillColor'];
}
export function SliderThumb({
index,
slider,
isDisabled: isThumbDisabled,
ariaLabel,
inputRef,
onBlur,
fillColor = 'primary',
}: SliderThumb) {
const inputObjRef = useObjectRef(inputRef);
const {addGlobalListener, removeGlobalListener} = useGlobalListeners();
const {
step,
values,
focusedThumb,
labelId,
thumbIds,
isDisabled: isSliderDisabled,
getThumbPercent,
getThumbMinValue,
getThumbMaxValue,
getThumbValueLabel,
setThumbValue,
updateDraggedThumbs,
isThumbDragging,
setThumbEditable,
setFocusedThumb,
isPointerOver,
showThumbOnHoverOnly,
thumbSize = 'w-18 h-18',
} = slider;
const isDragging = isThumbDragging(index);
const value = values[index];
// Immediately register editability with the state
setThumbEditable(index, !isThumbDisabled);
const isDisabled = isThumbDisabled || isSliderDisabled;
const focusInput = useCallback(() => {
if (inputObjRef.current) {
inputObjRef.current.focus({preventScroll: true});
}
}, [inputObjRef]);
// we will focus the native range input when slider is clicked or thumb is
// focused in some other way, and let browser handle keyboard interactions
const isFocused = focusedThumb === index;
useEffect(() => {
if (isFocused) {
focusInput();
}
}, [isFocused, focusInput]);
const currentPointer = useRef<number | undefined>(undefined);
const handlePointerUp = (e: PointerEvent) => {
if (e.pointerId === currentPointer.current) {
focusInput();
updateDraggedThumbs(index, false);
removeGlobalListener(window, 'pointerup', handlePointerUp, false);
}
};
const className = clsx(
'outline-none rounded-full top-1/2 -translate-y-1/2 -translate-x-1/2 absolute inset-0 transition-button duration-200',
thumbSize,
!isDisabled && 'shadow-md',
thumbColor({fillColor, isDisabled, isDragging: isDragging}),
// show thumb on hover and while dragging, otherwise "blur" event will fire on thumb and dragging will stop
!showThumbOnHoverOnly ||
(showThumbOnHoverOnly && isDragging) ||
isPointerOver
? 'visible'
: 'invisible',
);
return (
<div
role="presentation"
className={className}
style={{
left: `${Math.max(getThumbPercent(index) * 100, 0)}%`,
}}
onPointerDown={e => {
if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
focusInput();
currentPointer.current = e.pointerId;
updateDraggedThumbs(index, true);
addGlobalListener(window, 'pointerup', handlePointerUp, false);
}}
>
<input
id={thumbIds[index]}
onKeyDown={createEventHandler(() => {
updateDraggedThumbs(index, true);
})}
onKeyUp={createEventHandler(() => {
// make sure "onChangeEnd" is fired on keyboard navigation
updateDraggedThumbs(index, false);
})}
ref={inputObjRef}
tabIndex={!isDisabled ? 0 : undefined}
min={getThumbMinValue(index)}
max={getThumbMaxValue(index)}
step={step}
value={value}
disabled={isDisabled}
aria-label={ariaLabel}
aria-labelledby={labelId}
aria-orientation="horizontal"
aria-valuetext={getThumbValueLabel(index)}
onFocus={() => {
setFocusedThumb(index);
}}
onBlur={e => {
setFocusedThumb(undefined);
updateDraggedThumbs(index, false);
onBlur?.(e);
}}
onChange={e => {
setThumbValue(index, parseFloat(e.target.value));
}}
type="range"
className="sr-only"
/>
</div>
);
}
interface SliderThumbColorProps {
isDisabled?: boolean;
isDragging: boolean;
fillColor?: BaseSliderProps['fillColor'];
}
function thumbColor({
isDisabled,
isDragging,
fillColor,
}: SliderThumbColorProps): string {
if (isDisabled) {
return 'bg-slider-disabled cursor-default';
}
if (fillColor && fillColor !== 'primary') {
return fillColor;
}
return clsx(
'hover:bg-primary-dark',
isDragging ? 'bg-primary-dark' : 'bg-primary',
);
}

View File

@@ -0,0 +1,61 @@
import {BaseSlider} from './base-slider';
import {useSlider, UseSliderProps} from './use-slider';
import React, {Ref} from 'react';
import {SliderThumb} from './slider-thumb';
import {useController} from 'react-hook-form';
import {mergeProps} from '@react-aria/utils';
interface SliderProps extends UseSliderProps<number> {
inputRef?: Ref<HTMLInputElement>;
onBlur?: React.FocusEventHandler;
}
export function Slider({inputRef, onBlur, ...props}: SliderProps) {
const {onChange, onChangeEnd, value, defaultValue, ...otherProps} = props;
const baseProps: UseSliderProps = {
...otherProps,
// Normalize `value: number[]` to `value: number`
value: value != null ? [value] : undefined,
defaultValue: defaultValue != null ? [defaultValue] : undefined,
onChange: (v: number[]): void => {
onChange?.(v[0]);
},
onChangeEnd: (v: number[]): void => {
onChangeEnd?.(v[0]);
},
};
const slider = useSlider(baseProps);
return (
<BaseSlider {...baseProps} slider={slider}>
<SliderThumb
fillColor={props.fillColor}
index={0}
slider={slider}
inputRef={inputRef}
onBlur={onBlur}
/>
</BaseSlider>
);
}
export interface FormSliderProps extends SliderProps {
name: string;
}
export function FormSlider({name, ...props}: FormSliderProps) {
const {
field: {onChange, onBlur, value = '', ref},
} = useController({
name,
});
const formProps: SliderProps = {
onChange,
onBlur,
value: value || '', // avoid issues with "null" value when setting form defaults from backend model
};
return <Slider inputRef={ref} {...mergeProps(formProps, props)} />;
}

View File

@@ -0,0 +1,362 @@
import {
mergeProps,
snapValueToStep,
useGlobalListeners,
} from '@react-aria/utils';
import {useControlledState} from '@react-stately/utils';
import React, {
HTMLAttributes,
ReactNode,
RefObject,
useId,
useRef,
useState,
} from 'react';
import {clamp} from '@common/utils/number/clamp';
import {usePointerEvents} from '../../interactions/use-pointer-events';
import {useNumberFormatter} from '@common/i18n/use-number-formatter';
import type {NumberFormatOptions} from '@internationalized/number';
export interface UseSliderProps<T = number[]> {
formatOptions?: NumberFormatOptions;
onPointerDown?: () => void;
onPointerMove?: (e: React.PointerEvent) => void;
onChange?: (value: T) => void;
onChangeEnd?: (value: T) => void;
value?: T;
defaultValue?: T;
getValueLabel?: (value: number) => string;
minValue?: number;
maxValue?: number;
step?: number;
isDisabled?: boolean;
size?: 'xs' | 'sm' | 'md';
label?: ReactNode;
inline?: boolean;
className?: string;
width?: string;
showValueLabel?: boolean;
fillColor?: 'primary' | string;
trackColor?: 'primary' | 'neutral' | string;
showThumbOnHoverOnly?: boolean;
thumbSize?: string;
wrapperHeight?: string;
}
export interface UseSliderReturn {
domProps: HTMLAttributes<HTMLElement>;
trackRef: RefObject<HTMLDivElement>;
isPointerOver: boolean;
showThumbOnHoverOnly?: boolean;
thumbSize?: string;
step: number;
isDisabled: boolean;
values: number[];
minValue: number;
maxValue: number;
focusedThumb: number | undefined;
labelId: string | undefined;
groupId: string;
thumbIds: string[];
numberFormatter: Intl.NumberFormat;
getThumbPercent: (index: number) => number;
getThumbMinValue: (index: number) => number;
getThumbMaxValue: (index: number) => number;
getThumbValueLabel: (index: number) => string;
setThumbValue: (index: number, value: number) => void;
updateDraggedThumbs: (index: number, dragging: boolean) => void;
isThumbDragging: (index: number) => boolean;
setThumbEditable: (index: number, editable: boolean) => void;
setFocusedThumb: (index: number | undefined) => void;
getValueLabel?: (value: number) => string;
}
export function useSlider({
minValue = 0,
maxValue = 100,
isDisabled = false,
step = 1,
formatOptions,
onChangeEnd,
onPointerDown,
label,
getValueLabel,
showThumbOnHoverOnly,
thumbSize,
onPointerMove,
...props
}: UseSliderProps): UseSliderReturn {
const [isPointerOver, setIsPointerOver] = useState(false);
const numberFormatter = useNumberFormatter(formatOptions);
const {addGlobalListener, removeGlobalListener} = useGlobalListeners();
const trackRef = useRef<HTMLDivElement>(null);
// values will be stored in internal state as an array for both slider and range slider
const [values, setValues] = useControlledState<number[]>(
props.value ? props.value : undefined,
props.defaultValue ?? ([minValue] as any),
props.onChange as any,
);
// need to also store values in ref, because state value would
// lag behind by one between pointer down and move callbacks
const valuesRef = useRef<number[] | null>(null);
valuesRef.current = values;
// indices of thumbs that are being dragged currently (state and ref for same reasons as above)
const [draggedThumbs, setDraggedThumbs] = useState<boolean[]>(
new Array(values.length).fill(false),
);
const draggedThumbsRef = useRef<boolean[] | null>(null);
draggedThumbsRef.current = draggedThumbs;
// formatted value for <output> and thumb aria labels
function getFormattedValue(value: number) {
return numberFormatter.format(value);
}
const isThumbDragging = (index: number) => {
return draggedThumbsRef.current?.[index] || false;
};
const getThumbValueLabel = (index: number) =>
getFormattedValue(values[index]);
const getThumbMinValue = (index: number) =>
index === 0 ? minValue : values[index - 1];
const getThumbMaxValue = (index: number) =>
index === values.length - 1 ? maxValue : values[index + 1];
const setThumbValue = (index: number, value: number) => {
if (isDisabled || !isThumbEditable(index) || !valuesRef.current) {
return;
}
const thisMin = getThumbMinValue(index);
const thisMax = getThumbMaxValue(index);
// Round value to multiple of step, clamp value between min and max
value = snapValueToStep(value, thisMin, thisMax, step);
valuesRef.current = replaceIndex(valuesRef.current, index, value);
setValues(valuesRef.current);
};
// update "dragging" status of specified thumb
const updateDraggedThumbs = (index: number, dragging: boolean) => {
if (isDisabled || !isThumbEditable(index)) {
return;
}
const wasDragging = draggedThumbsRef.current?.[index];
draggedThumbsRef.current = replaceIndex(
draggedThumbsRef.current || [],
index,
dragging,
);
setDraggedThumbs(draggedThumbsRef.current);
// Call onChangeEnd if no handles are dragging.
if (onChangeEnd && wasDragging && !draggedThumbsRef.current.some(Boolean)) {
onChangeEnd(valuesRef.current || []);
}
};
const [focusedThumb, setFocusedThumb] = useState<number | undefined>(
undefined,
);
const getValuePercent = (value: number) => {
const x = Math.min(1, (value - minValue) / (maxValue - minValue));
if (isNaN(x)) {
return 0;
}
return x;
};
const getThumbPercent = (index: number) =>
getValuePercent(valuesRef.current![index]);
const setThumbPercent = (index: number, percent: number) => {
setThumbValue(index, getPercentValue(percent));
};
const getRoundedValue = (value: number) =>
Math.round((value - minValue) / step) * step + minValue;
const getPercentValue = (percent: number) => {
const val = percent * (maxValue - minValue) + minValue;
return clamp(getRoundedValue(val), minValue, maxValue);
};
// allows disabling individual thumbs in range slider, instead of disable the whole slider
const editableThumbsRef = useRef<boolean[]>(
new Array(values.length).fill(true),
);
const isThumbEditable = (index: number) => editableThumbsRef.current[index];
const setThumbEditable = (index: number, editable: boolean) => {
editableThumbsRef.current[index] = editable;
};
// When the user clicks or drags the track, we want the motion to set and drag the
// closest thumb. Hence, we also need to install useMove() on the track element.
// Here, we keep track of which index is the "closest" to the drag start point.
// It is set onMouseDown/onTouchDown; see trackProps below.
const realTimeTrackDraggingIndex = useRef<number | null>(null);
const currentPointer = useRef<number | null | undefined>(undefined);
const handlePointerDown = (e: React.PointerEvent) => {
if (
e.pointerType === 'mouse' &&
(e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey)
) {
return;
}
onPointerDown?.();
// We only trigger track-dragging if the user clicks on the track itself and nothing is currently being dragged.
if (
trackRef.current &&
!isDisabled &&
values.every((_, i) => !draggedThumbs[i])
) {
const size = trackRef.current.offsetWidth;
// Find the closest thumb
const trackPosition = trackRef.current.getBoundingClientRect().left;
const offset = e.clientX - trackPosition;
const percent = offset / size;
const value = getPercentValue(percent);
// to find the closet thumb we split the array based on the first thumb position to the "right/end" of the click.
let closestThumb;
const split = values.findIndex(v => value - v < 0);
if (split === 0) {
// If the index is zero then the closest thumb is the first one
closestThumb = split;
} else if (split === -1) {
// If no index is found they've clicked past all the thumbs
closestThumb = values.length - 1;
} else {
const lastLeft = values[split - 1];
const firstRight = values[split];
// Pick the last left/start thumb, unless they are stacked on top of each other, then pick the right/end one
if (Math.abs(lastLeft - value) < Math.abs(firstRight - value)) {
closestThumb = split - 1;
} else {
closestThumb = split;
}
}
// Confirm that the found closest thumb is editable, not disabled, and move it
if (closestThumb >= 0 && isThumbEditable(closestThumb)) {
// Don't un-focus anything
e.preventDefault();
realTimeTrackDraggingIndex.current = closestThumb;
setFocusedThumb(closestThumb);
currentPointer.current = e.pointerId;
updateDraggedThumbs(realTimeTrackDraggingIndex.current, true);
setThumbValue(closestThumb, value);
addGlobalListener(window, 'pointerup', onUpTrack, false);
} else {
realTimeTrackDraggingIndex.current = null;
}
}
};
const currentPosition = useRef<number | null>(null);
const {domProps: moveDomProps} = usePointerEvents({
onPointerDown: handlePointerDown,
onMoveStart() {
currentPosition.current = null;
},
onMove(e, deltaX) {
const size = trackRef.current?.offsetWidth || 0;
if (currentPosition.current == null) {
currentPosition.current =
getThumbPercent(realTimeTrackDraggingIndex.current || 0) * size;
}
currentPosition.current += deltaX;
if (realTimeTrackDraggingIndex.current != null && trackRef.current) {
const percent = clamp(currentPosition.current / size, 0, 1);
setThumbPercent(realTimeTrackDraggingIndex.current, percent);
}
},
onMoveEnd() {
if (realTimeTrackDraggingIndex.current != null) {
updateDraggedThumbs(realTimeTrackDraggingIndex.current, false);
realTimeTrackDraggingIndex.current = null;
}
},
});
const domProps = mergeProps(moveDomProps, {
onPointerEnter: () => {
setIsPointerOver(true);
},
onPointerLeave: () => {
setIsPointerOver(false);
},
onPointerMove: (e: React.PointerEvent) => {
onPointerMove?.(e);
},
});
const onUpTrack = (e: PointerEvent) => {
const id = e.pointerId;
if (id === currentPointer.current) {
if (realTimeTrackDraggingIndex.current != null) {
updateDraggedThumbs(realTimeTrackDraggingIndex.current, false);
realTimeTrackDraggingIndex.current = null;
}
removeGlobalListener(window, 'pointerup', onUpTrack, false);
}
};
const id = useId();
const labelId = label ? `${id}-label` : undefined;
const groupId = `${id}-group`;
const thumbIds = [...Array(values.length)].map((v, i) => {
return `${id}-thumb-${i}`;
});
return {
domProps,
trackRef,
isDisabled,
step,
values,
minValue,
maxValue,
focusedThumb,
labelId,
groupId,
thumbIds,
numberFormatter,
getThumbPercent,
getThumbMinValue,
getThumbMaxValue,
getThumbValueLabel,
isThumbDragging,
setThumbValue,
updateDraggedThumbs,
setThumbEditable,
setFocusedThumb,
getValueLabel,
isPointerOver,
showThumbOnHoverOnly,
thumbSize,
};
}
function replaceIndex<T>(array: T[], index: number, value: T) {
if (array[index] === value) {
return array;
}
return [...array.slice(0, index), value, ...array.slice(index + 1)];
}

View File

@@ -0,0 +1,6 @@
import {createSvgIcon} from '../../../icons/create-svg-icon';
export const CheckboxFilledIcon = createSvgIcon(
<path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />,
'CheckBox'
);

View File

@@ -0,0 +1,98 @@
import {
ChangeEventHandler,
Children,
cloneElement,
isValidElement,
ReactElement,
ReactNode,
useCallback,
useId,
useRef,
} from "react";
import clsx from "clsx";
import { useControlledState } from "@react-stately/utils";
import { Orientation } from "../orientation";
import { CheckboxProps } from "./checkbox";
import { getInputFieldClassNames } from "../input-field/get-input-field-class-names";
interface CheckboxGroupProps {
children: ReactElement<CheckboxProps> | ReactElement<CheckboxProps>[];
orientation?: Orientation;
className?: string;
value?: (string | number)[];
defaultValue?: (string | number)[];
onChange?: (newValue: (string | number)[]) => void;
label?: ReactNode;
disabled?: boolean;
readOnly?: boolean;
invalid?: boolean;
}
export function CheckboxGroup(props: CheckboxGroupProps) {
const {
label,
children,
orientation = "vertical",
value,
defaultValue,
onChange,
className,
disabled,
readOnly,
invalid,
} = props;
const ref = useRef(null);
const labelId = useId();
const [selectedValues, setSelectedValues] = useControlledState(
value,
defaultValue || [],
onChange
);
const style = getInputFieldClassNames(props);
const handleCheckboxToggle: ChangeEventHandler<HTMLInputElement> =
useCallback(
(e) => {
const c = e.currentTarget.value;
const i = selectedValues.indexOf(c);
if (i > -1) {
selectedValues.splice(i, 1);
} else {
selectedValues.push(c);
}
setSelectedValues([...selectedValues]);
},
[selectedValues, setSelectedValues]
);
return (
<div className={className} role="group" aria-labelledby={labelId} ref={ref}>
{label && (
<span id={labelId} className={style.label}>
{label}
</span>
)}
<div
role="presentation"
className={clsx(
"flex gap-6",
orientation === "vertical" ? "flex-col" : "flow-row"
)}
>
{Children.map(children, (child) => {
if (isValidElement(child)) {
return cloneElement<CheckboxProps>(child, {
disabled: child.props.disabled || disabled,
readOnly: child.props.readOnly || readOnly,
invalid: child.props.invalid || invalid,
checked: selectedValues?.includes(child.props.value as string),
onChange: handleCheckboxToggle,
});
}
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,175 @@
import React, {
ChangeEventHandler,
ComponentPropsWithoutRef,
ComponentType,
forwardRef,
useCallback,
useEffect,
} from 'react';
import clsx from 'clsx';
import {useController} from 'react-hook-form';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import {useControlledState} from '@react-stately/utils';
import {InputSize} from '../input-field/input-size';
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
import {CheckBoxOutlineBlankIcon} from '@common/icons/material/CheckBoxOutlineBlank';
import {CheckboxFilledIcon} from './checkbox-filled-icon';
import {IndeterminateCheckboxFilledIcon} from './indeterminate-checkbox-filled-icon';
import {SvgIconProps} from '@common/icons/svg-icon';
import {Orientation} from '../orientation';
import {AutoFocusProps, useAutoFocus} from '../../focus/use-auto-focus';
export interface CheckboxProps
extends AutoFocusProps,
Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
size?: InputSize;
className?: string;
icon?: React.ComponentType;
checkedIcon?: React.ComponentType;
orientation?: Orientation;
errorMessage?: string;
isIndeterminate?: boolean;
invalid?: boolean;
inputTestId?: string;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(props, ref) => {
const {
size = 'md',
children,
className,
icon,
checkedIcon,
disabled,
isIndeterminate,
errorMessage,
invalid,
orientation = 'horizontal',
onChange,
autoFocus,
required,
value,
name,
inputTestId,
} = props;
const style = getInputFieldClassNames({...props, label: children});
const Icon = icon || CheckBoxOutlineBlankIcon;
const CheckedIcon =
checkedIcon ||
(isIndeterminate ? IndeterminateCheckboxFilledIcon : CheckboxFilledIcon);
const inputObjRef = useObjectRef(ref);
useAutoFocus({autoFocus}, inputObjRef);
useEffect(() => {
// indeterminate is a property, but it can only be set via javascript
if (inputObjRef.current) {
inputObjRef.current.indeterminate = isIndeterminate || false;
}
});
const [isSelected, setSelected] = useControlledState(
props.checked,
props.defaultChecked || false,
);
const updateChecked: ChangeEventHandler<HTMLInputElement> = useCallback(
e => {
onChange?.(e);
setSelected(e.target.checked);
},
[onChange, setSelected],
);
const mergedClassName = clsx(
'select-none',
className,
invalid && 'text-danger',
!invalid && disabled && 'text-disabled',
);
let CheckboxIcon: ComponentType<SvgIconProps>;
let checkboxColor = invalid ? 'text-danger' : null;
if (isIndeterminate) {
CheckboxIcon = IndeterminateCheckboxFilledIcon;
checkboxColor = checkboxColor || 'text-primary';
} else if (isSelected) {
CheckboxIcon = CheckedIcon;
checkboxColor = checkboxColor || 'text-primary';
} else {
CheckboxIcon = Icon;
checkboxColor = checkboxColor || 'text-muted';
}
// input and icon sizes need to match, as checkbox input is being clicked and not the icon due to pointer-events-none
return (
<div>
<label className={mergedClassName}>
<div
className={clsx(
'relative flex items-center',
orientation === 'vertical' && 'flex-col flex-col-reverse',
)}
>
<input
className="absolute left-0 top-0 h-24 w-24 appearance-none overflow-hidden rounded outline-none ring-inset transition-shadow focus-visible:ring"
type="checkbox"
aria-checked={isIndeterminate ? 'mixed' : isSelected}
aria-invalid={invalid || undefined}
onChange={updateChecked}
ref={inputObjRef}
required={required}
disabled={disabled}
value={value}
name={name}
data-testid={inputTestId}
/>
<CheckboxIcon
size={size}
className={clsx(
'pointer-events-none',
disabled ? 'text-disabled' : checkboxColor,
)}
/>
{children && (
<div
className={clsx(
'first-letter:capitalize',
style.size.font,
orientation === 'vertical' ? 'mb-6' : 'ml-6',
)}
>
{children}
</div>
)}
</div>
</label>
{errorMessage && <div className={style.error}>{errorMessage}</div>}
</div>
);
},
);
interface FormCheckboxProps extends CheckboxProps {
name: string;
}
export function FormCheckbox(props: FormCheckboxProps) {
const {
field: {onChange, onBlur, value = false, ref},
fieldState: {invalid, error},
} = useController({
name: props.name,
});
const formProps: Partial<CheckboxProps> = {
onChange,
onBlur,
checked: value,
invalid,
errorMessage: error?.message,
name: props.name,
};
return <Checkbox ref={ref} {...mergeProps(formProps, props)} />;
}

View File

@@ -0,0 +1,6 @@
import {createSvgIcon} from "../../../icons/create-svg-icon";
export const IndeterminateCheckboxFilledIcon = createSvgIcon(
<path d="M19,3H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h14c1.1,0,2-0.9,2-2V5C21,3.9,20.1,3,19,3z M17,13H7v-2h10V13z" />,
'CheckBox'
);

View File

@@ -0,0 +1,135 @@
import React, {ComponentPropsWithoutRef, ReactNode, useId} from 'react';
import clsx from 'clsx';
import {useController} from 'react-hook-form';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import {InputSize} from '../input-field/input-size';
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
import {AutoFocusProps, useAutoFocus} from '../../focus/use-auto-focus';
interface SwitchProps
extends AutoFocusProps,
Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
size?: InputSize;
className?: string;
description?: ReactNode;
invalid?: boolean;
errorMessage?: string;
iconRight?: ReactNode;
}
export const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
(props, ref) => {
const {
children,
size = 'sm',
description,
className,
invalid,
autoFocus,
errorMessage,
iconRight,
...domProps
} = props;
const inputRef = useObjectRef(ref);
useAutoFocus({autoFocus}, inputRef);
const style = getSizeClassName(size);
const fieldClassNames = getInputFieldClassNames(props);
const descriptionId = useId();
return (
<div className={clsx(className, 'isolate')}>
<label className="flex select-none items-center">
<input
{...domProps}
type="checkbox"
role="switch"
aria-invalid={invalid || undefined}
aria-describedby={description ? descriptionId : undefined}
ref={inputRef}
aria-checked={domProps.checked}
className={clsx(
style,
!invalid &&
'checked:border-primary checked:bg-primary dark:checked:border-primary-dark dark:checked:bg-primary-dark',
invalid && 'checked:border-danger checked:bg-danger',
'relative flex flex-shrink-0 cursor-pointer appearance-none items-center overflow-hidden rounded-3xl border border-chip bg-chip p-0 outline-none transition-colors checked:border-primary checked:bg-primary',
'before:z-10 before:block before:translate-x-2 before:rounded-3xl before:border before:bg-white before:transition-transform',
'checked:before:border-white',
'focus-visible:ring',
props.disabled && 'cursor-not-allowed opacity-80',
)}
/>
{children && (
<span
className={clsx(
fieldClassNames.size.font,
'ml-12',
invalid && 'text-danger',
props.disabled && 'text-disabled',
)}
>
{children}
</span>
)}
{iconRight}
</label>
{description && !errorMessage && (
<div id={descriptionId} className={fieldClassNames.description}>
{description}
</div>
)}
{errorMessage && (
<div id={descriptionId} className={fieldClassNames.error}>
{errorMessage}
</div>
)}
</div>
);
},
);
interface FormSwitchProps extends SwitchProps {
name: string;
}
export function FormSwitch(props: FormSwitchProps) {
const {
field: {onChange, onBlur, value = false, ref},
fieldState: {invalid, error},
} = useController({
name: props.name,
});
const formProps: Partial<SwitchProps> = {
onChange: e => {
if (e.target.value && e.target.value !== 'on') {
onChange(e.target.checked ? e.target.value : false);
} else {
onChange(e);
}
},
onBlur,
checked: !!value,
invalid,
errorMessage: error?.message,
name: props.name,
};
return <Switch ref={ref} {...mergeProps(props, formProps)} />;
}
function getSizeClassName(size: InputSize): string {
switch (size) {
case 'xl':
return 'w-68 h-36 before:w-28 before:h-28 checked:before:translate-x-36';
case 'lg':
return 'w-56 h-30 before:w-22 before:h-22 checked:before:translate-x-30';
case 'md':
return 'w-46 h-24 before:w-18 before:h-18 checked:before:translate-x-24';
case 'xs':
return 'w-30 h-18 before:w-12 before:h-12 checked:before:translate-x-14';
default:
return 'w-38 h-20 before:w-14 before:h-14 checked:before:translate-x-20';
}
}