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