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

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

View File

@@ -0,0 +1,258 @@
import React, {
cloneElement,
isValidElement,
ReactElement,
ReactNode,
useId,
useRef,
} from 'react';
import clsx from 'clsx';
import {AnimatePresence, m} from 'framer-motion';
import {useControlledState} from '@react-stately/utils';
import {FocusScope, useFocusManager} from '@react-aria/focus';
import {AccordionAnimation} from '@common/ui/accordion/accordtion-animation';
import {ArrowDropDownIcon} from '@common/icons/material/ArrowDropDown';
type Props = {
variant?: 'outline' | 'default' | 'minimal';
children?: ReactNode;
mode?: 'single' | 'multiple';
expandedValues?: (string | number)[];
defaultExpandedValues?: (string | number)[];
onExpandedChange?: (key: (string | number)[]) => void;
className?: string;
isLazy?: boolean;
};
export const Accordion = React.forwardRef<HTMLDivElement, Props>(
(
{
variant = 'default',
mode = 'single',
children,
className,
isLazy,
...other
},
ref,
) => {
const [expandedValues, setExpandedValues] = useControlledState(
other.expandedValues,
other.defaultExpandedValues || [],
other.onExpandedChange,
);
const itemsCount = React.Children.count(children);
return (
<div
className={clsx(variant === 'outline' && 'space-y-10', className)}
ref={ref}
role="presentation"
>
<AnimatePresence>
<FocusScope>
{React.Children.map(children, (child, index) => {
if (!isValidElement<AccordionItemProps>(child)) return null;
return cloneElement<AccordionItemProps>(child, {
key: child.key || index,
value: child.props.value || index,
isFirst: index === 0,
isLast: index === itemsCount - 1,
mode,
variant,
expandedValues,
setExpandedValues,
isLazy,
});
})}
</FocusScope>
</AnimatePresence>
</div>
);
},
);
interface AccordionItemProps {
children: ReactNode;
disabled?: boolean;
label: ReactNode;
description?: ReactNode;
value?: string | number;
isFirst?: boolean;
isLast?: boolean;
bodyClassName?: string;
labelClassName?: string;
buttonPadding?: string;
chevronPosition?: 'left' | 'right';
startIcon?: ReactElement;
endAppend?: ReactElement;
variant?: 'outline' | 'default' | 'minimal';
expandedValues?: (string | number)[];
setExpandedValues?: (keys: (string | number)[]) => void;
mode?: 'single' | 'multiple';
footerContent?: ReactNode;
isLazy?: boolean;
onHeaderMouseEnter?: () => void;
onHeaderMouseLeave?: () => void;
}
export function AccordionItem(props: AccordionItemProps) {
const {
children,
label,
disabled,
bodyClassName,
labelClassName,
buttonPadding = 'py-10 pl-14 pr-10',
startIcon,
description,
endAppend,
chevronPosition = 'right',
isFirst,
mode,
isLazy,
variant,
footerContent,
onHeaderMouseEnter,
onHeaderMouseLeave,
} = props;
const expandedValues = props.expandedValues || [];
const value = props.value || 0;
const setExpandedValues = props.setExpandedValues || (() => {});
const ref = useRef<HTMLButtonElement>(null);
const isExpanded = !disabled && expandedValues!.includes(value!);
const wasExpandedOnce = useRef(false);
if (isExpanded) {
wasExpandedOnce.current = true;
}
const focusManager = useFocusManager();
const id = useId();
const buttonId = `${id}-button`;
const panelId = `${id}-panel`;
const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case 'ArrowDown':
focusManager?.focusNext();
break;
case 'ArrowUp':
focusManager?.focusPrevious();
break;
case 'Home':
focusManager?.focusFirst();
break;
case 'End':
focusManager?.focusLast();
break;
}
};
const toggle = () => {
const i = expandedValues.indexOf(value);
if (i > -1) {
const newKeys = [...expandedValues];
newKeys.splice(i, 1);
setExpandedValues(newKeys);
} else if (mode === 'single') {
setExpandedValues([value]);
} else {
setExpandedValues([...expandedValues, value]);
}
};
const chevron = (
<div className={clsx(variant === 'minimal' && '')}>
<ArrowDropDownIcon
aria-hidden="true"
size="md"
className={clsx(
disabled ? 'text-disabled' : 'text-muted',
isExpanded && 'rotate-180 transition-transform',
)}
/>
</div>
);
return (
<div
className={clsx(
variant === 'default' && 'border-b',
variant === 'outline' && 'rounded-panel border',
disabled && 'text-disabled',
)}
>
<h3
className={clsx(
'flex w-full items-center justify-between text-sm',
disabled && 'pointer-events-none',
isFirst && variant === 'default' && 'border-t',
isExpanded && variant !== 'minimal'
? 'border-b'
: 'border-b border-b-transparent',
variant === 'outline'
? isExpanded
? 'rounded-panel-t'
: 'rounded-panel'
: undefined,
)}
onMouseEnter={onHeaderMouseEnter}
onMouseLeave={onHeaderMouseLeave}
>
<button
disabled={disabled}
aria-expanded={isExpanded}
id={buttonId}
aria-controls={panelId}
type="button"
ref={ref}
onKeyDown={onKeyDown}
onClick={() => {
if (!disabled) {
toggle();
}
}}
className={clsx(
'flex flex-auto items-center gap-10 text-left outline-none hover:bg-hover focus-visible:bg-primary/focus',
buttonPadding,
)}
>
{chevronPosition === 'left' && chevron}
{startIcon &&
cloneElement(startIcon, {
size: 'md',
className: clsx(
startIcon.props.className,
disabled ? 'text-disabled' : 'text-muted',
),
})}
<div className="flex-auto overflow-hidden overflow-ellipsis">
<div className={labelClassName} data-testid="accordion-label">
{label}
</div>
{description && (
<div className="text-xs text-muted">{description}</div>
)}
</div>
{chevronPosition === 'right' && chevron}
</button>
{endAppend && (
<div className="flex-shrink-0 px-4 text-sm text-muted">
{endAppend}
</div>
)}
</h3>
<m.div
aria-labelledby={id}
role="region"
variants={AccordionAnimation.variants}
transition={AccordionAnimation.transition}
initial={false}
animate={isExpanded ? 'open' : 'closed'}
>
<div className={clsx('p-16', bodyClassName)}>
{!isLazy || wasExpandedOnce ? children : null}
</div>
{footerContent}
</m.div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
export const AccordionAnimation = {
variants: {
open: {
height: 'auto',
visibility: 'visible',
transitionEnd: {
overflow: 'auto',
},
},
closed: {
height: 0,
overflow: 'hidden',
transitionEnd: {
visibility: 'hidden',
},
},
},
transition: {type: 'tween', duration: 0.2},
} as const;

View File

@@ -0,0 +1,8 @@
import {HTMLMotionProps} from 'framer-motion';
export const opacityAnimation: HTMLMotionProps<any> = {
initial: {opacity: 0},
animate: {opacity: 1},
exit: {opacity: 0},
transition: {duration: 0.2},
};

View File

@@ -0,0 +1,32 @@
import {ReactNode} from 'react';
import clsx from 'clsx';
export interface BadgeProps {
children: ReactNode;
className?: string;
withBorder?: boolean;
top?: string;
right?: string;
}
export function Badge({
children,
className,
withBorder = true,
top = 'top-2',
right = 'right-4',
}: BadgeProps) {
return (
<span
className={clsx(
'absolute flex items-center justify-center whitespace-nowrap rounded-full bg-warning text-xs font-bold text-white shadow',
withBorder && 'border-2 border-white',
children ? 'h-18 w-18' : 'h-12 w-12',
className,
top,
right
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,78 @@
import React, {HTMLAttributes, ReactElement, ReactNode} from 'react';
import clsx from 'clsx';
import {ChevronRightIcon} from '../../icons/material/ChevronRight';
import type {BreadcrumbSizeStyle} from './breadcrumb';
export interface BreadcrumbItemProps {
sizeStyle?: BreadcrumbSizeStyle;
isMenuTrigger?: boolean;
isMenuItem?: boolean;
children: ReactNode | ((state: {isMenuItem?: boolean}) => ReactNode);
isCurrent?: boolean;
onSelected?: () => void;
isClickable?: boolean;
isDisabled?: boolean;
className?: string;
isLink?: boolean;
}
export function BreadcrumbItem(props: BreadcrumbItemProps) {
const {
isCurrent,
sizeStyle,
isMenuTrigger,
isClickable,
isDisabled,
onSelected,
className,
isMenuItem,
isLink,
} = props;
const children =
typeof props.children === 'function'
? props.children({isMenuItem})
: props.children;
if (isMenuItem) {
return children as ReactElement;
}
const domProps: HTMLAttributes<HTMLDivElement> = isMenuTrigger
? {}
: {
tabIndex: isLink && !isDisabled ? 0 : undefined,
role: isLink ? 'link' : undefined,
'aria-disabled': isLink ? isDisabled : undefined,
'aria-current': isCurrent && isLink ? 'page' : undefined,
onClick: () => onSelected?.(),
};
return (
<li
className={clsx(
`relative inline-flex min-w-0 flex-shrink-0 items-center justify-start ${sizeStyle?.font}`,
(!isClickable || isDisabled) && 'pointer-events-none',
!isCurrent && isDisabled && 'text-disabled'
)}
>
<div
{...domProps}
className={clsx(
className,
'cursor-pointer overflow-hidden whitespace-nowrap rounded px-8',
!isMenuTrigger && 'py-4 hover:bg-hover',
!isMenuTrigger && isLink && 'outline-none focus-visible:ring'
)}
>
{children}
</div>
{isCurrent === false && (
<ChevronRightIcon
size={sizeStyle?.icon}
className={clsx(isDisabled ? 'text-disabled' : 'text-muted')}
/>
)}
</li>
);
}

View File

@@ -0,0 +1,231 @@
import React, {
cloneElement,
ReactElement,
ReactNode,
useCallback,
useRef,
} from 'react';
import {
useLayoutEffect,
useResizeObserver,
useValueEffect,
} from '@react-aria/utils';
import clsx from 'clsx';
import {IconButton} from '../buttons/icon-button';
import {BreadcrumbItem, BreadcrumbItemProps} from './breadcrumb-item';
import {MoreHorizIcon} from '../../icons/material/MoreHoriz';
import {ButtonSize} from '../buttons/button-size';
import {Menu, MenuItem, MenuTrigger} from '../navigation/menu/menu-trigger';
import {IconSize} from '../../icons/svg-icon';
import {useTrans} from '../../i18n/use-trans';
const MIN_VISIBLE_ITEMS = 1;
const MAX_VISIBLE_ITEMS = 10;
export interface BreadcrumbsProps {
children?: ReactNode;
isDisabled?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
currentIsClickable?: boolean;
isNavigation?: boolean;
}
export function Breadcrumb(props: BreadcrumbsProps) {
const {
size = 'md',
children,
isDisabled,
className,
currentIsClickable,
isNavigation,
} = props;
const {trans} = useTrans();
const style = sizeStyle(size);
// Not using React.Children.toArray because it mutates the key prop.
const childArray: ReactElement<BreadcrumbItemProps>[] = [];
React.Children.forEach(children, child => {
if (React.isValidElement(child)) {
childArray.push(child as ReactElement<BreadcrumbItemProps>);
}
});
const domRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLOListElement>(null);
const [visibleItems, setVisibleItems] = useValueEffect(childArray.length);
const updateOverflow = useCallback(() => {
const computeVisibleItems = (itemCount: number) => {
// Refs can be null at runtime.
const currListRef: HTMLUListElement | null = listRef.current;
if (!currListRef) {
return;
}
const listItems = Array.from(currListRef.children) as HTMLLIElement[];
if (!listItems.length) return;
const containerWidth = currListRef.offsetWidth;
const isShowingMenu = childArray.length > itemCount;
let calculatedWidth = 0;
let newVisibleItems = 0;
let maxVisibleItems = MAX_VISIBLE_ITEMS;
calculatedWidth += listItems.shift()!.offsetWidth;
newVisibleItems++;
if (isShowingMenu) {
calculatedWidth += listItems.shift()?.offsetWidth ?? 0;
maxVisibleItems--;
}
if (calculatedWidth >= containerWidth) {
newVisibleItems--;
}
// Ensure the last breadcrumb isn't truncated when we measure it.
if (listItems.length > 0) {
const last = listItems.pop();
last!.style.overflow = 'visible';
calculatedWidth += last!.offsetWidth;
if (calculatedWidth < containerWidth) {
newVisibleItems++;
}
last!.style.overflow = '';
}
// eslint-disable-next-line no-restricted-syntax
for (const breadcrumb of listItems.reverse()) {
calculatedWidth += breadcrumb.offsetWidth;
if (calculatedWidth < containerWidth) {
newVisibleItems++;
}
}
return Math.max(
MIN_VISIBLE_ITEMS,
Math.min(maxVisibleItems, newVisibleItems),
);
};
// eslint-disable-next-line func-names
setVisibleItems(function* () {
// Update to show all items.
yield childArray.length;
// Measure, and update to show the items that fit.
const newVisibleItems = computeVisibleItems(childArray.length);
yield newVisibleItems;
// If the number of items is less than the number of children,
// then update again to ensure that the menu fits.
if (newVisibleItems! < childArray.length && newVisibleItems! > 1) {
yield computeVisibleItems(newVisibleItems!);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listRef, children, setVisibleItems]);
useResizeObserver({ref: domRef, onResize: updateOverflow});
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(updateOverflow, [children]);
let contents = childArray;
if (childArray.length > visibleItems) {
const selectedKey = childArray.length - 1;
const menuItem = (
<BreadcrumbItem key="menu" sizeStyle={style} isMenuTrigger>
<MenuTrigger selectionMode="single" selectedValue={selectedKey}>
<IconButton aria-label="…" disabled={isDisabled} size={style.btn}>
<MoreHorizIcon />
</IconButton>
<Menu>
{childArray.map((child, index) => {
const isLast = selectedKey === index;
return (
<MenuItem
key={index}
value={index}
onSelected={() => {
if (!isLast) {
child.props.onSelected?.();
}
}}
>
{cloneElement(child, {isMenuItem: true})}
</MenuItem>
);
})}
</Menu>
</MenuTrigger>
</BreadcrumbItem>
);
contents = [menuItem];
const breadcrumbs = [...childArray];
let endItems = visibleItems;
if (visibleItems > 1) {
contents.unshift(breadcrumbs.shift()!);
endItems--;
}
contents.push(...breadcrumbs.slice(-endItems));
}
const lastIndex = contents.length - 1;
const breadcrumbItems = contents.map((child, index) => {
const isCurrent = index === lastIndex;
const isClickable = !isCurrent || currentIsClickable;
return cloneElement<BreadcrumbItemProps>(child, {
key: child.key || index,
isCurrent,
sizeStyle: style,
isClickable,
isDisabled,
isLink: isNavigation && child.key !== 'menu',
});
});
const Element = isNavigation ? 'nav' : 'div';
return (
<Element
className={clsx(className, 'w-full min-w-0')} // prevent flex parent overflow
aria-label={trans({message: 'Breadcrumbs'})}
ref={domRef}
>
<ol
ref={listRef}
className={clsx('flex flex-nowrap justify-start', style.minHeight)}
>
{breadcrumbItems}
</ol>
</Element>
);
}
function sizeStyle(size: BreadcrumbsProps['size']): BreadcrumbSizeStyle {
switch (size) {
case 'sm':
return {font: 'text-sm', icon: 'sm', btn: 'sm', minHeight: 'min-h-36'};
case 'lg':
return {font: 'text-lg', icon: 'md', btn: 'md', minHeight: 'min-h-42'};
case 'xl':
return {font: 'text-xl', icon: 'md', btn: 'md', minHeight: 'min-h-42'};
default:
return {font: 'text-base', icon: 'md', btn: 'md', minHeight: 'min-h-42'};
}
}
export interface BreadcrumbSizeStyle {
font: string;
icon: IconSize;
btn: ButtonSize;
minHeight: string;
}

View File

@@ -0,0 +1,24 @@
import React, {Children, Fragment, ReactNode} from 'react';
import clsx from 'clsx';
interface BulletSeparatedItemsProps {
children: ReactNode;
className?: string;
}
export function BulletSeparatedItems({
children,
className,
}: BulletSeparatedItemsProps) {
const items = Children.toArray(children);
return (
<div className={clsx('flex items-center gap-4 overflow-hidden', className)}>
{items.map((child, index) => (
<Fragment key={index}>
<div>{child}</div>
{index < items.length - 1 ? <div>&bull;</div> : null}
</Fragment>
))}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import React, {
ComponentPropsWithRef,
forwardRef,
JSXElementConstructor,
} from 'react';
import clsx from 'clsx';
import {RelativeRoutingType, To} from 'react-router-dom';
import {
ButtonColor,
ButtonVariant,
getSharedButtonStyle,
} from './get-shared-button-style';
import {createEventHandler} from '../../utils/dom/create-event-handler';
export interface ButtonBaseProps
extends Omit<ComponentPropsWithRef<'button'>, 'color'> {
color?: ButtonColor;
variant?: ButtonVariant;
value?: any;
justify?: string;
display?: string;
radius?: string;
shadow?: string;
border?: string;
whitespace?: string;
form?: string;
to?: To;
relative?: RelativeRoutingType;
href?: string;
target?: '_blank';
rel?: string;
replace?: boolean;
end?: boolean;
elementType?: 'button' | 'a' | JSXElementConstructor<any>;
download?: boolean | string;
}
export const ButtonBase = forwardRef<
HTMLButtonElement | HTMLLinkElement,
ButtonBaseProps
>((props, ref) => {
const {
children,
color = null,
variant,
radius,
shadow,
whitespace,
justify = 'justify-center',
className,
href,
form,
border,
elementType,
to,
relative,
replace,
end,
display,
type = 'button',
onClick,
onPointerDown,
onPointerUp,
onKeyDown,
...domProps
} = props;
const Element = elementType || (href ? 'a' : 'button');
const isLink = Element === 'a';
return (
<Element
ref={ref as any}
form={isLink ? undefined : form}
href={href}
to={to}
relative={relative}
type={isLink ? undefined : type}
replace={replace}
end={end}
onPointerDown={createEventHandler(onPointerDown)}
onPointerUp={createEventHandler(onPointerUp)}
onClick={createEventHandler(onClick)}
onKeyDown={createEventHandler(onKeyDown)}
className={clsx(
'focus-visible:ring',
getSharedButtonStyle({variant, color, border, whitespace, display}),
radius,
justify,
className,
)}
{...domProps}
>
{children}
</Element>
);
});

View File

@@ -0,0 +1,111 @@
import React from 'react';
import clsx from 'clsx';
import {ButtonColor, ButtonVariant} from './get-shared-button-style';
import {ButtonProps} from './button';
import {ButtonSize} from './button-size';
export interface ButtonGroupProps {
children: React.ReactNode[];
color?: ButtonColor;
variant?: ButtonVariant;
size?: ButtonSize;
radius?: string;
className?: string;
value?: any;
onChange?: (newValue: any) => void;
multiple?: boolean;
disabled?: boolean;
}
export function ButtonGroup({
children,
color,
variant,
radius = 'rounded-button',
size,
className,
value,
onChange,
multiple,
disabled,
}: ButtonGroupProps) {
const isActive = (childValue: any): boolean => {
// assume that button group is not used as a toggle group, if there is no value given
if (value === undefined) return false;
if (multiple) {
return (value as any[]).includes(childValue);
}
return childValue === value;
};
const toggleMultipleValue = (childValue: any) => {
const newValue = [...value];
const childIndex = value.indexOf(childValue);
if (childIndex > -1) {
newValue.splice(childIndex, 1);
} else {
newValue.push(childValue);
}
return newValue;
};
const buttons = React.Children.map(children, (button, i) => {
if (React.isValidElement(button)) {
const active = isActive(button.props.value);
const adjustedColor = active ? 'primary' : color;
return React.cloneElement<ButtonProps>(button as any, {
color: active ? 'primary' : color,
variant,
size,
radius: null,
disabled: button.props.disabled || disabled,
...button.props,
onClick: e => {
if (button.props.onClick) {
button.props.onClick(e);
}
if (!onChange) return;
if (multiple) {
onChange?.(toggleMultipleValue(button.props.value));
} else {
onChange?.(button.props.value);
}
},
className: clsx(
button.props.className,
// borders are hidden via negative margin, make sure both are visible for active item
active ? 'z-20' : 'z-10',
getStyle(i, children, radius, adjustedColor),
),
});
}
});
return (
<div className={clsx(radius, 'isolate inline-flex', className)}>
{buttons}
</div>
);
}
function getStyle(
i: number,
children: ButtonGroupProps['children'],
radius: ButtonGroupProps['radius'],
color?: ButtonColor,
): string {
// first
if (i === 0) {
return clsx(
radius,
'rounded-tr-none rounded-br-none',
!color && 'border-r-transparent disabled:border-r-transparent',
);
}
// last
if (i === children.length - 1) {
return clsx(radius, 'rounded-tl-none rounded-bl-none -ml-1');
}
return clsx(
'rounded-none -ml-1',
!color && 'border-r-transparent disabled:border-r-transparent',
);
}

View File

@@ -0,0 +1,37 @@
import {ButtonVariant} from './get-shared-button-style';
export type ButtonSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | null;
interface Props {
padding?: string;
equalWidth?: boolean;
variant?: ButtonVariant;
}
export function getButtonSizeStyle(
size?: ButtonSize,
{padding, equalWidth, variant}: Props = {}
): string {
switch (size) {
case '2xs':
if (variant === 'link') return 'text-xs';
return `text-xs h-24 ${equalWidth ? 'w-24' : padding || 'px-10'}`;
case 'xs':
if (variant === 'link') return 'text-xs';
return `text-xs h-30 ${equalWidth ? 'w-30' : padding || 'px-14'}`;
case 'sm':
if (variant === 'link') return 'text-sm';
return `text-sm h-36 ${equalWidth ? 'w-36' : padding || 'px-18'}`;
case 'md':
if (variant === 'link') return 'text-base';
return `text-base h-42 ${equalWidth ? 'w-42' : padding || 'px-22'}`;
case 'lg':
if (variant === 'link') return 'text-lg';
return `text-base h-50 ${equalWidth ? 'w-50' : padding || 'px-26'}`;
case 'xl':
if (variant === 'link') return 'text-xl';
return `text-lg h-60 ${equalWidth ? 'w-60' : padding || 'px-32'}`;
default:
return size || '';
}
}

View File

@@ -0,0 +1,78 @@
import React, {ReactElement} from 'react';
import clsx from 'clsx';
import {ButtonSize, getButtonSizeStyle} from './button-size';
import {ButtonBase, ButtonBaseProps} from './button-base';
import {IconSize} from '../../icons/svg-icon';
export interface ButtonProps extends ButtonBaseProps {
size?: ButtonSize;
sizeClassName?: string;
equalWidth?: boolean;
startIcon?: ReactElement | null | false;
endIcon?: ReactElement | null | false;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
startIcon,
endIcon,
size = 'sm',
sizeClassName,
className,
equalWidth = false,
radius = 'rounded-button',
variant = 'text',
disabled,
elementType,
to,
href,
download,
...other
},
ref,
) => {
const mergedClassName = clsx(
'font-semibold',
sizeClassName || getButtonSizeStyle(size, {equalWidth, variant}),
className,
);
return (
<ButtonBase
className={mergedClassName}
ref={ref}
radius={radius}
variant={variant}
disabled={disabled}
to={disabled ? undefined : to}
href={disabled ? undefined : href}
download={disabled ? undefined : download}
elementType={disabled ? undefined : elementType}
{...other}
>
{startIcon && (
<InlineIcon position="start" icon={startIcon} size={size} />
)}
{children}
{endIcon && <InlineIcon position="end" icon={endIcon} size={size} />}
</ButtonBase>
);
},
);
type InlineIconProps = {
icon: ReactElement;
position: 'start' | 'end';
size?: IconSize | null;
};
function InlineIcon({icon, position, size}: InlineIconProps): ReactElement {
const className = clsx(
'm-auto',
{
'-ml-4 mr-8': position === 'start',
'-mr-4 ml-8': position === 'end',
},
icon.props.className,
);
return React.cloneElement(icon, {className, size});
}

View File

@@ -0,0 +1,18 @@
import {ComponentPropsWithRef} from 'react';
export const LinkStyle =
'text-link hover:underline hover:text-primary-dark focus-visible:ring focus-visible:ring-2 focus-visible:ring-offset-2 outline-none rounded transition-colors';
interface ExternalLinkProps extends ComponentPropsWithRef<'a'> {}
export function ExternalLink({
children,
className,
target = '_blank',
...domProps
}: ExternalLinkProps) {
return (
<a className={LinkStyle} target={target} {...domProps}>
{children}
</a>
);
}

View File

@@ -0,0 +1,172 @@
export type ButtonVariant =
| 'text'
| 'flat'
| 'raised'
| 'outline'
| 'link'
| null;
export type ButtonColor =
| null
| 'primary'
| 'danger'
| 'positive'
| 'paper'
| 'chip'
| 'white';
interface SharedButtonStyleProps {
variant?: ButtonVariant;
color?: ButtonColor;
border?: string;
shadow?: string;
whitespace?: string;
display?: string;
}
export function getSharedButtonStyle(
props: SharedButtonStyleProps,
): (string | boolean | null | undefined)[] {
const {
variant,
shadow,
whitespace = 'whitespace-nowrap',
display = 'inline-flex',
} = props;
const variantProps = {...props, border: props.border || 'border'};
let style: string[] = [];
if (variant === 'outline') {
style = outline(variantProps);
} else if (variant === 'text') {
style = text(variantProps);
} else if (variant === 'flat' || variant === 'raised') {
style = contained(variantProps);
} else if (variant === 'link') {
style = link(variantProps);
}
return [
...style,
shadow || (variant === 'raised' && 'shadow-md'),
whitespace,
display,
variant &&
'align-middle flex-shrink-0 items-center transition-button duration-200',
'select-none appearance-none no-underline outline-none disabled:pointer-events-none disabled:cursor-default',
];
}
function outline({color, border}: SharedButtonStyleProps) {
const disabled =
'disabled:text-disabled disabled:bg-transparent disabled:border-disabled-bg';
switch (color) {
case 'primary':
return [
`text-primary bg-transparent ${border} border-primary/50`,
'hover:bg-primary/hover hover:border-primary',
disabled,
];
case 'danger':
return [
`text-danger bg-transparent ${border} border-danger/50`,
'hover:bg-danger/4 hover:border-danger',
disabled,
];
case 'positive':
return [
`text-positive bg-transparent ${border} border-positive/50`,
'hover:bg-positive/4 hover:border-positive',
disabled,
];
case 'paper':
return [`text bg-paper ${border}`, 'hover:bg-hover', disabled];
case 'white':
return [
'text-white bg-transparent border border-white',
'hover:bg-white/20',
'disabled:text-white/70 disabled:border-white/70 disabled:bg-transparent',
];
default:
return [`bg-transparent ${border}`, 'hover:bg-hover', disabled];
}
}
function text({color}: SharedButtonStyleProps) {
const disabled = 'disabled:text-disabled disabled:bg-transparent';
switch (color) {
case 'primary':
return [
'text-primary bg-transparent border-transparent',
'hover:bg-primary/4',
disabled,
];
case 'danger':
return [
'text-danger bg-transparent border-transparent',
'hover:bg-danger/4',
disabled,
];
case 'positive':
return [
'text-positive bg-transparent border-transparent',
'hover:bg-positive/4',
disabled,
];
case 'white':
return [
'text-white bg-transparent border-transparent',
'hover:bg-white/20',
'disabled:text-white/70 disabled:bg-transparent',
];
default:
return ['bg-transparent border-transparent', 'hover:bg-hover', disabled];
}
}
function link({color}: SharedButtonStyleProps) {
switch (color) {
case 'primary':
return ['text-primary', 'hover:underline', 'disabled:text-disabled'];
case 'danger':
return ['text-danger', 'hover:underline', 'disabled:text-disabled'];
default:
return ['text-main', 'hover:underline', 'disabled:text-disabled'];
}
}
function contained({color, border}: SharedButtonStyleProps) {
const disabled =
'disabled:text-disabled disabled:bg-disabled disabled:border-transparent disabled:shadow-none';
switch (color) {
case 'primary':
return [
`text-on-primary bg-primary ${border} border-primary`,
'hover:bg-primary-dark hover:border-primary-dark',
disabled,
];
case 'danger':
return [
`text-white bg-danger ${border} border-danger`,
'hover:bg-danger/90 hover:border-danger/90',
disabled,
];
case 'chip':
return [
`text-main bg-chip ${border} border-chip`,
'hover:bg-chip/90 hover:border-chip/90',
disabled,
];
case 'paper':
return [
`text-main bg-paper ${border} border-paper`,
'hover:bg-paper/90 hover:border-paper/90',
disabled,
];
case 'white':
return [
`text-black bg-white ${border} border-white`,
'hover:bg-white',
disabled,
];
default:
return [`bg ${border} border-background`, 'hover:bg-hover', disabled];
}
}

View File

@@ -0,0 +1,51 @@
import React, {cloneElement, forwardRef, ReactElement} from 'react';
import clsx from 'clsx';
import {ButtonSize, getButtonSizeStyle} from './button-size';
import {ButtonBase, ButtonBaseProps} from './button-base';
import {BadgeProps} from '@common/ui/badge/badge';
export interface IconButtonProps extends ButtonBaseProps {
children: ReactElement;
padding?: string;
size?: ButtonSize | null;
iconSize?: ButtonSize | null;
equalWidth?: boolean;
badge?: ReactElement<BadgeProps>;
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(
{
children,
size = 'md',
// only set icon size based on button size if "ButtonSize" is passed in and not custom className
iconSize = size && size.length <= 3 ? size : 'md',
variant = 'text',
radius = 'rounded-button',
className,
padding,
equalWidth = true,
badge,
...other
},
ref,
) => {
const mergedClassName = clsx(
getButtonSizeStyle(size, {padding, equalWidth, variant}),
className,
badge && 'relative',
);
return (
<ButtonBase
{...other}
ref={ref}
radius={radius}
variant={variant}
className={mergedClassName}
>
{cloneElement(children, {size: iconSize})}
{badge}
</ButtonBase>
);
},
);

View File

@@ -0,0 +1,46 @@
import {ColorPicker} from './color-picker';
import {DialogFooter} from '../overlays/dialog/dialog-footer';
import {Button} from '../buttons/button';
import {useDialogContext} from '../overlays/dialog/dialog-context';
import {Dialog} from '../overlays/dialog/dialog';
import {Trans} from '../../i18n/trans';
interface ColorPickerDialogProps {
hideFooter?: boolean;
showInput?: boolean;
}
export function ColorPickerDialog({
hideFooter = false,
showInput = true,
}: ColorPickerDialogProps) {
const {close, value, setValue, initialValue} = useDialogContext<
string | null
>();
// todo: remove this once pixie and bedrive are refactored to use dialogTrigger currentValue (use "currentValue" for defaultValue as well)
//const initialValue = useRef(defaultValue);
return (
<Dialog size="2xs">
<ColorPicker
showInput={showInput}
defaultValue={initialValue ? initialValue : ''}
onChange={newValue => setValue(newValue)}
/>
{!hideFooter && (
<DialogFooter dividerTop>
<Button variant="text" size="xs" onClick={() => close()}>
<Trans message="Cancel" />
</Button>
<Button
variant="flat"
color="primary"
size="xs"
onClick={() => close(value)}
>
<Trans message="Apply" />
</Button>
</DialogFooter>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,74 @@
import {HexColorInput, HexColorPicker} from 'react-colorful';
import React, {useState} from 'react';
import {parseColor} from '@react-stately/color';
import {ColorSwatch} from './color-swatch';
import {getInputFieldClassNames} from '../forms/input-field/get-input-field-class-names';
import {ColorPresets} from '@common/ui/color-picker/color-presets';
const DefaultPresets = ColorPresets.map(({color}) => color).slice(0, 14);
type Props = {
defaultValue?: string;
onChange?: (e: string) => void;
colorPresets?: string[];
showInput?: boolean;
};
export function ColorPicker({
defaultValue,
onChange,
colorPresets,
showInput,
}: Props) {
const [color, setColor] = useState<string | undefined>(defaultValue);
const presets: string[] = colorPresets || DefaultPresets;
const style = getInputFieldClassNames({size: 'sm'});
return (
<div>
<HexColorPicker
className="!w-auto"
color={color}
onChange={newColor => {
onChange?.(newColor);
setColor(newColor);
}}
/>
<div className="py-20 px-12">
{presets && (
<ColorSwatch
colors={presets}
onChange={newColor => {
if (newColor) {
const hex = parseColor(newColor).toString('hex');
onChange?.(hex);
setColor(hex);
}
}}
value={color}
/>
)}
{showInput && (
<div className="pt-20">
<HexColorInput
autoComplete="off"
role="textbox"
autoCorrect="off"
spellCheck="false"
required
aria-label="Hex color"
prefixed
className={style.input}
color={color}
onChange={newColor => {
onChange?.(newColor);
setColor(newColor);
}}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import {message} from '@common/i18n/message';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
export const ColorPresets: {
color: string;
name: MessageDescriptor;
foreground?: string;
}[] = [
{
color: 'rgb(255, 255, 255)',
name: message('White'),
},
{
color: 'rgb(239,245,245)',
name: message('Solitude'),
},
{
color: 'rgb(245,213,174)',
name: message('Wheat'),
},
{
color: 'rgb(253,227,167)',
name: message('Cape Honey'),
},
{
color: 'rgb(242,222,186)',
name: message('Milk punch'),
},
{
color: 'rgb(97,118,75)',
name: message('Dingy'),
foreground: 'rgb(255, 255, 255)',
},
{
color: 'rgb(4, 147, 114)',
name: message('Aquamarine'),
foreground: 'rgb(255, 255, 255)',
},
{
color: 'rgb(222,245,229)',
name: message('Cosmic Latte'),
},
{
color: 'rgb(233,119,119)',
name: message('Geraldine'),
foreground: 'rgb(90,14,14)',
},
{
color: 'rgb(247,164,164)',
name: message('Sundown'),
},
{
color: 'rgb(30,139,195)',
name: message('Pelorous'),
foreground: 'rgb(255, 255, 255)',
},
{
color: 'rgb(142,68,173)',
name: message('Deep Lilac'),
foreground: 'rgb(255, 255, 255)',
},
{
color: 'rgb(108,74,182)',
name: message('Blue marguerite'),
foreground: 'rgb(255, 255, 255)',
},
{
color: 'rgb(139,126,116)',
name: message('Americano'),
foreground: 'rgb(255, 255, 255)',
},
{
color: 'rgb(0,0,0)',
name: message('Black'),
foreground: 'rgb(255, 255, 255)',
},
{
color: 'rgb(64,66,88)',
name: message('Blue zodiac'),
foreground: 'rgb(255, 255, 255)',
},
{
color: 'rgb(101,100,124)',
name: message('Comet'),
foreground: 'rgb(255, 255, 255)',
},
];

View File

@@ -0,0 +1,33 @@
import React from 'react';
import clsx from 'clsx';
import {ButtonBase} from '../buttons/button-base';
type Props = {
onChange?: (e: string) => void;
value?: string;
colors: string[];
};
export function ColorSwatch({onChange, value, colors}: Props) {
const presetButtons = colors.map(color => {
const isSelected = value === color;
return (
<ButtonBase
key={color}
onClick={() => {
onChange?.(color);
}}
className={clsx(
'relative block flex-shrink-0 w-26 h-26 border rounded',
isSelected && 'shadow-md'
)}
style={{backgroundColor: color}}
>
{isSelected && (
<span className="absolute inset-0 m-auto rounded-full w-8 h-8 bg-white" />
)}
</ButtonBase>
);
});
return <div className="flex flex-wrap gap-6">{presetButtons}</div>;
}

View File

@@ -0,0 +1,32 @@
export const COOKIE_LAW_COUNTRIES = [
'AT',
'BE',
'BG',
'BR',
'CY',
'CZ',
'DE',
'DK',
'EE',
'EL',
'ES',
'FI',
'FR',
'GB',
'HR',
'HU',
'IE',
'IT',
'LT',
'LU',
'LV',
'MT',
'NL',
'NO',
'PL',
'PT',
'RO',
'SE',
'SI',
'SK',
];

View File

@@ -0,0 +1,68 @@
import clsx from 'clsx';
import {Trans} from '../../i18n/trans';
import {CustomMenuItem} from '../../menus/custom-menu';
import {Button} from '../buttons/button';
import {useSettings} from '../../core/settings/use-settings';
import {useState} from 'react';
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
import {useCookie} from '@common/utils/hooks/use-cookie';
export function CookieNotice() {
const {
cookie_notice: {position, enable},
} = useSettings();
const [, setCookie] = useCookie('cookie_notice');
const [alreadyAccepted, setAlreadyAccepted] = useState(() => {
return !getBootstrapData().show_cookie_notice;
});
if (!enable || alreadyAccepted) {
return null;
}
return (
<div
className={clsx(
'fixed z-50 flex w-full justify-center gap-14 bg-toast p-14 text-sm text-white shadow max-md:flex-col md:items-center md:gap-30',
position == 'top' ? 'top-0' : 'bottom-0',
)}
>
<Trans
message="We use cookies to optimize site functionality and provide you with the
best possible experience."
/>
<InfoLink />
<Button
variant="flat"
color="primary"
size="xs"
className="max-w-100"
onClick={() => {
setCookie('true', {days: 30, path: '/'});
setAlreadyAccepted(true);
}}
>
<Trans message="OK" />
</Button>
</div>
);
}
function InfoLink() {
const {
cookie_notice: {button},
} = useSettings();
if (!button?.label) {
return null;
}
return (
<CustomMenuItem
className={() => 'text-primary-light hover:underline'}
item={button}
/>
);
}

View File

@@ -0,0 +1,35 @@
import {useQuery} from '@tanstack/react-query';
import axios from 'axios';
import {COOKIE_LAW_COUNTRIES} from './cookie-law-countries';
const endpoint = 'https://freegeoip.app/json';
interface Response {
userIsFromEu: boolean;
}
interface Props {
enabled: boolean;
}
export function useUserIsFromEu({enabled}: Props) {
return useQuery({
queryKey: [endpoint],
queryFn: () => checkIfFromEu(),
staleTime: Infinity,
enabled,
});
}
function checkIfFromEu(): Promise<Response> {
return axios
.get(endpoint)
.then(response => {
const userIsFromEu = COOKIE_LAW_COUNTRIES.includes(
response.data.country_code,
);
return {userIsFromEu};
})
.catch(() => {
return {userIsFromEu: true};
});
}

View File

@@ -0,0 +1,35 @@
import {ReactElement} from 'react';
import {GuestRoute} from '../auth/guards/guest-route';
import {RegisterPage} from '../auth/ui/register-page';
import {useSettings} from '../core/settings/use-settings';
import {CustomPageLayout} from '@common/custom-page/custom-page-layout';
import {LoginPageWrapper} from '@common/auth/ui/login-page-wrapper';
interface DynamicHomepageProps {
homepageResolver?: (type?: string) => ReactElement;
}
export function DynamicHomepage({homepageResolver}: DynamicHomepageProps) {
const {homepage} = useSettings();
if (homepage?.type === 'loginPage') {
return (
<GuestRoute>
<LoginPageWrapper />
</GuestRoute>
);
}
if (homepage?.type === 'registerPage') {
return (
<GuestRoute>
<RegisterPage />
</GuestRoute>
);
}
if (homepage?.type === 'customPage') {
return <CustomPageLayout slug={homepage.value} />;
}
return homepageResolver?.(homepage?.type) || null;
}

View File

@@ -0,0 +1,27 @@
import {RefObject, useEffect, useRef} from 'react';
export interface AutoFocusProps {
autoFocus?: boolean;
autoSelectText?: boolean;
disabled?: boolean;
}
export function useAutoFocus(
{autoFocus, autoSelectText}: AutoFocusProps,
ref: RefObject<HTMLElement>
) {
const autoFocusRef = useRef(autoFocus);
useEffect(() => {
if (autoFocusRef.current && ref.current) {
// run inside animation frame to prevent issues when opening
// dialog with via keyboard shortcut and focusing input
requestAnimationFrame(() => {
ref.current?.focus();
if (autoSelectText && ref.current?.nodeName.toLowerCase() === 'input') {
(ref.current as HTMLInputElement).select();
}
});
}
autoFocusRef.current = false;
}, [ref, autoSelectText]);
}

View File

@@ -0,0 +1,28 @@
import {FontConfig} from '@common/http/value-lists';
import {message} from '@common/i18n/message';
export const BrowserSafeFonts: FontConfig[] = [
{
label: message('System'),
family:
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
category: 'sans-serif',
},
{family: 'Impact, Charcoal, sans-serif', category: 'sans-serif'},
{family: 'Arial, Helvetica Neue, Helvetica, sans-serif', category: 'serif'},
{family: '"Comic Sans MS", cursive, sans-serif', category: 'Handwriting'},
{family: 'Century Gothic, sans-serif', category: 'sans-serif'},
{family: '"Courier New", Courier, monospace', category: 'monospace'},
{
family: '"Lucida Sans Unicode", "Lucida Grande", sans-serif',
category: 'sans-serif',
},
{family: '"Times New Roman", Times, serif', category: 'serif'},
{family: '"Lucida Console", Monaco, monospace', category: 'monospace'},
{family: '"Andele Mono", monospace, sans-serif', category: 'sans-serif'},
{family: 'Verdana, Geneva, sans-serif', category: 'sans-serif'},
{
family: '"Helvetica Neue", Helvetica, Arial, sans-serif',
category: 'sans-serif',
},
];

View File

@@ -0,0 +1,5 @@
export interface FontFaceConfig {
family: string;
src: string;
descriptors?: FontFaceDescriptors;
}

View File

@@ -0,0 +1,75 @@
import {FontFaceConfig} from './font-face-config';
import {FontConfig} from '@common/http/value-lists';
import lazyLoader from '@common/utils/http/lazy-loader';
function prefixId(id: string) {
return `be-fonts-${id}`;
}
export function loadFonts(
fonts: (FontFaceConfig | FontConfig)[],
options: {
prefixSrc?: (src?: string) => string;
id: string;
forceAssetLoad?: boolean;
document?: Document;
weights?: number[];
},
): Promise<FontFace[]> {
const doc = options.document || document;
const googleFonts: FontConfig[] = [];
const customFonts: FontFaceConfig[] = [];
let promises = [];
fonts.forEach(font => {
if ('google' in font && font.google) {
googleFonts.push(font);
} else if ('src' in font) {
customFonts.push(font);
}
// native fonts don't need to be loaded, they are already available in the browser
});
if (googleFonts?.length) {
const weights = options.weights || [400];
const families = fonts
.map(f => `${f.family}:${weights.join(',')}`)
.join('|');
const googlePromise = lazyLoader.loadAsset(
`https://fonts.googleapis.com/css?family=${families}&display=swap`,
{
type: 'css',
id: prefixId(options.id),
force: options.forceAssetLoad,
document: doc,
},
);
promises.push(googlePromise);
}
if (customFonts?.length) {
const customFontPromises = customFonts.map(async fontConfig => {
const loadedFont = Array.from(doc.fonts.values()).find(current => {
return current.family === fontConfig.family;
});
if (loadedFont) {
return loadedFont.loaded;
}
const fontFace = new FontFace(
fontConfig.family,
`url(${
options?.prefixSrc
? options.prefixSrc(fontConfig.src)
: fontConfig.src
})`,
fontConfig.descriptors,
);
doc.fonts.add(fontFace);
return fontFace.load();
});
promises = promises.concat(customFontPromises);
}
return Promise.all(promises);
}

View File

@@ -0,0 +1,13 @@
import {FontConfig} from '@common/http/value-lists';
import lazyLoader from '@common/utils/http/lazy-loader';
export function loadGoogleFonts(fonts: FontConfig[], id: string) {
const googleFonts = fonts.filter(f => f.google);
if (googleFonts?.length) {
const families = fonts.map(f => `${f.family}:400`).join('|');
lazyLoader.loadAsset(
`https://fonts.googleapis.com/css?family=${families}&display=swap`,
{type: 'css', id}
);
}
}

View File

@@ -0,0 +1,69 @@
import {useTrans} from '@common/i18n/use-trans';
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
import {SearchIcon} from '@common/icons/material/Search';
import {message} from '@common/i18n/message';
import {Select} from '@common/ui/forms/select/select';
import {Item} from '@common/ui/forms/listbox/item';
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {FontSelectorState} from '@common/ui/font-selector/font-selector-state';
export interface FontSelectorFilterValue {
query: string;
category: string;
}
interface FiltersHeaderProps {
state: FontSelectorState;
}
export function FontSelectorFilters({
state: {filters, setFilters},
}: FiltersHeaderProps) {
const {trans} = useTrans();
return (
<div className="mb-24 items-center gap-24 @xs:flex">
<TextField
className="mb-12 flex-auto @xs:mb-0"
value={filters.query}
onChange={e => {
setFilters({
...filters,
query: e.target.value,
});
}}
startAdornment={<SearchIcon />}
placeholder={trans(message('Search fonts'))}
/>
<Select
className="flex-auto"
selectionMode="single"
selectedValue={filters.category}
onSelectionChange={value => {
setFilters({
...filters,
category: value as string,
});
}}
>
<Item value="">
<Trans message="All categories" />
</Item>
<Item value="serif">
<Trans message="Serif" />
</Item>
<Item value="sans-serif">
<Trans message="Sans serif" />
</Item>
<Item value="display">
<Trans message="Display" />
</Item>
<Item value="handwriting">
<Trans message="Handwriting" />
</Item>
<Item value="monospace">
<Trans message="Monospace" />
</Item>
</Select>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import {Trans} from '@common/i18n/trans';
import {IconButton} from '@common/ui/buttons/icon-button';
import {KeyboardArrowLeftIcon} from '@common/icons/material/KeyboardArrowLeft';
import {KeyboardArrowRightIcon} from '@common/icons/material/KeyboardArrowRight';
import React from 'react';
import {FontSelectorState} from '@common/ui/font-selector/font-selector-state';
interface FontSelectorPaginationProps {
state: FontSelectorState;
}
export function FontSelectorPagination({
state: {currentPage = 0, setCurrentPage, filteredFonts, pages},
}: FontSelectorPaginationProps) {
const total = filteredFonts?.length || 0;
return (
<div className="flex items-center justify-end gap-24 text-sm mt-30 pt-14 border-t">
{total > 0 && (
<div>
<Trans
message=":from - :to of :total"
values={{
from: currentPage * 20 + 1,
to: Math.min((currentPage + 1) * 20, total),
total,
}}
/>
</div>
)}
<div className="text-muted">
<IconButton
disabled={currentPage < 1}
onClick={() => {
setCurrentPage(Math.max(0, currentPage - 1));
}}
>
<KeyboardArrowLeftIcon />
</IconButton>
<IconButton
disabled={currentPage >= pages.length - 1}
onClick={() => {
setCurrentPage(currentPage + 1);
}}
>
<KeyboardArrowRightIcon />
</IconButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {FontSelectorFilterValue} from '@common/ui/font-selector/font-selector-filters';
import {FontConfig, useValueLists} from '@common/http/value-lists';
import {useFilter} from '@common/i18n/use-filter';
import {BrowserSafeFonts} from '@common/ui/font-picker/browser-safe-fonts';
import {chunkArray} from '@common/utils/array/chunk-array';
import {loadFonts} from '@common/ui/font-picker/load-fonts';
export interface FontSelectorState extends UseFontSelectorProps {
fonts: FontConfig[];
filteredFonts: FontConfig[];
pages: FontConfig[][];
isLoading: boolean;
filters: FontSelectorFilterValue;
setFilters: (filters: FontSelectorFilterValue) => void;
currentPage: number;
setCurrentPage: (page: number) => void;
}
export interface UseFontSelectorProps {
value?: FontConfig;
onChange: (value: FontConfig) => void;
}
export function useFontSelectorState({
value,
onChange,
}: UseFontSelectorProps): FontSelectorState {
const {data, isLoading} = useValueLists(['googleFonts']);
const [currentPage, setCurrentPage] = useState(0);
const [filters, setFilterState] = useState<FontSelectorFilterValue>({
query: '',
category: value?.category ?? '',
});
const {contains} = useFilter({
sensitivity: 'base',
});
const setFilters = useCallback((filters: FontSelectorFilterValue) => {
setFilterState(filters);
// reset to first page when searching or changing category
setCurrentPage(0);
}, []);
const allFonts = useMemo(() => {
return BrowserSafeFonts.concat(data?.googleFonts ?? []);
}, [data?.googleFonts]);
const filteredFonts = useMemo(() => {
return allFonts.filter(font => {
return (
contains(font.family, filters.query) &&
(!filters.category ||
font.category?.toLowerCase() === filters.category.toLowerCase())
);
});
}, [allFonts, filters, contains]);
const pages = useMemo(() => {
return chunkArray(filteredFonts, 20);
}, [filteredFonts]);
const fonts = pages[currentPage];
useEffect(() => {
const id = 'font-selector';
if (fonts?.length) {
loadFonts(fonts, {id});
}
}, [fonts, currentPage]);
return {
fonts: fonts || [],
currentPage,
filteredFonts: filteredFonts || [],
setCurrentPage,
isLoading,
filters,
setFilters,
value,
onChange,
pages,
};
}

View File

@@ -0,0 +1,125 @@
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {ButtonBase} from '@common/ui/buttons/button-base';
import clsx from 'clsx';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import fontImage from './font.svg';
import {SvgImage} from '@common/ui/images/svg-image/svg-image';
import {FontSelectorFilters} from '@common/ui/font-selector/font-selector-filters';
import {
FontSelectorState,
UseFontSelectorProps,
useFontSelectorState,
} from '@common/ui/font-selector/font-selector-state';
import {FontSelectorPagination} from '@common/ui/font-selector/font-selector-pagination';
import {FontConfig} from '@common/http/value-lists';
import {Skeleton} from '@common/ui/skeleton/skeleton';
import {AnimatePresence, m} from 'framer-motion';
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
interface FontSelectorProps extends UseFontSelectorProps {
className?: string;
}
export function FontSelector(props: FontSelectorProps) {
const state = useFontSelectorState(props);
return (
<div className={props.className}>
<FontSelectorFilters state={state} />
<AnimatePresence initial={false} mode="wait">
<FontList state={state} />
</AnimatePresence>
<FontSelectorPagination state={state} />
</div>
);
}
interface FontListProps {
state: FontSelectorState;
}
function FontList({state}: FontListProps) {
const {isLoading, fonts} = state;
const gridClassName =
'grid gap-24 grid-cols-[repeat(auto-fill,minmax(90px,1fr))] items-start';
if (isLoading) {
return <FontListSkeleton className={gridClassName} />;
}
if (!fonts?.length) {
return (
<IllustratedMessage
className="mt-60"
size="sm"
image={<SvgImage src={fontImage} />}
title={<Trans message="No matching fonts" />}
description={
<Trans message="Try another search query or different category" />
}
/>
);
}
return (
<m.div key="font-list" {...opacityAnimation} className={gridClassName}>
{fonts?.map(font => (
<FontButton key={font.family} font={font} state={state} />
))}
</m.div>
);
}
interface FontButtonProps {
font: FontConfig;
state: FontSelectorState;
}
function FontButton({font, state: {value, onChange}}: FontButtonProps) {
const isActive = value?.family === font.family;
const displayName = font.family.split(',')[0].replace(/"/g, '');
return (
<ButtonBase
key={font.family}
display="block"
onClick={() => {
onChange(font);
}}
>
<span
className={clsx(
'flex aspect-square items-center justify-center rounded-panel border text-4xl transition-bg-color hover:bg-hover md:text-5xl',
isActive && 'ring-2 ring-primary ring-offset-2',
)}
>
<span style={{fontFamily: font.family}}>Aa</span>
</span>
<span
className={clsx(
'mt-6 block overflow-hidden overflow-ellipsis whitespace-nowrap text-sm',
isActive && 'text-primary',
)}
>
{font.label ? <Trans {...font.label} /> : displayName}
</span>
</ButtonBase>
);
}
interface FontListSkeletonProps {
className: string;
}
function FontListSkeleton({className}: FontListSkeletonProps) {
const items = Array.from(Array(20).keys());
return (
<m.div key="font-list-skeleton" {...opacityAnimation} className={className}>
{items.map(index => (
<div key={index}>
<div className="aspect-square">
<Skeleton display="block" variant="rect" />
</div>
<Skeleton className="mt-6 text-sm" />
</div>
))}
</m.div>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,87 @@
import clsx from 'clsx';
import {CustomMenu} from '../../menus/custom-menu';
import {LocaleSwitcher} from '../../i18n/locale-switcher';
import {Button} from '../buttons/button';
import {DarkModeIcon} from '../../icons/material/DarkMode';
import {LightbulbIcon} from '../../icons/material/Lightbulb';
import {Trans} from '../../i18n/trans';
import {useThemeSelector} from '../themes/theme-selector-context';
import {useSettings} from '../../core/settings/use-settings';
interface Props {
className?: string;
padding?: string;
}
export function Footer({className, padding}: Props) {
const year = new Date().getFullYear();
const {branding} = useSettings();
return (
<footer
className={clsx(
'text-sm',
padding ? padding : 'pb-28 pt-54 md:pb-54',
className,
)}
>
<Menus />
<div className="items-center justify-between gap-30 text-center text-muted md:flex md:text-left">
<Trans
message="Copyright © :year :name, All Rights Reserved"
values={{year, name: branding.site_name}}
/>
<div>
<ThemeSwitcher />
<LocaleSwitcher />
</div>
</div>
</footer>
);
}
function Menus() {
const settings = useSettings();
const primaryMenu = settings.menus.find(m => m.positions?.includes('footer'));
const secondaryMenu = settings.menus.find(
m => m.positions?.includes('footer-secondary'),
);
if (!primaryMenu && !secondaryMenu) return null;
return (
<div className="mb-14 items-center justify-between gap-30 overflow-x-auto border-b pb-14 md:flex">
{primaryMenu && (
<CustomMenu menu={primaryMenu} className="text-primary" />
)}
{secondaryMenu && (
<CustomMenu menu={secondaryMenu} className="mb:mt-0 mt-14 text-muted" />
)}
</div>
);
}
function ThemeSwitcher() {
const {themes} = useSettings();
const {selectedTheme, selectTheme} = useThemeSelector();
if (!selectedTheme || !themes?.user_change) return null;
return (
<Button
variant="text"
startIcon={selectedTheme.is_dark ? <DarkModeIcon /> : <LightbulbIcon />}
onClick={() => {
if (selectedTheme.is_dark) {
selectTheme('light');
} else {
selectTheme('dark');
}
}}
>
{selectedTheme.is_dark ? (
<Trans message="Light mode" />
) : (
<Trans message="Dark mode" />
)}
</Button>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
import React from 'react';
import clsx from 'clsx';
type AdornmentProps = {
children: React.ReactNode;
direction: 'start' | 'end';
position?: string;
className?: string;
};
export function Adornment({
children,
direction,
className,
position = direction === 'start' ? 'left-0' : 'right-0',
}: AdornmentProps) {
if (!children) return null;
return (
<div
className={clsx(
'pointer-events-none absolute top-0 z-10 flex h-full min-w-42 items-center justify-center text-muted',
position,
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,43 @@
import React, {ElementType, HTMLProps, ReactElement, ReactNode} from 'react';
import {InputSize} from './input-size';
export interface BaseFieldProps {
disabled?: boolean;
required?: boolean;
labelSuffix?: ReactNode;
labelSuffixPosition?: 'spaced' | 'inline';
autoFocus?: boolean;
autoSelectText?: boolean;
labelElementType?: ElementType;
label?: ReactNode;
labelPosition?: 'top' | 'side';
labelDisplay?: string;
size?: InputSize;
inputRadius?: 'rounded-full' | 'rounded' | 'rounded-none' | string;
inputRing?: string;
inputFontSize?: string;
inputBorder?: string;
inputShadow?: string;
invalid?: boolean;
errorMessage?: ReactNode;
description?: ReactNode;
descriptionPosition?: 'top' | 'bottom';
flexibleHeight?: boolean;
// usually an icon or icon button, displayed inside the input
startAdornment?: React.ReactNode;
endAdornment?: React.ReactNode;
adornmentPosition?: string;
// usually a text button, displayed side by side with input
startAppend?: ReactElement;
endAppend?: ReactElement;
className?: string;
inputWrapperClassName?: string;
inputClassName?: string;
unstyled?: boolean;
background?: 'bg-transparent' | 'bg-alt' | 'bg' | 'bg-white';
inputTestId?: string;
}
export interface BaseFieldPropsWithDom<T>
extends BaseFieldProps,
Omit<HTMLProps<T>, 'label' | 'size' | 'ref' | 'children'> {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
import React from 'react';
import clsx from 'clsx';
import {
CalendarDate,
DateValue,
getDayOfWeek,
isSameMonth,
isToday,
} from '@internationalized/date';
import {useSelectedLocale} from '../../../../../i18n/selected-locale';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {dateIsInvalid} from '../utils';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
interface CalendarCellProps {
date: CalendarDate;
currentMonth: DateValue;
state: DatePickerState | DateRangePickerState;
}
export function CalendarCell({
date,
currentMonth,
state: {
dayIsActive,
dayIsHighlighted,
dayIsRangeStart,
dayIsRangeEnd,
getCellProps,
timezone,
min,
max,
},
}: CalendarCellProps) {
const {localeCode} = useSelectedLocale();
const dayOfWeek = getDayOfWeek(date, localeCode);
const isActive = dayIsActive(date);
const isHighlighted = dayIsHighlighted(date);
const isRangeStart = dayIsRangeStart(date);
const isRangeEnd = dayIsRangeEnd(date);
const dayIsToday = isToday(date, timezone);
const sameMonth = isSameMonth(date, currentMonth);
const isDisabled = dateIsInvalid(date, min, max);
return (
<div
role="button"
aria-disabled={isDisabled}
className={clsx(
'w-40 h-40 text-sm relative isolate flex-shrink-0',
isDisabled && 'text-disabled pointer-events-none',
!sameMonth && 'invisible pointer-events-none'
)}
{...getCellProps(date, sameMonth)}
>
<span
className={clsx(
'absolute inset-0 flex items-center justify-center rounded-full w-full h-full select-none z-10 cursor-pointer',
!isActive && !dayIsToday && 'hover:bg-hover',
isActive && 'bg-primary text-on-primary font-semibold',
dayIsToday && !isActive && 'bg-chip'
)}
>
{date.day}
</span>
{isHighlighted && sameMonth && (
<span
className={clsx(
'absolute w-full h-full inset-0 bg-primary/focus',
(isRangeStart || dayOfWeek === 0 || date.day === 1) &&
'rounded-l-full',
(isRangeEnd ||
dayOfWeek === 6 ||
date.day ===
currentMonth.calendar.getDaysInMonth(currentMonth)) &&
'rounded-r-full'
)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,169 @@
import React from 'react';
import clsx from 'clsx';
import {m} from 'framer-motion';
import {
CalendarDate,
endOfMonth,
getWeeksInMonth,
startOfMonth,
startOfWeek,
} from '@internationalized/date';
import {KeyboardArrowLeftIcon} from '../../../../../icons/material/KeyboardArrowLeft';
import {IconButton} from '../../../../buttons/icon-button';
import {KeyboardArrowRightIcon} from '../../../../../icons/material/KeyboardArrowRight';
import {CalendarCell} from './calendar-cell';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {useDateFormatter} from '../../../../../i18n/use-date-formatter';
import {useSelectedLocale} from '../../../../../i18n/selected-locale';
import {dateIsInvalid} from '../utils';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
export interface CalendarMonthProps {
state: DatePickerState | DateRangePickerState;
startDate: CalendarDate;
isFirst: boolean;
isLast: boolean;
}
export function CalendarMonth({
startDate,
state,
isFirst,
isLast,
}: CalendarMonthProps) {
const {localeCode} = useSelectedLocale();
const weeksInMonth = getWeeksInMonth(startDate, localeCode);
const monthStart = startOfWeek(startDate, localeCode);
return (
<div className="w-280 flex-shrink-0">
<CalendarMonthHeader
isFirst={isFirst}
isLast={isLast}
state={state}
currentMonth={startDate}
/>
<div className="block" role="grid">
<WeekdayHeader state={state} startDate={startDate} />
{[...new Array(weeksInMonth).keys()].map(weekIndex => (
<m.div className="flex mb-6" key={weekIndex}>
{[...new Array(7).keys()].map(dayIndex => (
<CalendarCell
key={dayIndex}
date={monthStart.add({weeks: weekIndex, days: dayIndex})}
currentMonth={startDate}
state={state}
/>
))}
</m.div>
))}
</div>
</div>
);
}
interface CalendarMonthHeaderProps {
state: DatePickerState | DateRangePickerState;
currentMonth: CalendarDate;
isFirst: boolean;
isLast: boolean;
}
function CalendarMonthHeader({
currentMonth,
isFirst,
isLast,
state: {calendarDates, setCalendarDates, timezone, min, max},
}: CalendarMonthHeaderProps) {
const shiftCalendars = (direction: 'forward' | 'backward') => {
const count = calendarDates.length;
let newDates: CalendarDate[];
if (direction === 'forward') {
newDates = calendarDates.map(date =>
endOfMonth(date.add({months: count}))
);
} else {
newDates = calendarDates.map(date =>
endOfMonth(date.subtract({months: count}))
);
}
setCalendarDates(newDates);
};
const monthFormatter = useDateFormatter({
month: 'long',
year: 'numeric',
era: currentMonth.calendar.identifier !== 'gregory' ? 'long' : undefined,
calendar: currentMonth.calendar.identifier,
});
const isBackwardDisabled = dateIsInvalid(
currentMonth.subtract({days: 1}),
min,
max
);
const isForwardDisabled = dateIsInvalid(
startOfMonth(currentMonth.add({months: 1})),
min,
max
);
return (
<div className="flex items-center justify-between gap-10">
<IconButton
size="md"
className={clsx('text-muted', !isFirst && 'invisible')}
disabled={!isFirst || isBackwardDisabled}
aria-hidden={!isFirst}
onClick={() => {
shiftCalendars('backward');
}}
>
<KeyboardArrowLeftIcon />
</IconButton>
<div className="text-sm font-semibold select-none">
{monthFormatter.format(currentMonth.toDate(timezone))}
</div>
<IconButton
size="md"
className={clsx('text-muted', !isLast && 'invisible')}
disabled={!isLast || isForwardDisabled}
aria-hidden={!isLast}
onClick={() => {
shiftCalendars('forward');
}}
>
<KeyboardArrowRightIcon />
</IconButton>
</div>
);
}
interface WeekdayHeaderProps {
state: DatePickerState | DateRangePickerState;
startDate: CalendarDate;
}
function WeekdayHeader({state: {timezone}, startDate}: WeekdayHeaderProps) {
const {localeCode} = useSelectedLocale();
const dayFormatter = useDateFormatter({weekday: 'short'});
const monthStart = startOfWeek(startDate, localeCode);
return (
<div className="flex">
{[...new Array(7).keys()].map(index => {
const date = monthStart.add({days: index});
const dateDay = date.toDate(timezone);
const weekday = dayFormatter.format(dateDay);
return (
<div
className="w-40 h-40 text-sm font-semibold relative flex-shrink-0"
key={index}
>
<div className="absolute flex items-center justify-center w-full h-full select-none">
{weekday}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React, {Fragment} from 'react';
import {startOfMonth, toCalendarDate} from '@internationalized/date';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
import {CalendarMonth} from './calendar-month';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
interface CalendarProps {
state: DatePickerState | DateRangePickerState;
visibleMonths?: 1 | 2;
}
export function Calendar({state, visibleMonths = 1}: CalendarProps) {
const isMobile = useIsMobileMediaQuery();
if (isMobile) {
visibleMonths = 1;
}
return (
<Fragment>
{[...new Array(visibleMonths).keys()].map(index => {
const startDate = toCalendarDate(
startOfMonth(state.calendarDates[index])
);
const isFirst = index === 0;
const isLast = index === visibleMonths - 1;
return (
<CalendarMonth
key={index}
state={state}
startDate={startDate}
isFirst={isFirst}
isLast={isLast}
/>
);
})}
</Fragment>
);
}

View File

@@ -0,0 +1,181 @@
import React, {
ComponentPropsWithoutRef,
Fragment,
MouseEvent,
useRef,
} from 'react';
import {parseAbsoluteToLocal, ZonedDateTime} from '@internationalized/date';
import {useController} from 'react-hook-form';
import {mergeProps} from '@react-aria/utils';
import {
DatePickerValueProps,
useDatePickerState,
} from './use-date-picker-state';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {DateRangeIcon} from '@common/icons/material/DateRange';
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {Calendar} from '../calendar/calendar';
import {
DatePickerField,
DatePickerFieldProps,
} from '../date-range-picker/date-picker-field';
import {DateSegmentList} from '../segments/date-segment-list';
import {useDateFormatter} from '@common/i18n/use-date-formatter';
import {useTrans} from '@common/i18n/use-trans';
import clsx from 'clsx';
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
import {Button} from '@common/ui/buttons/button';
import {Trans} from '@common/i18n/trans';
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
export interface DatePickerProps
extends Omit<DatePickerFieldProps, 'children'>,
DatePickerValueProps<ZonedDateTime> {}
export function DatePicker({showCalendarFooter, ...props}: DatePickerProps) {
const state = useDatePickerState(props);
const inputRef = useRef<HTMLDivElement>(null);
const now = useCurrentDateTime();
const footer = showCalendarFooter && (
<DialogFooter
padding="px-14 pb-14"
startAction={
<Button
disabled={state.isPlaceholder}
variant="text"
color="primary"
onClick={() => {
state.clear();
}}
>
<Trans message="Clear" />
</Button>
}
>
<Button
variant="text"
color="primary"
onClick={() => {
state.setSelectedValue(now);
state.setCalendarIsOpen(false);
}}
>
<Trans message="Today" />
</Button>
</DialogFooter>
);
const dialog = (
<DialogTrigger
offset={8}
placement="bottom-start"
isOpen={state.calendarIsOpen}
onOpenChange={state.setCalendarIsOpen}
type="popover"
triggerRef={inputRef}
returnFocusToTrigger={false}
moveFocusToDialog={false}
>
<Dialog size="auto">
<DialogBody
className="flex items-start gap-40"
padding={showCalendarFooter ? 'px-24 pt-20 pb-10' : null}
>
<Calendar state={state} visibleMonths={1} />
</DialogBody>
{footer}
</Dialog>
</DialogTrigger>
);
const openOnClick: ComponentPropsWithoutRef<'div'> = {
onClick: e => {
e.stopPropagation();
e.preventDefault();
if (!isHourSegment(e)) {
state.setCalendarIsOpen(true);
} else {
state.setCalendarIsOpen(false);
}
},
};
return (
<Fragment>
<DatePickerField
ref={inputRef}
wrapperProps={openOnClick}
endAdornment={
<DateRangeIcon className={clsx(props.disabled && 'text-disabled')} />
}
{...props}
>
<DateSegmentList
segmentProps={openOnClick}
state={state}
value={state.selectedValue}
onChange={state.setSelectedValue}
isPlaceholder={state.isPlaceholder}
/>
</DatePickerField>
{dialog}
</Fragment>
);
}
interface FormDatePickerProps extends DatePickerProps {
name: string;
}
export function FormDatePicker(props: FormDatePickerProps) {
const {min, max} = props;
const {trans} = useTrans();
const {format} = useDateFormatter();
const {
field: {onChange, onBlur, value = null, ref},
fieldState: {invalid, error},
} = useController({
name: props.name,
rules: {
validate: v => {
if (!v) return;
const date = parseAbsoluteToLocal(v);
if (min && date.compare(min) < 0) {
return trans({
message: 'Enter a date after :date',
values: {date: format(v)},
});
}
if (max && date.compare(max) > 0) {
return trans({
message: 'Enter a date before :date',
values: {date: format(v)},
});
}
},
},
});
const parsedValue: null | ZonedDateTime = value
? parseAbsoluteToLocal(value)
: null;
const formProps: Partial<DatePickerProps> = {
onChange: e => {
onChange(e ? e.toAbsoluteString() : e);
},
onBlur,
value: parsedValue,
invalid,
errorMessage: error?.message,
inputRef: ref,
};
return <DatePicker {...mergeProps(formProps, props)} />;
}
function isHourSegment(e: MouseEvent<HTMLDivElement>): boolean {
return ['hour', 'minute', 'dayPeriod'].includes(
(e.currentTarget as HTMLElement).ariaLabel || ''
);
}

View File

@@ -0,0 +1,152 @@
import {useControlledState} from '@react-stately/utils';
import {HTMLAttributes, useCallback, useState} from 'react';
import {
CalendarDate,
DateValue,
isSameDay,
toCalendarDate,
toZoned,
ZonedDateTime,
} from '@internationalized/date';
import {useBaseDatePickerState} from '../use-base-date-picker-state';
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
export type Granularity = 'day' | 'minute';
export type DatePickerState = BaseDatePickerState;
export interface BaseDatePickerState<T = ZonedDateTime, P = boolean> {
timezone: string;
granularity: Granularity;
selectedValue: T;
setSelectedValue: (value: T) => void;
calendarIsOpen: boolean;
setCalendarIsOpen: (isOpen: boolean) => void;
calendarDates: CalendarDate[];
setCalendarDates: (dates: CalendarDate[]) => void;
dayIsActive: (day: CalendarDate) => boolean;
dayIsHighlighted: (day: CalendarDate) => boolean;
dayIsRangeStart: (day: CalendarDate) => boolean;
dayIsRangeEnd: (day: CalendarDate) => boolean;
isPlaceholder: P;
setIsPlaceholder: (value: P) => void;
clear: () => void;
min?: ZonedDateTime;
max?: ZonedDateTime;
closeDialogOnSelection: boolean;
getCellProps: (
date: CalendarDate,
isSameMonth: boolean,
) => HTMLAttributes<HTMLElement>;
}
export interface DatePickerValueProps<V, CV = V> {
value?: V | null | '';
defaultValue?: V | null;
onChange?: (value: CV | null) => void;
min?: DateValue;
max?: DateValue;
granularity?: Granularity;
closeDialogOnSelection?: boolean;
}
export function useDatePickerState(
props: DatePickerValueProps<ZonedDateTime>,
): BaseDatePickerState {
const now = useCurrentDateTime();
const [isPlaceholder, setIsPlaceholder] = useState(
!props.value && !props.defaultValue,
);
// if user clears the date, we will want to still keep an
// instance internally, but return null via "onChange" callback
const setStateValue = props.onChange;
const [internalValue, setInternalValue] = useControlledState(
props.value || now,
props.defaultValue || now,
value => {
setIsPlaceholder(false);
setStateValue?.(value);
},
);
const {
min,
max,
granularity,
timezone,
calendarIsOpen,
setCalendarIsOpen,
closeDialogOnSelection,
} = useBaseDatePickerState(internalValue, props);
const clear = useCallback(() => {
setIsPlaceholder(true);
setInternalValue(now);
setStateValue?.(null);
setCalendarIsOpen(false);
}, [now, setInternalValue, setStateValue, setCalendarIsOpen]);
const [calendarDates, setCalendarDates] = useState<CalendarDate[]>(() => {
return [toCalendarDate(internalValue)];
});
const setSelectedValue = useCallback(
(newValue: DateValue) => {
if (min && newValue.compare(min) < 0) {
newValue = min;
} else if (max && newValue.compare(max) > 0) {
newValue = max;
}
// preserve time
const value = internalValue
? internalValue.set(newValue)
: toZoned(newValue, timezone);
setInternalValue(value);
setCalendarDates([toCalendarDate(value)]);
setIsPlaceholder(false);
},
[setInternalValue, min, max, internalValue, timezone],
);
const dayIsActive = useCallback(
(day: DateValue) => !isPlaceholder && isSameDay(internalValue, day),
[internalValue, isPlaceholder],
);
const getCellProps = useCallback(
(date: DateValue): HTMLAttributes<HTMLElement> => {
return {
onClick: () => {
setSelectedValue?.(date);
if (closeDialogOnSelection) {
setCalendarIsOpen?.(false);
}
},
};
},
[setSelectedValue, setCalendarIsOpen, closeDialogOnSelection],
);
return {
selectedValue: internalValue,
setSelectedValue: setInternalValue,
calendarIsOpen,
setCalendarIsOpen,
dayIsActive,
dayIsHighlighted: () => false,
dayIsRangeStart: () => false,
dayIsRangeEnd: () => false,
getCellProps,
calendarDates,
setCalendarDates,
isPlaceholder,
clear,
setIsPlaceholder,
min,
max,
granularity,
timezone,
closeDialogOnSelection,
};
}

View File

@@ -0,0 +1,64 @@
import React, {ComponentPropsWithoutRef, FocusEventHandler, Ref} from 'react';
import clsx from 'clsx';
import {createFocusManager} from '@react-aria/focus';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import {getInputFieldClassNames} from '../../get-input-field-class-names';
import {Field, FieldProps} from '../../field';
import {Input} from '../../input';
import {useField} from '../../use-field';
export interface DatePickerFieldProps
extends Omit<FieldProps, 'fieldClassNames'> {
inputRef?: Ref<HTMLDivElement>;
onBlur?: FocusEventHandler;
showCalendarFooter?: boolean;
}
export const DatePickerField = React.forwardRef<
HTMLDivElement,
DatePickerFieldProps
>(({inputRef, wrapperProps, children, onBlur, ...other}, ref) => {
const fieldClassNames = getInputFieldClassNames(other);
const objRef = useObjectRef(ref);
const {fieldProps, inputProps} = useField({
...other,
focusRef: objRef,
labelElementType: 'span',
});
fieldClassNames.wrapper = clsx(
fieldClassNames.wrapper,
other.disabled && 'pointer-events-none',
);
return (
<Field
wrapperProps={mergeProps<ComponentPropsWithoutRef<'div'>[]>(
wrapperProps!,
{
onBlur: e => {
if (!objRef.current.contains(e.relatedTarget)) {
onBlur?.(e);
}
},
onClick: () => {
// focus first segment when clicking on label or somewhere else in the field, but no directly on segment
const focusManager = createFocusManager(objRef);
focusManager?.focusFirst();
},
},
)}
fieldClassNames={fieldClassNames}
ref={objRef}
{...fieldProps}
>
<Input
inputProps={inputProps}
className={clsx(fieldClassNames.input, 'gap-10')}
ref={inputRef}
>
{children}
</Input>
</Field>
);
});

View File

@@ -0,0 +1,98 @@
import React, {
ComponentPropsWithoutRef,
Fragment,
MouseEvent,
useRef,
} from 'react';
import {DateRangeIcon} from '../../../../../icons/material/DateRange';
import {DialogTrigger} from '../../../../overlays/dialog/dialog-trigger';
import {DatePickerField, DatePickerFieldProps} from './date-picker-field';
import {useDateRangePickerState} from './use-date-range-picker-state';
import {ArrowRightAltIcon} from '../../../../../icons/material/ArrowRightAlt';
import {DatePickerValueProps} from '../date-picker/use-date-picker-state';
import {DateRangeValue} from './date-range-value';
import {DateSegmentList} from '../segments/date-segment-list';
import {DateRangeDialog} from './dialog/date-range-dialog';
import {useIsMobileMediaQuery} from '../../../../../utils/hooks/is-mobile-media-query';
export interface DateRangePickerProps
extends DatePickerValueProps<Partial<DateRangeValue>>,
Omit<DatePickerFieldProps, 'children'> {}
export function DateRangePicker(props: DateRangePickerProps) {
const {granularity, closeDialogOnSelection, ...fieldProps} = props;
const state = useDateRangePickerState(props);
const inputRef = useRef<HTMLDivElement>(null);
const isMobile = useIsMobileMediaQuery();
const hideCalendarIcon = isMobile && granularity !== 'day';
const dialog = (
<DialogTrigger
offset={8}
placement="bottom-start"
isOpen={state.calendarIsOpen}
onOpenChange={state.setCalendarIsOpen}
type="popover"
triggerRef={inputRef}
returnFocusToTrigger={false}
moveFocusToDialog={false}
>
<DateRangeDialog state={state} />
</DialogTrigger>
);
const openOnClick: ComponentPropsWithoutRef<'div'> = {
onClick: e => {
e.stopPropagation();
e.preventDefault();
if (!isHourSegment(e)) {
state.setCalendarIsOpen(true);
} else {
state.setCalendarIsOpen(false);
}
},
};
const value = state.selectedValue;
const onChange = state.setSelectedValue;
return (
<Fragment>
<DatePickerField
ref={inputRef}
wrapperProps={openOnClick}
endAdornment={!hideCalendarIcon ? <DateRangeIcon /> : undefined}
{...fieldProps}
>
<DateSegmentList
isPlaceholder={state.isPlaceholder?.start}
state={state}
segmentProps={openOnClick}
value={value.start}
onChange={newValue => {
onChange({start: newValue, end: value.end});
}}
/>
<ArrowRightAltIcon
className="block flex-shrink-0 text-muted"
size="md"
/>
<DateSegmentList
isPlaceholder={state.isPlaceholder?.end}
state={state}
segmentProps={openOnClick}
value={value.end}
onChange={newValue => {
onChange({start: value.start, end: newValue});
}}
/>
</DatePickerField>
{dialog}
</Fragment>
);
}
function isHourSegment(e: MouseEvent<HTMLDivElement>): boolean {
return ['hour', 'minute', 'dayPeriod'].includes(
(e.currentTarget as HTMLElement).ariaLabel || ''
);
}

View File

@@ -0,0 +1,29 @@
import {ZonedDateTime} from '@internationalized/date';
export type DateRangeValue = {
start: ZonedDateTime;
end: ZonedDateTime;
preset?: number;
compareStart?: ZonedDateTime;
compareEnd?: ZonedDateTime;
comparePreset?: number;
};
export function dateRangeValueToPayload(value: {
dateRange?: DateRangeValue;
[key: string]: any;
}) {
const payload = {
...value,
};
if (payload.dateRange) {
payload.startDate = payload.dateRange.start.toAbsoluteString();
payload.endDate = payload.dateRange.end.toAbsoluteString();
payload.compareStartDate =
payload.dateRange.compareStart?.toAbsoluteString();
payload.compareEndDate = payload.dateRange.compareEnd?.toAbsoluteString();
payload.timezone = payload.dateRange.start.timeZone;
delete payload.dateRange;
}
return payload;
}

View File

@@ -0,0 +1,34 @@
import {List, ListItem} from '@common/ui/list/list';
import {Trans} from '@common/i18n/trans';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
import {DateRangeComparePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-presets';
interface DateRangePresetList {
originalRangeValue: DateRangeValue;
onPresetSelected: (value: DateRangeValue) => void;
selectedValue?: DateRangeValue | null;
}
export function DateRangeComparePresetList({
originalRangeValue,
onPresetSelected,
selectedValue,
}: DateRangePresetList) {
return (
<List>
{DateRangeComparePresets.map(preset => (
<ListItem
borderRadius="rounded-none"
capitalizeFirst
key={preset.key}
isSelected={selectedValue?.preset === preset.key}
onSelected={() => {
const newValue = preset.getRangeValue(originalRangeValue);
onPresetSelected(newValue);
}}
>
<Trans {...preset.label} />
</ListItem>
))}
</List>
);
}

View File

@@ -0,0 +1,52 @@
import {message} from '@common/i18n/message';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
export interface DateRangeComparePreset {
key: number;
label: MessageDescriptor;
getRangeValue: (range: DateRangeValue) => DateRangeValue;
}
export const DateRangeComparePresets: DateRangeComparePreset[] = [
{
key: 0,
label: message('Preceding period'),
getRangeValue: (range: DateRangeValue) => {
const startDate = range.start;
const endDate = range.end;
const diffInMilliseconds =
endDate.toDate().getTime() - startDate.toDate().getTime();
const diffInMinutes = diffInMilliseconds / (1000 * 60);
const newStart = startDate.subtract({minutes: diffInMinutes});
return {
preset: 0,
start: newStart,
end: startDate,
};
},
},
{
key: 1,
label: message('Same period last year'),
getRangeValue: (range: DateRangeValue) => {
return {
start: range.start.subtract({years: 1}),
end: range.end.subtract({years: 1}),
preset: 1,
};
},
},
{
key: 2,
label: message('Custom'),
getRangeValue: (range: DateRangeValue) => {
return {
start: range.start.subtract({weeks: 1}),
end: range.end.subtract({weeks: 1}),
preset: 2,
};
},
},
];

View File

@@ -0,0 +1,195 @@
import React, {Fragment, ReactNode, useRef, useState} from 'react';
import {AnimatePresence, m} from 'framer-motion';
import {DatePickerField} from '../date-picker-field';
import {DateRangePickerState} from '../use-date-range-picker-state';
import {Calendar} from '../../calendar/calendar';
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
import {Button} from '@common/ui/buttons/button';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {ArrowRightAltIcon} from '@common/icons/material/ArrowRightAlt';
import {DateSegmentList} from '../../segments/date-segment-list';
import {Trans} from '@common/i18n/trans';
import {FormattedDateTimeRange} from '@common/i18n/formatted-date-time-range';
import {DatePresetList} from './date-range-preset-list';
import {useIsTabletMediaQuery} from '@common/utils/hooks/is-tablet-media-query';
import {Switch} from '@common/ui/forms/toggle/switch';
import {DateRangeComparePresetList} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-preset-list';
interface DateRangeDialogProps {
state: DateRangePickerState;
compareState?: DateRangePickerState;
compareVisibleDefault?: boolean;
showInlineDatePickerField?: boolean;
}
export function DateRangeDialog({
state,
compareState,
showInlineDatePickerField = false,
compareVisibleDefault = false,
}: DateRangeDialogProps) {
const isTablet = useIsTabletMediaQuery();
const {close} = useDialogContext();
const initialStateRef = useRef<DateRangePickerState>(state);
const hasPlaceholder = state.isPlaceholder.start || state.isPlaceholder.end;
const [compareVisible, setCompareVisible] = useState(compareVisibleDefault);
const footer = (
<DialogFooter
dividerTop
startAction={
!hasPlaceholder && !isTablet ? (
<div className="text-xs">
<FormattedDateTimeRange
start={state.selectedValue.start.toDate()}
end={state.selectedValue.end.toDate()}
options={{dateStyle: 'medium'}}
/>
</div>
) : undefined
}
>
<Button
variant="text"
size="xs"
onClick={() => {
state.setSelectedValue(initialStateRef.current.selectedValue);
state.setIsPlaceholder(initialStateRef.current.isPlaceholder);
close();
}}
>
<Trans message="Cancel" />
</Button>
<Button
variant="flat"
color="primary"
size="xs"
onClick={() => {
const value = state.selectedValue;
if (compareState && compareVisible) {
value.compareStart = compareState.selectedValue.start;
value.compareEnd = compareState.selectedValue.end;
}
close(value);
}}
>
<Trans message="Select" />
</Button>
</DialogFooter>
);
return (
<Dialog size="auto">
<DialogBody className="flex" padding="p-0">
{!isTablet && (
<div className="min-w-192 py-14">
<DatePresetList
selectedValue={state.selectedValue}
onPresetSelected={preset => {
state.setSelectedValue(preset);
if (state.closeDialogOnSelection) {
close(preset);
}
}}
/>
{!!compareState && (
<Fragment>
<Switch
className="mx-20 mb-10 mt-14"
checked={compareVisible}
onChange={e => setCompareVisible(e.target.checked)}
>
<Trans message="Compare" />
</Switch>
{compareVisible && (
<DateRangeComparePresetList
originalRangeValue={state.selectedValue}
selectedValue={compareState.selectedValue}
onPresetSelected={preset => {
compareState.setSelectedValue(preset);
}}
/>
)}
</Fragment>
)}
</div>
)}
<AnimatePresence initial={false}>
<Calendars
state={state}
compareState={compareState}
showInlineDatePickerField={showInlineDatePickerField}
compareVisible={compareVisible}
/>
</AnimatePresence>
</DialogBody>
{!state.closeDialogOnSelection && footer}
</Dialog>
);
}
interface CustomRangePanelProps {
state: DateRangePickerState;
compareState?: DateRangePickerState;
showInlineDatePickerField?: boolean;
compareVisible: boolean;
}
function Calendars({
state,
compareState,
showInlineDatePickerField,
compareVisible,
}: CustomRangePanelProps) {
return (
<m.div
initial={{width: 0, overflow: 'hidden'}}
animate={{width: 'auto'}}
exit={{width: 0, overflow: 'hidden'}}
transition={{type: 'tween', duration: 0.125}}
className="border-l px-20 pb-20 pt-10"
>
{showInlineDatePickerField && (
<div>
<InlineDatePickerField state={state} />
{!!compareState && compareVisible && (
<InlineDatePickerField
state={compareState}
label={<Trans message="Compare" />}
/>
)}
</div>
)}
<div className="flex items-start gap-36">
<Calendar state={state} visibleMonths={2} />
</div>
</m.div>
);
}
interface InlineDatePickerFieldProps {
state: DateRangePickerState;
label?: ReactNode;
}
function InlineDatePickerField({state, label}: InlineDatePickerFieldProps) {
const {selectedValue, setSelectedValue} = state;
return (
<DatePickerField className="mb-20 mt-10" label={label}>
<DateSegmentList
state={state}
value={selectedValue.start}
onChange={newValue => {
setSelectedValue({...selectedValue, start: newValue});
}}
/>
<ArrowRightAltIcon className="block flex-shrink-0 text-muted" size="md" />
<DateSegmentList
state={state}
value={selectedValue.end}
onChange={newValue => {
setSelectedValue({...selectedValue, end: newValue});
}}
/>
</DatePickerField>
);
}

View File

@@ -0,0 +1,32 @@
import {List, ListItem} from '@common/ui/list/list';
import {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
import {Trans} from '@common/i18n/trans';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
interface DateRangePresetList {
onPresetSelected: (value: DateRangeValue) => void;
selectedValue?: DateRangeValue | null;
}
export function DatePresetList({
onPresetSelected,
selectedValue,
}: DateRangePresetList) {
return (
<List>
{DateRangePresets.map(preset => (
<ListItem
borderRadius="rounded-none"
capitalizeFirst
key={preset.key}
isSelected={selectedValue?.preset === preset.key}
onSelected={() => {
const newValue = preset.getRangeValue();
onPresetSelected(newValue);
}}
>
<Trans {...preset.label} />
</ListItem>
))}
</List>
);
}

View File

@@ -0,0 +1,130 @@
import {DateRangeValue} from '../date-range-value';
import {message} from '@common/i18n/message';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
import {
endOfMonth,
endOfWeek,
endOfYear,
now,
startOfMonth,
startOfWeek,
startOfYear,
} from '@internationalized/date';
import {startOfDay} from '@common/utils/date/start-of-day';
import {endOfDay} from '@common/utils/date/end-of-day';
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
import {getUserTimezone} from '@common/i18n/get-user-timezone';
const Now = startOfDay(now(getUserTimezone()));
const locale = getBootstrapData()?.i18n?.language || 'en';
export interface DateRangePreset {
key: number;
label: MessageDescriptor;
getRangeValue: () => DateRangeValue;
}
export const DateRangePresets: DateRangePreset[] = [
{
key: 0,
label: message('Today'),
getRangeValue: () => ({
preset: 0,
start: Now,
end: endOfDay(Now),
}),
},
{
key: 1,
label: message('Yesterday'),
getRangeValue: () => ({
preset: 1,
start: Now.subtract({days: 1}),
end: endOfDay(Now).subtract({days: 1}),
}),
},
{
key: 2,
label: message('This week'),
getRangeValue: () => ({
preset: 2,
start: startOfWeek(Now, locale),
end: endOfWeek(endOfDay(Now), locale),
}),
},
{
key: 3,
label: message('Last week'),
getRangeValue: () => {
const start = startOfWeek(Now, locale).subtract({days: 7});
return {
preset: 3,
start,
end: start.add({days: 6}),
};
},
},
{
key: 4,
label: message('Last 7 days'),
getRangeValue: () => ({
preset: 4,
start: Now.subtract({days: 7}),
end: endOfDay(Now),
}),
},
{
key: 6,
label: message('Last 30 days'),
getRangeValue: () => ({
preset: 6,
start: Now.subtract({days: 30}),
end: endOfDay(Now),
}),
},
{
key: 7,
label: message('Last 3 months'),
getRangeValue: () => ({
preset: 7,
start: Now.subtract({months: 3}),
end: endOfDay(Now),
}),
},
{
key: 8,
label: message('Last 12 months'),
getRangeValue: () => ({
preset: 8,
start: Now.subtract({months: 12}),
end: endOfDay(Now),
}),
},
{
key: 9,
label: message('This month'),
getRangeValue: () => ({
preset: 9,
start: startOfMonth(Now),
end: endOfMonth(endOfDay(Now)),
}),
},
{
key: 10,
label: message('This year'),
getRangeValue: () => ({
preset: 10,
start: startOfYear(Now),
end: endOfYear(endOfDay(Now)),
}),
},
{
key: 11,
label: message('Last year'),
getRangeValue: () => ({
preset: 11,
start: startOfYear(Now).subtract({years: 1}),
end: endOfYear(endOfDay(Now)).subtract({years: 1}),
}),
},
];

View File

@@ -0,0 +1,77 @@
import {parseAbsoluteToLocal, ZonedDateTime} from '@internationalized/date';
import {DateRangeValue} from './date-range-value';
import {useController} from 'react-hook-form';
import {mergeProps} from '@react-aria/utils';
import React from 'react';
import {DateRangePicker, DateRangePickerProps} from './date-range-picker';
export interface AbsoluteDateRange {
start?: string;
end?: string;
preset?: number;
}
interface FormDateRange {
start?: string | ZonedDateTime;
end?: string | ZonedDateTime;
preset?: number;
}
export interface FormDateRangePickerProps extends DateRangePickerProps {
name: string;
}
export function FormDateRangePicker(props: FormDateRangePickerProps) {
const {
field: {onChange, onBlur, value, ref},
fieldState: {invalid, error},
} = useController({
name: props.name,
});
const formProps: Partial<DateRangePickerProps> = {
onChange: e => {
onChange(e ? dateRangeToAbsoluteRange(e) : null);
},
onBlur,
value: absoluteRangeToDateRange(value),
invalid,
errorMessage: error?.message,
inputRef: ref,
};
return <DateRangePicker {...mergeProps(formProps, props)} />;
}
export function absoluteRangeToDateRange(props: FormDateRange | null) {
const {start, end, preset} = props || {};
const dateRange: Partial<DateRangeValue> = {preset};
try {
if (start) {
dateRange.start =
typeof start === 'string' ? parseAbsoluteToLocal(start) : start;
}
if (end) {
dateRange.end = typeof end === 'string' ? parseAbsoluteToLocal(end) : end;
}
} catch (e) {
// ignore
}
return dateRange;
}
export function dateRangeToAbsoluteRange({
start,
end,
preset,
}: Partial<DateRangeValue> = {}): AbsoluteDateRange {
const absoluteRange: AbsoluteDateRange = {
preset,
};
if (start) {
absoluteRange.start = start.toAbsoluteString();
}
if (end) {
absoluteRange.end = end.toAbsoluteString();
}
return absoluteRange;
}

View File

@@ -0,0 +1,268 @@
import {useControlledState} from '@react-stately/utils';
import {HTMLAttributes, useCallback, useState} from 'react';
import {
CalendarDate,
DateValue,
endOfMonth,
isSameDay,
isSameMonth,
maxDate,
minDate,
startOfMonth,
toCalendarDate,
toZoned,
ZonedDateTime,
} from '@internationalized/date';
import {
BaseDatePickerState,
DatePickerValueProps,
} from '../date-picker/use-date-picker-state';
import {DateRangeValue} from './date-range-value';
import {useBaseDatePickerState} from '../use-base-date-picker-state';
import {startOfDay} from '@common/utils/date/start-of-day';
import {endOfDay} from '@common/utils/date/end-of-day';
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
export interface IsPlaceholderValue {
start: boolean;
end: boolean;
}
export type DateRangePickerState = BaseDatePickerState<
DateRangeValue,
IsPlaceholderValue
>;
export function useDateRangePickerState(
props: DatePickerValueProps<Partial<DateRangeValue>, DateRangeValue>,
): DateRangePickerState {
const now = useCurrentDateTime();
const [isPlaceholder, setIsPlaceholder] = useState<IsPlaceholderValue>({
start: (!props.value || !props.value.start) && !props.defaultValue?.start,
end: (!props.value || !props.value.end) && !props.defaultValue?.end,
});
// if user clears the date, we will want to still keep an
// instance internally, but return null via "onChange" callback
const setStateValue = props.onChange;
const [internalValue, setInternalValue] = useControlledState(
props.value ? completeRange(props.value, now) : undefined,
!props.value ? completeRange(props.defaultValue, now) : undefined,
value => {
setIsPlaceholder({start: false, end: false});
setStateValue?.(value);
},
);
const {
min,
max,
granularity,
timezone,
calendarIsOpen,
setCalendarIsOpen,
closeDialogOnSelection,
} = useBaseDatePickerState(internalValue.start, props);
const clear = useCallback(() => {
setIsPlaceholder({start: true, end: true});
setInternalValue(completeRange(null, now));
setStateValue?.(null);
setCalendarIsOpen(false);
}, [now, setInternalValue, setStateValue, setCalendarIsOpen]);
const [anchorDate, setAnchorDate] = useState<CalendarDate | null>(null);
const [isHighlighting, setIsHighlighting] = useState(false);
const [highlightedRange, setHighlightedRange] =
useState<DateRangeValue>(internalValue);
const [calendarDates, setCalendarDates] = useState<CalendarDate[]>(() => {
return rangeToCalendarDates(internalValue, max);
});
const constrainRange = useCallback(
(range: DateRangeValue): DateRangeValue => {
let start = range.start;
let end = range.end;
// make sure start date is after min date and before max date/range end
if (min) {
start = maxDate(start, min);
}
const startMax = max ? minDate(max, end) : end;
start = minDate(start, startMax);
// make sure end date is after min date/range start and before max date
const endMin = min ? maxDate(min, start) : start;
end = maxDate(end, endMin);
if (max) {
end = minDate(end, max);
}
return {start: toZoned(start, timezone), end: toZoned(end, timezone)};
},
[min, max, timezone],
);
const setSelectedValue = useCallback(
(newRange: DateRangeValue) => {
const value = {
...constrainRange(newRange),
preset: newRange.preset,
};
setInternalValue(value);
setHighlightedRange(value);
setCalendarDates(rangeToCalendarDates(value, max));
setIsPlaceholder({start: false, end: false});
},
[setInternalValue, constrainRange, max],
);
const dayIsActive = useCallback(
(day: CalendarDate) => {
return (
(!isPlaceholder.start && isSameDay(day, highlightedRange.start)) ||
(!isPlaceholder.end && isSameDay(day, highlightedRange.end))
);
},
[highlightedRange, isPlaceholder],
);
const dayIsHighlighted = useCallback(
(day: CalendarDate) => {
return (
(isHighlighting || (!isPlaceholder.start && !isPlaceholder.end)) &&
day.compare(highlightedRange.start) >= 0 &&
day.compare(highlightedRange.end) <= 0
);
},
[highlightedRange, isPlaceholder, isHighlighting],
);
const dayIsRangeStart = useCallback(
(day: CalendarDate) => isSameDay(day, highlightedRange.start),
[highlightedRange],
);
const dayIsRangeEnd = useCallback(
(day: CalendarDate) => isSameDay(day, highlightedRange.end),
[highlightedRange],
);
const getCellProps = useCallback(
(date: CalendarDate, isSameMonth: boolean): HTMLAttributes<HTMLElement> => {
return {
onPointerEnter: () => {
if (isHighlighting && isSameMonth) {
setHighlightedRange(
makeRange({start: anchorDate!, end: date, timezone}),
);
}
},
onClick: () => {
if (!isHighlighting) {
setIsHighlighting(true);
setAnchorDate(date);
setHighlightedRange(makeRange({start: date, end: date, timezone}));
} else {
const finalRange = makeRange({
start: anchorDate!,
end: date,
timezone,
});
// cast to start and end of day after making range, because "makeRange"
// will flip start and end dates, if they are out of order
finalRange.start = startOfDay(finalRange.start);
finalRange.end = endOfDay(finalRange.end);
setIsHighlighting(false);
setAnchorDate(null);
setSelectedValue?.(finalRange);
if (closeDialogOnSelection) {
setCalendarIsOpen?.(false);
}
}
},
};
},
[
anchorDate,
isHighlighting,
setSelectedValue,
setCalendarIsOpen,
closeDialogOnSelection,
timezone,
],
);
return {
selectedValue: internalValue,
setSelectedValue,
calendarIsOpen,
setCalendarIsOpen,
dayIsActive,
dayIsHighlighted,
dayIsRangeStart,
dayIsRangeEnd,
getCellProps,
calendarDates,
setIsPlaceholder,
isPlaceholder,
clear,
setCalendarDates,
min,
max,
granularity,
timezone,
closeDialogOnSelection,
};
}
function rangeToCalendarDates(
range: DateRangeValue,
max?: DateValue,
): CalendarDate[] {
let start = toCalendarDate(startOfMonth(range.start));
let end = toCalendarDate(endOfMonth(range.end));
// make sure we don't show the same month twice
if (isSameMonth(start, end)) {
end = endOfMonth(end.add({months: 1}));
}
// if next month is disabled, show previous instead
if (max && end.compare(max) > 0) {
end = start;
start = startOfMonth(start.subtract({months: 1}));
}
return [start, end];
}
interface MakeRangeProps {
start: DateValue;
end: DateValue;
timezone: string;
}
function makeRange(props: MakeRangeProps): DateRangeValue {
const start = toZoned(props.start, props.timezone);
const end = toZoned(props.end, props.timezone);
if (start.compare(end) > 0) {
return {start: end, end: start};
}
return {start, end};
}
function completeRange(
range: Partial<DateRangeValue> | null | undefined,
now: ZonedDateTime,
): DateRangeValue {
if (range?.start && range?.end) {
return range as DateRangeValue;
} else if (!range?.start && range?.end) {
range.start = range.end.subtract({months: 1});
return range as DateRangeValue;
} else if (!range?.end && range?.start) {
range.end = range.start.add({months: 1});
return range as DateRangeValue;
}
return {start: now, end: now.add({months: 1})};
}

View File

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

View File

@@ -0,0 +1,88 @@
import React, {ComponentPropsWithoutRef, useMemo} from 'react';
import {ZonedDateTime} from '@internationalized/date';
import {EditableDateSegment, EditableSegment} from './editable-date-segment';
import {LiteralDateSegment, LiteralSegment} from './literal-segment';
import {useDateFormatter} from '@common/i18n/use-date-formatter';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {getSegmentLimits} from './utils/get-segment-limits';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
interface DateSegmentListProps {
segmentProps?: ComponentPropsWithoutRef<'div'>;
state: DatePickerState | DateRangePickerState;
value: ZonedDateTime;
onChange: (newValue: ZonedDateTime) => void;
isPlaceholder?: boolean;
}
export function DateSegmentList({
segmentProps,
state,
value,
onChange,
isPlaceholder,
}: DateSegmentListProps) {
const {granularity} = state;
const options = useMemo(() => {
const memoOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
};
if (granularity === 'minute') {
memoOptions.hour = 'numeric';
memoOptions.minute = 'numeric';
}
return memoOptions;
}, [granularity]);
const formatter = useDateFormatter(options);
const dateValue = useMemo(() => value.toDate(), [value]);
const segments = useMemo(() => {
return formatter.formatToParts(dateValue).map(segment => {
const limits = getSegmentLimits(
value,
segment.type,
formatter.resolvedOptions(),
);
const textValue =
isPlaceholder && segment.type !== 'literal'
? limits.placeholder
: segment.value;
return {
type: segment.type,
text: segment.value === ', ' ? ' ' : textValue,
...limits,
minLength:
segment.type !== 'literal' ? String(limits.maxValue).length : 1,
} as LiteralSegment | EditableSegment;
});
}, [dateValue, formatter, isPlaceholder, value]);
return (
<div className="flex items-center">
{segments.map((segment, index) => {
if (segment.type === 'literal') {
return (
<LiteralDateSegment
domProps={segmentProps}
key={index}
segment={segment}
/>
);
}
return (
<EditableDateSegment
isPlaceholder={isPlaceholder}
domProps={segmentProps}
state={state}
value={value}
onChange={onChange}
segment={segment}
key={index}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,275 @@
import {useFocusManager} from '@react-aria/focus';
import React, {
ComponentPropsWithoutRef,
HTMLAttributes,
KeyboardEventHandler,
useMemo,
useRef,
} from 'react';
import {NumberParser} from '@internationalized/number';
import {mergeProps} from '@react-aria/utils';
import {today, ZonedDateTime} from '@internationalized/date';
import {useSelectedLocale} from '@common/i18n/selected-locale';
import {useDateFormatter} from '@common/i18n/use-date-formatter';
import {DatePickerState} from '../date-picker/use-date-picker-state';
import {adjustSegment} from './utils/adjust-segment';
import {setSegment} from './utils/set-segment';
import {PAGE_STEP} from './utils/page-step';
import {DateRangePickerState} from '../date-range-picker/use-date-range-picker-state';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
export interface EditableSegment {
type: 'day' | 'dayPeriod' | 'hour' | 'minute' | 'month' | 'second' | 'year';
text: string;
value: number;
minValue: number;
maxValue: number;
minLength: number;
}
interface DatePickerSegmentProps {
segment: EditableSegment;
domProps?: ComponentPropsWithoutRef<'div'>;
state: DatePickerState | DateRangePickerState;
value: ZonedDateTime;
onChange: (newValue: ZonedDateTime) => void;
isPlaceholder?: boolean;
}
export function EditableDateSegment({
segment,
domProps,
value,
onChange,
isPlaceholder,
state: {timezone, calendarIsOpen, setCalendarIsOpen},
}: DatePickerSegmentProps) {
const isMobile = useIsMobileMediaQuery();
const enteredKeys = useRef('');
const {localeCode} = useSelectedLocale();
const focusManager = useFocusManager();
const formatter = useDateFormatter({timeZone: timezone});
const parser = useMemo(
() => new NumberParser(localeCode, {maximumFractionDigits: 0}),
[localeCode],
);
const setSegmentValue = (newValue: number) => {
onChange(
setSegment(value, segment.type, newValue, formatter.resolvedOptions()),
);
};
const adjustSegmentValue = (amount: number) => {
onChange(
adjustSegment(value, segment.type, amount, formatter.resolvedOptions()),
);
};
const backspace = () => {
if (parser.isValidPartialNumber(segment.text)) {
const newValue = segment.text.slice(0, -1);
const parsed = parser.parse(newValue);
if (newValue.length === 0 || parsed === 0) {
const now = today(timezone);
if (segment.type in now) {
// @ts-ignore
setSegmentValue(now[segment.type]);
}
} else {
setSegmentValue(parsed);
}
enteredKeys.current = newValue;
} else if (segment.type === 'dayPeriod') {
adjustSegmentValue(-1);
}
};
const onKeyDown: KeyboardEventHandler = e => {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
return;
}
// Navigation between date segments and deletion
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
focusManager?.focusPrevious();
break;
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
focusManager?.focusNext();
break;
case 'Enter':
(e.target as HTMLElement).closest('form')?.requestSubmit();
setCalendarIsOpen(!calendarIsOpen);
break;
case 'Tab':
break;
case 'Backspace':
case 'Delete': {
e.preventDefault();
e.stopPropagation();
backspace();
break;
}
// Spinbutton incrementing/decrementing
case 'ArrowUp':
e.preventDefault();
enteredKeys.current = '';
adjustSegmentValue(1);
break;
case 'ArrowDown':
e.preventDefault();
enteredKeys.current = '';
adjustSegmentValue(-1);
break;
case 'PageUp':
e.preventDefault();
enteredKeys.current = '';
adjustSegmentValue(PAGE_STEP[segment.type] || 1);
break;
case 'PageDown':
e.preventDefault();
enteredKeys.current = '';
adjustSegmentValue(-(PAGE_STEP[segment.type] || 1));
break;
case 'Home':
e.preventDefault();
enteredKeys.current = '';
setSegmentValue(segment.maxValue);
break;
case 'End':
e.preventDefault();
enteredKeys.current = '';
setSegmentValue(segment.minValue);
break;
}
onInput(e.key);
};
const amPmFormatter = useDateFormatter({hour: 'numeric', hour12: true});
const am = useMemo(() => {
const amDate = new Date();
amDate.setHours(0);
return amPmFormatter
.formatToParts(amDate)
.find(part => part.type === 'dayPeriod')!.value;
}, [amPmFormatter]);
const pm = useMemo(() => {
const pmDate = new Date();
pmDate.setHours(12);
return amPmFormatter
.formatToParts(pmDate)
.find(part => part.type === 'dayPeriod')!.value;
}, [amPmFormatter]);
// Update date values on user keyboard input
const onInput = (key: string) => {
const newValue = enteredKeys.current + key;
switch (segment.type) {
case 'dayPeriod':
if (am.toLowerCase().startsWith(key)) {
setSegmentValue(0);
} else if (pm.toLowerCase().startsWith(key)) {
setSegmentValue(12);
} else {
break;
}
focusManager?.focusNext();
break;
case 'day':
case 'hour':
case 'minute':
case 'second':
case 'month':
case 'year': {
if (!parser.isValidPartialNumber(newValue)) {
return;
}
let numberValue = parser.parse(newValue);
let segmentValue = numberValue;
let allowsZero = segment.minValue === 0;
if (segment.type === 'hour' && formatter.resolvedOptions().hour12) {
switch (formatter.resolvedOptions().hourCycle) {
case 'h11':
if (numberValue > 11) {
segmentValue = parser.parse(key);
}
break;
case 'h12':
allowsZero = false;
if (numberValue > 12) {
segmentValue = parser.parse(key);
}
break;
}
if (segment.value >= 12 && numberValue > 1) {
numberValue += 12;
}
} else if (numberValue > segment.maxValue) {
segmentValue = parser.parse(key);
}
if (Number.isNaN(numberValue)) {
return;
}
const shouldSetValue = segmentValue !== 0 || allowsZero;
if (shouldSetValue) {
setSegmentValue(segmentValue);
}
if (
Number(`${numberValue}0`) > segment.maxValue ||
newValue.length >= String(segment.maxValue).length
) {
enteredKeys.current = '';
if (shouldSetValue) {
focusManager?.focusNext();
}
} else {
enteredKeys.current = newValue;
}
break;
}
}
};
const spinButtonProps: HTMLAttributes<HTMLDivElement> = isMobile
? {}
: {
'aria-label': segment.type,
'aria-valuetext': isPlaceholder ? undefined : `${segment.value}`,
'aria-valuemin': segment.minValue,
'aria-valuemax': segment.maxValue,
'aria-valuenow': isPlaceholder ? undefined : segment.value,
tabIndex: 0,
onKeyDown,
};
return (
<div
{...mergeProps(domProps!, {
...spinButtonProps,
onFocus: e => {
enteredKeys.current = '';
e.target.scrollIntoView({block: 'nearest'});
},
onClick: e => {
e.preventDefault();
e.stopPropagation();
},
} as HTMLAttributes<HTMLDivElement>)}
className="box-content cursor-default select-none whitespace-nowrap rounded p-2 text-center tabular-nums caret-transparent outline-none focus:bg-primary focus:text-on-primary"
>
{segment.text.padStart(segment.minLength, '0')}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import {useFocusManager} from '@react-aria/focus';
import {ComponentPropsWithoutRef} from 'react';
export interface LiteralSegment {
type: 'literal';
minLength: 1;
text: string;
}
interface LiteralSegmentProps extends ComponentPropsWithoutRef<'div'> {
segment: LiteralSegment;
domProps?: ComponentPropsWithoutRef<'div'>;
}
export function LiteralDateSegment({segment, domProps}: LiteralSegmentProps) {
const focusManager = useFocusManager();
return (
<div
{...domProps}
onPointerDown={e => {
if (e.pointerType === 'mouse') {
e.preventDefault();
const res = focusManager?.focusNext({from: e.target as HTMLElement});
if (!res) {
focusManager?.focusPrevious({from: e.target as HTMLElement});
}
}
}}
aria-hidden
className="min-w-4 cursor-default select-none"
>
{segment.text}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import {ZonedDateTime} from '@internationalized/date';
export function adjustSegment(
value: ZonedDateTime,
part: string,
amount: number,
options: Intl.ResolvedDateTimeFormatOptions
) {
switch (part) {
case 'era':
case 'year':
case 'month':
case 'day':
return value.cycle(part, amount, {round: part === 'year'});
}
if ('hour' in value) {
switch (part) {
case 'dayPeriod': {
const hours = value.hour;
const isPM = hours >= 12;
return value.set({hour: isPM ? hours - 12 : hours + 12});
}
case 'hour':
case 'minute':
case 'second':
return value.cycle(part, amount, {
round: part !== 'hour',
hourCycle: options.hour12 ? 12 : 24,
});
}
}
return value;
}

View File

@@ -0,0 +1,73 @@
import {
DateValue,
getMinimumDayInMonth,
getMinimumMonthInYear,
} from '@internationalized/date';
export function getSegmentLimits(
date: DateValue,
type: string,
options: Intl.ResolvedDateTimeFormatOptions
) {
switch (type) {
case 'year':
return {
value: date.year,
placeholder: 'yyyy',
minValue: 1,
maxValue: date.calendar.getYearsInEra(date),
};
case 'month':
return {
value: date.month,
placeholder: 'mm',
minValue: getMinimumMonthInYear(date),
maxValue: date.calendar.getMonthsInYear(date),
};
case 'day':
return {
value: date.day,
minValue: getMinimumDayInMonth(date),
maxValue: date.calendar.getDaysInMonth(date),
placeholder: 'dd',
};
}
if ('hour' in date) {
switch (type) {
case 'dayPeriod':
return {
value: date.hour >= 12 ? 12 : 0,
minValue: 0,
maxValue: 12,
placeholder: '--',
};
case 'hour':
if (options.hour12) {
const isPM = date.hour >= 12;
return {
value: date.hour,
minValue: isPM ? 12 : 0,
maxValue: isPM ? 23 : 11,
placeholder: '--',
};
}
return {
value: date.hour,
minValue: 0,
maxValue: 23,
placeholder: '--',
};
case 'minute':
return {
value: date.minute,
minValue: 0,
maxValue: 59,
placeholder: '--',
};
}
}
return {};
}

View File

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

View File

@@ -0,0 +1,47 @@
import {ZonedDateTime} from '@internationalized/date';
export function setSegment(
value: ZonedDateTime,
part: string,
segmentValue: number,
options: Intl.ResolvedDateTimeFormatOptions
) {
switch (part) {
case 'day':
case 'month':
case 'year':
return value.set({[part]: segmentValue});
}
if ('hour' in value) {
switch (part) {
case 'dayPeriod': {
const hours = value.hour;
const wasPM = hours >= 12;
const isPM = segmentValue >= 12;
if (isPM === wasPM) {
return value;
}
return value.set({hour: wasPM ? hours - 12 : hours + 12});
}
case 'hour':
// In 12 hour time, ensure that AM/PM does not change
if (options.hour12) {
const hours = value.hour;
const wasPM = hours >= 12;
if (!wasPM && segmentValue === 12) {
segmentValue = 0;
}
if (wasPM && segmentValue < 12) {
segmentValue += 12;
}
}
// fallthrough
case 'minute':
case 'second':
return value.set({[part]: segmentValue});
}
}
return value;
}

View File

@@ -0,0 +1,31 @@
import {useState} from 'react';
import {DateValue, toZoned, ZonedDateTime} from '@internationalized/date';
import {getDefaultGranularity} from './utils';
import type {DatePickerValueProps} from './date-picker/use-date-picker-state';
import {DateRangeValue} from './date-range-picker/date-range-value';
import {useUserTimezone} from '@common/i18n/use-user-timezone';
export function useBaseDatePickerState(
selectedDate: DateValue,
props:
| DatePickerValueProps<ZonedDateTime>
| DatePickerValueProps<Partial<DateRangeValue>, DateRangeValue>
) {
const timezone = useUserTimezone();
const [calendarIsOpen, setCalendarIsOpen] = useState(false);
const closeDialogOnSelection = props.closeDialogOnSelection ?? true;
const granularity = props.granularity || getDefaultGranularity(selectedDate);
const min = props.min ? toZoned(props.min, timezone) : undefined;
const max = props.max ? toZoned(props.max, timezone) : undefined;
return {
timezone,
granularity,
min,
max,
calendarIsOpen,
setCalendarIsOpen,
closeDialogOnSelection,
};
}

View File

@@ -0,0 +1,19 @@
import {CalendarDate, DateValue} from '@internationalized/date';
export function getDefaultGranularity(date: DateValue) {
if (date instanceof CalendarDate) {
return 'day';
}
return 'minute';
}
export function dateIsInvalid(
date: CalendarDate,
min?: DateValue,
max?: DateValue
) {
return (
(min != null && date.compare(min) < 0) ||
(max != null && date.compare(max) > 0)
);
}

View File

@@ -0,0 +1,137 @@
import React, {ComponentPropsWithoutRef, ReactElement, ReactNode} from 'react';
import {Adornment} from './adornment';
import {InputFieldStyle} from './get-input-field-class-names';
import {BaseFieldProps} from './base-field-props';
import {removeEmptyValuesFromObject} from '@common/utils/objects/remove-empty-values-from-object';
import clsx from 'clsx';
export interface FieldProps extends BaseFieldProps {
children: ReactNode;
wrapperProps?: ComponentPropsWithoutRef<'div'>;
labelProps?: ComponentPropsWithoutRef<'label' | 'span'>;
descriptionProps?: ComponentPropsWithoutRef<'div'>;
errorMessageProps?: ComponentPropsWithoutRef<'div'>;
fieldClassNames: InputFieldStyle;
}
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
(props, ref) => {
const {
children,
// Not every component that uses <Field> supports help text.
description,
errorMessage,
descriptionProps = {},
errorMessageProps = {},
startAdornment,
endAdornment,
adornmentPosition,
startAppend,
endAppend,
fieldClassNames,
disabled,
wrapperProps,
} = props;
return (
<div className={fieldClassNames.wrapper} ref={ref} {...wrapperProps}>
<Label {...props} />
<div className={fieldClassNames.inputWrapper}>
<Adornment
direction="start"
className={fieldClassNames.adornment}
position={adornmentPosition}
>
{startAdornment}
</Adornment>
{startAppend && (
<Append style={fieldClassNames.append} disabled={disabled}>
{startAppend}
</Append>
)}
{children}
{endAppend && (
<Append style={fieldClassNames.append} disabled={disabled}>
{endAppend}
</Append>
)}
<Adornment
direction="end"
className={fieldClassNames.adornment}
position={adornmentPosition}
>
{endAdornment}
</Adornment>
</div>
{description && !errorMessage && (
<div className={fieldClassNames.description} {...descriptionProps}>
{description}
</div>
)}
{errorMessage && (
<div className={fieldClassNames.error} {...errorMessageProps}>
{errorMessage}
</div>
)}
</div>
);
},
);
function Label({
labelElementType,
fieldClassNames,
labelProps,
label,
labelSuffix,
labelSuffixPosition = 'spaced',
required,
}: Omit<FieldProps, 'children'>) {
if (!label) {
return null;
}
const ElementType = labelElementType || 'label';
const labelNode = (
<ElementType className={fieldClassNames.label} {...labelProps}>
{label}
{required && <span className="text-danger"> *</span>}
</ElementType>
);
if (labelSuffix) {
return (
<div
className={clsx(
'mb-4 flex w-full gap-4',
labelSuffixPosition === 'spaced' ? 'items-end' : 'items-center',
)}
>
{labelNode}
<div
className={clsx(
'text-xs text-muted',
labelSuffixPosition === 'spaced' ? 'ml-auto' : '',
)}
>
{labelSuffix}
</div>
</div>
);
}
return labelNode;
}
interface AppendProps {
children: ReactElement;
style: InputFieldStyle['append'];
disabled?: boolean;
}
function Append({children, style, disabled}: AppendProps) {
return React.cloneElement(children, {
...children.props,
disabled: children.props.disabled || disabled,
// make sure append styles are not overwritten with empty values
...removeEmptyValuesFromObject(style),
});
}

View File

@@ -0,0 +1,284 @@
import React, {
cloneElement,
ComponentPropsWithRef,
ReactElement,
ReactNode,
useCallback,
useId,
useRef,
} from 'react';
import clsx from 'clsx';
import {mergeProps} from '@react-aria/utils';
import {toast} from '@common/ui/toast/toast';
import {Field} from '@common/ui/forms/input-field/field';
import {
getInputFieldClassNames,
InputFieldStyle,
} from '@common/ui/forms/input-field/get-input-field-class-names';
import {FileEntry} from '@common/uploads/file-entry';
import {useAutoFocus} from '@common/ui/focus/use-auto-focus';
import {UploadStrategyConfig} from '@common/uploads/uploader/strategy/upload-strategy';
import {useActiveUpload} from '@common/uploads/uploader/use-active-upload';
import {Disk} from '@common/uploads/types/backend-metadata';
import {Button} from '@common/ui/buttons/button';
import {Trans} from '@common/i18n/trans';
import {ProgressBar} from '@common/ui/progress/progress-bar';
import {Input} from '@common/ui/forms/input-field/input';
import {useController} from 'react-hook-form';
import {useFileEntryModel} from '@common/uploads/requests/use-file-entry-model';
import {Skeleton} from '@common/ui/skeleton/skeleton';
import {AnimatePresence, m} from 'framer-motion';
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
import {validateUpload} from '@common/uploads/uploader/validate-upload';
import {UploadedFile} from '@common/uploads/uploaded-file';
interface Props {
className?: string;
label?: ReactNode;
description?: ReactNode;
invalid?: boolean;
errorMessage?: ReactNode;
required?: boolean;
disabled?: boolean;
value?: string;
onChange?: (newValue: string) => void;
allowedFileTypes?: string[];
maxFileSize?: number;
diskPrefix: string;
disk?: Disk;
showRemoveButton?: boolean;
autoFocus?: boolean;
}
export function FileEntryField({
className,
label,
description,
value,
onChange,
diskPrefix,
disk = Disk.uploads,
showRemoveButton,
invalid,
errorMessage,
required,
autoFocus,
disabled,
allowedFileTypes,
maxFileSize,
}: Props) {
const {
uploadFile,
entry,
uploadStatus,
deleteEntry,
isDeletingEntry,
percentage,
} = useActiveUpload();
const inputRef = useRef<HTMLInputElement>(null);
useAutoFocus({autoFocus}, inputRef);
const {data} = useFileEntryModel(value, {enabled: !entry && !!value});
const fieldId = useId();
const labelId = label ? `${fieldId}-label` : undefined;
const descriptionId = description ? `${fieldId}-description` : undefined;
const currentValue = value || entry?.url;
const currentEntry = entry || data?.fileEntry;
const uploadOptions: UploadStrategyConfig = {
showToastOnRestrictionFail: true,
restrictions: {
allowedFileTypes,
maxFileSize,
},
metadata: {
diskPrefix,
disk,
},
onSuccess: (entry: FileEntry) => onChange?.(entry.url),
onError: message => {
if (message) {
toast.danger(message);
}
},
};
const inputFieldClassNames = getInputFieldClassNames({
description,
descriptionPosition: 'top',
invalid,
disabled: disabled || uploadStatus === 'inProgress',
});
const removeButton = showRemoveButton ? (
<Button
variant="link"
color="danger"
size="xs"
disabled={isDeletingEntry || !currentValue || disabled}
onClick={() => {
deleteEntry({
onSuccess: () => onChange?.(''),
});
}}
>
<Trans message="Remove file" />
</Button>
) : null;
const handleUpload = useCallback(() => {
inputRef.current?.click();
}, []);
return (
<div className={clsx('text-sm', className)}>
{label && (
<div className="flex items-center justify-between gap-24">
<div id={labelId} className={inputFieldClassNames.label}>
{label}
</div>
{removeButton}
</div>
)}
{description && (
<div className={inputFieldClassNames.description}>{description}</div>
)}
<div aria-labelledby={labelId} aria-describedby={descriptionId}>
<Field
fieldClassNames={inputFieldClassNames}
errorMessage={errorMessage}
invalid={invalid}
>
<FileInputField
inputFieldClassNames={inputFieldClassNames}
currentValue={currentValue}
currentEntry={currentEntry}
handleUpload={handleUpload}
>
<input
ref={inputRef}
aria-labelledby={labelId}
aria-describedby={descriptionId}
// if file is already uploaded (from form or via props) set
// required to false, otherwise farm validation will always fail
required={currentValue ? false : required}
accept={allowedFileTypes?.join(',')}
type="file"
disabled={uploadStatus === 'inProgress'}
className="sr-only"
onChange={e => {
if (e.target.files?.length) {
// "uploadFile" will validate, but need to validate here as well
// because there's no easy way to listen for errors using "uploadFile"
const errorMessage = validateUpload(
new UploadedFile(e.target.files[0]),
uploadOptions.restrictions
);
if (errorMessage && inputRef.current) {
inputRef.current.value = '';
toast.danger(errorMessage);
} else {
uploadFile(e.target.files[0], uploadOptions);
}
}
}}
/>
</FileInputField>
{uploadStatus === 'inProgress' && (
<ProgressBar
className="absolute left-0 right-0 top-0"
size="xs"
value={percentage}
/>
)}
</Field>
</div>
</div>
);
}
interface FileInputFieldProps {
children: ReactElement<ComponentPropsWithRef<'input'>>;
inputFieldClassNames: InputFieldStyle;
currentValue?: string;
currentEntry?: FileEntry;
handleUpload: () => void;
}
function FileInputField({
children,
inputFieldClassNames,
currentValue,
currentEntry,
handleUpload,
}: FileInputFieldProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
if (currentValue) {
return (
<Field
wrapperProps={{
onClick: () => {
buttonRef.current?.focus();
buttonRef.current?.click();
},
}}
fieldClassNames={inputFieldClassNames}
>
<Input className={clsx(inputFieldClassNames.input, 'gap-10')}>
<button
ref={buttonRef}
type="button"
className="flex-shrink-0 rounded bg-primary px-10 py-2 text-sm font-semibold text-on-primary outline-none"
onClick={() => handleUpload()}
>
<Trans message="Replace file" />
</button>
<AnimatePresence initial={false} mode="wait">
<div className="min-w-0 overflow-hidden overflow-ellipsis whitespace-nowrap">
{currentEntry ? (
<m.div key="file-entry-name" {...opacityAnimation}>
{currentEntry.name}
</m.div>
) : (
<m.div key="skeleton" {...opacityAnimation}>
<Skeleton className="min-w-144" />
</m.div>
)}
</div>
</AnimatePresence>
{children}
</Input>
</Field>
);
}
return cloneElement(children, {
className: clsx(
inputFieldClassNames.input,
'py-8',
'file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10'
),
});
}
interface FormFileEntryFieldProps extends Props {
name: string;
}
export function FormFileEntryField(props: FormFileEntryFieldProps) {
const {
field: {onChange, value = null},
fieldState: {error},
} = useController({
name: props.name,
});
const formProps: Partial<Props> = {
onChange,
value,
invalid: error != null,
errorMessage: error ? <Trans message="Please select a file." /> : null,
};
return <FileEntryField {...mergeProps(formProps, props)} />;
}

View File

@@ -0,0 +1,66 @@
import React, {ChangeEventHandler} from 'react';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import {useController} from 'react-hook-form';
import clsx from 'clsx';
import {BaseFieldProps} from './base-field-props';
import {useField} from './use-field';
import {getInputFieldClassNames} from './get-input-field-class-names';
import {Field} from './field';
import {TextFieldProps} from './text-field/text-field';
export interface FileFieldProps
extends Omit<BaseFieldProps, 'type'> {
onChange?: ChangeEventHandler<'input'>;
accept?: string;
}
export const FileField = React.forwardRef<HTMLInputElement, FileFieldProps>(
(props, ref) => {
const inputRef = useObjectRef(ref);
const {fieldProps, inputProps} = useField({...props, focusRef: inputRef});
const inputFieldClassNames = getInputFieldClassNames(props);
return (
<Field ref={ref} fieldClassNames={inputFieldClassNames} {...fieldProps}>
<input
type="file"
ref={inputRef}
{...inputProps as any}
className={clsx(
inputFieldClassNames.input,
'py-8',
'file:bg-primary file:text-on-primary file:border-none file:rounded file:text-sm file:font-semibold file:px-10 file:h-24 file:mr-10'
)}
/>
</Field>
);
}
);
export interface FormFileFieldProps extends FileFieldProps {
name: string;
}
export function FormFileField({name, ...props}: FormFileFieldProps) {
const {
field: {onChange, onBlur, ref},
fieldState: {invalid, error},
} = useController({
name,
});
const [value, setValue] = React.useState('');
const formProps: TextFieldProps = {
onChange: e => {
onChange(e.target.files?.[0]);
setValue(e.target.value);
},
onBlur,
value,
invalid,
errorMessage: error?.message,
};
return <FileField ref={ref} {...mergeProps(formProps, props)} />;
}

View File

@@ -0,0 +1,113 @@
import {useController} from 'react-hook-form';
import {mergeProps} from '@react-aria/utils';
import React, {useEffect, useState} from 'react';
import memoize from 'nano-memoize';
import {
FormTextFieldProps,
TextField,
TextFieldProps,
} from './text-field/text-field';
import {prettyBytes} from '../../../uploads/utils/pretty-bytes';
import {Option, Select} from '../select/select';
import {spaceUnits} from '../../../uploads/utils/space-units';
import {
convertToBytes,
SpaceUnit,
} from '../../../uploads/utils/convert-to-bytes';
// 99TB
const MaxValue = 108851651149824;
export const FormFileSizeField = React.forwardRef<
HTMLDivElement,
FormTextFieldProps
>(({name, ...props}, ref) => {
const {
field: {
onChange: setByteValue,
onBlur,
value: byteValue = '',
ref: inputRef,
},
fieldState: {invalid, error},
} = useController({
name,
});
const [liveValue, setLiveValue] = useState<number | string>('');
const [unit, setUnit] = useState<SpaceUnit | string>('MB');
useEffect(() => {
if (byteValue == null || byteValue === '') {
setLiveValue('');
return;
}
const {amount, unit: newUnit} = fromBytes({
bytes: Math.min(byteValue, MaxValue),
});
setUnit(newUnit || 'MB');
setLiveValue(Number.isNaN(amount) ? '' : amount);
}, [byteValue, unit]);
const formProps: TextFieldProps = {
onChange: e => {
const value = parseInt(e.target.value);
if (Number.isNaN(value)) {
setByteValue(value);
} else {
const newBytes = convertToBytes(
parseInt(e.target.value),
unit as SpaceUnit
);
setByteValue(newBytes);
}
},
onBlur,
value: liveValue,
invalid,
errorMessage: error?.message,
inputRef,
};
const unitSelect = (
<Select
minWidth="min-w-80"
selectionMode="single"
selectedValue={unit}
disabled={!byteValue}
onSelectionChange={newUnit => {
const newBytes = convertToBytes(
(liveValue || 0) as number,
newUnit as SpaceUnit
);
setByteValue(newBytes);
}}
>
{spaceUnits.slice(0, 5).map(u => (
<Option value={u} key={u}>
{u === 'B' ? 'Bytes' : u}
</Option>
))}
</Select>
);
return (
<TextField
{...mergeProps(formProps, props)}
type="number"
ref={ref}
endAppend={unitSelect}
/>
);
});
const fromBytes = memoize(
({bytes}: {bytes: number}): {amount: number | string; unit: SpaceUnit} => {
const pretty = prettyBytes(bytes);
if (!pretty) return {amount: '', unit: 'MB'};
let amount = parseInt(pretty.split(' ')[0]);
// get rid of any punctuation
amount = Math.round(amount);
return {amount, unit: pretty.split(' ')[1] as SpaceUnit};
}
);

View File

@@ -0,0 +1,233 @@
import clsx from 'clsx';
import {BaseFieldProps} from './base-field-props';
import {ButtonSize, getButtonSizeStyle} from '../../buttons/button-size';
export interface InputFieldStyle {
label: string;
input: string;
wrapper: string;
inputWrapper: string;
adornment: string;
append: {size: string; radius: string};
size: {font: string; height: string};
description: string;
error: string;
}
type InputFieldStyleProps = Omit<
BaseFieldProps,
'value' | 'defaultValue' | 'onChange'
>;
export function getInputFieldClassNames(
props: InputFieldStyleProps = {},
): InputFieldStyle {
const {
size = 'md',
startAppend,
endAppend,
className,
labelPosition,
labelDisplay = 'block',
inputClassName,
inputWrapperClassName,
unstyled,
invalid,
disabled,
background = 'bg-transparent',
flexibleHeight,
inputShadow = 'shadow-sm',
descriptionPosition = 'bottom',
inputRing,
inputFontSize,
labelSuffix,
} = {...props};
if (unstyled) {
return {
label: '',
input: inputClassName || '',
wrapper: className || '',
inputWrapper: inputWrapperClassName || '',
adornment: '',
append: {size: '', radius: ''},
size: {font: '', height: ''},
description: '',
error: '',
};
}
const sizeClass = inputSizeClass({
size: props.size,
flexibleHeight,
});
if (inputFontSize) {
sizeClass.font = inputFontSize;
}
const isInputGroup = startAppend || endAppend;
const ringColor = invalid
? 'focus:ring-danger/focus focus:border-danger/60'
: 'focus:ring-primary/focus focus:border-primary/60';
const ringClassName = inputRing || `focus:ring ${ringColor}`;
const radius = getRadius(props);
return {
label: clsx(
labelDisplay,
'first-letter:capitalize text-left whitespace-nowrap',
disabled && 'text-disabled',
sizeClass.font,
labelSuffix ? '' : labelPosition === 'side' ? 'mr-16' : 'mb-4',
),
input: clsx(
'block text-left relative w-full appearance-none transition-shadow text',
background,
// radius
radius.input,
getInputBorder(props),
!disabled && `${ringClassName} focus:outline-none ${inputShadow}`,
disabled && 'text-disabled cursor-not-allowed',
inputClassName,
sizeClass.font,
sizeClass.height,
getInputPadding(props),
),
adornment: iconSizeClass(size),
append: {
size: getButtonSizeStyle(size),
radius: radius.append,
},
wrapper: clsx(className, sizeClass.font, {
'flex items-center': labelPosition === 'side',
}),
inputWrapper: clsx(
'isolate relative',
inputWrapperClassName,
isInputGroup && 'flex items-stretch',
),
size: sizeClass,
description: `text-muted ${
descriptionPosition === 'bottom' ? 'pt-10' : 'pb-10'
} text-xs`,
error: 'text-danger pt-10 text-xs',
};
}
function getInputBorder({
startAppend,
endAppend,
inputBorder,
invalid,
}: InputFieldStyleProps) {
if (inputBorder) return inputBorder;
const isInputGroup = startAppend || endAppend;
const borderColor = invalid ? 'border-danger' : 'border-divider';
if (!isInputGroup) {
return `${borderColor} border`;
}
if (startAppend) {
return `${borderColor} border-y border-r`;
}
return `${borderColor} border-y border-l`;
}
function getInputPadding({
startAdornment,
endAdornment,
inputRadius,
}: InputFieldStyleProps) {
if (inputRadius === 'rounded-full') {
return clsx(
startAdornment ? 'pl-54' : 'pl-28',
endAdornment ? 'pr-54' : 'pr-28',
);
}
return clsx(
startAdornment ? 'pl-46' : 'pl-12',
endAdornment ? 'pr-46' : 'pr-12',
);
}
function getRadius(props: InputFieldStyleProps): {
input: string;
append: string;
} {
const {startAppend, endAppend, inputRadius} = props;
const isInputGroup = startAppend || endAppend;
if (inputRadius === 'rounded-full') {
return {
input: clsx(
!isInputGroup && 'rounded-full',
startAppend && 'rounded-r-full rounded-l-none',
endAppend && 'rounded-l-full rounded-r-none',
),
append: startAppend ? 'rounded-l-full' : 'rounded-r-full',
};
} else if (inputRadius === 'rounded-none') {
return {
input: '',
append: '',
};
} else if (inputRadius) {
return {
input: inputRadius,
append: inputRadius,
};
}
return {
input: clsx(
!isInputGroup && 'rounded-input',
startAppend && 'rounded-r-input rounded-l-none',
endAppend && 'rounded-l-input rounded-r-none',
),
append: startAppend ? 'rounded-l-input' : 'rounded-r-input',
};
}
function inputSizeClass({size, flexibleHeight}: BaseFieldProps) {
switch (size) {
case '2xs':
return {font: 'text-xs', height: flexibleHeight ? 'min-h-24' : 'h-24'};
case 'xs':
return {font: 'text-xs', height: flexibleHeight ? 'min-h-30' : 'h-30'};
case 'sm':
return {font: 'text-sm', height: flexibleHeight ? 'min-h-36' : 'h-36'};
case 'lg':
return {
font: 'text-md md:text-lg',
height: flexibleHeight ? 'min-h-50' : 'h-50',
};
case 'xl':
return {font: 'text-xl', height: flexibleHeight ? 'min-h-60' : 'h-60'};
default:
return {font: 'text-sm', height: flexibleHeight ? 'min-h-42' : 'h-42'};
}
}
function iconSizeClass(size?: ButtonSize): string {
switch (size) {
case '2xs':
return 'icon-2xs';
case 'xs':
return 'icon-xs';
case 'sm':
return 'icon-sm';
case 'md':
return 'icon-sm';
case 'lg':
return 'icon-lg';
case 'xl':
return 'icon-xl';
default:
// can't return "size" variable here, append in field will not work with it
return '';
}
}

View File

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

View File

@@ -0,0 +1,45 @@
import {FocusScope} from '@react-aria/focus';
import React, {ComponentPropsWithoutRef, CSSProperties, ReactNode} from 'react';
import clsx from 'clsx';
interface InputProps {
className?: string;
children: ReactNode;
autoFocus?: boolean;
disabled?: boolean;
style?: CSSProperties;
inputProps?: ComponentPropsWithoutRef<'div'>;
wrapperProps?: ComponentPropsWithoutRef<'div'>;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}
export const Input = React.forwardRef<HTMLDivElement, InputProps>(
(props, ref) => {
const {
children,
inputProps,
wrapperProps,
className,
autoFocus,
style,
onClick,
} = props;
return (
<div {...wrapperProps} onClick={onClick}>
<div
{...inputProps}
role="group"
className={clsx(
className,
'flex items-center focus-within:ring focus-within:ring-primary/focus focus-within:border-primary/60'
)}
ref={ref}
style={style}
>
<FocusScope autoFocus={autoFocus}>{children}</FocusScope>
</div>
</div>
);
}
);

View File

@@ -0,0 +1,71 @@
import React, {ComponentPropsWithoutRef, forwardRef, Ref} from 'react';
import type {TextFieldProps} from './text-field';
import {Field} from '../field';
import {getInputFieldClassNames} from '../get-input-field-class-names';
interface Props extends TextFieldProps {
labelProps?: ComponentPropsWithoutRef<'label'>;
inputProps:
| ComponentPropsWithoutRef<'input'>
| ComponentPropsWithoutRef<'textarea'>;
descriptionProps?: ComponentPropsWithoutRef<'div'>;
errorMessageProps?: ComponentPropsWithoutRef<'div'>;
inputRef?: Ref<HTMLInputElement>;
isLoading?: boolean;
rows?: number;
}
export const TextFieldBase = forwardRef<HTMLDivElement, Props>((props, ref) => {
const {
label,
startAdornment,
endAdornment,
startAppend,
endAppend,
errorMessage,
description,
labelProps,
inputProps,
inputRef,
descriptionProps,
errorMessageProps,
inputWrapperClassName,
className,
inputClassName,
disabled,
inputElementType,
rows,
} = props;
const isTextArea = inputElementType === 'textarea';
const ElementType: React.ElementType = isTextArea ? 'textarea' : 'input';
const fieldClassNames = getInputFieldClassNames(props);
return (
<Field
ref={ref}
label={label}
labelProps={labelProps}
startAdornment={startAdornment}
endAdornment={endAdornment}
startAppend={startAppend}
endAppend={endAppend}
errorMessage={errorMessage}
description={description}
descriptionProps={descriptionProps}
errorMessageProps={errorMessageProps}
inputWrapperClassName={inputWrapperClassName}
className={className}
inputClassName={inputClassName}
fieldClassNames={fieldClassNames}
disabled={disabled}
>
<ElementType
ref={inputRef as any}
{...(inputProps as any)}
rows={isTextArea ? rows || 4 : undefined}
className={fieldClassNames.input}
/>
</Field>
);
});

View File

@@ -0,0 +1,89 @@
import React, {forwardRef, HTMLProps, Ref} from 'react';
import {useController} from 'react-hook-form';
import {mergeProps, useObjectRef} from '@react-aria/utils';
import {BaseFieldPropsWithDom} from '../base-field-props';
import {getInputFieldClassNames} from '../get-input-field-class-names';
import {Field} from '../field';
import {useField} from '../use-field';
export interface TextFieldProps
extends BaseFieldPropsWithDom<HTMLInputElement> {
rows?: number;
inputElementType?: 'input' | 'textarea';
inputRef?: Ref<HTMLInputElement>;
value?: string | number;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
(
{
inputElementType = 'input',
flexibleHeight,
inputRef,
inputTestId,
...props
},
ref
) => {
const inputObjRef = useObjectRef(inputRef);
const {fieldProps, inputProps} = useField<HTMLInputElement>({
...props,
focusRef: inputObjRef,
});
const isTextArea = inputElementType === 'textarea';
const ElementType: React.ElementType = isTextArea ? 'textarea' : 'input';
const inputFieldClassNames = getInputFieldClassNames({
...props,
flexibleHeight: flexibleHeight || inputElementType === 'textarea',
});
if (inputElementType === 'textarea' && !props.unstyled) {
inputFieldClassNames.input = `${inputFieldClassNames.input} py-12`;
}
return (
<Field ref={ref} fieldClassNames={inputFieldClassNames} {...fieldProps}>
<ElementType
data-testid={inputTestId}
ref={inputObjRef}
{...(inputProps as any)}
rows={
isTextArea
? (inputProps as HTMLProps<HTMLTextAreaElement>).rows || 4
: undefined
}
className={inputFieldClassNames.input}
/>
</Field>
);
}
);
export interface FormTextFieldProps extends TextFieldProps {
name: string;
}
export const FormTextField = React.forwardRef<
HTMLDivElement,
FormTextFieldProps
>(({name, ...props}, ref) => {
const {
field: {onChange, onBlur, value = '', ref: inputRef},
fieldState: {invalid, error},
} = useController({
name,
});
const formProps: TextFieldProps = {
onChange,
onBlur,
value: value == null ? '' : value, // avoid issues with "null" value when setting form defaults from backend model
invalid,
errorMessage: error?.message,
inputRef,
name,
};
return <TextField ref={ref} {...mergeProps(formProps, props)} />;
});

View File

@@ -0,0 +1,138 @@
import {HTMLAttributes, HTMLProps, RefObject, useId} from 'react';
import {BaseFieldPropsWithDom} from './base-field-props';
import {useAutoFocus} from '../../focus/use-auto-focus';
import type {FieldProps} from './field';
interface UseFieldReturn<T> {
fieldProps: Omit<FieldProps, 'fieldClassNames' | 'children'>;
inputProps: HTMLAttributes<T>;
}
interface Props<T> extends BaseFieldPropsWithDom<T> {
focusRef: RefObject<HTMLElement>;
}
export function useField<T>(props: Props<T>): UseFieldReturn<T> {
const {
focusRef,
labelElementType = 'label',
label,
labelSuffix,
labelSuffixPosition,
autoFocus,
autoSelectText,
labelPosition,
descriptionPosition,
size,
errorMessage,
description,
flexibleHeight,
startAdornment,
endAdornment,
startAppend,
adornmentPosition,
endAppend,
className,
inputClassName,
inputWrapperClassName,
unstyled,
background,
invalid,
disabled,
id,
inputRadius,
inputBorder,
inputShadow,
inputRing,
inputFontSize,
...inputDomProps
} = props;
useAutoFocus(props, focusRef);
const defaultId = useId();
const inputId = id || defaultId;
const labelId = `${inputId}-label`;
const descriptionId = `${inputId}-description`;
const errorId = `${inputId}-error`;
const labelProps = {
id: labelId,
htmlFor: labelElementType === 'label' ? inputId : undefined,
};
const descriptionProps = {
id: descriptionId,
};
const errorMessageProps = {
id: errorId,
};
const ariaLabel =
!props.label && !props['aria-label'] && props.placeholder
? props.placeholder
: props['aria-label'];
const inputProps: HTMLProps<T> = {
'aria-label': ariaLabel,
'aria-invalid': invalid || undefined,
id: inputId,
disabled,
...inputDomProps,
};
const labelledBy = [];
if (label) {
labelledBy.push(labelProps.id);
}
if (inputProps['aria-labelledby']) {
labelledBy.push(inputProps['aria-labelledby']);
}
inputProps['aria-labelledby'] = labelledBy.length
? labelledBy.join(' ')
: undefined;
const describedBy = [];
if (description) {
describedBy.push(descriptionProps.id);
}
if (errorMessage) {
describedBy.push(errorMessageProps.id);
}
if (inputProps['aria-describedby']) {
describedBy.push(inputProps['aria-describedby']);
}
inputProps['aria-describedby'] = describedBy.length
? describedBy.join(' ')
: undefined;
return {
fieldProps: {
errorMessageProps,
descriptionProps,
labelProps,
disabled,
label,
labelSuffix,
labelSuffixPosition,
autoFocus,
autoSelectText,
labelPosition,
descriptionPosition,
size,
errorMessage,
description,
flexibleHeight,
startAdornment,
endAdornment,
startAppend,
adornmentPosition,
endAppend,
className,
inputClassName,
inputWrapperClassName,
unstyled,
background,
invalid,
},
inputProps,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More